Persist device row moves

This commit is contained in:
2026-07-25 21:09:02 +02:00
parent 2eba4ea75e
commit 08a2775a88
25 changed files with 391 additions and 1678 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 move, reorder The editor's visible undo/redo stack remains session-local while reorder and
and renumber operations are being migrated. Existing Circuit and renumber operations are being migrated. Existing Circuit and CircuitDeviceRow
CircuitDeviceRow cell edits plus standalone insertion/deletion already execute cell edits, standalone insertion/deletion and CircuitDeviceRow moves already
persistent project commands; their toolbar undo/redo actions use the execute persistent project commands; their toolbar undo/redo actions use the
project-wide history endpoints. Insertions use client-generated stable UUIDs, project-wide history endpoints. Insertions and generated move targets use
and deletion undo restores the same ids from complete server snapshots. The client-generated stable UUIDs, and undo restores the same ids from complete
tree response supplies the optimistic `currentRevision`; obsolete direct field server snapshots. The tree response supplies the optimistic `currentRevision`;
PATCH, structure POST and CircuitDeviceRow DELETE routes are removed. obsolete direct field PATCH, structure POST, move, Circuit and CircuitDeviceRow
DELETE routes are removed.
Reloading the page does not yet reconstruct the visible editor stack. 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
+8 -45
View File
@@ -36,9 +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 plus standalone insert/delete actions in the editor CircuitDeviceRow cell edits, standalone insert/delete actions and device-row
consume these endpoints. Move, reorder and renumber UI paths are still being moves in the editor consume these endpoints. Reorder and renumber UI paths are
migrated. 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
@@ -169,48 +169,11 @@ Response sketch:
- `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`)
Circuit and device-row field updates, standalone insertions and standalone Circuit and device-row field updates, standalone insertions/deletions and
deletions are available only as the corresponding versioned commands through single or bulk device-row moves are available only as the corresponding
`POST /projects/:projectId/commands`. There are no direct field-update PATCH, versioned commands through `POST /projects/:projectId/commands`. There are no
structure POST or CircuitDeviceRow DELETE routes. The direct direct field-update PATCH, structure POST, move, Circuit DELETE or
`DELETE /circuits/:circuitId` route remains temporarily for the inverse of the CircuitDeviceRow DELETE routes.
not-yet-migrated placeholder move UI and must not be used by new features.
### Move Device Row
- `PATCH /circuit-device-rows/:rowId/move`
- Purpose: move one row to existing circuit or to newly created circuit in target section.
Request sketch:
```json
{
"targetCircuitId": "cir_target"
}
```
or
```json
{
"targetSectionId": "sec_target",
"createNewCircuit": true
}
```
### Bulk Move Device Rows
- `PATCH /circuit-device-rows/move-bulk`
- Purpose: move multiple rows in one command flow.
Request sketch:
```json
{
"rowIds": ["row_1", "row_2"],
"targetCircuitId": "cir_target"
}
```
### Reorder Circuits ### Reorder Circuits
@@ -1,10 +1,10 @@
# Circuit List Editor Known Limitations # Circuit List Editor Known Limitations
- Existing Circuit and CircuitDeviceRow cell edits plus standalone - Existing Circuit and CircuitDeviceRow cell edits plus standalone
insert/delete actions use persistent project commands and project-wide insert/delete actions and CircuitDeviceRow moves use persistent project
toolbar undo/redo. The editor's visible stack is still session-local, is commands and project-wide toolbar undo/redo. The editor's visible stack is
empty after reload and still contains direct move/reorder/renumber operations still session-local, is empty after reload and still contains direct
during the cutover. 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 -4
View File
@@ -91,10 +91,11 @@ sowie eigenständiges Einfügen und Löschen verwendet das Grid bereits über di
öffentliche Command-Grenze. Neue Circuits und Gerätezeilen erhalten ihre stabile ö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 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 dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektweite
Serverhistorie. Direkte Feld-PATCH-, Struktur-POST- und Gerätezeilen-DELETE- Serverhistorie. Direkte Feld-PATCH-, Struktur-POST-, Move-, Circuit- und
Endpunkte sind entfernt. Move-, Reorder- und Renumber-UI-Pfade verbleiben Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves im Grid
vorerst im sichtbaren sitzungslokalen Stack; nach einem Reload wird dieser noch verwenden die persistenten Kommandos bereits. Nur Reorder- und
nicht aus der Serverhistorie rekonstruiert. 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;
@@ -104,6 +105,9 @@ Verschieben auf einen freien Platz ab: Ein Zielstromkreis mit stabiler UUID und
BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten
Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen
Felder und vollständiger Zeilenbestand unverändert sind. Felder und vollständiger Zeilenbestand unverändert sind.
Der Editor erzeugt die vollständigen Move-Zuweisungen aus dem geladenen Tree,
vergibt für neue Ziele vor dem Kommando eine stabile UUID und führt das
Toolbar-Undo/Redo über die projektweite Historie aus.
`circuit.reorder-section` speichert die erwartete und neue Sortierposition `circuit.reorder-section` speichert die erwartete und neue Sortierposition
jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo
ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs
+4 -23
View File
@@ -130,27 +130,8 @@ Expected response shape:
If migrations were not applied, endpoint may return an empty fallback with a warning. If migrations were not applied, endpoint may return an empty fallback with a warning.
## Dev-only visual test helper (multi-device circuit) ## Visual verification
Add one extra manual device row to an existing circuit: Manual device rows and multi-device circuits can be created directly in the
circuit-list editor. The former development scripts for direct database writes
```bash were removed because they bypassed project revisions and persistent undo/redo.
npm run dev:add-manual-circuit-row -- <circuitId>
```
Default inserted values:
- `name`: `Test sub device`
- `displayName`: `Beleuchtung WC`
- `phaseType`: `single_phase`
- `quantity`: `1`
- `powerPerUnit`: `0.05`
- `simultaneityFactor`: `1`
- `cosPhi`: `1`
The script prints the created row id.
Delete the test row again:
```bash
npm run dev:delete-circuit-row -- <rowId>
```
@@ -194,6 +194,9 @@ Completed foundation:
empty target-circuit snapshot; forward creation and all row moves share one empty target-circuit snapshot; forward creation and all row moves share one
transaction, while undo restores the source positions and deletes only an transaction, while undo restores the source positions and deletes only an
unchanged generated target unchanged generated target
- the circuit-list editor executes existing-target and generated-target row
moves through these commands; obsolete direct move and Circuit DELETE routes
plus their separate transaction adapter are removed
- `circuit.reorder-section` requires the complete section circuit set and - `circuit.reorder-section` requires the complete section circuit set and
stores exact expected/target sort positions; its inverse changes no stores exact expected/target sort positions; its inverse changes no
equipment identifier and never splits a circuit block equipment identifier and never splits a circuit block
@@ -211,9 +214,8 @@ Completed foundation:
Remaining constraints before completing persistent history: Remaining constraints before completing persistent history:
- route the remaining move, reorder and renumber editor writes through their - route the remaining reorder and renumber editor writes through their existing
existing typed persistent commands and remove the final direct mutation typed persistent commands and remove the final direct mutation routes
routes
- reconstruct the visible frontend history state after reload; Circuit and - reconstruct the visible frontend history state after reload; Circuit and
CircuitDeviceRow field/insert/delete actions plus ProjectDevice operations CircuitDeviceRow field/insert/delete actions plus ProjectDevice operations
already use persistent commands and project-wide undo already use persistent commands and project-wide undo
+3 -5
View File
@@ -14,16 +14,14 @@
"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-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": "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-device-row-move-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.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", "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-device-row-move-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.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",
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js", "db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
"db:backfill:sections": "tsx scripts/db-backfill-sections.ts", "db:backfill:sections": "tsx scripts/db-backfill-sections.ts",
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts", "db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts"
"dev:add-manual-circuit-row": "tsx scripts/dev-add-manual-circuit-row.ts",
"dev:delete-circuit-row": "tsx scripts/dev-delete-circuit-row.ts"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
-39
View File
@@ -1,39 +0,0 @@
import { db } from "../src/db/client.js";
import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
async function run() {
const circuitId = process.argv[2];
if (!circuitId) {
console.error("Usage: npm run dev:add-manual-circuit-row -- <circuitId>");
process.exit(1);
}
const circuitRepository = new CircuitRepository();
const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db);
const circuit = await circuitRepository.findById(circuitId);
if (!circuit) {
console.error(`Circuit not found: ${circuitId}`);
process.exit(1);
}
const createdRowId = rowTransactionRepository.createInCircuit({
circuitId,
name: "Test sub device",
displayName: "Beleuchtung WC",
phaseType: "single_phase",
quantity: 1,
powerPerUnit: 0.05,
simultaneityFactor: 1,
cosPhi: 1,
});
console.log(`Created test row id: ${createdRowId}`);
}
run().catch((error) => {
console.error("Failed to create test row:", error);
process.exit(1);
});
-28
View File
@@ -1,28 +0,0 @@
import { db } from "../src/db/client.js";
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
async function run() {
const rowId = process.argv[2];
if (!rowId) {
console.error("Usage: npm run dev:delete-circuit-row -- <rowId>");
process.exit(1);
}
const rowRepository = new CircuitDeviceRowRepository();
const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db);
const row = await rowRepository.findById(rowId);
if (!row) {
console.error(`Row not found: ${rowId}`);
process.exit(1);
}
rowTransactionRepository.deleteFromCircuit(rowId, row.circuitId);
console.log(`Deleted row id: ${rowId}`);
}
run().catch((error) => {
console.error("Failed to delete row:", error);
process.exit(1);
});
@@ -1,282 +0,0 @@
import crypto from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import type {
CircuitDeviceRowTransactionStore,
CreateCircuitDeviceRowTransactionInput,
CreateCircuitWithDeviceRowsTransactionInput,
MoveCircuitDeviceRowsTransactionInput,
} from "../../domain/ports/circuit-device-row-transaction.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuits } from "../schema/circuits.js";
import { toCircuitCreateValues } from "./circuit.persistence.js";
import {
toCircuitDeviceRowCreateValues,
} from "./circuit-device-row.persistence.js";
export class CircuitDeviceRowTransactionRepository
implements CircuitDeviceRowTransactionStore
{
constructor(private readonly database: AppDatabase) {}
createInCircuit(input: CreateCircuitDeviceRowTransactionInput) {
const id = crypto.randomUUID();
this.database.transaction((tx) => {
const [circuit] = tx
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.id, input.circuitId))
.limit(1)
.all();
if (!circuit) {
throw new Error("Der Stromkreis ist ungültig.");
}
const existingRows = tx
.select({ sortOrder: circuitDeviceRows.sortOrder })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, input.circuitId))
.all();
const lastSortOrder = existingRows.reduce(
(highest, row) => Math.max(highest, row.sortOrder),
0
);
const sortOrder = input.sortOrder ?? lastSortOrder + 10;
tx
.insert(circuitDeviceRows)
.values(toCircuitDeviceRowCreateValues(id, { ...input, sortOrder }))
.run();
tx
.update(circuits)
.set({ isReserve: 0 })
.where(eq(circuits.id, input.circuitId))
.run();
});
return id;
}
createCircuitWithDeviceRows(input: CreateCircuitWithDeviceRowsTransactionInput) {
if (input.deviceRows.length === 0) {
throw new Error("Mindestens eine Gerätezeile ist erforderlich.");
}
const circuitId = crypto.randomUUID();
const rowIds = input.deviceRows.map(() => crypto.randomUUID());
this.database.transaction((tx) => {
tx
.insert(circuits)
.values(
toCircuitCreateValues(circuitId, {
...input.circuit,
isReserve: false,
})
)
.run();
for (let index = 0; index < input.deviceRows.length; index += 1) {
const row = input.deviceRows[index];
tx
.insert(circuitDeviceRows)
.values(
toCircuitDeviceRowCreateValues(rowIds[index], {
...row,
circuitId,
sortOrder: row.sortOrder ?? (index + 1) * 10,
})
)
.run();
}
});
return { circuitId, rowIds };
}
deleteFromCircuit(rowId: string, expectedCircuitId: string) {
this.database.transaction((tx) => {
const [row] = tx
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, rowId))
.limit(1)
.all();
if (!row || row.circuitId !== expectedCircuitId) {
throw new Error("Die Gerätezeile wurde vor dem Löschen verändert.");
}
const result = tx
.delete(circuitDeviceRows)
.where(
and(
eq(circuitDeviceRows.id, rowId),
eq(circuitDeviceRows.circuitId, expectedCircuitId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("Die Gerätezeile konnte nicht gelöscht werden.");
}
const remainingRows = tx
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, expectedCircuitId))
.all();
tx
.update(circuits)
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
.where(eq(circuits.id, expectedCircuitId))
.run();
});
}
moveRows(input: MoveCircuitDeviceRowsTransactionInput) {
if (input.rows.length === 0) {
throw new Error("Keine Gerätezeilen zum Verschieben angegeben.");
}
if (Boolean(input.targetCircuitId) === Boolean(input.createTargetCircuit)) {
throw new Error("Für die Mehrfachverschiebung muss genau ein Ziel angegeben werden.");
}
const rowIds = input.rows.map((row) => row.id);
if (new Set(rowIds).size !== rowIds.length) {
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
}
const createdCircuitId = input.createTargetCircuit ? crypto.randomUUID() : undefined;
const targetCircuitId = input.targetCircuitId ?? createdCircuitId!;
this.database.transaction((tx) => {
const persistedRows = tx
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (persistedRows.length !== input.rows.length) {
throw new Error("Eine oder mehrere Gerätezeilen sind ungültig.");
}
const persistedRowsById = new Map(persistedRows.map((row) => [row.id, row]));
for (const expectedRow of input.rows) {
if (
persistedRowsById.get(expectedRow.id)?.circuitId !==
expectedRow.expectedCircuitId
) {
throw new Error(
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
);
}
}
let targetCircuit: { id: string; circuitListId: string };
if (input.createTargetCircuit) {
tx
.insert(circuits)
.values({
id: targetCircuitId,
circuitListId: input.createTargetCircuit.circuitListId,
sectionId: input.createTargetCircuit.sectionId,
equipmentIdentifier: input.createTargetCircuit.equipmentIdentifier,
displayName: input.createTargetCircuit.displayName,
sortOrder: input.createTargetCircuit.sortOrder,
isReserve: 0,
})
.run();
targetCircuit = {
id: targetCircuitId,
circuitListId: input.createTargetCircuit.circuitListId,
};
} else {
const [persistedTargetCircuit] = tx
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
.from(circuits)
.where(eq(circuits.id, targetCircuitId))
.limit(1)
.all();
if (!persistedTargetCircuit) {
throw new Error("Der Zielstromkreis ist ungültig.");
}
targetCircuit = persistedTargetCircuit;
}
const sourceCircuitIds = [
...new Set(input.rows.map((row) => row.expectedCircuitId)),
];
const sourceCircuits = tx
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
.from(circuits)
.where(inArray(circuits.id, sourceCircuitIds))
.all();
if (sourceCircuits.length !== sourceCircuitIds.length) {
throw new Error("Einer oder mehrere Quellstromkreise sind ungültig.");
}
if (
sourceCircuits.some(
(circuit) => circuit.circuitListId !== targetCircuit.circuitListId
)
) {
throw new Error(
"Alle verschobenen Zeilen müssen zur Stromkreisliste des Ziels gehören."
);
}
const movedRowIdSet = new Set(rowIds);
const retainedTargetRows = tx
.select({ id: circuitDeviceRows.id, sortOrder: circuitDeviceRows.sortOrder })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, targetCircuitId))
.all()
.filter((row) => !movedRowIdSet.has(row.id));
const lastTargetSortOrder = retainedTargetRows.reduce(
(highest, row) => Math.max(highest, row.sortOrder),
0
);
for (let index = 0; index < input.rows.length; index += 1) {
const row = input.rows[index];
const result = tx
.update(circuitDeviceRows)
.set({
circuitId: targetCircuitId,
sortOrder: lastTargetSortOrder + (index + 1) * 10,
})
.where(
and(
eq(circuitDeviceRows.id, row.id),
eq(circuitDeviceRows.circuitId, row.expectedCircuitId)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
);
}
}
for (const sourceCircuitId of sourceCircuitIds) {
const remainingRows = tx
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, sourceCircuitId))
.all();
tx
.update(circuits)
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
.where(eq(circuits.id, sourceCircuitId))
.run();
}
tx
.update(circuits)
.set({ isReserve: 0 })
.where(eq(circuits.id, targetCircuitId))
.run();
});
return {
movedRowIds: rowIds,
targetCircuitId,
createdCircuitId,
};
}
}
@@ -1,75 +0,0 @@
export interface CreateCircuitDeviceRowTransactionInput {
circuitId: string;
linkedProjectDeviceId?: string;
sortOrder?: number;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}
export interface CreateCircuitTransactionInput {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
protectionType?: string;
protectionRatedCurrent?: number;
protectionCharacteristic?: string;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
voltage?: number;
controlRequirement?: string;
remark?: string;
rcdAssignment?: string;
terminalDesignation?: string;
status?: string;
}
export interface CreateCircuitWithDeviceRowsTransactionInput {
circuit: CreateCircuitTransactionInput;
deviceRows: Array<Omit<CreateCircuitDeviceRowTransactionInput, "circuitId">>;
}
export interface MoveCircuitDeviceRowsTransactionInput {
rows: Array<{ id: string; expectedCircuitId: string }>;
targetCircuitId?: string;
createTargetCircuit?: {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
};
}
export interface MoveCircuitDeviceRowsTransactionResult {
movedRowIds: string[];
targetCircuitId: string;
createdCircuitId?: string;
}
export interface CircuitDeviceRowTransactionStore {
createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string;
createCircuitWithDeviceRows(
input: CreateCircuitWithDeviceRowsTransactionInput
): { circuitId: string; rowIds: string[] };
deleteFromCircuit(rowId: string, expectedCircuitId: string): void;
moveRows(
input: MoveCircuitDeviceRowsTransactionInput
): MoveCircuitDeviceRowsTransactionResult;
}
@@ -1,11 +1,7 @@
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 type { CircuitSectionTransactionStore } from "../ports/circuit-section-transaction.store.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 type { import type {
MoveCircuitDeviceRowInput,
MoveCircuitDeviceRowsBulkInput,
ReorderSectionCircuitsInput, ReorderSectionCircuitsInput,
UpdateSectionEquipmentIdentifiersInput, UpdateSectionEquipmentIdentifiersInput,
} from "../../shared/validation/circuit.schemas.js"; } from "../../shared/validation/circuit.schemas.js";
@@ -14,46 +10,21 @@ 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 deviceRowRepository: CircuitDeviceRowRepository;
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore; private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
private readonly numberingService: CircuitNumberingService; private readonly numberingService: CircuitNumberingService;
constructor(deps?: { constructor(deps?: {
circuitRepository?: CircuitRepository; circuitRepository?: CircuitRepository;
circuitSectionRepository?: CircuitSectionRepository; circuitSectionRepository?: CircuitSectionRepository;
deviceRowRepository?: CircuitDeviceRowRepository;
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
circuitSectionTransactionStore?: CircuitSectionTransactionStore; circuitSectionTransactionStore?: CircuitSectionTransactionStore;
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.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore; this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
this.numberingService = deps?.numberingService ?? new CircuitNumberingService(); this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
} }
// Ensures writes never connect a section to the wrong circuit list.
private async assertSectionInList(sectionId: string, circuitListId: string) {
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
throw new Error("Invalid section id.");
}
if (section.circuitListId !== circuitListId) {
throw new Error("Section does not belong to circuit list.");
}
return section;
}
private getDeviceRowTransactionStore() {
if (!this.deviceRowTransactionStore) {
throw new Error("Circuit device-row transactions are not configured.");
}
return this.deviceRowTransactionStore;
}
private getCircuitSectionTransactionStore() { private getCircuitSectionTransactionStore() {
if (!this.circuitSectionTransactionStore) { if (!this.circuitSectionTransactionStore) {
throw new Error("Circuit-section transactions are not configured."); throw new Error("Circuit-section transactions are not configured.");
@@ -61,157 +32,6 @@ export class CircuitWriteService {
return this.circuitSectionTransactionStore; return this.circuitSectionTransactionStore;
} }
async deleteCircuit(circuitId: string) {
const current = await this.circuitRepository.findById(circuitId);
if (!current) {
throw new Error("Invalid circuit id.");
}
await this.circuitRepository.delete(circuitId);
}
async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) {
const row = await this.deviceRowRepository.findById(rowId);
if (!row) {
throw new Error("Invalid device row id.");
}
const sourceCircuit = await this.circuitRepository.findById(row.circuitId);
if (!sourceCircuit) {
throw new Error("Invalid circuit id.");
}
let targetCircuit = input.targetCircuitId
? await this.circuitRepository.findById(input.targetCircuitId)
: null;
if (input.targetCircuitId && !targetCircuit) {
throw new Error("Invalid target circuit id.");
}
let createTargetCircuit:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
// Placeholder-target move prepares a new circuit explicitly. Its creation and
// the row assignment are committed together without renumbering other circuits.
if (!targetCircuit) {
if (!input.targetSectionId || !input.createNewCircuit) {
throw new Error("Invalid move target.");
}
const section = await this.assertSectionInList(input.targetSectionId, sourceCircuit.circuitListId);
const nextIdentifier = await this.numberingService.getNextIdentifier(section.id);
const sectionCircuits = await this.circuitRepository.listBySection(section.id);
const nextSortOrder =
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
createTargetCircuit = {
circuitListId: sourceCircuit.circuitListId,
sectionId: section.id,
equipmentIdentifier: nextIdentifier,
displayName: "Neuer Stromkreis",
sortOrder: nextSortOrder,
};
}
if (targetCircuit && targetCircuit.circuitListId !== sourceCircuit.circuitListId) {
throw new Error("Target circuit does not belong to same circuit list.");
}
if (targetCircuit?.id === sourceCircuit.id) {
return this.deviceRowRepository.findById(rowId);
}
this.getDeviceRowTransactionStore().moveRows({
rows: [{ id: row.id, expectedCircuitId: row.circuitId }],
targetCircuitId: targetCircuit?.id,
createTargetCircuit,
});
return this.deviceRowRepository.findById(rowId);
}
async moveDeviceRowsBulk(input: MoveCircuitDeviceRowsBulkInput) {
// Bulk move keeps input order and resolves all source circuits first so undo can
// restore per-source assignment deterministically.
const uniqueRowIds = [...new Set(input.rowIds)];
if (uniqueRowIds.length === 0) {
throw new Error("No device rows provided.");
}
const rows = [];
for (const rowId of uniqueRowIds) {
const row = await this.deviceRowRepository.findById(rowId);
if (!row) {
throw new Error("Invalid device row id.");
}
rows.push(row);
}
const sourceCircuits = new Map<string, Awaited<ReturnType<CircuitRepository["findById"]>>>();
for (const row of rows) {
if (!sourceCircuits.has(row.circuitId)) {
const circuit = await this.circuitRepository.findById(row.circuitId);
if (!circuit) {
throw new Error("Invalid circuit id.");
}
sourceCircuits.set(row.circuitId, circuit);
}
}
const referenceSourceCircuit = sourceCircuits.get(rows[0].circuitId)!;
let targetCircuit = input.targetCircuitId
? await this.circuitRepository.findById(input.targetCircuitId)
: null;
if (input.targetCircuitId && !targetCircuit) {
throw new Error("Invalid target circuit id.");
}
// Bulk placeholder move prepares exactly one new circuit as common target.
// Its actual creation happens together with the row moves in one transaction.
let createTargetCircuit:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
if (!targetCircuit) {
if (!input.targetSectionId || !input.createNewCircuit) {
throw new Error("Invalid move target.");
}
const section = await this.assertSectionInList(input.targetSectionId, referenceSourceCircuit.circuitListId);
const nextIdentifier = await this.numberingService.getNextIdentifier(section.id);
const sectionCircuits = await this.circuitRepository.listBySection(section.id);
const nextSortOrder =
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
createTargetCircuit = {
circuitListId: referenceSourceCircuit.circuitListId,
sectionId: section.id,
equipmentIdentifier: nextIdentifier,
displayName: "Neuer Stromkreis",
sortOrder: nextSortOrder,
};
}
const targetCircuitListId = targetCircuit?.circuitListId ?? createTargetCircuit!.circuitListId;
for (const sourceCircuit of sourceCircuits.values()) {
if (sourceCircuit.circuitListId !== targetCircuitListId) {
throw new Error("All moved rows must belong to same circuit list as target.");
}
}
return this.getDeviceRowTransactionStore().moveRows({
rows: rows.map((row) => ({ id: row.id, expectedCircuitId: row.circuitId })),
targetCircuitId: targetCircuit?.id,
createTargetCircuit,
});
}
async getNextIdentifier(sectionId: string) { async getNextIdentifier(sectionId: string) {
return this.numberingService.getNextIdentifier(sectionId); return this.numberingService.getNextIdentifier(sectionId);
} }
+94 -40
View File
@@ -27,6 +27,9 @@ import {
buildCircuitDeviceRowInsertSnapshot, buildCircuitDeviceRowInsertSnapshot,
buildCircuitInsertSnapshot, buildCircuitInsertSnapshot,
} from "../utils/circuit-structure-command"; } from "../utils/circuit-structure-command";
import {
buildCircuitDeviceRowMoveAssignments,
} from "../utils/circuit-device-row-move-command";
import type { import type {
CellKey, CellKey,
CellKind, CellKind,
@@ -40,7 +43,6 @@ 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 {
deleteCircuitById,
deleteCircuitCommand, deleteCircuitCommand,
deleteCircuitDeviceRowCommand, deleteCircuitDeviceRowCommand,
getCircuitTree, getCircuitTree,
@@ -48,8 +50,8 @@ import {
insertCircuitCommand, insertCircuitCommand,
insertCircuitDeviceRowCommand, insertCircuitDeviceRowCommand,
listProjectDevices, listProjectDevices,
moveCircuitDeviceRowsBulk, moveCircuitDeviceRowsCommand,
moveCircuitDeviceRowById, moveCircuitDeviceRowsToNewCircuitCommand,
reorderSectionCircuits, reorderSectionCircuits,
renumberCircuitSection, renumberCircuitSection,
redoProjectCommand, redoProjectCommand,
@@ -2230,8 +2232,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}); });
} }
// Handles device-row drag intent (single or bulk) and preserves enough source grouping // Handles single and bulk device-row drag through persistent project history.
// for deterministic undo back to original circuits. // Exact source and target positions make undo deterministic.
async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) { async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -2269,66 +2271,118 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; return;
} }
// Device-row drag supports bulk selection, but target intent is always explicit: const circuits = (data?.sections ?? []).flatMap(
// move into existing circuit or create new circuit in a target section. (section) => section.circuits
);
const label =
rowIds.length > 1
? `${rowIds.length} Gerätezeilen verschieben`
: "Gerätezeile verschieben";
if (intent.kind === "move-to-circuit") { if (intent.kind === "move-to-circuit") {
const groupedBySource = new Map<string, string[]>(); const targetCircuit = circuits.find(
for (const rowId of rowIds) { (circuit) => circuit.id === intent.circuitId
const source = sourceByRowId.get(rowId)!; );
if (!groupedBySource.has(source)) { if (!targetCircuit) {
groupedBySource.set(source, []); setError("Der Zielstromkreis ist ungültig.");
return;
} }
groupedBySource.get(source)!.push(rowId); const moves = buildCircuitDeviceRowMoveAssignments({
circuits,
rowIds,
targetCircuitId: targetCircuit.id,
targetDeviceRows: targetCircuit.deviceRows,
});
if (moves.length === 0) {
return;
} }
await runCommand({ await runCommand({
label: rowIds.length > 1 ? `${rowIds.length} Gerätezeilen verschieben` : "Gerätezeile verschieben", label,
redo: async () => { redo: async () => {
await moveCircuitDeviceRowsBulk({ rowIds, targetCircuitId: intent.circuitId }); const result = await moveCircuitDeviceRowsCommand(
projectId,
getExpectedProjectRevision(),
moves,
label
);
applyProjectCommandResult(result);
pendingSelectedDeviceRowIdsAfterReload.current = rowIds; pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null; return null;
}, },
undo: async () => { undo: async () => null,
for (const [source, ids] of groupedBySource) { persistent: {
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source }); undoIntent: () => {
}
pendingSelectedDeviceRowIdsAfterReload.current = rowIds; pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null; return null;
}, },
redoIntent: () => {
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
},
}); });
return; return;
} }
let createdCircuitId: string | null = null; const targetSection = data?.sections.find(
const groupedBySource = new Map<string, string[]>(); (section) => section.id === intent.sectionId
for (const rowId of rowIds) { );
const source = sourceByRowId.get(rowId)!; if (!targetSection) {
if (!groupedBySource.has(source)) { setError("Der Zielbereich ist ungültig.");
groupedBySource.set(source, []); return;
}
groupedBySource.get(source)!.push(rowId);
} }
const newCircuitLabel =
rowIds.length > 1
? `${rowIds.length} Gerätezeilen in neuen Stromkreis verschieben`
: "Gerätezeile in neuen Stromkreis verschieben";
await runCommand({ await runCommand({
label: rowIds.length > 1 ? `${rowIds.length} Gerätezeilen in neuen Stromkreis verschieben` : "Gerätezeile in neuen Stromkreis verschieben", label: newCircuitLabel,
redo: async () => { redo: async () => {
const moved = await moveCircuitDeviceRowsBulk({ const next = await getNextCircuitIdentifier(intent.sectionId);
rowIds, const sortOrder =
targetSectionId: intent.sectionId, targetSection.circuits.length > 0
createNewCircuit: true, ? Math.max(
...targetSection.circuits.map(
(circuit) => circuit.sortOrder
)
) + 10
: 10;
const targetCircuit = createCircuitSnapshot({
sectionId: intent.sectionId,
equipmentIdentifier: next.nextIdentifier,
displayName: "Neuer Stromkreis",
sortOrder,
isReserve: true,
}); });
createdCircuitId = moved.createdCircuitId ?? null; const moves = buildCircuitDeviceRowMoveAssignments({
circuits,
rowIds,
targetCircuitId: targetCircuit.id,
targetDeviceRows: [],
});
const result =
await moveCircuitDeviceRowsToNewCircuitCommand(
projectId,
getExpectedProjectRevision(),
targetCircuit,
moves,
newCircuitLabel
);
applyProjectCommandResult(result);
pendingSelectedDeviceRowIdsAfterReload.current = rowIds; pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null; return null;
}, },
undo: async () => { undo: async () => null,
for (const [source, ids] of groupedBySource) { persistent: {
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source }); undoIntent: () => {
}
if (createdCircuitId) {
await deleteCircuitById(createdCircuitId);
}
pendingSelectedDeviceRowIdsAfterReload.current = rowIds; pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null; return null;
}, },
redoIntent: () => {
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
},
}); });
} }
+37 -24
View File
@@ -33,6 +33,9 @@ import type {
import type { import type {
CircuitSnapshot, CircuitSnapshot,
} from "../../domain/models/circuit-structure-project-command.model"; } from "../../domain/models/circuit-structure-project-command.model";
import type {
CircuitDeviceRowMoveAssignment,
} from "../../domain/models/circuit-device-row-move-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, {
@@ -212,12 +215,6 @@ export function deleteCircuitCommand(
); );
} }
export function deleteCircuitById(circuitId: string) {
return request<void>(`/api/circuits/${circuitId}`, {
method: "DELETE",
});
}
export function getNextCircuitIdentifier(sectionId: string) { export function getNextCircuitIdentifier(sectionId: string) {
return request<{ sectionId: string; nextIdentifier: string }>( return request<{ sectionId: string; nextIdentifier: string }>(
`/api/circuit-sections/${sectionId}/next-identifier` `/api/circuit-sections/${sectionId}/next-identifier`
@@ -275,28 +272,44 @@ export function deleteCircuitDeviceRowCommand(
); );
} }
export function moveCircuitDeviceRowById( export function moveCircuitDeviceRowsCommand(
rowId: string, projectId: string,
input: { targetCircuitId?: string; targetSectionId?: string; createNewCircuit?: boolean } expectedRevision: number,
moves: CircuitDeviceRowMoveAssignment[],
description: string
) { ) {
return request(`/api/circuit-device-rows/${rowId}/move`, { return executeProjectCommand(
method: "PATCH", projectId,
body: JSON.stringify(input), expectedRevision,
}); {
schemaVersion: 1,
type: "circuit-device-row.move",
payload: { moves },
},
description
);
} }
export function moveCircuitDeviceRowsBulk(input: { export function moveCircuitDeviceRowsToNewCircuitCommand(
rowIds: string[]; projectId: string,
targetCircuitId?: string; expectedRevision: number,
targetSectionId?: string; targetCircuit: CircuitSnapshot,
createNewCircuit?: boolean; moves: CircuitDeviceRowMoveAssignment[],
}) { description: string
return request<{ movedRowIds: string[]; targetCircuitId: string; createdCircuitId?: string }>( ) {
"/api/circuit-device-rows/move-bulk", return executeProjectCommand(
projectId,
expectedRevision,
{ {
method: "PATCH", schemaVersion: 1,
body: JSON.stringify(input), type: "circuit-device-row.move-with-new-circuit",
} payload: {
targetCircuitAction: "create",
targetCircuit,
moves,
},
},
description
); );
} }
@@ -0,0 +1,66 @@
import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
} from "../types.js";
import type {
CircuitDeviceRowMoveAssignment,
} from "../../domain/models/circuit-device-row-move-project-command.model.js";
export function buildCircuitDeviceRowMoveAssignments(input: {
circuits: CircuitTreeCircuitDto[];
rowIds: string[];
targetCircuitId: string;
targetDeviceRows: CircuitTreeDeviceRowDto[];
}): CircuitDeviceRowMoveAssignment[] {
const { circuits, rowIds, targetCircuitId, targetDeviceRows } =
input;
if (new Set(rowIds).size !== rowIds.length) {
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
}
const sourceByRowId = new Map<
string,
{ circuitId: string; sortOrder: number }
>();
for (const circuit of circuits) {
for (const row of circuit.deviceRows) {
sourceByRowId.set(row.id, {
circuitId: circuit.id,
sortOrder: row.sortOrder,
});
}
}
const movedRowIds = new Set(rowIds);
const retainedTargetRows = targetDeviceRows.filter(
(row) => !movedRowIds.has(row.id)
);
const lastTargetSortOrder = retainedTargetRows.reduce(
(highest, row) => Math.max(highest, row.sortOrder),
0
);
return rowIds.flatMap((rowId, index) => {
const source = sourceByRowId.get(rowId);
if (!source) {
throw new Error("Eine ausgewählte Gerätezeile wurde nicht gefunden.");
}
const targetSortOrder =
lastTargetSortOrder + (index + 1) * 10;
if (
source.circuitId === targetCircuitId &&
source.sortOrder === targetSortOrder
) {
return [];
}
return [
{
rowId,
expectedCircuitId: source.circuitId,
expectedSortOrder: source.sortOrder,
targetCircuitId,
targetSortOrder,
},
];
});
}
@@ -1,9 +1,7 @@
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js"; import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
import { db } from "../../db/client.js"; import { db } from "../../db/client.js";
import { CircuitDeviceRowTransactionRepository } from "../../db/repositories/circuit-device-row-transaction.repository.js";
import { CircuitSectionTransactionRepository } from "../../db/repositories/circuit-section-transaction.repository.js"; import { CircuitSectionTransactionRepository } from "../../db/repositories/circuit-section-transaction.repository.js";
export const circuitWriteService = new CircuitWriteService({ export const circuitWriteService = new CircuitWriteService({
deviceRowTransactionStore: new CircuitDeviceRowTransactionRepository(db),
circuitSectionTransactionStore: new CircuitSectionTransactionRepository(db), circuitSectionTransactionStore: new CircuitSectionTransactionRepository(db),
}); });
@@ -1,39 +0,0 @@
import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js";
import {
moveCircuitDeviceRowsBulkSchema,
moveCircuitDeviceRowSchema,
} from "../../shared/validation/circuit.schemas.js";
export async function moveCircuitDeviceRow(req: Request, res: Response) {
const { rowId } = req.params;
if (typeof rowId !== "string") {
return res.status(400).json({ error: "Invalid rowId" });
}
const parsed = moveCircuitDeviceRowSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const moved = await circuitWriteService.moveDeviceRow(rowId, parsed.data);
return res.json(moved);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device row." });
}
}
export async function moveCircuitDeviceRowsBulk(req: Request, res: Response) {
const parsed = moveCircuitDeviceRowsBulkSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const moved = await circuitWriteService.moveDeviceRowsBulk(parsed.data);
return res.json(moved);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device rows." });
}
}
@@ -1,20 +1,6 @@
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";
export async function deleteCircuit(req: Request, res: Response) {
const { circuitId } = req.params;
if (typeof circuitId !== "string") {
return res.status(400).json({ error: "Invalid circuitId" });
}
try {
await circuitWriteService.deleteCircuit(circuitId);
return res.status(204).send();
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to delete circuit." });
}
}
export async function getNextCircuitIdentifier(req: Request, res: Response) { export async function getNextCircuitIdentifier(req: Request, res: Response) {
const { sectionId } = req.params; const { sectionId } = req.params;
if (typeof sectionId !== "string") { if (typeof sectionId !== "string") {
-2
View File
@@ -1,5 +1,4 @@
import express from "express"; import express from "express";
import { circuitDeviceRowRouter } from "./routes/circuit-device-row.routes.js";
import { circuitRouter } from "./routes/circuit.routes.js"; import { circuitRouter } from "./routes/circuit.routes.js";
import { circuitSectionRouter } from "./routes/circuit-section.routes.js"; import { circuitSectionRouter } from "./routes/circuit-section.routes.js";
import { globalDeviceRouter } from "./routes/global-device.routes.js"; import { globalDeviceRouter } from "./routes/global-device.routes.js";
@@ -18,7 +17,6 @@ app.get("/health", (_req, res) => {
app.use("/api/projects", projectRouter); app.use("/api/projects", projectRouter);
app.use("/api", circuitRouter); app.use("/api", circuitRouter);
app.use("/api", circuitDeviceRowRouter);
app.use("/api", circuitSectionRouter); app.use("/api", circuitSectionRouter);
app.use("/api/global-devices", globalDeviceRouter); app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter); app.use("/api/project-devices", projectDeviceRouter);
@@ -1,10 +0,0 @@
import { Router } from "express";
import {
moveCircuitDeviceRowsBulk,
moveCircuitDeviceRow,
} from "../controllers/circuit-device-row.controller.js";
export const circuitDeviceRowRouter = Router();
circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk);
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
-2
View File
@@ -1,11 +1,9 @@
import { Router } from "express"; import { Router } from "express";
import { import {
deleteCircuit,
getNextCircuitIdentifier, getNextCircuitIdentifier,
} from "../controllers/circuit.controller.js"; } from "../controllers/circuit.controller.js";
export const circuitRouter = Router(); export const circuitRouter = Router();
circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier); circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
-29
View File
@@ -1,32 +1,5 @@
import { z } from "zod"; import { z } from "zod";
export const moveCircuitDeviceRowSchema = z
.object({
targetCircuitId: z.string().min(1).optional(),
targetSectionId: z.string().min(1).optional(),
createNewCircuit: z.boolean().optional(),
})
.refine(
(value) =>
Boolean(value.targetCircuitId) ||
(Boolean(value.targetSectionId) && value.createNewCircuit === true),
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
);
export const moveCircuitDeviceRowsBulkSchema = z
.object({
rowIds: z.array(z.string().min(1)).min(1),
targetCircuitId: z.string().min(1).optional(),
targetSectionId: z.string().min(1).optional(),
createNewCircuit: z.boolean().optional(),
})
.refine(
(value) =>
Boolean(value.targetCircuitId) ||
(Boolean(value.targetSectionId) && value.createNewCircuit === true),
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
);
export const reorderSectionCircuitsSchema = z.object({ export const reorderSectionCircuitsSchema = z.object({
orderedCircuitIds: z.array(z.string().min(1)).min(1), orderedCircuitIds: z.array(z.string().min(1)).min(1),
}); });
@@ -42,7 +15,5 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({
.min(1), .min(1),
}); });
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>; export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>; export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;
@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildCircuitDeviceRowMoveAssignments,
} from "../src/frontend/utils/circuit-device-row-move-command.js";
import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
} from "../src/frontend/types.js";
function row(
id: string,
sortOrder: number
): CircuitTreeDeviceRowDto {
return {
id,
sortOrder,
name: id,
displayName: id,
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
rowTotalPower: 0.1,
};
}
function circuit(
id: string,
deviceRows: CircuitTreeDeviceRowDto[]
): CircuitTreeCircuitDto {
return {
id,
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: id,
sortOrder: 10,
isReserve: deviceRows.length === 0,
circuitTotalPower: 0,
deviceRows,
};
}
describe("circuit device-row move command builder", () => {
it("records exact sources and appends rows in selection order", () => {
const targetRows = [row("target-row", 20)];
const circuits = [
circuit("source-1", [row("row-1", 30)]),
circuit("source-2", [row("row-2", 10)]),
circuit("target", targetRows),
];
assert.deepEqual(
buildCircuitDeviceRowMoveAssignments({
circuits,
rowIds: ["row-2", "row-1"],
targetCircuitId: "target",
targetDeviceRows: targetRows,
}),
[
{
rowId: "row-2",
expectedCircuitId: "source-2",
expectedSortOrder: 10,
targetCircuitId: "target",
targetSortOrder: 30,
},
{
rowId: "row-1",
expectedCircuitId: "source-1",
expectedSortOrder: 30,
targetCircuitId: "target",
targetSortOrder: 40,
},
]
);
});
it("starts a generated target circuit at sort order ten", () => {
assert.deepEqual(
buildCircuitDeviceRowMoveAssignments({
circuits: [circuit("source", [row("row-1", 40)])],
rowIds: ["row-1"],
targetCircuitId: "generated-target",
targetDeviceRows: [],
}),
[
{
rowId: "row-1",
expectedCircuitId: "source",
expectedSortOrder: 40,
targetCircuitId: "generated-target",
targetSortOrder: 10,
},
]
);
});
it("omits unchanged assignments during a same-circuit reorder", () => {
const rows = [row("row-1", 10), row("row-2", 20)];
assert.deepEqual(
buildCircuitDeviceRowMoveAssignments({
circuits: [circuit("target", rows)],
rowIds: ["row-1", "row-2"],
targetCircuitId: "target",
targetDeviceRows: rows,
}),
[]
);
});
it("rejects duplicate or missing selected rows", () => {
const circuits = [circuit("source", [row("row-1", 10)])];
assert.throws(
() =>
buildCircuitDeviceRowMoveAssignments({
circuits,
rowIds: ["row-1", "row-1"],
targetCircuitId: "target",
targetDeviceRows: [],
}),
/mehrfach/
);
assert.throws(
() =>
buildCircuitDeviceRowMoveAssignments({
circuits,
rowIds: ["missing"],
targetCircuitId: "target",
targetDeviceRows: [],
}),
/nicht gefunden/
);
});
});
@@ -1,529 +0,0 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projects } from "../src/db/schema/projects.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
const board = new DistributionBoardRepository(
context.db
).createWithCircuitListAndDefaultSections(
"project-1",
"UV-01"
);
const [section] = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.limit(1)
.all();
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Reserve",
sortOrder: 10,
isReserve: 1,
})
.run();
return context;
}
function insertCircuit(
context: DatabaseContext,
input: {
id: string;
equipmentIdentifier: string;
sortOrder: number;
isReserve?: number;
}
) {
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
context.db
.insert(circuits)
.values({
id: input.id,
circuitListId: seedCircuit.circuitListId,
sectionId: seedCircuit.sectionId,
equipmentIdentifier: input.equipmentIdentifier,
displayName: input.equipmentIdentifier,
sortOrder: input.sortOrder,
isReserve: input.isReserve ?? 1,
})
.run();
}
function insertDeviceRow(
context: DatabaseContext,
input: {
id?: string;
circuitId?: string;
sortOrder?: number;
displayName?: string;
} = {}
) {
const rowId = input.id ?? "row-1";
const circuitId = input.circuitId ?? "circuit-1";
context.db
.insert(circuitDeviceRows)
.values({
id: rowId,
circuitId,
sortOrder: input.sortOrder ?? 10,
name: "Leuchte",
displayName: input.displayName ?? "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})
.run();
context.db
.update(circuits)
.set({ isReserve: 0 })
.where(eq(circuits.id, circuitId))
.run();
}
describe("circuit device-row transaction repository", () => {
it("commits a new device row and clears the circuit reserve status together", () => {
const context = createTestDatabase();
try {
const repository = new CircuitDeviceRowTransactionRepository(context.db);
const rowId = repository.createInCircuit({
circuitId: "circuit-1",
name: "Steckdose",
displayName: "Steckdose",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
});
const [row] = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, rowId))
.all();
const [circuit] = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.all();
assert.equal(row.circuitId, "circuit-1");
assert.equal(row.sortOrder, 10);
assert.equal(circuit.isReserve, 0);
} finally {
context.close();
}
});
it("rolls back the row insert when clearing the reserve status fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_reserve_clear
BEFORE UPDATE OF is_reserve ON circuits
WHEN NEW.is_reserve = 0
BEGIN
SELECT RAISE(ABORT, 'forced reserve clear failure');
END;
`);
const repository = new CircuitDeviceRowTransactionRepository(context.db);
assert.throws(
() =>
repository.createInCircuit({
circuitId: "circuit-1",
name: "Steckdose",
displayName: "Steckdose",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
}),
/forced reserve clear failure/
);
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
assert.equal(context.db.select().from(circuits).all()[0].isReserve, 1);
} finally {
context.close();
}
});
it("commits a new circuit and all initial device rows together", () => {
const context = createTestDatabase();
try {
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
const repository = new CircuitDeviceRowTransactionRepository(context.db);
const created = repository.createCircuitWithDeviceRows({
circuit: {
circuitListId: seedCircuit.circuitListId,
sectionId: seedCircuit.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Beleuchtung",
sortOrder: 20,
},
deviceRows: [
{
name: "Leuchte",
displayName: "Leuchte 1",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
sortOrder: 15,
},
{
name: "Leuchte",
displayName: "Leuchte 2",
quantity: 2,
powerPerUnit: 0.1,
simultaneityFactor: 0.8,
},
],
});
const [createdCircuit] = context.db
.select()
.from(circuits)
.where(eq(circuits.id, created.circuitId))
.all();
const createdRows = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, created.circuitId))
.all()
.sort((left, right) => left.sortOrder - right.sortOrder);
assert.equal(createdCircuit.equipmentIdentifier, "-1F2");
assert.equal(createdCircuit.isReserve, 0);
assert.deepEqual(
createdRows.map((row) => [row.id, row.displayName, row.sortOrder]),
[
[created.rowIds[0], "Leuchte 1", 15],
[created.rowIds[1], "Leuchte 2", 20],
]
);
} finally {
context.close();
}
});
it("rolls back the circuit and earlier rows when an initial row insert fails", () => {
const context = createTestDatabase();
try {
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
context.sqlite.exec(`
CREATE TRIGGER fail_initial_device_row
BEFORE INSERT ON circuit_device_rows
WHEN NEW.name = 'Fail'
BEGIN
SELECT RAISE(ABORT, 'forced initial row failure');
END;
`);
const repository = new CircuitDeviceRowTransactionRepository(context.db);
assert.throws(
() =>
repository.createCircuitWithDeviceRows({
circuit: {
circuitListId: seedCircuit.circuitListId,
sectionId: seedCircuit.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Rollback",
sortOrder: 20,
},
deviceRows: [
{
name: "First",
displayName: "Erste Zeile",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
name: "Fail",
displayName: "Fehlerhafte Zeile",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
],
}),
/forced initial row failure/
);
assert.equal(context.db.select().from(circuits).all().length, 1);
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
} finally {
context.close();
}
});
it("commits the last-row deletion and activates the circuit reserve status together", () => {
const context = createTestDatabase();
try {
insertDeviceRow(context);
const repository = new CircuitDeviceRowTransactionRepository(context.db);
repository.deleteFromCircuit("row-1", "circuit-1");
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
assert.equal(context.db.select().from(circuits).all()[0].isReserve, 1);
} finally {
context.close();
}
});
it("rolls back the row deletion when activating the reserve status fails", () => {
const context = createTestDatabase();
try {
insertDeviceRow(context);
context.sqlite.exec(`
CREATE TRIGGER fail_reserve_activation
BEFORE UPDATE OF is_reserve ON circuits
WHEN NEW.is_reserve = 1
BEGIN
SELECT RAISE(ABORT, 'forced reserve activation failure');
END;
`);
const repository = new CircuitDeviceRowTransactionRepository(context.db);
assert.throws(
() => repository.deleteFromCircuit("row-1", "circuit-1"),
/forced reserve activation failure/
);
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 1);
assert.equal(context.db.select().from(circuits).all()[0].isReserve, 0);
} finally {
context.close();
}
});
it("moves rows to an existing circuit and updates both reserve states together", () => {
const context = createTestDatabase();
try {
insertCircuit(context, {
id: "circuit-2",
equipmentIdentifier: "-1F2",
sortOrder: 20,
});
insertCircuit(context, {
id: "circuit-3",
equipmentIdentifier: "-1F3",
sortOrder: 30,
});
insertDeviceRow(context);
insertDeviceRow(context, {
id: "row-2",
circuitId: "circuit-2",
sortOrder: 10,
displayName: "Zweite Quellzeile",
});
insertDeviceRow(context, {
id: "target-row",
circuitId: "circuit-3",
sortOrder: 10,
displayName: "Bestehende Zielzeile",
});
const repository = new CircuitDeviceRowTransactionRepository(context.db);
const result = repository.moveRows({
rows: [
{ id: "row-1", expectedCircuitId: "circuit-1" },
{ id: "row-2", expectedCircuitId: "circuit-2" },
],
targetCircuitId: "circuit-3",
});
const movedRows = context.db
.select()
.from(circuitDeviceRows)
.all()
.filter((row) => row.id === "row-1" || row.id === "row-2")
.sort((left, right) => left.sortOrder - right.sortOrder);
const persistedCircuits = context.db.select().from(circuits).all();
const targetCircuit = persistedCircuits.find(
(circuit) => circuit.id === "circuit-3"
);
assert.deepEqual(result, {
movedRowIds: ["row-1", "row-2"],
targetCircuitId: "circuit-3",
createdCircuitId: undefined,
});
assert.deepEqual(
movedRows.map((row) => [row.id, row.circuitId, row.sortOrder]),
[
["row-1", "circuit-3", 20],
["row-2", "circuit-3", 30],
]
);
assert.equal(
persistedCircuits.find((circuit) => circuit.id === "circuit-1")?.isReserve,
1
);
assert.equal(
persistedCircuits.find((circuit) => circuit.id === "circuit-2")?.isReserve,
1
);
assert.equal(targetCircuit?.isReserve, 0);
} finally {
context.close();
}
});
it("rolls back an existing-target move when a reserve update fails", () => {
const context = createTestDatabase();
try {
insertCircuit(context, {
id: "circuit-2",
equipmentIdentifier: "-1F2",
sortOrder: 20,
});
insertDeviceRow(context);
context.sqlite.exec(`
CREATE TRIGGER fail_source_reserve_update
BEFORE UPDATE OF is_reserve ON circuits
WHEN NEW.id = 'circuit-1' AND NEW.is_reserve = 1
BEGIN
SELECT RAISE(ABORT, 'forced source reserve failure');
END;
`);
const repository = new CircuitDeviceRowTransactionRepository(context.db);
assert.throws(
() =>
repository.moveRows({
rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }],
targetCircuitId: "circuit-2",
}),
/forced source reserve failure/
);
const [row] = context.db.select().from(circuitDeviceRows).all();
const persistedCircuits = context.db.select().from(circuits).all();
assert.equal(row.circuitId, "circuit-1");
assert.equal(
persistedCircuits.find((circuit) => circuit.id === "circuit-1")?.isReserve,
0
);
assert.equal(
persistedCircuits.find((circuit) => circuit.id === "circuit-2")?.isReserve,
1
);
} finally {
context.close();
}
});
it("creates a new target circuit and moves rows into it atomically", () => {
const context = createTestDatabase();
try {
insertDeviceRow(context);
const [sourceCircuit] = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.all();
const repository = new CircuitDeviceRowTransactionRepository(context.db);
const result = repository.moveRows({
rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }],
createTargetCircuit: {
circuitListId: sourceCircuit.circuitListId,
sectionId: sourceCircuit.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Neues Ziel",
sortOrder: 20,
},
});
const [movedRow] = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.all();
const [createdCircuit] = context.db
.select()
.from(circuits)
.where(eq(circuits.id, result.createdCircuitId!))
.all();
const [updatedSource] = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.all();
assert.equal(result.targetCircuitId, result.createdCircuitId);
assert.equal(createdCircuit.equipmentIdentifier, "-1F2");
assert.equal(createdCircuit.isReserve, 0);
assert.equal(movedRow.circuitId, createdCircuit.id);
assert.equal(updatedSource.isReserve, 1);
} finally {
context.close();
}
});
it("rolls back a newly created target circuit when the move fails", () => {
const context = createTestDatabase();
try {
insertDeviceRow(context);
const [sourceCircuit] = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.all();
context.sqlite.exec(`
CREATE TRIGGER fail_new_target_source_reserve
BEFORE UPDATE OF is_reserve ON circuits
WHEN NEW.id = 'circuit-1' AND NEW.is_reserve = 1
BEGIN
SELECT RAISE(ABORT, 'forced new-target move failure');
END;
`);
const repository = new CircuitDeviceRowTransactionRepository(context.db);
assert.throws(
() =>
repository.moveRows({
rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }],
createTargetCircuit: {
circuitListId: sourceCircuit.circuitListId,
sectionId: sourceCircuit.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Rollback-Ziel",
sortOrder: 20,
},
}),
/forced new-target move failure/
);
const [row] = context.db.select().from(circuitDeviceRows).all();
const persistedCircuits = context.db.select().from(circuits).all();
assert.equal(row.circuitId, "circuit-1");
assert.equal(persistedCircuits.length, 1);
assert.equal(persistedCircuits[0].isReserve, 0);
} finally {
context.close();
}
});
});
+14 -286
View File
@@ -23,7 +23,10 @@ describe("circuit write service rules", () => {
}, },
} as never, } as never,
circuitSectionTransactionStore: { circuitSectionTransactionStore: {
updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) { updateEquipmentIdentifiers(
_listId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>
) {
safeUpdatePayload = updates; safeUpdatePayload = updates;
}, },
} as never, } as never,
@@ -58,7 +61,10 @@ describe("circuit write service rules", () => {
}, },
} as never, } as never,
circuitSectionTransactionStore: { circuitSectionTransactionStore: {
updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) { updateEquipmentIdentifiers(
_listId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>
) {
safeUpdatePayload = updates; safeUpdatePayload = updates;
}, },
} as never, } as never,
@@ -72,7 +78,7 @@ describe("circuit write service rules", () => {
]); ]);
}); });
it("renumber handles gaps and keeps device rows untouched by identifier-only update path", async () => { it("renumber handles gaps without touching device rows", async () => {
let safeCalled = 0; let safeCalled = 0;
const service = new CircuitWriteService({ const service = new CircuitWriteService({
circuitSectionRepository: { circuitSectionRepository: {
@@ -97,292 +103,12 @@ describe("circuit write service rules", () => {
safeCalled += 1; safeCalled += 1;
}, },
} as never, } as never,
deviceRowRepository: {
async update() {
throw new Error("device rows must not be touched");
},
} as never,
}); });
await service.renumberSection("s1"); await service.renumberSection("s1");
assert.equal(safeCalled, 1); assert.equal(safeCalled, 1);
}); });
it("moving a device row to another circuit preserves row and toggles reserve flags", async () => {
let transactionalMove:
| {
rows: Array<{ id: string; expectedCircuitId: string }>;
targetCircuitId?: string;
createTargetCircuit?: unknown;
}
| undefined;
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return { id: "r1", circuitId: "c1" } as never;
},
} as never,
deviceRowTransactionStore: {
moveRows(input: typeof transactionalMove) {
transactionalMove = input;
return {
movedRowIds: input!.rows.map((row) => row.id),
targetCircuitId: input!.targetCircuitId!,
};
},
} as never,
circuitRepository: {
async findById(circuitId: string) {
if (circuitId === "c1") {
return {
id: "c1",
sectionId: "s1",
circuitListId: "l1",
equipmentIdentifier: "-1F1",
sortOrder: 10,
isReserve: 0,
} as never;
}
return {
id: "c2",
sectionId: "s1",
circuitListId: "l1",
equipmentIdentifier: "-1F2",
sortOrder: 20,
isReserve: 1,
} as never;
},
} as never,
});
await service.moveDeviceRow("r1", { targetCircuitId: "c2" });
assert.deepEqual(transactionalMove, {
rows: [{ id: "r1", expectedCircuitId: "c1" }],
targetCircuitId: "c2",
createTargetCircuit: undefined,
});
});
it("moving a device row to placeholder creates a new circuit in target section", async () => {
let preparedTarget:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return { id: "r1", circuitId: "c1" } as never;
},
} as never,
deviceRowTransactionStore: {
moveRows(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) {
preparedTarget = input.createTargetCircuit;
return {
movedRowIds: input.rows.map((row) => row.id),
targetCircuitId: "c-new",
createdCircuitId: "c-new",
};
},
} as never,
circuitRepository: {
async findById(circuitId: string) {
if (circuitId === "c1") {
return {
id: "c1",
sectionId: "s1",
circuitListId: "l1",
equipmentIdentifier: "-1F1",
sortOrder: 10,
isReserve: 0,
} as never;
}
return null as never;
},
async listBySection() {
return [{ sortOrder: 40 }] as never[];
},
} as never,
circuitSectionRepository: {
async findById() {
return { id: "s2", circuitListId: "l1", prefix: "-2F" } as never;
},
} as never,
numberingService: {
async getNextIdentifier() {
return "-2F8";
},
} as never,
});
await service.moveDeviceRow("r1", { targetSectionId: "s2", createNewCircuit: true });
assert.deepEqual(preparedTarget, {
circuitListId: "l1",
sectionId: "s2",
equipmentIdentifier: "-2F8",
displayName: "Neuer Stromkreis",
sortOrder: 50,
});
});
it("moving a device row to its current circuit remains a no-op", async () => {
let transactionalMoveCount = 0;
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return { id: "r1", circuitId: "c1" } as never;
},
} as never,
deviceRowTransactionStore: {
moveRows() {
transactionalMoveCount += 1;
throw new Error("same-circuit move must not write");
},
} as never,
circuitRepository: {
async findById() {
return {
id: "c1",
sectionId: "s1",
circuitListId: "l1",
equipmentIdentifier: "-1F1",
sortOrder: 10,
isReserve: 0,
} as never;
},
} as never,
});
const result = await service.moveDeviceRow("r1", { targetCircuitId: "c1" });
assert.equal(transactionalMoveCount, 0);
assert.equal(result?.id, "r1");
});
it("moving multiple device rows to a circuit preserves input order and toggles reserve", async () => {
let transactionalMove:
| {
rows: Array<{ id: string; expectedCircuitId: string }>;
targetCircuitId?: string;
createTargetCircuit?: unknown;
}
| undefined;
const service = new CircuitWriteService({
deviceRowRepository: {
async findById(rowId: string) {
if (rowId === "r1") {
return { id: "r1", circuitId: "c1" } as never;
}
return { id: "r2", circuitId: "c2" } as never;
},
} as never,
deviceRowTransactionStore: {
moveRows(input: typeof transactionalMove) {
transactionalMove = input;
return {
movedRowIds: input!.rows.map((row) => row.id),
targetCircuitId: input!.targetCircuitId!,
};
},
} as never,
circuitRepository: {
async findById(circuitId: string) {
if (circuitId === "c1") {
return { id: "c1", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never;
}
if (circuitId === "c2") {
return { id: "c2", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 } as never;
}
return { id: "c3", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F3", sortOrder: 30, isReserve: 1 } as never;
},
} as never,
});
const result = await service.moveDeviceRowsBulk({ rowIds: ["r1", "r2"], targetCircuitId: "c3" });
assert.deepEqual(transactionalMove, {
rows: [
{ id: "r1", expectedCircuitId: "c1" },
{ id: "r2", expectedCircuitId: "c2" },
],
targetCircuitId: "c3",
createTargetCircuit: undefined,
});
assert.deepEqual(result, {
movedRowIds: ["r1", "r2"],
targetCircuitId: "c3",
});
});
it("moving multiple device rows to placeholder creates exactly one new circuit", async () => {
let transactionCount = 0;
let preparedTarget:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
const service = new CircuitWriteService({
deviceRowRepository: {
async findById(rowId: string) {
return { id: rowId, circuitId: rowId === "r1" ? "c1" : "c2" } as never;
},
} as never,
deviceRowTransactionStore: {
moveRows(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) {
transactionCount += 1;
preparedTarget = input.createTargetCircuit;
return {
movedRowIds: input.rows.map((row) => row.id),
targetCircuitId: "c-new",
createdCircuitId: "c-new",
};
},
} as never,
circuitRepository: {
async findById(circuitId: string) {
if (circuitId === "c1" || circuitId === "c2") {
return { id: circuitId, sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never;
}
return null as never;
},
async listBySection() {
return [{ sortOrder: 30 }] as never[];
},
} as never,
circuitSectionRepository: {
async findById() {
return { id: "s2", circuitListId: "l1", prefix: "-2F" } as never;
},
} as never,
numberingService: {
async getNextIdentifier() {
return "-2F8";
},
} as never,
});
const result = await service.moveDeviceRowsBulk({
rowIds: ["r1", "r2"],
targetSectionId: "s2",
createNewCircuit: true,
});
assert.equal(transactionCount, 1);
assert.deepEqual(preparedTarget, {
circuitListId: "l1",
sectionId: "s2",
equipmentIdentifier: "-2F8",
displayName: "Neuer Stromkreis",
sortOrder: 40,
});
assert.equal(result.createdCircuitId, "c-new");
});
it("reorders circuits inside one section without renumbering identifiers", async () => { it("reorders circuits inside one section without renumbering identifiers", async () => {
let safeReorder: { sectionId: string; circuitIds: string[] } | undefined; let safeReorder: { sectionId: string; circuitIds: string[] } | undefined;
const service = new CircuitWriteService({ const service = new CircuitWriteService({
@@ -407,14 +133,16 @@ describe("circuit write service rules", () => {
} as never, } as never,
}); });
await service.reorderCircuitsInSection("s1", { orderedCircuitIds: ["c3", "c1", "c2"] }); await service.reorderCircuitsInSection("s1", {
orderedCircuitIds: ["c3", "c1", "c2"],
});
assert.deepEqual(safeReorder, { assert.deepEqual(safeReorder, {
sectionId: "s1", sectionId: "s1",
circuitIds: ["c3", "c1", "c2"], circuitIds: ["c3", "c1", "c2"],
}); });
}); });
it("safe section identifier bulk update validates full section set (undo safety)", async () => { it("validates the complete section set before safe identifier updates", async () => {
let safeCalled = false; let safeCalled = false;
const service = new CircuitWriteService({ const service = new CircuitWriteService({
circuitSectionRepository: { circuitSectionRepository: {
@@ -436,6 +164,7 @@ describe("circuit write service rules", () => {
}, },
} as never, } as never,
}); });
await service.updateSectionEquipmentIdentifiers("s1", { await service.updateSectionEquipmentIdentifiers("s1", {
identifiers: [ identifiers: [
{ circuitId: "c1", equipmentIdentifier: "-1F2" }, { circuitId: "c1", equipmentIdentifier: "-1F2" },
@@ -444,5 +173,4 @@ describe("circuit write service rules", () => {
}); });
assert.equal(safeCalled, true); assert.equal(safeCalled, true);
}); });
}); });