Persist circuit structure edits

This commit is contained in:
2026-07-25 20:49:34 +02:00
parent 76d8d59391
commit 2eba4ea75e
19 changed files with 809 additions and 964 deletions
+9 -8
View File
@@ -220,14 +220,15 @@ After saving, the row becomes linked to the new project device.
## Undo / Redo ## Undo / Redo
The editor's visible undo/redo stack remains session-local while structural and The editor's visible undo/redo stack remains session-local while move, reorder
destructive operations are being migrated. Existing Circuit and and renumber operations are being migrated. Existing Circuit and
CircuitDeviceRow cell edits already execute persistent project commands and CircuitDeviceRow cell edits plus standalone insertion/deletion already execute
their toolbar undo/redo actions use the project-wide history endpoints. The persistent project commands; their toolbar undo/redo actions use the
tree response supplies the optimistic `currentRevision`; the obsolete direct project-wide history endpoints. Insertions use client-generated stable UUIDs,
PATCH routes for these fields are removed. Reloading the page does not yet and deletion undo restores the same ids from complete server snapshots. The
reconstruct the visible editor stack. Public command, undo and redo endpoints tree response supplies the optimistic `currentRevision`; obsolete direct field
also exist for Circuit/CircuitDeviceRow insertion and deletion. 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 CircuitDeviceRow moves between existing circuits and moves that create one new
placeholder target circuit are persisted. The latter stores the complete empty placeholder target circuit are persisted. The latter stores the complete empty
target snapshot so undo can restore the rows and remove only the unchanged target snapshot so undo can restore the rows and remove only the unchanged
+10 -31
View File
@@ -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 contain each row's expected and target circuit plus its exact expected and
target sort order. This makes deletion and moves undoable without generating target sort order. This makes deletion and moves undoable without generating
replacement identities or renumbering circuits. Existing Circuit and replacement identities or renumbering circuits. Existing Circuit and
CircuitDeviceRow cell edits in the editor consume these endpoints; the CircuitDeviceRow cell edits plus standalone insert/delete actions in the editor
remaining structural editor writes are still being migrated. 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` targets existing circuits in the same circuit list.
`circuit-device-row.move-with-new-circuit` atomically creates exactly one `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` - `GET /circuit-sections/:sectionId/next-identifier`
- preview next identifier for section (`prefix + maxSuffix + 1`) - preview next identifier for section (`prefix + maxSuffix + 1`)
Request sketch (`POST .../circuits`): Circuit and device-row field updates, standalone insertions and standalone
deletions are available only as the corresponding versioned commands through
```json `POST /projects/:projectId/commands`. There are no direct field-update PATCH,
{ structure POST or CircuitDeviceRow DELETE routes. The direct
"sectionId": "sec_1", `DELETE /circuits/:circuitId` route remains temporarily for the inverse of the
"equipmentIdentifier": "-2F14", not-yet-migrated placeholder move UI and must not be used by new features.
"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.
### Move Device Row ### Move Device Row
+8 -6
View File
@@ -123,10 +123,12 @@ Cross-section device moves show confirmation-required feedback before drop. The
## Undo/Redo ## Undo/Redo
Undo/redo wraps editor operations as command objects and reloads the tree after Undo/redo wraps editor operations as command objects and reloads the tree after
each command. Existing Circuit and device-row cell edits execute persistent each command. Existing Circuit and device-row cell edits plus standalone
project commands; their toolbar undo/redo calls the project-wide server insert/delete actions execute persistent project commands; their toolbar
history. Other covered operations still use their existing direct API writes undo/redo calls the project-wide server history. New entities receive stable
and session-local inverse callbacks during the cutover. 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: Covered operations include:
@@ -140,5 +142,5 @@ Covered operations include:
Current limitations: Current limitations:
- the visible editor stack is still session-local and is empty after reload - the visible editor stack is still session-local and is empty after reload
- structural and destructive editor actions are not all connected to the - move, reorder and renumber editor actions are not connected to the persistent
persistent command boundary yet command boundary yet
@@ -1,9 +1,10 @@
# Circuit List Editor Known Limitations # Circuit List Editor Known Limitations
- Existing Circuit and CircuitDeviceRow cell edits use persistent project - Existing Circuit and CircuitDeviceRow cell edits plus standalone
commands and project-wide toolbar undo/redo. The editor's visible stack is insert/delete actions use persistent project commands and project-wide
still session-local, is empty after reload and still contains direct toolbar undo/redo. The editor's visible stack is still session-local, is
structural/destructive operations during the cutover. 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 - Server-side project revisions and persistent undo/redo eligibility exist for
the command types documented in `circuit-list-editor-api.md`; complete editor the command types documented in `circuit-list-editor-api.md`; complete editor
command coverage and history reconstruction are not complete. command coverage and history reconstruction are not complete.
+8 -6
View File
@@ -87,12 +87,14 @@ wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block. Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen
verwendet das Grid bereits über die öffentliche Command-Grenze. Der Tree liefert sowie eigenständiges Einfügen und Löschen verwendet das Grid bereits über die
dazu `currentRevision`; Undo/Redo für diese Zellaktionen läuft über die öffentliche Command-Grenze. Neue Circuits und Gerätezeilen erhalten ihre stabile
projektweite Serverhistorie. Die direkten PATCH-Endpunkte für diese Felder sind UUID vor dem Command; Löschen und Undo bewahren diese Identität. Der Tree liefert
entfernt. Andere Editoraktionen verbleiben vorerst im sichtbaren dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektweite
sitzungslokalen Stack; nach einem Reload wird dieser noch nicht aus der Serverhistorie. Direkte Feld-PATCH-, Struktur-POST- und Gerätezeilen-DELETE-
Serverhistorie rekonstruiert. 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 `circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
@@ -184,6 +184,9 @@ Completed foundation:
- `circuit.insert` and `circuit.delete` persist a complete circuit block with - `circuit.insert` and `circuit.delete` persist a complete circuit block with
zero, one or multiple device rows; undo restores all ids and fields without zero, one or multiple device rows; undo restores all ids and fields without
renumbering 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 - `circuit-device-row.move` stores exact expected and target circuit/sort
positions for one or multiple rows, recalculates all affected reserve states positions for one or multiple rows, recalculates all affected reserve states
and reconstructs a deterministic inverse across multiple source circuits and reconstructs a deterministic inverse across multiple source circuits
@@ -208,17 +211,14 @@ Completed foundation:
Remaining constraints before completing persistent history: Remaining constraints before completing persistent history:
- extend the concrete project-scoped command union and executors beyond the - route the remaining move, reorder and renumber editor writes through their
Circuit field/insert/delete and CircuitDeviceRow existing typed persistent commands and remove the final direct mutation
field/insert/delete/move, section-reorder/renumber and ProjectDevice routes
field/insert/delete/row-sync slices; the generic versioned command envelope - reconstruct the visible frontend history state after reload; Circuit and
is complete CircuitDeviceRow field/insert/delete actions plus ProjectDevice operations
- route the remaining structural and destructive domain already use persistent commands and project-wide undo
writes through the shared command, revision and stack transaction boundary - extend project-scoped commands only when further project mutations are
- finish routing structural and destructive circuit-editor operations through deliberately added to history; the generic versioned envelope is complete
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
- do not model IFCGUID as an overloaded circuit equipment identifier - do not model IFCGUID as an overloaded circuit equipment identifier
- keep database backup/restore checks separate from project history tests - keep database backup/restore checks separate from project history tests
+6 -3
View File
@@ -398,6 +398,9 @@ Implemented foundation:
stack transition commit or roll back together stack transition commit or roll back together
- typed Circuit insert/delete commands treat the circuit and all device rows as - typed Circuit insert/delete commands treat the circuit and all device rows as
one block, preserve every stable id and never renumber neighbouring circuits 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 - typed CircuitDeviceRow move commands persist exact old/new circuit and sort
positions for single or multi-row moves between existing circuits and derive positions for single or multi-row moves between existing circuits and derive
affected reserve states in the same transaction affected reserve states in the same transaction
@@ -428,9 +431,9 @@ Implemented foundation:
and rollback after a forced late persistence failure and rollback after a forced late persistence failure
ProjectDevice CRUD, synchronization and disconnect on the project page and ProjectDevice CRUD, synchronization and disconnect on the project page and
existing Circuit/CircuitDeviceRow cell edits in the circuit-list editor are Circuit/CircuitDeviceRow field/insert/delete actions in the circuit-list editor
connected to this history boundary. Remaining structural editor writes are not are connected to this history boundary. Move, reorder and renumber UI paths are
fully connected; the editor's visible undo/redo stack therefore remains not yet connected; the editor's visible undo/redo stack therefore remains
session-local and is not reconstructed after reload. session-local and is not reconstructed after reload.
Acceptance criteria: Acceptance criteria:
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"start": "node dist/server/index.js", "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": "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-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:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
@@ -1,14 +1,9 @@
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js"; import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js"; import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js";
import type { CircuitSectionTransactionStore } from "../ports/circuit-section-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 { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import type { import type {
CreateCircuitDeviceRowInput,
CreateCircuitInput,
CreateCircuitWithDeviceRowsInput,
MoveCircuitDeviceRowInput, MoveCircuitDeviceRowInput,
MoveCircuitDeviceRowsBulkInput, MoveCircuitDeviceRowsBulkInput,
ReorderSectionCircuitsInput, ReorderSectionCircuitsInput,
@@ -19,30 +14,24 @@ import { CircuitNumberingService } from "./circuit-numbering.service.js";
export class CircuitWriteService { export class CircuitWriteService {
private readonly circuitRepository: CircuitRepository; private readonly circuitRepository: CircuitRepository;
private readonly circuitSectionRepository: CircuitSectionRepository; private readonly circuitSectionRepository: CircuitSectionRepository;
private readonly circuitListRepository: CircuitListRepository;
private readonly deviceRowRepository: CircuitDeviceRowRepository; private readonly deviceRowRepository: CircuitDeviceRowRepository;
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore; private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore; private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
private readonly projectDeviceRepository: ProjectDeviceRepository;
private readonly numberingService: CircuitNumberingService; private readonly numberingService: CircuitNumberingService;
constructor(deps?: { constructor(deps?: {
circuitRepository?: CircuitRepository; circuitRepository?: CircuitRepository;
circuitSectionRepository?: CircuitSectionRepository; circuitSectionRepository?: CircuitSectionRepository;
circuitListRepository?: CircuitListRepository;
deviceRowRepository?: CircuitDeviceRowRepository; deviceRowRepository?: CircuitDeviceRowRepository;
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore; deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
circuitSectionTransactionStore?: CircuitSectionTransactionStore; circuitSectionTransactionStore?: CircuitSectionTransactionStore;
projectDeviceRepository?: ProjectDeviceRepository;
numberingService?: CircuitNumberingService; numberingService?: CircuitNumberingService;
}) { }) {
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository(); this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository(); this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository();
this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository();
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository(); this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore; this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore; this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
this.numberingService = deps?.numberingService ?? new CircuitNumberingService(); this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
} }
@@ -58,36 +47,6 @@ export class CircuitWriteService {
return section; 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() { private getDeviceRowTransactionStore() {
if (!this.deviceRowTransactionStore) { if (!this.deviceRowTransactionStore) {
throw new Error("Circuit device-row transactions are not configured."); throw new Error("Circuit device-row transactions are not configured.");
@@ -102,92 +61,6 @@ export class CircuitWriteService {
return this.circuitSectionTransactionStore; 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) { async deleteCircuit(circuitId: string) {
const current = await this.circuitRepository.findById(circuitId); const current = await this.circuitRepository.findById(circuitId);
if (!current) { if (!current) {
@@ -196,50 +69,6 @@ export class CircuitWriteService {
await this.circuitRepository.delete(circuitId); 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) { async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) {
const row = await this.deviceRowRepository.findById(rowId); const row = await this.deviceRowRepository.findById(rowId);
if (!row) { if (!row) {
+392 -278
View File
@@ -23,6 +23,10 @@ import {
deviceFieldKeys, deviceFieldKeys,
formatValue, formatValue,
} from "../utils/circuit-grid-model"; } from "../utils/circuit-grid-model";
import {
buildCircuitDeviceRowInsertSnapshot,
buildCircuitInsertSnapshot,
} from "../utils/circuit-structure-command";
import type { import type {
CellKey, CellKey,
CellKind, CellKind,
@@ -36,13 +40,13 @@ import {
} from "../utils/circuit-grid-projection"; } from "../utils/circuit-grid-projection";
import type { VisibleGridRow } from "../utils/circuit-grid-projection"; import type { VisibleGridRow } from "../utils/circuit-grid-projection";
import { import {
createCircuit,
createCircuitWithDeviceRows,
createCircuitDeviceRow,
deleteCircuitById, deleteCircuitById,
deleteCircuitDeviceRowById, deleteCircuitCommand,
deleteCircuitDeviceRowCommand,
getCircuitTree, getCircuitTree,
getNextCircuitIdentifier, getNextCircuitIdentifier,
insertCircuitCommand,
insertCircuitDeviceRowCommand,
listProjectDevices, listProjectDevices,
moveCircuitDeviceRowsBulk, moveCircuitDeviceRowsBulk,
moveCircuitDeviceRowById, moveCircuitDeviceRowById,
@@ -56,11 +60,18 @@ import {
} from "../utils/api"; } from "../utils/api";
import type { import type {
CircuitTreeCircuitDto, CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
CreateCircuitDeviceRowInputDto,
CreateCircuitInputDto,
ProjectCommandResultDto, ProjectCommandResultDto,
ProjectDeviceDto, ProjectDeviceDto,
} from "../types"; } 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 SaveDirection = "stay" | "next" | "prev";
type StartEditMode = "selectExisting" | "replaceWithTypedChar"; 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 = type ProjectDeviceDropIntent =
| { kind: "new-circuit"; sectionId: string; valid: boolean } | { kind: "new-circuit"; sectionId: string; valid: boolean }
| { kind: "add-to-circuit"; circuitId: string; 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. // Runs a normal command and records it in session-local history.
async function runCommand(command: HistoryCommand) { async function runCommand(command: HistoryCommand) {
try { try {
@@ -1247,15 +1320,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const editValues = createValuesFromEditPatch( const editValues = createValuesFromEditPatch(
buildDeviceRowEditPatch(key, draft) buildDeviceRowEditPatch(key, draft)
); );
const created = await createCircuitWithDeviceRows(projectId, circuitListId, { const circuit = createCircuitSnapshot(
circuit: { {
sectionId, sectionId,
equipmentIdentifier: next.nextIdentifier, equipmentIdentifier: next.nextIdentifier,
displayName: "Neuer Stromkreis", displayName: "Neuer Stromkreis",
sortOrder, sortOrder,
isReserve: false, isReserve: false,
}, },
deviceRows: [ [
{ {
name: "Manuelles Gerät", name: "Manuelles Gerät",
displayName: "Manuelles Gerät", displayName: "Manuelles Gerät",
@@ -1266,24 +1339,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
cosPhi: 1, cosPhi: 1,
...editValues, ...editValues,
}, },
], ]
}); );
const createdCircuit = created.circuit; await persistCircuitInsert(circuit, "Aus freier Zeile erstellen");
return { rowKey: `circuitCompact:${createdCircuit.id}`, cellKey: key }; return { rowKey: `circuitCompact:${circuit.id}`, cellKey: key };
} }
const editValues = createValuesFromEditPatch( const editValues = createValuesFromEditPatch(
buildCircuitEditPatch(key, draft) buildCircuitEditPatch(key, draft)
); );
const createdCircuit = (await createCircuit(projectId, circuitListId, { const circuit = createCircuitSnapshot({
sectionId, sectionId,
equipmentIdentifier: next.nextIdentifier, equipmentIdentifier: next.nextIdentifier,
displayName: "Neuer Stromkreis", displayName: "Neuer Stromkreis",
sortOrder, sortOrder,
isReserve: true, isReserve: true,
...editValues, ...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. // 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(); return nextSelectionIntent();
}, },
undo: async () => { undo: async () => {
if (!createdCircuitId) { return {
return null; rowKey: `placeholder:${row.sectionId}`,
} cellKey: editingCell.cellKey,
await deleteCircuitById(createdCircuitId); rowType: "placeholder",
return buildSelectionIntent({ rowKey: `placeholder:${row.sectionId}`, cellKey: editingCell.cellKey }); 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); setEditingCell(null);
@@ -1412,26 +1507,54 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const editValues = createValuesFromEditPatch( const editValues = createValuesFromEditPatch(
buildDeviceRowEditPatch(editingCell.cellKey, next) buildDeviceRowEditPatch(editingCell.cellKey, next)
); );
const created = (await createCircuitDeviceRow(row.circuit!.id, { const snapshot = createDeviceRowSnapshot(
name: "Reserveverbraucher", row.circuit!.id,
displayName: "Reserveverbraucher", {
phaseType: "single_phase", name: "Reserveverbraucher",
quantity: 1, displayName: "Reserveverbraucher",
powerPerUnit: 0, phaseType: "single_phase",
simultaneityFactor: 1, quantity: 1,
cosPhi: 1, powerPerUnit: 0,
...editValues, simultaneityFactor: 1,
})) as { id: string }; cosPhi: 1,
createdRowId = created.id; ...editValues,
},
10
);
await persistDeviceRowInsert(
snapshot,
"Gerätezeile im Reservestromkreis erstellen"
);
createdRowId = snapshot.id;
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey }; targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
return nextSelectionIntent(); return nextSelectionIntent();
}, },
undo: async () => { undo: async () => ({
if (!createdRowId) { rowKey: `reserveCircuit:${row.circuit!.id}`,
return null; cellKey: editingCell.cellKey,
} rowType: "reserveCircuit",
await deleteCircuitDeviceRowById(createdRowId); sectionId: row.sectionId,
return buildSelectionIntent({ rowKey: `reserveCircuit:${row.circuit!.id}`, cellKey: editingCell.cellKey }); 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); setEditingCell(null);
@@ -1460,33 +1583,47 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
redo: async () => { redo: async () => {
const next = await getNextCircuitIdentifier(sectionId); const next = await getNextCircuitIdentifier(sectionId);
const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId); const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId);
const created = (await createCircuit(projectId, circuitListId, { const circuit = createCircuitSnapshot({
sectionId, sectionId,
equipmentIdentifier: next.nextIdentifier, equipmentIdentifier: next.nextIdentifier,
displayName: "Reserve", displayName: "Reserve",
sortOrder, sortOrder,
isReserve: true, isReserve: true,
})) as CircuitTreeCircuitDto; });
createdCircuitId = created.id; await persistCircuitInsert(circuit, "Stromkreis hinzufügen");
createdCircuitId = circuit.id;
setActiveSectionId(sectionId); setActiveSectionId(sectionId);
return { return {
rowKey: `reserveCircuit:${created.id}`, rowKey: `reserveCircuit:${circuit.id}`,
cellKey: "equipmentIdentifier", cellKey: "equipmentIdentifier",
rowType: "reserveCircuit", rowType: "reserveCircuit",
sectionId, sectionId,
circuitId: created.id, circuitId: circuit.id,
}; };
}, },
undo: async () => { undo: async () => ({
if (createdCircuitId) { rowKey: `placeholder:${sectionId}`,
await deleteCircuitById(createdCircuitId); cellKey: "equipmentIdentifier",
} rowType: "placeholder",
return { sectionId,
}),
persistent: {
undoIntent: () => ({
rowKey: `placeholder:${sectionId}`, rowKey: `placeholder:${sectionId}`,
cellKey: "equipmentIdentifier", cellKey: "equipmentIdentifier",
rowType: "placeholder", rowType: "placeholder",
sectionId, 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 originalDeviceCount = circuit.deviceRows.length;
const section = data?.sections.find((entry) => entry.id === sectionId); const section = data?.sections.find((entry) => entry.id === sectionId);
let createdRowId: string | null = null; 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({ await runCommand({
label: "Manuelles Gerät hinzufügen", label: "Manuelles Gerät hinzufügen",
redo: async () => { redo: async () => {
const created = (await createCircuitDeviceRow(circuit.id, { const rowSnapshot = createDeviceRowSnapshot(circuit.id, {
name: "Manuelles Gerät", name: "Manuelles Gerät",
displayName: "Manuelles Gerät", displayName: "Manuelles Gerät",
phaseType: section?.key === "three_phase" ? "three_phase" : "single_phase", phaseType: section?.key === "three_phase" ? "three_phase" : "single_phase",
@@ -1511,45 +1673,42 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
simultaneityFactor: 1, simultaneityFactor: 1,
cosPhi: 1, cosPhi: 1,
sortOrder: getInsertionSortOrder(circuit.deviceRows, afterDeviceRowId), sortOrder: getInsertionSortOrder(circuit.deviceRows, afterDeviceRowId),
})) as { id: string }; }, 10);
createdRowId = created.id; await persistDeviceRowInsert(
rowSnapshot,
"Manuelles Gerät hinzufügen"
);
createdRowId = rowSnapshot.id;
setActiveSectionId(sectionId); setActiveSectionId(sectionId);
return { return {
rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${created.id}`, rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${rowSnapshot.id}`,
cellKey: "displayName", cellKey: "displayName",
rowType: originalDeviceCount === 0 ? "circuitCompact" : "deviceRow", rowType: originalDeviceCount === 0 ? "circuitCompact" : "deviceRow",
sectionId, sectionId,
circuitId: circuit.id, circuitId: circuit.id,
deviceId: created.id, deviceId: rowSnapshot.id,
}; };
}, },
undo: async () => { undo: async () => getUndoIntent(),
if (createdRowId) { persistent: {
await deleteCircuitDeviceRowById(createdRowId); undoIntent: getUndoIntent,
} redoIntent: () =>
const rowKey = createdRowId
originalDeviceCount === 0 ? {
? `reserveCircuit:${circuit.id}` rowKey:
: originalDeviceCount === 1 originalDeviceCount === 0
? `circuitCompact:${circuit.id}` ? `circuitCompact:${circuit.id}`
: afterDeviceRowId : `device:${createdRowId}`,
? `device:${afterDeviceRowId}` cellKey: "displayName",
: `circuitSummary:${circuit.id}`; rowType:
return { originalDeviceCount === 0
rowKey, ? "circuitCompact"
cellKey: "displayName", : "deviceRow",
rowType: sectionId,
originalDeviceCount === 0 circuitId: circuit.id,
? "reserveCircuit" deviceId: createdRowId,
: originalDeviceCount === 1 }
? "circuitCompact" : null,
: afterDeviceRowId
? "deviceRow"
: "circuitSummary",
sectionId,
circuitId: circuit.id,
deviceId: afterDeviceRowId,
};
}, },
}); });
} }
@@ -1646,16 +1805,30 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceId: created.rowId, deviceId: created.rowId,
}; };
}, },
undo: async () => { undo: async () => ({
if (createdCircuitId) { rowKey: `placeholder:${targetSectionId}`,
await deleteCircuitById(createdCircuitId); cellKey: "displayName",
} rowType: "placeholder",
return { sectionId: targetSectionId,
}),
persistent: {
undoIntent: () => ({
rowKey: `placeholder:${targetSectionId}`, rowKey: `placeholder:${targetSectionId}`,
cellKey: "displayName", cellKey: "displayName",
rowType: "placeholder", rowType: "placeholder",
sectionId: targetSectionId, 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, deviceId: created.rowId,
}; };
}, },
undo: async () => { undo: async () => ({
if (createdRowId) { rowKey: `circuitSummary:${circuitId}`,
await deleteCircuitDeviceRowById(createdRowId); cellKey: "displayName",
} rowType: "circuitSummary",
return { sectionId,
circuitId,
}),
persistent: {
undoIntent: () => ({
rowKey: `circuitSummary:${circuitId}`, rowKey: `circuitSummary:${circuitId}`,
cellKey: "displayName", cellKey: "displayName",
rowType: "circuitSummary", rowType: "circuitSummary",
sectionId: findCircuitSectionId(circuitId) ?? "", sectionId,
circuitId, 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 next = await getNextCircuitIdentifier(sectionId);
const sortOrder = const sortOrder =
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const created = await createCircuitWithDeviceRows(projectId, circuitListId, { const circuit = createCircuitSnapshot(
circuit: { {
sectionId, sectionId,
equipmentIdentifier: next.nextIdentifier, equipmentIdentifier: next.nextIdentifier,
displayName: device.displayName || device.name, displayName: device.displayName || device.name,
sortOrder, sortOrder,
isReserve: false, isReserve: false,
}, },
deviceRows: [ [
{ {
linkedProjectDeviceId: device.id, linkedProjectDeviceId: device.id,
name: device.name, name: device.name,
@@ -1744,125 +1932,47 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
category: device.category ?? undefined, category: device.category ?? undefined,
remark: device.remark ?? undefined, remark: device.remark ?? undefined,
}, },
], ]
}); );
const createdCircuit = created.circuit; await persistCircuitInsert(
const createdRow = created.deviceRows[0]; circuit,
"Projektgerät als neuen Stromkreis hinzufügen"
);
const createdRow = circuit.deviceRows[0];
setActiveSectionId(sectionId); setActiveSectionId(sectionId);
setTargetCircuitId(createdCircuit.id); setTargetCircuitId(circuit.id);
return { circuitId: createdCircuit.id, rowId: createdRow.id }; return { circuitId: circuit.id, rowId: createdRow.id };
} }
async function insertProjectDeviceToCircuit(device: ProjectDeviceDto, circuitId: string) { async function insertProjectDeviceToCircuit(device: ProjectDeviceDto, circuitId: string) {
const createdRow = (await createCircuitDeviceRow(circuitId, { const circuit = allCircuits.find((entry) => entry.id === circuitId);
linkedProjectDeviceId: device.id, if (!circuit) {
name: device.name, throw new Error("Der Zielstromkreis ist ungültig.");
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 })),
};
} }
return null; const row = createDeviceRowSnapshot(
} circuitId,
{
async function recreateCircuit(snapshot: CircuitSnapshot) { linkedProjectDeviceId: device.id,
const circuitInput = { name: device.name,
sectionId: snapshot.sectionId, displayName: device.displayName,
equipmentIdentifier: snapshot.equipmentIdentifier, phaseType: resolvePhaseType(device),
displayName: snapshot.displayName, connectionKind: device.connectionKind ?? undefined,
sortOrder: snapshot.sortOrder, costGroup: device.costGroup ?? undefined,
protectionType: snapshot.protectionType, quantity: device.quantity,
protectionRatedCurrent: snapshot.protectionRatedCurrent, powerPerUnit: device.powerPerUnit,
protectionCharacteristic: snapshot.protectionCharacteristic, simultaneityFactor: device.simultaneityFactor,
cableType: snapshot.cableType, cosPhi: device.cosPhi ?? undefined,
cableCrossSection: snapshot.cableCrossSection, category: device.category ?? undefined,
cableLength: snapshot.cableLength, remark: device.remark ?? undefined,
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,
}, },
deviceRows: snapshot.deviceRows.map((row) => ({ getInsertionSortOrder(circuit.deviceRows)
linkedProjectDeviceId: row.linkedProjectDeviceId, );
name: row.name, await persistDeviceRowInsert(
displayName: row.displayName, row,
phaseType: row.phaseType, "Projektgerät zum Stromkreis hinzufügen"
connectionKind: row.connectionKind, );
costGroup: row.costGroup, return { rowId: row.id };
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),
};
} }
function parseDraggedProjectDeviceId(event: DragEvent<HTMLElement>) { function parseDraggedProjectDeviceId(event: DragEvent<HTMLElement>) {
@@ -2053,11 +2163,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// it can create new rows/circuits instead of moving existing ones. // it can create new rows/circuits instead of moving existing ones.
if (intent.kind === "new-circuit") { if (intent.kind === "new-circuit") {
let createdCircuitId: string | null = null; let createdCircuitId: string | null = null;
let createdRowId: string | null = null;
await runCommand({ await runCommand({
label: "Projektgerät in neuen Stromkreis ziehen", label: "Projektgerät in neuen Stromkreis ziehen",
redo: async () => { redo: async () => {
const created = await insertProjectDeviceAsNewCircuit(device, intent.sectionId); const created = await insertProjectDeviceAsNewCircuit(device, intent.sectionId);
createdCircuitId = created.circuitId; createdCircuitId = created.circuitId;
createdRowId = created.rowId;
return { return {
rowKey: `device:${created.rowId}`, rowKey: `device:${created.rowId}`,
cellKey: "displayName", cellKey: "displayName",
@@ -2067,11 +2179,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceId: created.rowId, deviceId: created.rowId,
}; };
}, },
undo: async () => { undo: async () => null,
if (createdCircuitId) { persistent: {
await deleteCircuitById(createdCircuitId); undoIntent: () => null,
} redoIntent: () =>
return null; createdCircuitId && createdRowId
? {
rowKey: `device:${createdRowId}`,
cellKey: "displayName",
rowType: "deviceRow",
sectionId: intent.sectionId,
circuitId: createdCircuitId,
deviceId: createdRowId,
}
: null,
}, },
}); });
return; return;
@@ -2091,11 +2212,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceId: created.rowId, deviceId: created.rowId,
}; };
}, },
undo: async () => { undo: async () => null,
if (createdRowId) { persistent: {
await deleteCircuitDeviceRowById(createdRowId); undoIntent: () => null,
} redoIntent: () =>
return null; 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) { async function handleDeleteDevice(rowId: string) {
const sourceCircuitId = findDeviceRowCircuitId(rowId); 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); const rowSnapshot = sourceCircuit?.deviceRows.find((row) => row.id === rowId);
if (!sourceCircuitId || !sourceCircuit || !rowSnapshot) { if (!sourceCircuitId || !sourceCircuit || !rowSnapshot) {
setError("Die Gerätezeile ist ungültig."); 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?`)) { } else if (!confirm(`Gerätezeile „${rowSnapshot.displayName}“ löschen?`)) {
return; return;
} }
let recreatedRowId: string | null = null;
await runCommand({ await runCommand({
label: "Gerätezeile löschen", label: "Gerätezeile löschen",
redo: async () => { redo: async () => {
await deleteCircuitDeviceRowById(recreatedRowId ?? rowId); await persistDeviceRowDelete(
rowId,
sourceCircuitId,
"Gerätezeile löschen"
);
return null; return null;
}, },
undo: async () => { undo: async () => null,
// Recreate instead of "undelete": backend ids are immutable once deleted. persistent: {
const created = (await createCircuitDeviceRow(sourceCircuitId, { undoIntent: () => null,
linkedProjectDeviceId: rowSnapshot.linkedProjectDeviceId, redoIntent: () => null,
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;
}, },
}); });
} }
// 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) { async function handleDeleteCircuit(circuitId: string, askForConfirmation = true) {
const snapshot = getCircuitSnapshot(circuitId); const circuit = allCircuits.find((entry) => entry.id === circuitId);
if (!snapshot) { if (!circuit) {
setError("Der Stromkreis ist ungültig."); setError("Der Stromkreis ist ungültig.");
return; return;
} }
if ( if (
askForConfirmation && askForConfirmation &&
!confirm( !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." "Bestehende Betriebsmittelkennzeichen werden nicht neu nummeriert."
) )
) { ) {
return; return;
} }
let recreatedCircuitId: string | null = null;
await runCommand({ await runCommand({
label: "Stromkreis löschen", label: "Stromkreis löschen",
redo: async () => { redo: async () => {
await deleteCircuitById(recreatedCircuitId ?? circuitId); await persistCircuitDelete(circuitId, "Stromkreis löschen");
return null; return null;
}, },
undo: async () => { undo: async () => null,
const recreated = await recreateCircuit(snapshot); persistent: {
recreatedCircuitId = recreated.circuitId; undoIntent: () => null,
return null; redoIntent: () => null,
}, },
}); });
} }
+78 -42
View File
@@ -17,10 +17,6 @@ import type {
ProjectDto, ProjectDto,
RoomDto, RoomDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
CreateCircuitInputDto,
CreateCircuitDeviceRowInputDto,
} from "../types"; } from "../types";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields"; import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
import { import {
@@ -31,6 +27,12 @@ import {
createCircuitDeviceRowUpdateProjectCommand, createCircuitDeviceRowUpdateProjectCommand,
type CircuitDeviceRowUpdatePatch, type CircuitDeviceRowUpdatePatch,
} from "../../domain/models/circuit-device-row-project-command.model"; } 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<T>(url: string, init?: RequestInit): Promise<T> { async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { const response = await fetch(url, {
@@ -159,33 +161,6 @@ export function getCircuitTree(projectId: string, circuitListId: string) {
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`); return request<CircuitTreeResponseDto>(`/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( export function updateCircuitById(
projectId: string, projectId: string,
expectedRevision: number, 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) { export function deleteCircuitById(circuitId: string) {
return request<void>(`/api/circuits/${circuitId}`, { return request<void>(`/api/circuits/${circuitId}`, {
method: "DELETE", 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( export function updateCircuitDeviceRowById(
projectId: string, projectId: string,
expectedRevision: number, expectedRevision: number,
@@ -233,10 +238,41 @@ export function updateCircuitDeviceRowById(
); );
} }
export function deleteCircuitDeviceRowById(rowId: string) { export function insertCircuitDeviceRowCommand(
return request<void>(`/api/circuit-device-rows/${rowId}`, { projectId: string,
method: "DELETE", 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( export function moveCircuitDeviceRowById(
@@ -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,
};
}
@@ -1,44 +1,10 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js"; import { circuitWriteService } from "../composition/circuit-write-service.js";
import { import {
createCircuitDeviceRowSchema,
moveCircuitDeviceRowsBulkSchema, moveCircuitDeviceRowsBulkSchema,
moveCircuitDeviceRowSchema, moveCircuitDeviceRowSchema,
} from "../../shared/validation/circuit.schemas.js"; } 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) { export async function moveCircuitDeviceRow(req: Request, res: Response) {
const { rowId } = req.params; const { rowId } = req.params;
if (typeof rowId !== "string") { if (typeof rowId !== "string") {
@@ -1,56 +1,5 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js"; 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) { export async function deleteCircuit(req: Request, res: Response) {
const { circuitId } = req.params; const { circuitId } = req.params;
@@ -1,6 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { import {
deleteCircuitDeviceRow,
moveCircuitDeviceRowsBulk, moveCircuitDeviceRowsBulk,
moveCircuitDeviceRow, moveCircuitDeviceRow,
} from "../controllers/circuit-device-row.controller.js"; } 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/move-bulk", moveCircuitDeviceRowsBulk);
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow); circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
-9
View File
@@ -1,20 +1,11 @@
import { Router } from "express"; import { Router } from "express";
import { import {
createCircuit,
createCircuitWithDeviceRows,
deleteCircuit, deleteCircuit,
getNextCircuitIdentifier, getNextCircuitIdentifier,
} from "../controllers/circuit.controller.js"; } from "../controllers/circuit.controller.js";
import { createCircuitDeviceRow } from "../controllers/circuit-device-row.controller.js";
export const circuitRouter = Router(); 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.delete("/circuits/:circuitId", deleteCircuit);
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier); circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
circuitRouter.post("/circuits/:circuitId/device-rows", createCircuitDeviceRow);
-49
View File
@@ -1,51 +1,5 @@
import { z } from "zod"; 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 export const moveCircuitDeviceRowSchema = z
.object({ .object({
targetCircuitId: z.string().min(1).optional(), targetCircuitId: z.string().min(1).optional(),
@@ -88,9 +42,6 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({
.min(1), .min(1),
}); });
export type CreateCircuitInput = z.infer<typeof createCircuitSchema>;
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
export type CreateCircuitWithDeviceRowsInput = z.infer<typeof createCircuitWithDeviceRowsSchema>;
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>; export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>; export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>; export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
+207
View File
@@ -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<string, unknown> }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({
url: String(input),
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
});
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"],
]
);
});
});
-257
View File
@@ -1,265 +1,8 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js"; import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
import { createCircuitSchema } from "../src/shared/validation/circuit.schemas.js";
describe("circuit write service rules", () => { 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 () => { it("renumber uses safe bulk identifier update for swapped identifiers", async () => {
let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = []; let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = [];
const service = new CircuitWriteService({ const service = new CircuitWriteService({