From 6f8b292b6b3dfae3ab3702cb6995cc5af2b65f93 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 22 Jul 2026 19:38:21 +0200 Subject: [PATCH] Add explicit project device sync --- docs/circuit-list-editor-api.md | 12 + docs/circuit-list-editor-known-limitations.md | 3 +- package.json | 4 +- src/app/projects/[projectId]/page.tsx | 213 ++++++++++++++++++ .../circuit-device-row.repository.ts | 49 +++- src/domain/services/circuit-write.service.ts | 22 +- .../services/project-device-sync.service.ts | 170 ++++++++++++++ src/frontend/types.ts | 31 +++ src/frontend/utils/api.ts | 37 +++ .../controllers/project-device.controller.ts | 56 +++++ src/server/routes/project-device.routes.ts | 6 + .../constants/project-device-sync-fields.ts | 15 ++ .../validation/project-device.schemas.ts | 10 + tests/circuit-write.rules.test.ts | 30 +++ tests/project-device-sync.service.test.ts | 118 ++++++++++ 15 files changed, 771 insertions(+), 5 deletions(-) create mode 100644 src/domain/services/project-device-sync.service.ts create mode 100644 src/shared/constants/project-device-sync-fields.ts create mode 100644 tests/project-device-sync.service.test.ts diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 4e729ac..2c42938 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -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. diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 0bb0478..0f88c73 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -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. diff --git a/package.json b/package.json index ddbff13..80b5112 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index be23ef2..7e889b5 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -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 = { + 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(null); + const [selectedSyncRowIds, setSelectedSyncRowIds] = useState([]); + const [selectedSyncFields, setSelectedSyncFields] = useState([]); const [error, setError] = useState(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() { {device.totalPower}
+
+ {syncPreview ? ( +
+
+
+

Verknüpfte Zeilen: {syncPreview.projectDevice.displayName}

+

+ Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal. +

+
+ +
+ +
+ {projectDeviceSyncFields.map((field) => ( + + ))} +
+ +
+ + + + + + + + + + + {syncPreview.rows.map((row) => ( + + + + + + + ))} + {syncPreview.rows.length === 0 ? ( + + + + ) : null} + +
AuswahlVerteilung / StromkreisGerätezeileAbweichungen
+ toggleSyncRow(row.rowId)} + /> + + {row.distributionBoardName} +
+ {row.equipmentIdentifier} + {row.circuitDisplayName ? ` – ${row.circuitDisplayName}` : ""} +
+
{row.rowDisplayName} +
+ {row.differences.map((difference) => ( + + {projectDeviceSyncFieldLabels[difference.field]}: {String(difference.currentValue ?? "–")} →{" "} + {String(difference.sourceValue ?? "–")} + + ))} + {row.differences.length === 0 ? ( + Aktuell + ) : null} +
+
+ Keine verknüpften Circuit-First-Gerätezeilen vorhanden. +
+
+ +
+ + +
+
+ ) : null}
diff --git a/src/db/repositories/circuit-device-row.repository.ts b/src/db/repositories/circuit-device-row.repository.ts index ad94294..a8dceba 100644 --- a/src/db/repositories/circuit-device-row.repository.ts +++ b/src/db/repositories/circuit-device-row.repository.ts @@ -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; diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index eb718eb..7d28b46 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -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; + const currentValues = current as unknown as Record; + 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); } diff --git a/src/domain/services/project-device-sync.service.ts b/src/domain/services/project-device-sync.service.ts new file mode 100644 index 0000000..3c106c0 --- /dev/null +++ b/src/domain/services/project-device-sync.service.ts @@ -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>>; +type LinkedRow = Awaited>[number]; + +type SyncDependencies = { + projectDeviceRepository: Pick; + deviceRowRepository: Pick; +}; + +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): 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) { + 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, + }; + } +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 226c577..f3b1bae 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -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; +import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields"; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index d176e95..2bd49cf 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -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(url: string, init?: RequestInit): Promise { 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( + `/api/project-devices/projects/${projectId}/${projectDeviceId}/links` + ); +} + +export function synchronizeProjectDeviceRows( + projectId: string, + projectDeviceId: string, + rowIds: string[], + fields: ProjectDeviceSyncField[] +) { + return request( + `/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 }), + } + ); +} diff --git a/src/server/controllers/project-device.controller.ts b/src/server/controllers/project-device.controller.ts index 70c7642..ac6cd29 100644 --- a/src/server/controllers/project-device.controller.ts +++ b/src/server/controllers/project-device.controller.ts @@ -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." }); + } +} diff --git a/src/server/routes/project-device.routes.ts b/src/server/routes/project-device.routes.ts index a108bb2..97f84f2 100644 --- a/src/server/routes/project-device.routes.ts +++ b/src/server/routes/project-device.routes.ts @@ -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); diff --git a/src/shared/constants/project-device-sync-fields.ts b/src/shared/constants/project-device-sync-fields.ts new file mode 100644 index 0000000..ad76661 --- /dev/null +++ b/src/shared/constants/project-device-sync-fields.ts @@ -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]; diff --git a/src/shared/validation/project-device.schemas.ts b/src/shared/validation/project-device.schemas.ts index 3b8bf48..2692340 100644 --- a/src/shared/validation/project-device.schemas.ts +++ b/src/shared/validation/project-device.schemas.ts @@ -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; export type UpdateProjectDeviceInput = z.infer; diff --git a/tests/circuit-write.rules.test.ts b/tests/circuit-write.rules.test.ts index 1ff7591..9e28966 100644 --- a/tests/circuit-write.rules.test.ts +++ b/tests/circuit-write.rules.test.ts @@ -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"]); + }); }); diff --git a/tests/project-device-sync.service.test.ts b/tests/project-device-sync.service.test.ts new file mode 100644 index 0000000..0bcf7de --- /dev/null +++ b/tests/project-device-sync.service.test.ts @@ -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> = []; + 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); + }); +});