Add explicit project device sync

This commit is contained in:
2026-07-22 19:38:21 +02:00
parent 9d07ed9856
commit 6f8b292b6b
15 changed files with 771 additions and 5 deletions
+12
View File
@@ -149,3 +149,15 @@ Request sketch:
- `DELETE /consumers/:consumerId`
These remain for migration/legacy views. New circuit-list interactions must use circuit-first endpoints above.
## Linked Project Device Review
- `GET /project-devices/projects/:projectId/:projectDeviceId/links`
- returns all linked circuit device rows with distribution-board/circuit context and field differences
- `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize`
- applies only explicitly selected fields to explicitly selected linked rows
- `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect`
- disconnects explicitly selected rows without changing their local values
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
project device never triggers synchronization implicitly.
@@ -2,7 +2,6 @@
- Undo/redo history is session-local only.
- Undo/redo history is not persisted across reloads or between users.
- No linked project-device sync review dialog is implemented yet.
- No global cross-project device library workflow is implemented yet.
- Final electrical sizing logic is not implemented yet.
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
@@ -10,3 +9,5 @@
- Sorting is view-only until users explicitly apply sorted order.
- Cross-section circuit drag-reorder is intentionally blocked.
- Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline.
- Project-device synchronization and disconnect actions are explicit, but are not yet connected to an undo command.
- Multi-row project-device synchronization is sequential and not yet wrapped in one database transaction.
+2 -2
View File
@@ -11,8 +11,8 @@
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"start": "node dist/server/index.js",
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:backup": "node scripts/db-backup.js",
+213
View File
@@ -11,6 +11,8 @@ import {
createProjectDevice,
createRoom,
deleteProjectDevice,
disconnectProjectDeviceRows,
getProjectDeviceSyncPreview,
listCircuitLists,
listDistributionBoards,
listFloors,
@@ -18,6 +20,7 @@ import {
listProjectDevices,
listProjects,
listRooms,
synchronizeProjectDeviceRows,
updateProjectDevice,
updateProjectSettings,
} from "../../../frontend/utils/api";
@@ -28,9 +31,28 @@ import type {
FloorDto,
GlobalDeviceDto,
ProjectDeviceDto,
ProjectDeviceSyncPreviewDto,
ProjectDto,
RoomDto,
} from "../../../frontend/types";
import {
projectDeviceSyncFields,
type ProjectDeviceSyncField,
} from "../../../shared/constants/project-device-sync-fields";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
name: "Technischer Name",
displayName: "Anzeigename",
phaseType: "Phasenart",
connectionKind: "Anschlussart",
costGroup: "Kostengruppe",
category: "Kategorie",
quantity: "Anzahl",
powerPerUnit: "Leistung je Stück",
simultaneityFactor: "Gleichzeitigkeitsfaktor",
cosPhi: "cos Phi",
remark: "Bemerkung",
};
const emptyProjectDevice: CreateProjectDeviceInput = {
name: "",
@@ -85,6 +107,9 @@ export default function ProjectDetailPage() {
remark: "",
});
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
@@ -311,11 +336,81 @@ export default function ProjectDetailPage() {
try {
const updated = await updateProjectDevice(projectId, device.id, payload);
setProjectDevices((current) => current.map((item) => (item.id === device.id ? updated : item)));
await openProjectDeviceSyncPreview(updated.id);
} catch (err) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
}
}
async function openProjectDeviceSyncPreview(projectDeviceId: string) {
if (!projectId) {
return;
}
try {
const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId);
setSyncPreview(preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
const differentFields = new Set(
preview.rows.flatMap((row) => row.differences.map((difference) => difference.field))
);
setSelectedSyncFields(
projectDeviceSyncFields.filter((field) => field !== "displayName" && differentFields.has(field))
);
} catch (err) {
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht geladen werden.");
}
}
function toggleSyncRow(rowId: string) {
setSelectedSyncRowIds((current) =>
current.includes(rowId) ? current.filter((id) => id !== rowId) : [...current, rowId]
);
}
function toggleSyncField(field: ProjectDeviceSyncField) {
setSelectedSyncFields((current) =>
current.includes(field) ? current.filter((entry) => entry !== field) : [...current, field]
);
}
async function handleSynchronizeProjectDeviceRows() {
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0) {
return;
}
setIsSaving(true);
setError(null);
try {
const preview = await synchronizeProjectDeviceRows(
projectId,
syncPreview.projectDevice.id,
selectedSyncRowIds,
selectedSyncFields
);
setSyncPreview(preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
} catch (err) {
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
} finally {
setIsSaving(false);
}
}
async function handleDisconnectProjectDeviceRows() {
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0) {
return;
}
setIsSaving(true);
setError(null);
try {
await disconnectProjectDeviceRows(projectId, syncPreview.projectDevice.id, selectedSyncRowIds);
await openProjectDeviceSyncPreview(syncPreview.projectDevice.id);
} catch (err) {
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
} finally {
setIsSaving(false);
}
}
async function handleCopyGlobalToProject() {
if (!projectId || !selectedGlobalDeviceId) {
return;
@@ -815,6 +910,14 @@ export default function ProjectDetailPage() {
<td>{device.totalPower}</td>
<td>
<div className="btn-group btn-group-sm">
<button
className="btn btn-outline-primary"
type="button"
onClick={() => void openProjectDeviceSyncPreview(device.id)}
disabled={isSaving}
>
Verknüpfungen
</button>
<button
className="btn btn-outline-secondary"
type="button"
@@ -845,6 +948,116 @@ export default function ProjectDetailPage() {
</tbody>
</table>
</div>
{syncPreview ? (
<div className="card-body border-top">
<div className="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3">
<div>
<h3 className="h6 mb-1">Verknüpfte Zeilen: {syncPreview.projectDevice.displayName}</h3>
<p className="text-secondary small mb-0">
Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.
</p>
</div>
<button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSyncPreview(null)}>
Schließen
</button>
</div>
<div className="d-flex flex-wrap gap-3 mb-3">
{projectDeviceSyncFields.map((field) => (
<label className="form-check" key={field}>
<input
className="form-check-input"
type="checkbox"
checked={selectedSyncFields.includes(field)}
onChange={() => toggleSyncField(field)}
/>
<span className="form-check-label">
{projectDeviceSyncFieldLabels[field]}
{field === "displayName" ? " (lokal)" : ""}
</span>
</label>
))}
</div>
<div className="table-responsive border rounded mb-3">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Auswahl</th>
<th>Verteilung / Stromkreis</th>
<th>Gerätezeile</th>
<th>Abweichungen</th>
</tr>
</thead>
<tbody>
{syncPreview.rows.map((row) => (
<tr key={row.rowId}>
<td>
<input
className="form-check-input"
type="checkbox"
checked={selectedSyncRowIds.includes(row.rowId)}
onChange={() => toggleSyncRow(row.rowId)}
/>
</td>
<td>
<strong>{row.distributionBoardName}</strong>
<div className="small text-secondary">
{row.equipmentIdentifier}
{row.circuitDisplayName ? ` ${row.circuitDisplayName}` : ""}
</div>
</td>
<td>{row.rowDisplayName}</td>
<td>
<div className="d-flex flex-wrap gap-1">
{row.differences.map((difference) => (
<span
className={`badge ${difference.isOverridden ? "text-bg-warning" : "text-bg-light"}`}
key={difference.field}
title={difference.isOverridden ? "Lokal überschrieben" : undefined}
>
{projectDeviceSyncFieldLabels[difference.field]}: {String(difference.currentValue ?? "")} {" "}
{String(difference.sourceValue ?? "")}
</span>
))}
{row.differences.length === 0 ? (
<span className="text-success small">Aktuell</span>
) : null}
</div>
</td>
</tr>
))}
{syncPreview.rows.length === 0 ? (
<tr>
<td colSpan={4} className="text-center text-secondary py-3">
Keine verknüpften Circuit-First-Gerätezeilen vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<div className="d-flex flex-wrap gap-2">
<button
className="btn btn-primary"
type="button"
onClick={() => void handleSynchronizeProjectDeviceRows()}
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
>
Auswahl synchronisieren
</button>
<button
className="btn btn-outline-danger"
type="button"
onClick={() => void handleDisconnectProjectDeviceRows()}
disabled={isSaving || selectedSyncRowIds.length === 0}
>
Auswahl trennen
</button>
</div>
</div>
) : null}
</section>
<div className="mt-4">
@@ -1,7 +1,10 @@
import crypto from "node:crypto";
import { asc, eq, inArray } from "drizzle-orm";
import { and, asc, eq, inArray } from "drizzle-orm";
import { db } from "../client.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
export class CircuitDeviceRowRepository {
async findById(rowId: string) {
@@ -25,6 +28,50 @@ export class CircuitDeviceRowRepository {
.orderBy(asc(circuitDeviceRows.sortOrder));
}
async listLinkedByProjectDevice(projectId: string, projectDeviceId: string) {
return db
.select({
id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId,
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
legacyConsumerId: circuitDeviceRows.legacyConsumerId,
sortOrder: circuitDeviceRows.sortOrder,
name: circuitDeviceRows.name,
displayName: circuitDeviceRows.displayName,
phaseType: circuitDeviceRows.phaseType,
connectionKind: circuitDeviceRows.connectionKind,
costGroup: circuitDeviceRows.costGroup,
category: circuitDeviceRows.category,
level: circuitDeviceRows.level,
roomId: circuitDeviceRows.roomId,
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
quantity: circuitDeviceRows.quantity,
powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi,
remark: circuitDeviceRows.remark,
overriddenFields: circuitDeviceRows.overriddenFields,
equipmentIdentifier: circuits.equipmentIdentifier,
circuitDisplayName: circuits.displayName,
circuitListId: circuitLists.id,
circuitListName: circuitLists.name,
distributionBoardId: distributionBoards.id,
distributionBoardName: distributionBoards.name,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuitDeviceRows.circuitId, circuits.id))
.innerJoin(circuitLists, eq(circuits.circuitListId, circuitLists.id))
.innerJoin(distributionBoards, eq(circuitLists.distributionBoardId, distributionBoards.id))
.where(
and(
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId),
eq(circuitLists.projectId, projectId)
)
)
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
}
async create(input: {
circuitId: string;
linkedProjectDeviceId?: string;
+21 -1
View File
@@ -14,6 +14,11 @@ import type {
UpdateCircuitInput,
} from "../../shared/validation/circuit.schemas.js";
import { CircuitNumberingService } from "./circuit-numbering.service.js";
import { projectDeviceSyncFields } from "../../shared/constants/project-device-sync-fields.js";
import {
parseOverriddenFields,
serializeOverriddenFields,
} from "./project-device-sync.service.js";
export class CircuitWriteService {
private readonly circuitRepository: CircuitRepository;
@@ -218,6 +223,21 @@ export class CircuitWriteService {
throw new Error("Invalid device row id.");
}
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
let overriddenFields = input.overriddenFields ?? current.overriddenFields ?? undefined;
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
const inputValues = input as Record<string, unknown>;
const currentValues = current as unknown as Record<string, unknown>;
for (const field of projectDeviceSyncFields) {
if (
Object.prototype.hasOwnProperty.call(input, field) &&
(inputValues[field] ?? null) !== (currentValues[field] ?? null)
) {
overrides.add(field);
}
}
overriddenFields = serializeOverriddenFields(overrides);
}
await this.deviceRowRepository.update(rowId, {
linkedProjectDeviceId: input.linkedProjectDeviceId ?? current.linkedProjectDeviceId ?? undefined,
name: input.name ?? current.name,
@@ -235,7 +255,7 @@ export class CircuitWriteService {
simultaneityFactor: input.simultaneityFactor ?? current.simultaneityFactor,
cosPhi: input.cosPhi ?? current.cosPhi ?? undefined,
remark: input.remark ?? current.remark ?? undefined,
overriddenFields: input.overriddenFields ?? current.overriddenFields ?? undefined,
overriddenFields,
});
return this.deviceRowRepository.findById(rowId);
}
@@ -0,0 +1,170 @@
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import {
projectDeviceSyncFields,
type ProjectDeviceSyncField,
} from "../../shared/constants/project-device-sync-fields.js";
type ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>;
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number];
type SyncDependencies = {
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
deviceRowRepository: Pick<CircuitDeviceRowRepository, "listLinkedByProjectDevice" | "update">;
};
export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] {
if (!value) {
return [];
}
try {
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((field): field is ProjectDeviceSyncField =>
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
);
} catch {
return [];
}
}
export function serializeOverriddenFields(fields: Iterable<ProjectDeviceSyncField>): string | undefined {
const fieldSet = new Set(fields);
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
return unique.length > 0 ? JSON.stringify(unique) : undefined;
}
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
return projectDevice[field];
}
function valuesEqual(left: unknown, right: unknown) {
return (left ?? null) === (right ?? null);
}
function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
return projectDeviceSyncFields
.filter((field) => !valuesEqual(row[field], sourceValue(projectDevice, field)))
.map((field) => ({
field,
currentValue: row[field] ?? null,
sourceValue: sourceValue(projectDevice, field) ?? null,
isOverridden: parseOverriddenFields(row.overriddenFields).includes(field),
}));
}
export class ProjectDeviceSyncService {
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"];
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"];
constructor(deps?: Partial<SyncDependencies>) {
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
}
async getPreview(projectId: string, projectDeviceId: string) {
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
if (!projectDevice) {
throw new Error("Project device not found.");
}
const rows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
return {
projectDevice: {
id: projectDevice.id,
name: projectDevice.name,
displayName: projectDevice.displayName,
},
rows: rows.map((row) => ({
rowId: row.id,
circuitId: row.circuitId,
equipmentIdentifier: row.equipmentIdentifier,
circuitDisplayName: row.circuitDisplayName,
circuitListId: row.circuitListId,
circuitListName: row.circuitListName,
distributionBoardId: row.distributionBoardId,
distributionBoardName: row.distributionBoardName,
rowDisplayName: row.displayName,
overriddenFields: parseOverriddenFields(row.overriddenFields),
differences: buildDifferences(projectDevice, row),
})),
};
}
async synchronize(
projectId: string,
projectDeviceId: string,
rowIds: string[],
fields: ProjectDeviceSyncField[]
) {
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
if (!projectDevice) {
throw new Error("Project device not found.");
}
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
const selectedFields = [...new Set(fields)];
for (const row of selectedRows) {
const next = this.copyRow(row);
for (const field of selectedFields) {
Object.assign(next, { [field]: sourceValue(projectDevice, field) ?? undefined });
}
const remainingOverrides = parseOverriddenFields(row.overriddenFields).filter(
(field) => !selectedFields.includes(field)
);
next.overriddenFields = serializeOverriddenFields(remainingOverrides);
await this.deviceRowRepository.update(row.id, next);
}
return this.getPreview(projectId, projectDeviceId);
}
async disconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
for (const row of selectedRows) {
await this.deviceRowRepository.update(row.id, {
...this.copyRow(row),
linkedProjectDeviceId: undefined,
});
}
return { disconnectedRowIds: selectedRows.map((row) => row.id) };
}
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {
const uniqueRowIds = [...new Set(rowIds)];
const byId = new Map(linkedRows.map((row) => [row.id, row]));
const selectedRows = uniqueRowIds.map((rowId) => byId.get(rowId)).filter(Boolean) as LinkedRow[];
if (selectedRows.length !== uniqueRowIds.length) {
throw new Error("One or more rows are not linked to this project device.");
}
return selectedRows;
}
private copyRow(row: LinkedRow) {
return {
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
name: row.name,
displayName: row.displayName,
phaseType: row.phaseType ?? undefined,
connectionKind: row.connectionKind ?? undefined,
costGroup: row.costGroup ?? undefined,
category: row.category ?? undefined,
level: row.level ?? undefined,
roomId: row.roomId ?? undefined,
roomNumberSnapshot: row.roomNumberSnapshot ?? undefined,
roomNameSnapshot: row.roomNameSnapshot ?? undefined,
quantity: row.quantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi ?? undefined,
remark: row.remark ?? undefined,
overriddenFields: row.overriddenFields ?? undefined,
};
}
}
+31
View File
@@ -180,6 +180,36 @@ export interface CreateProjectDeviceInput {
voltageV?: number;
}
export interface ProjectDeviceSyncDifferenceDto {
field: ProjectDeviceSyncField;
currentValue: string | number | null;
sourceValue: string | number | null;
isOverridden: boolean;
}
export interface ProjectDeviceSyncRowDto {
rowId: string;
circuitId: string;
equipmentIdentifier: string;
circuitDisplayName: string | null;
circuitListId: string;
circuitListName: string;
distributionBoardId: string;
distributionBoardName: string;
rowDisplayName: string;
overriddenFields: ProjectDeviceSyncField[];
differences: ProjectDeviceSyncDifferenceDto[];
}
export interface ProjectDeviceSyncPreviewDto {
projectDevice: {
id: string;
name: string;
displayName: string;
};
rows: ProjectDeviceSyncRowDto[];
}
export interface CircuitTreeDeviceRowDto {
id: string;
linkedProjectDeviceId?: string;
@@ -296,3 +326,4 @@ export interface CreateCircuitDeviceRowInputDto {
}
export type UpdateCircuitDeviceRowInputDto = Partial<CreateCircuitDeviceRowInputDto>;
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
+37
View File
@@ -10,6 +10,7 @@ import type {
FloorDto,
GlobalDeviceDto,
ProjectDeviceDto,
ProjectDeviceSyncPreviewDto,
ProjectDto,
RoomDto,
UpdateConsumerInput,
@@ -19,6 +20,7 @@ import type {
CreateCircuitDeviceRowInputDto,
UpdateCircuitDeviceRowInputDto,
} from "../types";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -285,3 +287,38 @@ export function copyGlobalDeviceToProject(projectId: string, globalDeviceId: str
method: "POST",
});
}
export function getProjectDeviceSyncPreview(projectId: string, projectDeviceId: string) {
return request<ProjectDeviceSyncPreviewDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/links`
);
}
export function synchronizeProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rowIds: string[],
fields: ProjectDeviceSyncField[]
) {
return request<ProjectDeviceSyncPreviewDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`,
{
method: "POST",
body: JSON.stringify({ rowIds, fields }),
}
);
}
export function disconnectProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rowIds: string[]
) {
return request<{ disconnectedRowIds: string[] }>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`,
{
method: "POST",
body: JSON.stringify({ rowIds }),
}
);
}
@@ -1,13 +1,17 @@
import type { Request, Response } from "express";
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js";
import {
createProjectDeviceSchema,
disconnectProjectDeviceRowsSchema,
synchronizeProjectDeviceRowsSchema,
updateProjectDeviceSchema,
} from "../../shared/validation/project-device.schemas.js";
const globalDeviceRepository = new GlobalDeviceRepository();
const projectDeviceRepository = new ProjectDeviceRepository();
const projectDeviceSyncService = new ProjectDeviceSyncService();
export async function listProjectDevicesByProject(req: Request, res: Response) {
const { projectId } = req.params;
@@ -88,3 +92,55 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
return res.status(201).json(created);
}
export async function getProjectDeviceSyncPreview(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
try {
const preview = await projectDeviceSyncService.getPreview(projectId, projectDeviceId);
return res.json(preview);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to load sync preview." });
}
}
export async function synchronizeProjectDeviceRows(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = synchronizeProjectDeviceRowsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const preview = await projectDeviceSyncService.synchronize(
projectId,
projectDeviceId,
parsed.data.rowIds,
parsed.data.fields
);
return res.json(preview);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to synchronize rows." });
}
}
export async function disconnectProjectDeviceRows(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = disconnectProjectDeviceRowsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const result = await projectDeviceSyncService.disconnect(projectId, projectDeviceId, parsed.data.rowIds);
return res.json(result);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to disconnect rows." });
}
}
@@ -3,7 +3,10 @@ import {
copyGlobalDeviceToProject,
createProjectDevice,
deleteProjectDevice,
disconnectProjectDeviceRows,
getProjectDeviceSyncPreview,
listProjectDevicesByProject,
synchronizeProjectDeviceRows,
updateProjectDevice,
} from "../controllers/project-device.controller.js";
@@ -12,5 +15,8 @@ export const projectDeviceRouter = Router();
projectDeviceRouter.get("/projects/:projectId", listProjectDevicesByProject);
projectDeviceRouter.post("/projects/:projectId", createProjectDevice);
projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject);
projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/synchronize", synchronizeProjectDeviceRows);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/disconnect", disconnectProjectDeviceRows);
projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice);
projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice);
@@ -0,0 +1,15 @@
export const projectDeviceSyncFields = [
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
] as const;
export type ProjectDeviceSyncField = (typeof projectDeviceSyncFields)[number];
@@ -1,4 +1,5 @@
import { z } from "zod";
import { projectDeviceSyncFields } from "../constants/project-device-sync-fields.js";
export const createProjectDeviceSchema = z.object({
name: z.string().min(1),
@@ -18,5 +19,14 @@ export const createProjectDeviceSchema = z.object({
export const updateProjectDeviceSchema = createProjectDeviceSchema;
export const synchronizeProjectDeviceRowsSchema = z.object({
rowIds: z.array(z.string().min(1)).min(1),
fields: z.array(z.enum(projectDeviceSyncFields)).min(1),
});
export const disconnectProjectDeviceRowsSchema = z.object({
rowIds: z.array(z.string().min(1)).min(1),
});
export type CreateProjectDeviceInput = z.infer<typeof createProjectDeviceSchema>;
export type UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>;
+30
View File
@@ -599,4 +599,34 @@ describe("circuit write service rules", () => {
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
}
});
it("tracks local edits on linked device rows as overridden fields", async () => {
let savedOverrides: string | undefined;
const current = {
id: "r1",
circuitId: "c1",
linkedProjectDeviceId: "pd1",
name: "Luminaire",
displayName: "Local name",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
sortOrder: 10,
overriddenFields: null,
};
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return current as never;
},
async update(_rowId: string, input: { overriddenFields?: string }) {
savedOverrides = input.overriddenFields;
},
} as never,
});
await service.updateDeviceRow("r1", { quantity: 2 });
assert.deepEqual(JSON.parse(savedOverrides ?? "[]"), ["quantity"]);
});
});
+118
View File
@@ -0,0 +1,118 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { ProjectDeviceSyncService } from "../src/domain/services/project-device-sync.service.js";
function projectDevice() {
return {
id: "pd1",
projectId: "p1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase",
connectionKind: "fixed",
costGroup: "440",
category: "Lighting",
quantity: 6,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
totalPower: 0.192,
cosPhi: 0.95,
remark: "DALI",
};
}
function linkedRow() {
return {
id: "row1",
circuitId: "c1",
linkedProjectDeviceId: "pd1",
legacyConsumerId: null,
sortOrder: 10,
name: "Old luminaire",
displayName: "Local display name",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "Lighting",
level: null,
roomId: null,
roomNumberSnapshot: "1.01",
roomNameSnapshot: "Office",
quantity: 2,
powerPerUnit: 0.03,
simultaneityFactor: 1,
cosPhi: 1,
remark: null,
overriddenFields: JSON.stringify(["displayName", "quantity"]),
equipmentIdentifier: "-1F1",
circuitDisplayName: "Office",
circuitListId: "list1",
circuitListName: "UV-01 Stromkreisliste",
distributionBoardId: "db1",
distributionBoardName: "UV-01",
};
}
function createService() {
const rows = [linkedRow()];
const updates: Array<Record<string, unknown>> = [];
const service = new ProjectDeviceSyncService({
projectDeviceRepository: {
async findById() {
return projectDevice() as never;
},
},
deviceRowRepository: {
async listLinkedByProjectDevice() {
return rows as never;
},
async update(rowId, input) {
updates.push({ rowId, ...input });
Object.assign(rows[0], input);
},
} as never,
});
return { service, rows, updates };
}
describe("project device synchronization", () => {
it("shows field differences and local overrides without changing rows", async () => {
const { service, updates } = createService();
const preview = await service.getPreview("p1", "pd1");
assert.equal(updates.length, 0);
assert.equal(preview.rows[0].equipmentIdentifier, "-1F1");
assert.equal(preview.rows[0].differences.some((difference) => difference.field === "quantity"), true);
assert.equal(
preview.rows[0].differences.find((difference) => difference.field === "displayName")?.isOverridden,
true
);
});
it("synchronizes only explicitly selected fields", async () => {
const { service, rows } = createService();
await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
assert.equal(rows[0].quantity, 6);
assert.equal(rows[0].powerPerUnit, 0.04);
assert.equal(rows[0].displayName, "Local display name");
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName"]);
});
it("rejects rows that are not linked to the selected project device", async () => {
const { service } = createService();
await assert.rejects(
() => service.synchronize("p1", "pd1", ["other-row"], ["quantity"]),
/not linked/
);
});
it("disconnects selected rows without changing local values", async () => {
const { service, updates } = createService();
await service.disconnect("p1", "pd1", ["row1"]);
assert.equal(updates[0].linkedProjectDeviceId, undefined);
assert.equal(updates[0].displayName, "Local display name");
assert.equal(updates[0].quantity, 2);
});
});