From 2eba4ea75e684df35c49a708a9bce35ef715ac72 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 25 Jul 2026 20:49:34 +0200 Subject: [PATCH] Persist circuit structure edits --- AGENTS.md | 17 +- docs/circuit-list-editor-api.md | 41 +- docs/circuit-list-editor-interactions.md | 14 +- docs/circuit-list-editor-known-limitations.md | 9 +- docs/current-architecture.md | 14 +- ...history-and-external-model-architecture.md | 22 +- docs/spec/07-implementation-phases-todo.md | 9 +- package.json | 4 +- src/domain/services/circuit-write.service.ts | 171 ----- .../components/circuit-tree-editor.tsx | 670 ++++++++++-------- src/frontend/utils/api.ts | 120 ++-- .../utils/circuit-structure-command.ts | 73 ++ .../circuit-device-row.controller.ts | 34 - src/server/controllers/circuit.controller.ts | 51 -- .../routes/circuit-device-row.routes.ts | 2 - src/server/routes/circuit.routes.ts | 9 - src/shared/validation/circuit.schemas.ts | 49 -- tests/circuit-structure-command.test.ts | 207 ++++++ tests/circuit-write.rules.test.ts | 257 ------- 19 files changed, 809 insertions(+), 964 deletions(-) create mode 100644 src/frontend/utils/circuit-structure-command.ts create mode 100644 tests/circuit-structure-command.test.ts diff --git a/AGENTS.md b/AGENTS.md index c432686..ad490c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,14 +220,15 @@ After saving, the row becomes linked to the new project device. ## Undo / Redo -The editor's visible undo/redo stack remains session-local while structural and -destructive operations are being migrated. Existing Circuit and -CircuitDeviceRow cell edits already execute persistent project commands and -their toolbar undo/redo actions use the project-wide history endpoints. The -tree response supplies the optimistic `currentRevision`; the obsolete direct -PATCH routes for these fields are removed. Reloading the page does not yet -reconstruct the visible editor stack. Public command, undo and redo endpoints -also exist for Circuit/CircuitDeviceRow insertion and deletion. +The editor's visible undo/redo stack remains session-local while move, reorder +and renumber operations are being migrated. Existing Circuit and +CircuitDeviceRow cell edits plus standalone insertion/deletion already execute +persistent project commands; their toolbar undo/redo actions use the +project-wide history endpoints. Insertions use client-generated stable UUIDs, +and deletion undo restores the same ids from complete server snapshots. The +tree response supplies the optimistic `currentRevision`; obsolete direct field +PATCH, structure POST and CircuitDeviceRow DELETE routes are removed. +Reloading the page does not yet reconstruct the visible editor stack. CircuitDeviceRow moves between existing circuits and moves that create one new placeholder target circuit are persisted. The latter stores the complete empty target snapshot so undo can restore the rows and remove only the unchanged diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 3c88b16..bca62cd 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -36,8 +36,9 @@ snapshots contain zero, one or multiple complete device rows. Move commands contain each row's expected and target circuit plus its exact expected and target sort order. This makes deletion and moves undoable without generating replacement identities or renumbering circuits. Existing Circuit and -CircuitDeviceRow cell edits in the editor consume these endpoints; the -remaining structural editor writes are still being migrated. +CircuitDeviceRow cell edits plus standalone insert/delete actions in the editor +consume these endpoints. Move, reorder and renumber UI paths are still being +migrated. `circuit-device-row.move` targets existing circuits in the same circuit list. `circuit-device-row.move-with-new-circuit` atomically creates exactly one @@ -163,39 +164,17 @@ Response sketch: } ``` -### Circuit CRUD +### Circuit Structure -- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits` - - create circuit in list/section -- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows` - - atomically create one circuit and one or more initial device rows -- `DELETE /circuits/:circuitId` - - delete circuit (and related rows via DB relations) - `GET /circuit-sections/:sectionId/next-identifier` - preview next identifier for section (`prefix + maxSuffix + 1`) -Request sketch (`POST .../circuits`): - -```json -{ - "sectionId": "sec_1", - "equipmentIdentifier": "-2F14", - "displayName": "Sockets East", - "sortOrder": 140 -} -``` - -### Device Row CRUD - -- `POST /circuits/:circuitId/device-rows` - - create row inside a circuit -- `DELETE /circuit-device-rows/:rowId` - - delete row - -Circuit and device-row field updates are available only as versioned -`circuit.update` and `circuit-device-row.update` commands through -`POST /projects/:projectId/commands`. There are no direct field-update PATCH -routes. +Circuit and device-row field updates, standalone insertions and standalone +deletions are available only as the corresponding versioned commands through +`POST /projects/:projectId/commands`. There are no direct field-update PATCH, +structure POST or CircuitDeviceRow DELETE routes. The direct +`DELETE /circuits/:circuitId` route remains temporarily for the inverse of the +not-yet-migrated placeholder move UI and must not be used by new features. ### Move Device Row diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index 3e04d8e..7e93c83 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -123,10 +123,12 @@ Cross-section device moves show confirmation-required feedback before drop. The ## Undo/Redo Undo/redo wraps editor operations as command objects and reloads the tree after -each command. Existing Circuit and device-row cell edits execute persistent -project commands; their toolbar undo/redo calls the project-wide server -history. Other covered operations still use their existing direct API writes -and session-local inverse callbacks during the cutover. +each command. Existing Circuit and device-row cell edits plus standalone +insert/delete actions execute persistent project commands; their toolbar +undo/redo calls the project-wide server history. New entities receive stable +UUIDs before insertion, and deletion undo restores those same ids. Move, +reorder and renumber operations still use their existing direct API writes and +session-local inverse callbacks during the cutover. Covered operations include: @@ -140,5 +142,5 @@ Covered operations include: Current limitations: - the visible editor stack is still session-local and is empty after reload -- structural and destructive editor actions are not all connected to the - persistent command boundary yet +- move, reorder and renumber editor actions are not connected to the persistent + command boundary yet diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 5da1990..1754773 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -1,9 +1,10 @@ # Circuit List Editor Known Limitations -- Existing Circuit and CircuitDeviceRow cell edits use persistent project - commands and project-wide toolbar undo/redo. The editor's visible stack is - still session-local, is empty after reload and still contains direct - structural/destructive operations during the cutover. +- Existing Circuit and CircuitDeviceRow cell edits plus standalone + insert/delete actions use persistent project commands and project-wide + toolbar undo/redo. The editor's visible stack is still session-local, is + empty after reload and still contains direct move/reorder/renumber operations + during the cutover. - Server-side project revisions and persistent undo/redo eligibility exist for the command types documented in `circuit-list-editor-api.md`; complete editor command coverage and history reconstruction are not complete. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index b07bcac..89328b0 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -87,12 +87,14 @@ wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block. Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen -verwendet das Grid bereits über die öffentliche Command-Grenze. Der Tree liefert -dazu `currentRevision`; Undo/Redo für diese Zellaktionen läuft über die -projektweite Serverhistorie. Die direkten PATCH-Endpunkte für diese Felder sind -entfernt. Andere Editoraktionen verbleiben vorerst im sichtbaren -sitzungslokalen Stack; nach einem Reload wird dieser noch nicht aus der -Serverhistorie rekonstruiert. +sowie eigenständiges Einfügen und Löschen verwendet das Grid bereits über die +öffentliche Command-Grenze. Neue Circuits und Gerätezeilen erhalten ihre stabile +UUID vor dem Command; Löschen und Undo bewahren diese Identität. Der Tree liefert +dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektweite +Serverhistorie. Direkte Feld-PATCH-, Struktur-POST- und Gerätezeilen-DELETE- +Endpunkte sind entfernt. Move-, Reorder- und Renumber-UI-Pfade verbleiben +vorerst im sichtbaren sitzungslokalen Stack; nach einem Reload wird dieser noch +nicht aus der Serverhistorie rekonstruiert. `circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 0e0c4e7..a8c49f2 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -184,6 +184,9 @@ Completed foundation: - `circuit.insert` and `circuit.delete` persist a complete circuit block with zero, one or multiple device rows; undo restores all ids and fields without renumbering +- standalone Circuit/CircuitDeviceRow insert/delete UI paths use these commands + with client-generated stable UUIDs; obsolete direct structure POST and + CircuitDeviceRow DELETE routes are removed - `circuit-device-row.move` stores exact expected and target circuit/sort positions for one or multiple rows, recalculates all affected reserve states and reconstructs a deterministic inverse across multiple source circuits @@ -208,17 +211,14 @@ Completed foundation: Remaining constraints before completing persistent history: -- extend the concrete project-scoped command union and executors beyond the - Circuit field/insert/delete and CircuitDeviceRow - field/insert/delete/move, section-reorder/renumber and ProjectDevice - field/insert/delete/row-sync slices; the generic versioned command envelope - is complete -- route the remaining structural and destructive domain - writes through the shared command, revision and stack transaction boundary -- finish routing structural and destructive circuit-editor operations through - persistent commands, then reconstruct the visible frontend history state - after reload; Circuit/CircuitDeviceRow cell edits and ProjectDevice - synchronization already use persistent commands and project-wide undo +- route the remaining move, reorder and renumber editor writes through their + existing typed persistent commands and remove the final direct mutation + routes +- reconstruct the visible frontend history state after reload; Circuit and + CircuitDeviceRow field/insert/delete actions plus ProjectDevice operations + already use persistent commands and project-wide undo +- extend project-scoped commands only when further project mutations are + deliberately added to history; the generic versioned envelope is complete - do not model IFCGUID as an overloaded circuit equipment identifier - keep database backup/restore checks separate from project history tests diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index ab6774d..2c528cf 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -398,6 +398,9 @@ Implemented foundation: stack transition commit or roll back together - typed Circuit insert/delete commands treat the circuit and all device rows as one block, preserve every stable id and never renumber neighbouring circuits +- standalone Circuit/CircuitDeviceRow insert/delete editor paths use those + commands with stable client-generated UUIDs and project-wide undo/redo; the + obsolete direct structure POST and CircuitDeviceRow DELETE routes are removed - typed CircuitDeviceRow move commands persist exact old/new circuit and sort positions for single or multi-row moves between existing circuits and derive affected reserve states in the same transaction @@ -428,9 +431,9 @@ Implemented foundation: and rollback after a forced late persistence failure ProjectDevice CRUD, synchronization and disconnect on the project page and -existing Circuit/CircuitDeviceRow cell edits in the circuit-list editor are -connected to this history boundary. Remaining structural editor writes are not -fully connected; the editor's visible undo/redo stack therefore remains +Circuit/CircuitDeviceRow field/insert/delete actions in the circuit-list editor +are connected to this history boundary. Move, reorder and renumber UI paths are +not yet connected; the editor's visible undo/redo stack therefore remains session-local and is not reconstructed after reload. Acceptance criteria: diff --git a/package.json b/package.json index 49202e9..0669604 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.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 tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.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 tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.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 tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.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 tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index 0cf7f25..4dd33bf 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -1,14 +1,9 @@ import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js"; import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js"; import type { CircuitSectionTransactionStore } from "../ports/circuit-section-transaction.store.js"; -import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; -import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; import type { - CreateCircuitDeviceRowInput, - CreateCircuitInput, - CreateCircuitWithDeviceRowsInput, MoveCircuitDeviceRowInput, MoveCircuitDeviceRowsBulkInput, ReorderSectionCircuitsInput, @@ -19,30 +14,24 @@ import { CircuitNumberingService } from "./circuit-numbering.service.js"; export class CircuitWriteService { private readonly circuitRepository: CircuitRepository; private readonly circuitSectionRepository: CircuitSectionRepository; - private readonly circuitListRepository: CircuitListRepository; private readonly deviceRowRepository: CircuitDeviceRowRepository; private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore; private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore; - private readonly projectDeviceRepository: ProjectDeviceRepository; private readonly numberingService: CircuitNumberingService; constructor(deps?: { circuitRepository?: CircuitRepository; circuitSectionRepository?: CircuitSectionRepository; - circuitListRepository?: CircuitListRepository; deviceRowRepository?: CircuitDeviceRowRepository; deviceRowTransactionStore?: CircuitDeviceRowTransactionStore; circuitSectionTransactionStore?: CircuitSectionTransactionStore; - projectDeviceRepository?: ProjectDeviceRepository; numberingService?: CircuitNumberingService; }) { this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository(); this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository(); - this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository(); this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository(); this.deviceRowTransactionStore = deps?.deviceRowTransactionStore; this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore; - this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository(); this.numberingService = deps?.numberingService ?? new CircuitNumberingService(); } @@ -58,36 +47,6 @@ export class CircuitWriteService { return section; } - // Enforces BMK uniqueness inside one circuit list. - private async assertUniqueEquipmentIdentifier( - circuitListId: string, - equipmentIdentifier: string, - excludeCircuitId?: string - ) { - const exists = await this.circuitRepository.existsByEquipmentIdentifier( - circuitListId, - equipmentIdentifier, - excludeCircuitId - ); - if (exists) { - throw new Error("Duplicate equipmentIdentifier in circuit list."); - } - } - - // Validates linked project-device id against owning project of the circuit list. - private async assertValidLinkedProjectDeviceForProject( - projectId: string, - linkedProjectDeviceId?: string - ) { - if (!linkedProjectDeviceId) { - return; - } - const device = await this.projectDeviceRepository.findById(projectId, linkedProjectDeviceId); - if (!device) { - throw new Error("Invalid linked project device id."); - } - } - private getDeviceRowTransactionStore() { if (!this.deviceRowTransactionStore) { throw new Error("Circuit device-row transactions are not configured."); @@ -102,92 +61,6 @@ export class CircuitWriteService { return this.circuitSectionTransactionStore; } - private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) { - if (!linkedProjectDeviceId) { - return; - } - const circuit = await this.circuitRepository.findById(circuitId); - if (!circuit) { - throw new Error("Invalid circuit id."); - } - const list = await this.circuitListRepository.findByIdByListIdOnly(circuit.circuitListId); - if (!list) { - throw new Error("Circuit list not found."); - } - await this.assertValidLinkedProjectDeviceForProject(list.projectId, linkedProjectDeviceId); - } - - async createCircuit(projectId: string, circuitListId: string, input: CreateCircuitInput) { - const list = await this.circuitListRepository.findById(projectId, circuitListId); - if (!list) { - throw new Error("Circuit list not found in project."); - } - await this.assertSectionInList(input.sectionId, circuitListId); - await this.assertUniqueEquipmentIdentifier(circuitListId, input.equipmentIdentifier); - const id = await this.circuitRepository.create({ - circuitListId, - sectionId: input.sectionId, - equipmentIdentifier: input.equipmentIdentifier, - displayName: input.displayName, - sortOrder: input.sortOrder, - protectionType: input.protectionType, - protectionRatedCurrent: input.protectionRatedCurrent, - protectionCharacteristic: input.protectionCharacteristic, - cableType: input.cableType, - cableCrossSection: input.cableCrossSection, - cableLength: input.cableLength, - rcdAssignment: input.rcdAssignment, - terminalDesignation: input.terminalDesignation, - voltage: input.voltage, - controlRequirement: input.controlRequirement, - status: input.status, - isReserve: input.isReserve ?? true, - remark: input.remark, - }); - return this.circuitRepository.findById(id); - } - - async createCircuitWithDeviceRows( - projectId: string, - circuitListId: string, - input: CreateCircuitWithDeviceRowsInput - ) { - const list = await this.circuitListRepository.findById(projectId, circuitListId); - if (!list) { - throw new Error("Circuit list not found in project."); - } - await this.assertSectionInList(input.circuit.sectionId, circuitListId); - await this.assertUniqueEquipmentIdentifier( - circuitListId, - input.circuit.equipmentIdentifier - ); - for (const row of input.deviceRows) { - await this.assertValidLinkedProjectDeviceForProject( - list.projectId, - row.linkedProjectDeviceId - ); - } - - const created = this.getDeviceRowTransactionStore().createCircuitWithDeviceRows({ - circuit: { - circuitListId, - ...input.circuit, - }, - deviceRows: input.deviceRows, - }); - const circuit = await this.circuitRepository.findById(created.circuitId); - const deviceRows = await Promise.all( - created.rowIds.map((rowId) => this.deviceRowRepository.findById(rowId)) - ); - if (!circuit || deviceRows.some((row) => !row)) { - throw new Error("Der angelegte Stromkreis konnte nicht geladen werden."); - } - return { - circuit, - deviceRows, - }; - } - async deleteCircuit(circuitId: string) { const current = await this.circuitRepository.findById(circuitId); if (!current) { @@ -196,50 +69,6 @@ export class CircuitWriteService { await this.circuitRepository.delete(circuitId); } - async createDeviceRow(circuitId: string, input: CreateCircuitDeviceRowInput) { - const circuit = await this.circuitRepository.findById(circuitId); - if (!circuit) { - throw new Error("Invalid circuit id."); - } - await this.assertValidLinkedProjectDevice(circuitId, input.linkedProjectDeviceId); - - const rowId = this.getDeviceRowTransactionStore().createInCircuit({ - circuitId, - linkedProjectDeviceId: input.linkedProjectDeviceId, - sortOrder: input.sortOrder, - name: input.name, - displayName: input.displayName, - phaseType: input.phaseType, - connectionKind: input.connectionKind, - costGroup: input.costGroup, - category: input.category, - level: input.level, - roomId: input.roomId, - roomNumberSnapshot: input.roomNumberSnapshot, - roomNameSnapshot: input.roomNameSnapshot, - quantity: input.quantity, - powerPerUnit: input.powerPerUnit, - simultaneityFactor: input.simultaneityFactor, - cosPhi: input.cosPhi, - remark: input.remark, - overriddenFields: input.overriddenFields, - }); - - return this.deviceRowRepository.findById(rowId); - } - - async deleteDeviceRow(rowId: string) { - const current = await this.deviceRowRepository.findById(rowId); - if (!current) { - throw new Error("Invalid device row id."); - } - const circuit = await this.circuitRepository.findById(current.circuitId); - if (!circuit) { - throw new Error("Invalid circuit id."); - } - this.getDeviceRowTransactionStore().deleteFromCircuit(rowId, circuit.id); - } - async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) { const row = await this.deviceRowRepository.findById(rowId); if (!row) { diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 866dd4b..40f2dd8 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -23,6 +23,10 @@ import { deviceFieldKeys, formatValue, } from "../utils/circuit-grid-model"; +import { + buildCircuitDeviceRowInsertSnapshot, + buildCircuitInsertSnapshot, +} from "../utils/circuit-structure-command"; import type { CellKey, CellKind, @@ -36,13 +40,13 @@ import { } from "../utils/circuit-grid-projection"; import type { VisibleGridRow } from "../utils/circuit-grid-projection"; import { - createCircuit, - createCircuitWithDeviceRows, - createCircuitDeviceRow, deleteCircuitById, - deleteCircuitDeviceRowById, + deleteCircuitCommand, + deleteCircuitDeviceRowCommand, getCircuitTree, getNextCircuitIdentifier, + insertCircuitCommand, + insertCircuitDeviceRowCommand, listProjectDevices, moveCircuitDeviceRowsBulk, moveCircuitDeviceRowById, @@ -56,11 +60,18 @@ import { } from "../utils/api"; import type { CircuitTreeCircuitDto, - CircuitTreeDeviceRowDto, CircuitTreeResponseDto, + CreateCircuitDeviceRowInputDto, + CreateCircuitInputDto, ProjectCommandResultDto, ProjectDeviceDto, } from "../types"; +import type { + CircuitDeviceRowSnapshot, +} from "../../domain/models/circuit-device-row-structure-project-command.model"; +import type { + CircuitSnapshot, +} from "../../domain/models/circuit-structure-project-command.model"; type SaveDirection = "stay" | "next" | "prev"; type StartEditMode = "selectExisting" | "replaceWithTypedChar"; @@ -96,28 +107,6 @@ interface HistoryCommand { }; } -interface CircuitSnapshot { - id: string; - sectionId: string; - equipmentIdentifier: string; - displayName?: string; - sortOrder: number; - protectionType?: string; - protectionRatedCurrent?: number; - protectionCharacteristic?: string; - cableType?: string; - cableCrossSection?: string; - cableLength?: number; - rcdAssignment?: string; - terminalDesignation?: string; - voltage?: number; - controlRequirement?: string; - status?: string; - isReserve: boolean; - remark?: string; - deviceRows: CircuitTreeDeviceRowDto[]; -} - type ProjectDeviceDropIntent = | { kind: "new-circuit"; sectionId: string; valid: boolean } | { kind: "add-to-circuit"; circuitId: string; sectionId: string; valid: boolean }; @@ -805,6 +794,90 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ); } + function createDeviceRowSnapshot( + circuitId: string, + values: CreateCircuitDeviceRowInputDto, + defaultSortOrder: number + ) { + return buildCircuitDeviceRowInsertSnapshot({ + id: crypto.randomUUID(), + circuitId, + values, + defaultSortOrder, + }); + } + + function createCircuitSnapshot( + values: CreateCircuitInputDto, + deviceRowValues: CreateCircuitDeviceRowInputDto[] = [] + ) { + const circuitId = crypto.randomUUID(); + const deviceRows = deviceRowValues.map((row, index) => + createDeviceRowSnapshot(circuitId, row, (index + 1) * 10) + ); + return buildCircuitInsertSnapshot({ + id: circuitId, + circuitListId, + values, + deviceRows, + }); + } + + async function persistCircuitInsert( + circuit: CircuitSnapshot, + description: string + ) { + const result = await insertCircuitCommand( + projectId, + getExpectedProjectRevision(), + circuit, + description + ); + applyProjectCommandResult(result); + } + + async function persistCircuitDelete( + circuitId: string, + description: string + ) { + const result = await deleteCircuitCommand( + projectId, + getExpectedProjectRevision(), + circuitId, + circuitListId, + description + ); + applyProjectCommandResult(result); + } + + async function persistDeviceRowInsert( + row: CircuitDeviceRowSnapshot, + description: string + ) { + const result = await insertCircuitDeviceRowCommand( + projectId, + getExpectedProjectRevision(), + row, + description + ); + applyProjectCommandResult(result); + } + + async function persistDeviceRowDelete( + rowId: string, + circuitId: string, + description: string + ) { + const result = await deleteCircuitDeviceRowCommand( + projectId, + getExpectedProjectRevision(), + rowId, + circuitId, + description + ); + applyProjectCommandResult(result); + } + // Runs a normal command and records it in session-local history. async function runCommand(command: HistoryCommand) { try { @@ -1247,15 +1320,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const editValues = createValuesFromEditPatch( buildDeviceRowEditPatch(key, draft) ); - const created = await createCircuitWithDeviceRows(projectId, circuitListId, { - circuit: { + const circuit = createCircuitSnapshot( + { sectionId, equipmentIdentifier: next.nextIdentifier, displayName: "Neuer Stromkreis", sortOrder, isReserve: false, }, - deviceRows: [ + [ { name: "Manuelles Gerät", displayName: "Manuelles Gerät", @@ -1266,24 +1339,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str cosPhi: 1, ...editValues, }, - ], - }); - const createdCircuit = created.circuit; - return { rowKey: `circuitCompact:${createdCircuit.id}`, cellKey: key }; + ] + ); + await persistCircuitInsert(circuit, "Aus freier Zeile erstellen"); + return { rowKey: `circuitCompact:${circuit.id}`, cellKey: key }; } const editValues = createValuesFromEditPatch( buildCircuitEditPatch(key, draft) ); - const createdCircuit = (await createCircuit(projectId, circuitListId, { + const circuit = createCircuitSnapshot({ sectionId, equipmentIdentifier: next.nextIdentifier, displayName: "Neuer Stromkreis", sortOrder, isReserve: true, ...editValues, - })) as CircuitTreeCircuitDto; - return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key }; + }); + await persistCircuitInsert(circuit, "Aus freier Zeile erstellen"); + return { rowKey: `reserveCircuit:${circuit.id}`, cellKey: key }; } // Commits the active editor input through command history so edits remain undoable. @@ -1337,11 +1411,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return nextSelectionIntent(); }, undo: async () => { - if (!createdCircuitId) { - return null; - } - await deleteCircuitById(createdCircuitId); - return buildSelectionIntent({ rowKey: `placeholder:${row.sectionId}`, cellKey: editingCell.cellKey }); + return { + rowKey: `placeholder:${row.sectionId}`, + cellKey: editingCell.cellKey, + rowType: "placeholder", + sectionId: row.sectionId, + }; + }, + persistent: { + undoIntent: () => ({ + rowKey: `placeholder:${row.sectionId}`, + cellKey: editingCell.cellKey, + rowType: "placeholder", + sectionId: row.sectionId, + }), + redoIntent: () => + createdCircuitId + ? { + rowKey: targetSelection.rowKey, + cellKey: editingCell.cellKey, + rowType: targetSelection.rowKey.startsWith("circuitCompact:") + ? "circuitCompact" + : "reserveCircuit", + sectionId: row.sectionId, + circuitId: createdCircuitId, + } + : null, }, }; setEditingCell(null); @@ -1412,26 +1507,54 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const editValues = createValuesFromEditPatch( buildDeviceRowEditPatch(editingCell.cellKey, next) ); - const created = (await createCircuitDeviceRow(row.circuit!.id, { - name: "Reserveverbraucher", - displayName: "Reserveverbraucher", - phaseType: "single_phase", - quantity: 1, - powerPerUnit: 0, - simultaneityFactor: 1, - cosPhi: 1, - ...editValues, - })) as { id: string }; - createdRowId = created.id; + const snapshot = createDeviceRowSnapshot( + row.circuit!.id, + { + name: "Reserveverbraucher", + displayName: "Reserveverbraucher", + phaseType: "single_phase", + quantity: 1, + powerPerUnit: 0, + simultaneityFactor: 1, + cosPhi: 1, + ...editValues, + }, + 10 + ); + await persistDeviceRowInsert( + snapshot, + "Gerätezeile im Reservestromkreis erstellen" + ); + createdRowId = snapshot.id; targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey }; return nextSelectionIntent(); }, - undo: async () => { - if (!createdRowId) { - return null; - } - await deleteCircuitDeviceRowById(createdRowId); - return buildSelectionIntent({ rowKey: `reserveCircuit:${row.circuit!.id}`, cellKey: editingCell.cellKey }); + undo: async () => ({ + rowKey: `reserveCircuit:${row.circuit!.id}`, + cellKey: editingCell.cellKey, + rowType: "reserveCircuit", + sectionId: row.sectionId, + circuitId: row.circuit!.id, + }), + persistent: { + undoIntent: () => ({ + rowKey: `reserveCircuit:${row.circuit!.id}`, + cellKey: editingCell.cellKey, + rowType: "reserveCircuit", + sectionId: row.sectionId, + circuitId: row.circuit!.id, + }), + redoIntent: () => + createdRowId + ? { + rowKey: `circuitCompact:${row.circuit!.id}`, + cellKey: editingCell.cellKey, + rowType: "circuitCompact", + sectionId: row.sectionId, + circuitId: row.circuit!.id, + deviceId: createdRowId, + } + : null, }, }; setEditingCell(null); @@ -1460,33 +1583,47 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str redo: async () => { const next = await getNextCircuitIdentifier(sectionId); const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId); - const created = (await createCircuit(projectId, circuitListId, { + const circuit = createCircuitSnapshot({ sectionId, equipmentIdentifier: next.nextIdentifier, displayName: "Reserve", sortOrder, isReserve: true, - })) as CircuitTreeCircuitDto; - createdCircuitId = created.id; + }); + await persistCircuitInsert(circuit, "Stromkreis hinzufügen"); + createdCircuitId = circuit.id; setActiveSectionId(sectionId); return { - rowKey: `reserveCircuit:${created.id}`, + rowKey: `reserveCircuit:${circuit.id}`, cellKey: "equipmentIdentifier", rowType: "reserveCircuit", sectionId, - circuitId: created.id, + circuitId: circuit.id, }; }, - undo: async () => { - if (createdCircuitId) { - await deleteCircuitById(createdCircuitId); - } - return { + undo: async () => ({ + rowKey: `placeholder:${sectionId}`, + cellKey: "equipmentIdentifier", + rowType: "placeholder", + sectionId, + }), + persistent: { + undoIntent: () => ({ rowKey: `placeholder:${sectionId}`, cellKey: "equipmentIdentifier", rowType: "placeholder", sectionId, - }; + }), + redoIntent: () => + createdCircuitId + ? { + rowKey: `reserveCircuit:${createdCircuitId}`, + cellKey: "equipmentIdentifier", + rowType: "reserveCircuit", + sectionId, + circuitId: createdCircuitId, + } + : null, }, }); } @@ -1499,10 +1636,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const originalDeviceCount = circuit.deviceRows.length; const section = data?.sections.find((entry) => entry.id === sectionId); let createdRowId: string | null = null; + const getUndoIntent = (): SelectionIntent => { + const rowKey = + originalDeviceCount === 0 + ? `reserveCircuit:${circuit.id}` + : originalDeviceCount === 1 + ? `circuitCompact:${circuit.id}` + : afterDeviceRowId + ? `device:${afterDeviceRowId}` + : `circuitSummary:${circuit.id}`; + return { + rowKey, + cellKey: "displayName", + rowType: + originalDeviceCount === 0 + ? "reserveCircuit" + : originalDeviceCount === 1 + ? "circuitCompact" + : afterDeviceRowId + ? "deviceRow" + : "circuitSummary", + sectionId, + circuitId: circuit.id, + deviceId: afterDeviceRowId, + }; + }; await runCommand({ label: "Manuelles Gerät hinzufügen", redo: async () => { - const created = (await createCircuitDeviceRow(circuit.id, { + const rowSnapshot = createDeviceRowSnapshot(circuit.id, { name: "Manuelles Gerät", displayName: "Manuelles Gerät", phaseType: section?.key === "three_phase" ? "three_phase" : "single_phase", @@ -1511,45 +1673,42 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str simultaneityFactor: 1, cosPhi: 1, sortOrder: getInsertionSortOrder(circuit.deviceRows, afterDeviceRowId), - })) as { id: string }; - createdRowId = created.id; + }, 10); + await persistDeviceRowInsert( + rowSnapshot, + "Manuelles Gerät hinzufügen" + ); + createdRowId = rowSnapshot.id; setActiveSectionId(sectionId); return { - rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${created.id}`, + rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${rowSnapshot.id}`, cellKey: "displayName", rowType: originalDeviceCount === 0 ? "circuitCompact" : "deviceRow", sectionId, circuitId: circuit.id, - deviceId: created.id, + deviceId: rowSnapshot.id, }; }, - undo: async () => { - if (createdRowId) { - await deleteCircuitDeviceRowById(createdRowId); - } - const rowKey = - originalDeviceCount === 0 - ? `reserveCircuit:${circuit.id}` - : originalDeviceCount === 1 - ? `circuitCompact:${circuit.id}` - : afterDeviceRowId - ? `device:${afterDeviceRowId}` - : `circuitSummary:${circuit.id}`; - return { - rowKey, - cellKey: "displayName", - rowType: - originalDeviceCount === 0 - ? "reserveCircuit" - : originalDeviceCount === 1 - ? "circuitCompact" - : afterDeviceRowId - ? "deviceRow" - : "circuitSummary", - sectionId, - circuitId: circuit.id, - deviceId: afterDeviceRowId, - }; + undo: async () => getUndoIntent(), + persistent: { + undoIntent: getUndoIntent, + redoIntent: () => + createdRowId + ? { + rowKey: + originalDeviceCount === 0 + ? `circuitCompact:${circuit.id}` + : `device:${createdRowId}`, + cellKey: "displayName", + rowType: + originalDeviceCount === 0 + ? "circuitCompact" + : "deviceRow", + sectionId, + circuitId: circuit.id, + deviceId: createdRowId, + } + : null, }, }); } @@ -1646,16 +1805,30 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str deviceId: created.rowId, }; }, - undo: async () => { - if (createdCircuitId) { - await deleteCircuitById(createdCircuitId); - } - return { + undo: async () => ({ + rowKey: `placeholder:${targetSectionId}`, + cellKey: "displayName", + rowType: "placeholder", + sectionId: targetSectionId, + }), + persistent: { + undoIntent: () => ({ rowKey: `placeholder:${targetSectionId}`, cellKey: "displayName", rowType: "placeholder", sectionId: targetSectionId, - }; + }), + redoIntent: () => + createdCircuitId && createdRowId + ? { + rowKey: `device:${createdRowId}`, + cellKey: "displayName", + rowType: "deviceRow", + sectionId: targetSectionId, + circuitId: createdCircuitId, + deviceId: createdRowId, + } + : null, }, }); } @@ -1698,17 +1871,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str deviceId: created.rowId, }; }, - undo: async () => { - if (createdRowId) { - await deleteCircuitDeviceRowById(createdRowId); - } - return { + undo: async () => ({ + rowKey: `circuitSummary:${circuitId}`, + cellKey: "displayName", + rowType: "circuitSummary", + sectionId, + circuitId, + }), + persistent: { + undoIntent: () => ({ rowKey: `circuitSummary:${circuitId}`, cellKey: "displayName", rowType: "circuitSummary", - sectionId: findCircuitSectionId(circuitId) ?? "", + sectionId, circuitId, - }; + }), + redoIntent: () => + createdRowId + ? { + rowKey: `device:${createdRowId}`, + cellKey: "displayName", + rowType: "deviceRow", + sectionId, + circuitId, + deviceId: createdRowId, + } + : null, }, }); } @@ -1721,15 +1909,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const next = await getNextCircuitIdentifier(sectionId); const sortOrder = section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; - const created = await createCircuitWithDeviceRows(projectId, circuitListId, { - circuit: { + const circuit = createCircuitSnapshot( + { sectionId, equipmentIdentifier: next.nextIdentifier, displayName: device.displayName || device.name, sortOrder, isReserve: false, }, - deviceRows: [ + [ { linkedProjectDeviceId: device.id, name: device.name, @@ -1744,125 +1932,47 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str category: device.category ?? undefined, remark: device.remark ?? undefined, }, - ], - }); - const createdCircuit = created.circuit; - const createdRow = created.deviceRows[0]; + ] + ); + await persistCircuitInsert( + circuit, + "Projektgerät als neuen Stromkreis hinzufügen" + ); + const createdRow = circuit.deviceRows[0]; setActiveSectionId(sectionId); - setTargetCircuitId(createdCircuit.id); - return { circuitId: createdCircuit.id, rowId: createdRow.id }; + setTargetCircuitId(circuit.id); + return { circuitId: circuit.id, rowId: createdRow.id }; } async function insertProjectDeviceToCircuit(device: ProjectDeviceDto, circuitId: string) { - const createdRow = (await createCircuitDeviceRow(circuitId, { - linkedProjectDeviceId: device.id, - name: device.name, - displayName: device.displayName, - phaseType: resolvePhaseType(device), - connectionKind: device.connectionKind ?? undefined, - costGroup: device.costGroup ?? undefined, - quantity: device.quantity, - powerPerUnit: device.powerPerUnit, - simultaneityFactor: device.simultaneityFactor, - cosPhi: device.cosPhi ?? undefined, - category: device.category ?? undefined, - remark: device.remark ?? undefined, - })) as { id: string }; - return { rowId: createdRow.id }; - } - - function getCircuitSnapshot(circuitId: string): CircuitSnapshot | null { - for (const section of data?.sections ?? []) { - const found = section.circuits.find((entry) => entry.id === circuitId); - if (!found) { - continue; - } - return { - id: found.id, - sectionId: found.sectionId, - equipmentIdentifier: found.equipmentIdentifier, - displayName: found.displayName ?? undefined, - sortOrder: found.sortOrder, - protectionType: found.protectionType ?? undefined, - protectionRatedCurrent: found.protectionRatedCurrent ?? undefined, - protectionCharacteristic: found.protectionCharacteristic ?? undefined, - cableType: found.cableType ?? undefined, - cableCrossSection: found.cableCrossSection ?? undefined, - cableLength: found.cableLength ?? undefined, - rcdAssignment: found.rcdAssignment ?? undefined, - terminalDesignation: found.terminalDesignation ?? undefined, - voltage: found.voltage ?? undefined, - controlRequirement: found.controlRequirement ?? undefined, - status: found.status ?? undefined, - isReserve: found.isReserve, - remark: found.remark ?? undefined, - deviceRows: found.deviceRows.map((row) => ({ ...row })), - }; + const circuit = allCircuits.find((entry) => entry.id === circuitId); + if (!circuit) { + throw new Error("Der Zielstromkreis ist ungültig."); } - return null; - } - - async function recreateCircuit(snapshot: CircuitSnapshot) { - const circuitInput = { - sectionId: snapshot.sectionId, - equipmentIdentifier: snapshot.equipmentIdentifier, - displayName: snapshot.displayName, - sortOrder: snapshot.sortOrder, - protectionType: snapshot.protectionType, - protectionRatedCurrent: snapshot.protectionRatedCurrent, - protectionCharacteristic: snapshot.protectionCharacteristic, - cableType: snapshot.cableType, - cableCrossSection: snapshot.cableCrossSection, - cableLength: snapshot.cableLength, - rcdAssignment: snapshot.rcdAssignment, - terminalDesignation: snapshot.terminalDesignation, - voltage: snapshot.voltage, - controlRequirement: snapshot.controlRequirement, - status: snapshot.status, - isReserve: snapshot.isReserve, - remark: snapshot.remark, - }; - - if (snapshot.deviceRows.length === 0) { - const createdCircuit = (await createCircuit( - projectId, - circuitListId, - circuitInput - )) as CircuitTreeCircuitDto; - return { circuitId: createdCircuit.id, rowIds: [] }; - } - - const created = await createCircuitWithDeviceRows(projectId, circuitListId, { - circuit: { - ...circuitInput, - isReserve: false, + const row = createDeviceRowSnapshot( + circuitId, + { + linkedProjectDeviceId: device.id, + name: device.name, + displayName: device.displayName, + phaseType: resolvePhaseType(device), + connectionKind: device.connectionKind ?? undefined, + costGroup: device.costGroup ?? undefined, + quantity: device.quantity, + powerPerUnit: device.powerPerUnit, + simultaneityFactor: device.simultaneityFactor, + cosPhi: device.cosPhi ?? undefined, + category: device.category ?? undefined, + remark: device.remark ?? undefined, }, - deviceRows: snapshot.deviceRows.map((row) => ({ - linkedProjectDeviceId: row.linkedProjectDeviceId, - name: row.name, - displayName: row.displayName, - phaseType: row.phaseType, - connectionKind: row.connectionKind, - costGroup: row.costGroup, - category: row.category, - level: row.level, - roomId: row.roomId, - roomNumberSnapshot: row.roomNumberSnapshot, - roomNameSnapshot: row.roomNameSnapshot, - quantity: row.quantity, - powerPerUnit: row.powerPerUnit, - simultaneityFactor: row.simultaneityFactor, - cosPhi: row.cosPhi, - remark: row.remark, - overriddenFields: row.overriddenFields, - sortOrder: row.sortOrder, - })), - }); - return { - circuitId: created.circuit.id, - rowIds: created.deviceRows.map((row) => row.id), - }; + getInsertionSortOrder(circuit.deviceRows) + ); + await persistDeviceRowInsert( + row, + "Projektgerät zum Stromkreis hinzufügen" + ); + return { rowId: row.id }; } function parseDraggedProjectDeviceId(event: DragEvent) { @@ -2053,11 +2163,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // it can create new rows/circuits instead of moving existing ones. if (intent.kind === "new-circuit") { let createdCircuitId: string | null = null; + let createdRowId: string | null = null; await runCommand({ label: "Projektgerät in neuen Stromkreis ziehen", redo: async () => { const created = await insertProjectDeviceAsNewCircuit(device, intent.sectionId); createdCircuitId = created.circuitId; + createdRowId = created.rowId; return { rowKey: `device:${created.rowId}`, cellKey: "displayName", @@ -2067,11 +2179,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str deviceId: created.rowId, }; }, - undo: async () => { - if (createdCircuitId) { - await deleteCircuitById(createdCircuitId); - } - return null; + undo: async () => null, + persistent: { + undoIntent: () => null, + redoIntent: () => + createdCircuitId && createdRowId + ? { + rowKey: `device:${createdRowId}`, + cellKey: "displayName", + rowType: "deviceRow", + sectionId: intent.sectionId, + circuitId: createdCircuitId, + deviceId: createdRowId, + } + : null, }, }); return; @@ -2091,11 +2212,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str deviceId: created.rowId, }; }, - undo: async () => { - if (createdRowId) { - await deleteCircuitDeviceRowById(createdRowId); - } - return null; + undo: async () => null, + persistent: { + undoIntent: () => null, + redoIntent: () => + createdRowId + ? { + rowKey: `device:${createdRowId}`, + cellKey: "displayName", + rowType: "deviceRow", + sectionId: intent.sectionId, + circuitId: intent.circuitId, + deviceId: createdRowId, + } + : null, }, }); } @@ -2259,10 +2389,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } - // Delete undo recreates the row from captured snapshot because delete is destructive. + // Persistent delete commands preserve the complete row and its stable id. async function handleDeleteDevice(rowId: string) { const sourceCircuitId = findDeviceRowCircuitId(rowId); - const sourceCircuit = sourceCircuitId ? getCircuitSnapshot(sourceCircuitId) : null; + const sourceCircuit = sourceCircuitId + ? allCircuits.find((circuit) => circuit.id === sourceCircuitId) + : null; const rowSnapshot = sourceCircuit?.deviceRows.find((row) => row.id === rowId); if (!sourceCircuitId || !sourceCircuit || !rowSnapshot) { setError("Die Gerätezeile ist ungültig."); @@ -2287,68 +2419,50 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } else if (!confirm(`Gerätezeile „${rowSnapshot.displayName}“ löschen?`)) { return; } - let recreatedRowId: string | null = null; await runCommand({ label: "Gerätezeile löschen", redo: async () => { - await deleteCircuitDeviceRowById(recreatedRowId ?? rowId); + await persistDeviceRowDelete( + rowId, + sourceCircuitId, + "Gerätezeile löschen" + ); return null; }, - undo: async () => { - // Recreate instead of "undelete": backend ids are immutable once deleted. - const created = (await createCircuitDeviceRow(sourceCircuitId, { - linkedProjectDeviceId: rowSnapshot.linkedProjectDeviceId, - name: rowSnapshot.name, - displayName: rowSnapshot.displayName, - phaseType: rowSnapshot.phaseType, - connectionKind: rowSnapshot.connectionKind, - costGroup: rowSnapshot.costGroup, - category: rowSnapshot.category, - level: rowSnapshot.level, - roomId: rowSnapshot.roomId, - roomNumberSnapshot: rowSnapshot.roomNumberSnapshot, - roomNameSnapshot: rowSnapshot.roomNameSnapshot, - quantity: rowSnapshot.quantity, - powerPerUnit: rowSnapshot.powerPerUnit, - simultaneityFactor: rowSnapshot.simultaneityFactor, - cosPhi: rowSnapshot.cosPhi, - remark: rowSnapshot.remark, - overriddenFields: rowSnapshot.overriddenFields, - sortOrder: rowSnapshot.sortOrder, - })) as { id: string }; - recreatedRowId = created.id; - return null; + undo: async () => null, + persistent: { + undoIntent: () => null, + redoIntent: () => null, }, }); } - // Deletes a circuit and all child rows; undo recreates from captured snapshot. + // Persistent circuit delete preserves the complete circuit block and all stable ids. async function handleDeleteCircuit(circuitId: string, askForConfirmation = true) { - const snapshot = getCircuitSnapshot(circuitId); - if (!snapshot) { + const circuit = allCircuits.find((entry) => entry.id === circuitId); + if (!circuit) { setError("Der Stromkreis ist ungültig."); return; } if ( askForConfirmation && !confirm( - `Stromkreis ${snapshot.equipmentIdentifier} und ${snapshot.deviceRows.length} zugeordnete Gerätezeile(n) löschen?\n\n` + + `Stromkreis ${circuit.equipmentIdentifier} und ${circuit.deviceRows.length} zugeordnete Gerätezeile(n) löschen?\n\n` + "Bestehende Betriebsmittelkennzeichen werden nicht neu nummeriert." ) ) { return; } - let recreatedCircuitId: string | null = null; await runCommand({ label: "Stromkreis löschen", redo: async () => { - await deleteCircuitById(recreatedCircuitId ?? circuitId); + await persistCircuitDelete(circuitId, "Stromkreis löschen"); return null; }, - undo: async () => { - const recreated = await recreateCircuit(snapshot); - recreatedCircuitId = recreated.circuitId; - return null; + undo: async () => null, + persistent: { + undoIntent: () => null, + redoIntent: () => null, }, }); } diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index adad7e7..bcd171a 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -17,10 +17,6 @@ import type { ProjectDto, RoomDto, CircuitTreeResponseDto, - CircuitTreeCircuitDto, - CircuitTreeDeviceRowDto, - CreateCircuitInputDto, - CreateCircuitDeviceRowInputDto, } from "../types"; import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields"; import { @@ -31,6 +27,12 @@ import { createCircuitDeviceRowUpdateProjectCommand, type CircuitDeviceRowUpdatePatch, } from "../../domain/models/circuit-device-row-project-command.model"; +import type { + CircuitDeviceRowSnapshot, +} from "../../domain/models/circuit-device-row-structure-project-command.model"; +import type { + CircuitSnapshot, +} from "../../domain/models/circuit-structure-project-command.model"; async function request(url: string, init?: RequestInit): Promise { const response = await fetch(url, { @@ -159,33 +161,6 @@ export function getCircuitTree(projectId: string, circuitListId: string) { return request(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`); } -export function createCircuit(projectId: string, circuitListId: string, input: CreateCircuitInputDto) { - return request(`/api/projects/${projectId}/circuit-lists/${circuitListId}/circuits`, { - method: "POST", - body: JSON.stringify(input), - }); -} - -export function createCircuitWithDeviceRows( - projectId: string, - circuitListId: string, - input: { - circuit: CreateCircuitInputDto; - deviceRows: CreateCircuitDeviceRowInputDto[]; - } -) { - return request<{ - circuit: CircuitTreeCircuitDto; - deviceRows: CircuitTreeDeviceRowDto[]; - }>( - `/api/projects/${projectId}/circuit-lists/${circuitListId}/circuits-with-device-rows`, - { - method: "POST", - body: JSON.stringify(input), - } - ); -} - export function updateCircuitById( projectId: string, expectedRevision: number, @@ -200,6 +175,43 @@ export function updateCircuitById( ); } +export function insertCircuitCommand( + projectId: string, + expectedRevision: number, + circuit: CircuitSnapshot, + description: string +) { + return executeProjectCommand( + projectId, + expectedRevision, + { + schemaVersion: 1, + type: "circuit.insert", + payload: { circuit }, + }, + description + ); +} + +export function deleteCircuitCommand( + projectId: string, + expectedRevision: number, + circuitId: string, + expectedCircuitListId: string, + description: string +) { + return executeProjectCommand( + projectId, + expectedRevision, + { + schemaVersion: 1, + type: "circuit.delete", + payload: { circuitId, expectedCircuitListId }, + }, + description + ); +} + export function deleteCircuitById(circuitId: string) { return request(`/api/circuits/${circuitId}`, { method: "DELETE", @@ -212,13 +224,6 @@ export function getNextCircuitIdentifier(sectionId: string) { ); } -export function createCircuitDeviceRow(circuitId: string, input: CreateCircuitDeviceRowInputDto) { - return request(`/api/circuits/${circuitId}/device-rows`, { - method: "POST", - body: JSON.stringify(input), - }); -} - export function updateCircuitDeviceRowById( projectId: string, expectedRevision: number, @@ -233,10 +238,41 @@ export function updateCircuitDeviceRowById( ); } -export function deleteCircuitDeviceRowById(rowId: string) { - return request(`/api/circuit-device-rows/${rowId}`, { - method: "DELETE", - }); +export function insertCircuitDeviceRowCommand( + projectId: string, + expectedRevision: number, + row: CircuitDeviceRowSnapshot, + description: string +) { + return executeProjectCommand( + projectId, + expectedRevision, + { + schemaVersion: 1, + type: "circuit-device-row.insert", + payload: { row }, + }, + description + ); +} + +export function deleteCircuitDeviceRowCommand( + projectId: string, + expectedRevision: number, + rowId: string, + expectedCircuitId: string, + description: string +) { + return executeProjectCommand( + projectId, + expectedRevision, + { + schemaVersion: 1, + type: "circuit-device-row.delete", + payload: { rowId, expectedCircuitId }, + }, + description + ); } export function moveCircuitDeviceRowById( diff --git a/src/frontend/utils/circuit-structure-command.ts b/src/frontend/utils/circuit-structure-command.ts new file mode 100644 index 0000000..222c754 --- /dev/null +++ b/src/frontend/utils/circuit-structure-command.ts @@ -0,0 +1,73 @@ +import type { + CircuitDeviceRowSnapshot, +} from "../../domain/models/circuit-device-row-structure-project-command.model.js"; +import type { + CircuitSnapshot, +} from "../../domain/models/circuit-structure-project-command.model.js"; +import type { + CreateCircuitDeviceRowInputDto, + CreateCircuitInputDto, +} from "../types.js"; + +export function buildCircuitDeviceRowInsertSnapshot(input: { + id: string; + circuitId: string; + values: CreateCircuitDeviceRowInputDto; + defaultSortOrder: number; +}): CircuitDeviceRowSnapshot { + const { id, circuitId, values, defaultSortOrder } = input; + return { + id, + circuitId, + linkedProjectDeviceId: values.linkedProjectDeviceId ?? null, + legacyConsumerId: null, + sortOrder: values.sortOrder ?? defaultSortOrder, + name: values.name, + displayName: values.displayName, + phaseType: values.phaseType ?? null, + connectionKind: values.connectionKind ?? null, + costGroup: values.costGroup ?? null, + category: values.category ?? null, + level: values.level ?? null, + roomId: values.roomId ?? null, + roomNumberSnapshot: values.roomNumberSnapshot ?? null, + roomNameSnapshot: values.roomNameSnapshot ?? null, + quantity: values.quantity, + powerPerUnit: values.powerPerUnit, + simultaneityFactor: values.simultaneityFactor, + cosPhi: values.cosPhi ?? null, + remark: values.remark ?? null, + overriddenFields: values.overriddenFields ?? null, + }; +} + +export function buildCircuitInsertSnapshot(input: { + id: string; + circuitListId: string; + values: CreateCircuitInputDto; + deviceRows: CircuitDeviceRowSnapshot[]; +}): CircuitSnapshot { + const { id, circuitListId, values, deviceRows } = input; + return { + id, + circuitListId, + sectionId: values.sectionId, + equipmentIdentifier: values.equipmentIdentifier, + displayName: values.displayName ?? null, + sortOrder: values.sortOrder, + protectionType: values.protectionType ?? null, + protectionRatedCurrent: values.protectionRatedCurrent ?? null, + protectionCharacteristic: values.protectionCharacteristic ?? null, + cableType: values.cableType ?? null, + cableCrossSection: values.cableCrossSection ?? null, + cableLength: values.cableLength ?? null, + rcdAssignment: values.rcdAssignment ?? null, + terminalDesignation: values.terminalDesignation ?? null, + voltage: values.voltage ?? null, + controlRequirement: values.controlRequirement ?? null, + status: values.status ?? null, + isReserve: deviceRows.length === 0, + remark: values.remark ?? null, + deviceRows, + }; +} diff --git a/src/server/controllers/circuit-device-row.controller.ts b/src/server/controllers/circuit-device-row.controller.ts index 2befe78..97fe3a9 100644 --- a/src/server/controllers/circuit-device-row.controller.ts +++ b/src/server/controllers/circuit-device-row.controller.ts @@ -1,44 +1,10 @@ import type { Request, Response } from "express"; import { circuitWriteService } from "../composition/circuit-write-service.js"; import { - createCircuitDeviceRowSchema, moveCircuitDeviceRowsBulkSchema, moveCircuitDeviceRowSchema, } from "../../shared/validation/circuit.schemas.js"; -export async function createCircuitDeviceRow(req: Request, res: Response) { - const { circuitId } = req.params; - if (typeof circuitId !== "string") { - return res.status(400).json({ error: "Invalid circuitId" }); - } - - const parsed = createCircuitDeviceRowSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.flatten() }); - } - - try { - const created = await circuitWriteService.createDeviceRow(circuitId, parsed.data); - return res.status(201).json(created); - } catch (error) { - return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to create device row." }); - } -} - -export async function deleteCircuitDeviceRow(req: Request, res: Response) { - const { rowId } = req.params; - if (typeof rowId !== "string") { - return res.status(400).json({ error: "Invalid rowId" }); - } - - try { - await circuitWriteService.deleteDeviceRow(rowId); - return res.status(204).send(); - } catch (error) { - return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to delete device row." }); - } -} - export async function moveCircuitDeviceRow(req: Request, res: Response) { const { rowId } = req.params; if (typeof rowId !== "string") { diff --git a/src/server/controllers/circuit.controller.ts b/src/server/controllers/circuit.controller.ts index 6a31923..d1f8a11 100644 --- a/src/server/controllers/circuit.controller.ts +++ b/src/server/controllers/circuit.controller.ts @@ -1,56 +1,5 @@ import type { Request, Response } from "express"; import { circuitWriteService } from "../composition/circuit-write-service.js"; -import { - createCircuitSchema, - createCircuitWithDeviceRowsSchema, -} from "../../shared/validation/circuit.schemas.js"; - -export async function createCircuit(req: Request, res: Response) { - const { projectId, circuitListId } = req.params; - if (typeof projectId !== "string" || typeof circuitListId !== "string") { - return res.status(400).json({ error: "Invalid parameters" }); - } - - const parsed = createCircuitSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.flatten() }); - } - - try { - const created = await circuitWriteService.createCircuit(projectId, circuitListId, parsed.data); - return res.status(201).json(created); - } catch (error) { - return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to create circuit." }); - } -} - -export async function createCircuitWithDeviceRows(req: Request, res: Response) { - const { projectId, circuitListId } = req.params; - if (typeof projectId !== "string" || typeof circuitListId !== "string") { - return res.status(400).json({ error: "Ungültige Parameter." }); - } - - const parsed = createCircuitWithDeviceRowsSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.flatten() }); - } - - try { - const created = await circuitWriteService.createCircuitWithDeviceRows( - projectId, - circuitListId, - parsed.data - ); - return res.status(201).json(created); - } catch (error) { - return res.status(400).json({ - error: - error instanceof Error - ? error.message - : "Stromkreis und Gerätezeilen konnten nicht erstellt werden.", - }); - } -} export async function deleteCircuit(req: Request, res: Response) { const { circuitId } = req.params; diff --git a/src/server/routes/circuit-device-row.routes.ts b/src/server/routes/circuit-device-row.routes.ts index 2e7fac2..5630253 100644 --- a/src/server/routes/circuit-device-row.routes.ts +++ b/src/server/routes/circuit-device-row.routes.ts @@ -1,6 +1,5 @@ import { Router } from "express"; import { - deleteCircuitDeviceRow, moveCircuitDeviceRowsBulk, moveCircuitDeviceRow, } from "../controllers/circuit-device-row.controller.js"; @@ -9,4 +8,3 @@ export const circuitDeviceRowRouter = Router(); circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk); circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow); -circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow); diff --git a/src/server/routes/circuit.routes.ts b/src/server/routes/circuit.routes.ts index 378a01e..8af9728 100644 --- a/src/server/routes/circuit.routes.ts +++ b/src/server/routes/circuit.routes.ts @@ -1,20 +1,11 @@ import { Router } from "express"; import { - createCircuit, - createCircuitWithDeviceRows, deleteCircuit, getNextCircuitIdentifier, } from "../controllers/circuit.controller.js"; -import { createCircuitDeviceRow } from "../controllers/circuit-device-row.controller.js"; export const circuitRouter = Router(); -circuitRouter.post("/projects/:projectId/circuit-lists/:circuitListId/circuits", createCircuit); -circuitRouter.post( - "/projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows", - createCircuitWithDeviceRows -); circuitRouter.delete("/circuits/:circuitId", deleteCircuit); circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier); -circuitRouter.post("/circuits/:circuitId/device-rows", createCircuitDeviceRow); diff --git a/src/shared/validation/circuit.schemas.ts b/src/shared/validation/circuit.schemas.ts index 43595e8..30c480b 100644 --- a/src/shared/validation/circuit.schemas.ts +++ b/src/shared/validation/circuit.schemas.ts @@ -1,51 +1,5 @@ import { z } from "zod"; -export const createCircuitSchema = z.object({ - sectionId: z.string().min(1), - equipmentIdentifier: z.string().min(1), - displayName: z.string().optional(), - sortOrder: z.number(), - protectionType: z.string().optional(), - protectionRatedCurrent: z.number().min(0).optional(), - protectionCharacteristic: z.string().optional(), - cableType: z.string().optional(), - cableCrossSection: z.string().optional(), - cableLength: z.number().min(0).optional(), - rcdAssignment: z.string().optional(), - terminalDesignation: z.string().optional(), - voltage: z.number().positive().optional(), - controlRequirement: z.string().optional(), - status: z.string().optional(), - isReserve: z.boolean().optional(), - remark: z.string().optional(), -}); - -export const createCircuitDeviceRowSchema = z.object({ - linkedProjectDeviceId: z.string().min(1).optional(), - name: z.string().min(1), - displayName: z.string().min(1), - phaseType: z.string().optional(), - connectionKind: z.string().optional(), - costGroup: z.string().optional(), - category: z.string().optional(), - level: z.string().optional(), - roomId: z.string().min(1).optional(), - roomNumberSnapshot: z.string().optional(), - roomNameSnapshot: z.string().optional(), - quantity: z.number().min(0), - powerPerUnit: z.number().min(0), - simultaneityFactor: z.number().min(0), - cosPhi: z.number().positive().optional(), - remark: z.string().optional(), - overriddenFields: z.string().optional(), - sortOrder: z.number().optional(), -}); - -export const createCircuitWithDeviceRowsSchema = z.object({ - circuit: createCircuitSchema, - deviceRows: z.array(createCircuitDeviceRowSchema).min(1), -}); - export const moveCircuitDeviceRowSchema = z .object({ targetCircuitId: z.string().min(1).optional(), @@ -88,9 +42,6 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({ .min(1), }); -export type CreateCircuitInput = z.infer; -export type CreateCircuitDeviceRowInput = z.infer; -export type CreateCircuitWithDeviceRowsInput = z.infer; export type MoveCircuitDeviceRowInput = z.infer; export type MoveCircuitDeviceRowsBulkInput = z.infer; export type ReorderSectionCircuitsInput = z.infer; diff --git a/tests/circuit-structure-command.test.ts b/tests/circuit-structure-command.test.ts new file mode 100644 index 0000000..51ea7fc --- /dev/null +++ b/tests/circuit-structure-command.test.ts @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + buildCircuitDeviceRowInsertSnapshot, + buildCircuitInsertSnapshot, +} from "../src/frontend/utils/circuit-structure-command.js"; +import { + deleteCircuitCommand, + deleteCircuitDeviceRowCommand, + insertCircuitCommand, + insertCircuitDeviceRowCommand, +} from "../src/frontend/utils/api.js"; + +describe("circuit structure command adapters", () => { + it("builds a complete row snapshot without legacy identities", () => { + const row = buildCircuitDeviceRowInsertSnapshot({ + id: "row-1", + circuitId: "circuit-1", + defaultSortOrder: 20, + values: { + linkedProjectDeviceId: "device-1", + name: "Steckdose", + displayName: "Steckdose Büro", + quantity: 2, + powerPerUnit: 0.4, + simultaneityFactor: 0.8, + }, + }); + + assert.deepEqual(row, { + id: "row-1", + circuitId: "circuit-1", + linkedProjectDeviceId: "device-1", + legacyConsumerId: null, + sortOrder: 20, + name: "Steckdose", + displayName: "Steckdose Büro", + phaseType: null, + connectionKind: null, + costGroup: null, + category: null, + level: null, + roomId: null, + roomNumberSnapshot: null, + roomNameSnapshot: null, + quantity: 2, + powerPerUnit: 0.4, + simultaneityFactor: 0.8, + cosPhi: null, + remark: null, + overriddenFields: null, + }); + }); + + it("derives reserve state from the complete device-row snapshot", () => { + const reserve = buildCircuitInsertSnapshot({ + id: "circuit-1", + circuitListId: "list-1", + values: { + sectionId: "section-1", + equipmentIdentifier: "-2F1", + sortOrder: 10, + isReserve: false, + voltage: 230, + controlRequirement: "DALI", + }, + deviceRows: [], + }); + + assert.equal(reserve.isReserve, true); + assert.equal(reserve.displayName, null); + assert.equal(reserve.voltage, 230); + assert.equal(reserve.controlRequirement, "DALI"); + + const row = buildCircuitDeviceRowInsertSnapshot({ + id: "row-1", + circuitId: reserve.id, + defaultSortOrder: 10, + values: { + name: "Gerät", + displayName: "Gerät", + quantity: 1, + powerPerUnit: 0, + simultaneityFactor: 1, + cosPhi: 1, + }, + }); + const populated = buildCircuitInsertSnapshot({ + id: reserve.id, + circuitListId: reserve.circuitListId, + values: { + sectionId: reserve.sectionId, + equipmentIdentifier: reserve.equipmentIdentifier, + sortOrder: reserve.sortOrder, + isReserve: true, + }, + deviceRows: [row], + }); + + assert.equal(populated.isReserve, false); + assert.deepEqual(populated.deviceRows, [row]); + }); + + it("sends structure envelopes through the project command route", async () => { + const requests: Array<{ url: string; body: Record }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + requests.push({ + url: String(input), + body: JSON.parse(String(init?.body)) as Record, + }); + return new Response( + JSON.stringify({ + revision: { + revisionId: "revision-1", + changeSetId: "change-1", + projectId: "project-1", + revisionNumber: requests.length, + createdAtIso: "2026-07-25T00:00:00.000Z", + }, + history: { + projectId: "project-1", + currentRevision: requests.length, + undoDepth: requests.length, + redoDepth: 0, + undoChangeSetId: "change-1", + redoChangeSetId: null, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }; + + try { + const row = buildCircuitDeviceRowInsertSnapshot({ + id: "row-1", + circuitId: "circuit-1", + defaultSortOrder: 10, + values: { + name: "Gerät", + displayName: "Gerät", + quantity: 1, + powerPerUnit: 0, + simultaneityFactor: 1, + }, + }); + const circuit = buildCircuitInsertSnapshot({ + id: "circuit-1", + circuitListId: "list-1", + values: { + sectionId: "section-1", + equipmentIdentifier: "-2F1", + sortOrder: 10, + }, + deviceRows: [row], + }); + + await insertCircuitCommand("project-1", 0, circuit, "insert circuit"); + await deleteCircuitCommand( + "project-1", + 1, + circuit.id, + circuit.circuitListId, + "delete circuit" + ); + await insertCircuitDeviceRowCommand( + "project-1", + 2, + row, + "insert row" + ); + await deleteCircuitDeviceRowCommand( + "project-1", + 3, + row.id, + row.circuitId, + "delete row" + ); + } finally { + globalThis.fetch = originalFetch; + } + + assert.deepEqual( + requests.map((request) => request.url), + Array(4).fill("/api/projects/project-1/commands") + ); + assert.deepEqual( + requests.map((request) => { + const command = request.body.command as { + schemaVersion: number; + type: string; + }; + return [ + request.body.expectedRevision, + command.schemaVersion, + command.type, + ]; + }), + [ + [0, 1, "circuit.insert"], + [1, 1, "circuit.delete"], + [2, 1, "circuit-device-row.insert"], + [3, 1, "circuit-device-row.delete"], + ] + ); + }); +}); diff --git a/tests/circuit-write.rules.test.ts b/tests/circuit-write.rules.test.ts index ede6af2..1b5f155 100644 --- a/tests/circuit-write.rules.test.ts +++ b/tests/circuit-write.rules.test.ts @@ -1,265 +1,8 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js"; -import { createCircuitSchema } from "../src/shared/validation/circuit.schemas.js"; describe("circuit write service rules", () => { - it("accepts future sizing inputs without calculating sizing suggestions", () => { - const parsed = createCircuitSchema.safeParse({ - sectionId: "s1", - equipmentIdentifier: "-1F1", - sortOrder: 10, - voltage: 230, - controlRequirement: "DALI", - }); - - assert.equal(parsed.success, true); - }); - - it("rejects duplicate equipment identifiers in same circuit list", async () => { - const service = new CircuitWriteService({ - circuitListRepository: { - async findById() { - return { id: "list1", projectId: "p1" } as never; - }, - } as never, - circuitSectionRepository: { - async findById() { - return { id: "sec1", circuitListId: "list1" } as never; - }, - } as never, - circuitRepository: { - async existsByEquipmentIdentifier() { - return true; - }, - } as never, - }); - - await assert.rejects( - () => - service.createCircuit("p1", "list1", { - sectionId: "sec1", - equipmentIdentifier: "-2F1", - sortOrder: 10, - }), - /Duplicate equipmentIdentifier/ - ); - }); - - it("rejects section and list mismatch", async () => { - const service = new CircuitWriteService({ - circuitListRepository: { - async findById() { - return { id: "list1", projectId: "p1" } as never; - }, - } as never, - circuitSectionRepository: { - async findById() { - return { id: "sec1", circuitListId: "other" } as never; - }, - } as never, - circuitRepository: { - async existsByEquipmentIdentifier() { - return false; - }, - } as never, - }); - - await assert.rejects( - () => - service.createCircuit("p1", "list1", { - sectionId: "sec1", - equipmentIdentifier: "-2F1", - sortOrder: 10, - }), - /Section does not belong to circuit list/ - ); - }); - - it("deleting last device row keeps circuit and sets reserve", async () => { - let transactionalDelete: { rowId: string; circuitId: string } | undefined; - const service = new CircuitWriteService({ - deviceRowRepository: { - async findById() { - return { id: "r1", circuitId: "c1" } as never; - }, - } as never, - deviceRowTransactionStore: { - deleteFromCircuit(rowId: string, circuitId: string) { - transactionalDelete = { rowId, circuitId }; - }, - } as never, - circuitRepository: { - async findById() { - return { - id: "c1", - sectionId: "s1", - circuitListId: "l1", - equipmentIdentifier: "-2F1", - sortOrder: 10, - isReserve: 0, - } as never; - }, - } as never, - }); - - await service.deleteDeviceRow("r1"); - assert.deepEqual(transactionalDelete, { rowId: "r1", circuitId: "c1" }); - }); - - it("creating device row in reserve circuit clears reserve status", async () => { - let transactionalCreate: - | { - circuitId: string; - sortOrder?: number; - name: string; - displayName: string; - } - | undefined; - const service = new CircuitWriteService({ - circuitRepository: { - async findById() { - return { - id: "c1", - sectionId: "s1", - circuitListId: "l1", - equipmentIdentifier: "-2F1", - sortOrder: 10, - isReserve: 1, - } as never; - }, - } as never, - deviceRowRepository: { - async findById() { - return { id: "row1" } as never; - }, - } as never, - deviceRowTransactionStore: { - createInCircuit(input: typeof transactionalCreate) { - transactionalCreate = input; - return "row1"; - }, - } as never, - circuitListRepository: {} as never, - circuitSectionRepository: {} as never, - projectDeviceRepository: { - async findById() { - return { id: "pd1" } as never; - }, - } as never, - }); - - await service.createDeviceRow("c1", { - name: "Load", - displayName: "Load", - quantity: 1, - powerPerUnit: 1, - simultaneityFactor: 1, - }); - assert.deepEqual(transactionalCreate, { - circuitId: "c1", - linkedProjectDeviceId: undefined, - sortOrder: undefined, - name: "Load", - displayName: "Load", - phaseType: undefined, - connectionKind: undefined, - costGroup: undefined, - category: undefined, - level: undefined, - roomId: undefined, - roomNumberSnapshot: undefined, - roomNameSnapshot: undefined, - quantity: 1, - powerPerUnit: 1, - simultaneityFactor: 1, - cosPhi: undefined, - remark: undefined, - overriddenFields: undefined, - }); - }); - - it("creates a circuit and all first device rows through one repository command", async () => { - let transactionalCreate: { - circuit: { circuitListId: string; sectionId: string; equipmentIdentifier: string }; - deviceRows: Array<{ linkedProjectDeviceId?: string; name: string }>; - } | undefined; - const service = new CircuitWriteService({ - circuitListRepository: { - async findById() { - return { id: "l1", projectId: "p1" } as never; - }, - } as never, - circuitSectionRepository: { - async findById() { - return { id: "s1", circuitListId: "l1" } as never; - }, - } as never, - circuitRepository: { - async existsByEquipmentIdentifier() { - return false; - }, - async findById() { - return { id: "c-new", circuitListId: "l1", sectionId: "s1" } as never; - }, - } as never, - projectDeviceRepository: { - async findById(projectId: string, deviceId: string) { - return projectId === "p1" && deviceId === "pd1" ? ({ id: "pd1" } as never) : null; - }, - } as never, - deviceRowRepository: { - async findById(rowId: string) { - return { id: rowId, circuitId: "c-new" } as never; - }, - } as never, - deviceRowTransactionStore: { - createCircuitWithDeviceRows(input: typeof transactionalCreate) { - transactionalCreate = input; - return { circuitId: "c-new", rowIds: ["r1", "r2"] }; - }, - } as never, - }); - - const created = await service.createCircuitWithDeviceRows("p1", "l1", { - circuit: { - sectionId: "s1", - equipmentIdentifier: "-2F1", - displayName: "Steckdosen", - sortOrder: 10, - }, - deviceRows: [ - { - linkedProjectDeviceId: "pd1", - name: "Socket", - displayName: "Steckdose", - quantity: 1, - powerPerUnit: 0.2, - simultaneityFactor: 1, - }, - { - name: "Manual", - displayName: "Manuell", - quantity: 2, - powerPerUnit: 0.1, - simultaneityFactor: 0.8, - }, - ], - }); - - assert.equal(transactionalCreate?.circuit.circuitListId, "l1"); - assert.equal(transactionalCreate?.circuit.sectionId, "s1"); - assert.equal(transactionalCreate?.circuit.equipmentIdentifier, "-2F1"); - assert.deepEqual( - transactionalCreate?.deviceRows.map((row) => row.name), - ["Socket", "Manual"] - ); - assert.deepEqual( - created.deviceRows.map((row) => row?.id), - ["r1", "r2"] - ); - }); - it("renumber uses safe bulk identifier update for swapped identifiers", async () => { let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = []; const service = new CircuitWriteService({