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
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
+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
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
+8 -6
View File
@@ -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
@@ -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.
+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.
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;
@@ -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
+6 -3
View File
@@ -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:
+2 -2
View File
@@ -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",
@@ -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) {
+392 -278
View File
@@ -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<HTMLElement>) {
@@ -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,
},
});
}
+78 -42
View File
@@ -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<T>(url: string, init?: RequestInit): Promise<T> {
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`);
}
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<void>(`/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<void>(`/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(
@@ -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 { 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") {
@@ -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;
@@ -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);
-9
View File
@@ -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);
-49
View File
@@ -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<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 MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
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 { 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({