Persist section renumbering

This commit is contained in:
2026-07-25 21:30:29 +02:00
parent abcb468807
commit c45afd0981
24 changed files with 317 additions and 638 deletions
+12 -11
View File
@@ -220,17 +220,18 @@ After saving, the row becomes linked to the new project device.
## Undo / Redo
The editor's visible undo/redo stack remains session-local while renumber
operations are being migrated. Existing Circuit and CircuitDeviceRow cell
edits, standalone insertion/deletion, CircuitDeviceRow moves and Circuit
reorders already execute persistent project commands; their toolbar undo/redo
actions use the project-wide history endpoints. Applying a sorted view across
multiple sections is one atomic `circuit.reorder-sections` command and one undo
step. Insertions and generated move targets use client-generated stable UUIDs,
and undo restores the same ids from complete server snapshots. The tree
response supplies the optimistic `currentRevision`; obsolete direct field
PATCH, structure POST, move, reorder, Circuit and CircuitDeviceRow DELETE
routes are removed.
The editor's visible undo/redo stack remains session-local and is not yet
reconstructed after reload. All currently supported Circuit and
CircuitDeviceRow writes execute persistent project commands; their toolbar
undo/redo actions use the project-wide history endpoints. Applying a sorted
view across multiple sections is one atomic `circuit.reorder-sections` command
and one undo step. Explicit renumbering uses the collision-safe
`circuit.renumber-section` command and is never triggered implicitly.
Insertions and generated move targets use client-generated stable UUIDs, and
undo restores the same ids from complete server snapshots. The tree response
supplies the optimistic `currentRevision`; obsolete direct field PATCH,
structure POST, move, reorder, renumber, identifier-restore, Circuit 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
+6 -28
View File
@@ -37,9 +37,8 @@ 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, standalone insert/delete actions and device-row
moves and reorders in the editor consume these endpoints. The renumber UI path
is still being migrated.
CircuitDeviceRow cell edits, standalone insert/delete actions, device-row
moves, reorders and explicit renumbering in the editor consume these endpoints.
`circuit-device-row.move` targets existing circuits in the same circuit list.
`circuit-device-row.move-with-new-circuit` atomically creates exactly one
@@ -176,32 +175,11 @@ Response sketch:
- preview next identifier for section (`prefix + maxSuffix + 1`)
Circuit and device-row field updates, standalone insertions/deletions, single
or bulk device-row moves and circuit reorders are available only as the
corresponding versioned commands through
or bulk device-row moves, circuit reorders and explicit renumbering are
available only as the corresponding versioned commands through
`POST /projects/:projectId/commands`. There are no direct field-update PATCH,
structure POST, move, reorder, Circuit DELETE or CircuitDeviceRow DELETE
routes.
### Renumber Section
- `POST /circuit-sections/:sectionId/renumber`
- Purpose: explicit renumbering by section prefix; never implicit on move/sort.
### Safe Equipment Identifier Update
- `PATCH /circuit-sections/:sectionId/equipment-identifiers`
- Purpose: apply explicit per-circuit identifiers safely even with unique constraints.
Request sketch:
```json
{
"identifiers": [
{ "circuitId": "cir_1", "equipmentIdentifier": "-2F1" },
{ "circuitId": "cir_2", "equipmentIdentifier": "-2F2" }
]
}
```
structure POST, move, reorder, renumber, identifier-restore, Circuit DELETE or
CircuitDeviceRow DELETE routes.
## Removed Legacy Endpoints
+2 -4
View File
@@ -129,8 +129,8 @@ undo/redo calls the project-wide server history. New entities receive stable
UUIDs before insertion, and deletion undo restores those same ids. Device-row
moves and circuit reorders also use persistent project commands. Applying a
sorted view across multiple sections is atomic and occupies one history step.
Renumbering still uses its direct API write and session-local inverse callback
during the cutover.
Explicit renumbering uses a collision-safe persistent project command and one
project-history step.
Covered operations include:
@@ -144,5 +144,3 @@ Covered operations include:
Current limitations:
- the visible editor stack is still session-local and is empty after reload
- renumber editor actions are not connected to the persistent command boundary
yet
@@ -1,14 +1,12 @@
# Circuit List Editor Known Limitations
- Existing Circuit and CircuitDeviceRow cell edits plus standalone
insert/delete actions and CircuitDeviceRow moves 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 the direct
renumber operation during the cutover. Circuit reorder is already persistent,
including atomic multi-section sorted-order application.
- All currently supported Circuit and CircuitDeviceRow editor writes use
persistent project commands and project-wide toolbar undo/redo, including
moves, atomic multi-section reorders and explicit renumbering. The editor's
visible stack is still session-local and empty after reload.
- 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.
the command types documented in `circuit-list-editor-api.md`; visible history
reconstruction is not complete.
- Named logical project snapshots and restore are not implemented yet.
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
+9 -4
View File
@@ -92,10 +92,10 @@ sowie eigenständiges Einfügen und Löschen verwendet das Grid bereits über di
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-, Move-, Circuit- und
Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves und
Stromkreis-Reorders im Grid verwenden die persistenten Kommandos bereits. Nur
der Renumber-UI-Pfad verbleibt vorerst im sichtbaren sitzungslokalen Stack;
nach einem Reload wird dieser noch nicht aus der Serverhistorie rekonstruiert.
Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves,
Stromkreis-Reorders und die explizite Neunummerierung im Grid verwenden die
persistenten Kommandos. Der sichtbare Undo-/Redo-Stack wird nach einem Reload
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;
@@ -115,6 +115,11 @@ bleiben unverändert. `circuit.renumber-section` bildet die getrennte,
ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und
neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und
ändert weder Sortierung noch Gerätezeilen.
Die Bereichsaktion erzeugt die vollständigen erwarteten und neuen BMKs aus
Präfix und aktueller Stromkreisreihenfolge und verwendet das persistente
Kommando für Toolbar-Undo/Redo. Die früheren direkten Renumber- und
Identifier-Restore-Endpunkte sowie ihr separater Transaktionsadapter sind
entfernt.
Drag-and-drop verwendet `circuit.reorder-section`. Die explizite Übernahme
einer sortierten Ansicht verwendet `circuit.reorder-sections`, damit alle
betroffenen Bereiche in einer Transaktion und als ein Undo-Schritt gespeichert
@@ -207,6 +207,9 @@ Completed foundation:
- `circuit.renumber-section` is the separate explicit renumber operation; it
stores every expected/target equipment identifier, uses collision-safe
temporary values and restores the exact prior identifiers on undo
- the editor's explicit renumber action uses this command; obsolete direct
renumber and identifier-restore routes plus their transaction adapter are
removed
- `project-device.sync-rows` records synchronization, disconnect and reconnect
as one atomic multi-row operation; complete expected/target sync snapshots
protect local values, links and override metadata against silent stale writes
@@ -218,11 +221,9 @@ Completed foundation:
Remaining constraints before completing persistent history:
- route the remaining renumber editor writes through its existing typed
persistent command 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
CircuitDeviceRow operations 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
+4 -3
View File
@@ -335,7 +335,7 @@ Implemented foundation:
- a new circuit and all of its initial device rows use the same transaction store
- device-row moves, reserve-state updates and optional target creation share one transaction
- project-device synchronization, disconnect and reconnect use a separate injected transaction store
- section renumbering and circuit reordering use a separate injected transaction store
- section renumbering and circuit reordering use persistent command stores
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
- project-device row synchronization has real multi-row SQLite commit and rollback coverage
- BMK swaps and section reorder have real SQLite commit and rollback coverage
@@ -434,8 +434,9 @@ ProjectDevice CRUD, synchronization and disconnect on the project page and
Circuit/CircuitDeviceRow field/insert/delete actions, device-row moves and
circuit reorders in the circuit-list editor are connected to this history
boundary. Applying sorted order across multiple sections is one atomic command.
The renumber UI path is not yet connected; the editor's visible undo/redo stack
therefore remains session-local and is not reconstructed after reload.
Explicit renumbering is connected to the same persistent boundary. The
editor's visible undo/redo stack 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-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-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-device-row-move-command.test.ts tests/circuit-section-reorder-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": "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-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-section-reorder-command.test.ts tests/circuit-section-renumber-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-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-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-section-reorder-command.test.ts tests/circuit-section-renumber-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-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,68 +0,0 @@
import { and, eq, inArray } from "drizzle-orm";
import type { CircuitSectionTransactionStore } from "../../domain/ports/circuit-section-transaction.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuits } from "../schema/circuits.js";
export class CircuitSectionTransactionRepository
implements CircuitSectionTransactionStore
{
constructor(private readonly database: AppDatabase) {}
updateEquipmentIdentifiers(
circuitListId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>,
tempNamespace: string
) {
if (updates.length === 0) {
return;
}
this.database.transaction((tx) => {
const ids = updates.map((entry) => entry.id);
const existing = tx
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, circuitListId),
inArray(circuits.id, ids)
)
)
.all();
if (existing.length !== ids.length) {
throw new Error(
"One or more circuit ids are invalid for circuit list."
);
}
const stamp = Date.now();
for (let index = 0; index < updates.length; index += 1) {
const entry = updates[index];
const tempIdentifier = `__tmp_renumber_${tempNamespace}_${stamp}_${index}`;
tx
.update(circuits)
.set({ equipmentIdentifier: tempIdentifier })
.where(
and(
eq(circuits.circuitListId, circuitListId),
eq(circuits.id, entry.id)
)
)
.run();
}
for (const entry of updates) {
tx
.update(circuits)
.set({ equipmentIdentifier: entry.equipmentIdentifier })
.where(
and(
eq(circuits.circuitListId, circuitListId),
eq(circuits.id, entry.id)
)
)
.run();
}
});
}
}
@@ -1,7 +0,0 @@
export interface CircuitSectionTransactionStore {
updateEquipmentIdentifiers(
circuitListId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>,
tempNamespace: string
): void;
}
@@ -1,99 +0,0 @@
import type { CircuitSectionTransactionStore } from "../ports/circuit-section-transaction.store.js";
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import type {
UpdateSectionEquipmentIdentifiersInput,
} from "../../shared/validation/circuit.schemas.js";
import { CircuitNumberingService } from "./circuit-numbering.service.js";
export class CircuitWriteService {
private readonly circuitRepository: CircuitRepository;
private readonly circuitSectionRepository: CircuitSectionRepository;
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
private readonly numberingService: CircuitNumberingService;
constructor(deps?: {
circuitRepository?: CircuitRepository;
circuitSectionRepository?: CircuitSectionRepository;
circuitSectionTransactionStore?: CircuitSectionTransactionStore;
numberingService?: CircuitNumberingService;
}) {
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository();
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
}
private getCircuitSectionTransactionStore() {
if (!this.circuitSectionTransactionStore) {
throw new Error("Circuit-section transactions are not configured.");
}
return this.circuitSectionTransactionStore;
}
async getNextIdentifier(sectionId: string) {
return this.numberingService.getNextIdentifier(sectionId);
}
async renumberSection(sectionId: string) {
// Explicit renumber operation for one section only.
// Never renumbers other sections and never runs implicitly during move/sort operations.
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
throw new Error("Invalid section id.");
}
const sectionCircuits = await this.circuitRepository.listBySection(sectionId);
const otherCircuits = (await this.circuitRepository.listByCircuitList(section.circuitListId)).filter(
(circuit) => circuit.sectionId !== sectionId
);
const otherIdentifiers = new Set(otherCircuits.map((circuit) => circuit.equipmentIdentifier));
const finalAssignments: Array<{ id: string; equipmentIdentifier: string }> = [];
let index = 1;
for (const circuit of sectionCircuits) {
let candidate = `${section.prefix}${index}`;
while (otherIdentifiers.has(candidate)) {
index += 1;
candidate = `${section.prefix}${index}`;
}
finalAssignments.push({ id: circuit.id, equipmentIdentifier: candidate });
index += 1;
}
// Uses safe two-phase identifier update to avoid UNIQUE collisions during swaps.
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
section.circuitListId,
finalAssignments,
sectionId
);
return this.circuitRepository.listBySection(sectionId);
}
async updateSectionEquipmentIdentifiers(
sectionId: string,
input: UpdateSectionEquipmentIdentifiersInput
) {
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
throw new Error("Invalid section id.");
}
const sectionCircuits = await this.circuitRepository.listBySection(sectionId);
const sectionIds = new Set(sectionCircuits.map((circuit) => circuit.id));
if (input.identifiers.length !== sectionCircuits.length) {
throw new Error("identifiers must include all circuits in the section.");
}
for (const entry of input.identifiers) {
if (!sectionIds.has(entry.circuitId)) {
throw new Error("Circuit id does not belong to section.");
}
}
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
section.circuitListId,
input.identifiers.map((entry) => ({ id: entry.circuitId, equipmentIdentifier: entry.equipmentIdentifier })),
sectionId
);
return this.circuitRepository.listBySection(sectionId);
}
}
+27 -19
View File
@@ -33,6 +33,9 @@ import {
import {
buildCircuitSectionReorderAssignments,
} from "../utils/circuit-section-reorder-command";
import {
buildCircuitSectionRenumberAssignments,
} from "../utils/circuit-section-renumber-command";
import type {
CellKey,
CellKind,
@@ -57,9 +60,8 @@ import {
moveCircuitDeviceRowsToNewCircuitCommand,
reorderCircuitSectionCommand,
reorderCircuitSectionsCommand,
renumberCircuitSection,
renumberCircuitSectionCommand,
redoProjectCommand,
updateSectionEquipmentIdentifiers,
updateCircuitById,
updateCircuitDeviceRowById,
undoProjectCommand,
@@ -2592,7 +2594,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await handleDeleteCircuit(intent.circuitId);
}
// Explicit renumber action. Undo restores previous BMKs via safe section identifier update.
// Explicit renumber action through project history. Sorting and device rows
// remain unchanged; the server handles collision-safe identifier swaps.
async function handleRenumberSection(sectionId: string) {
if (!confirm("Diesen Bereich neu nummerieren? Nur die Stromkreise in diesem Bereich werden geändert.")) {
return;
@@ -2601,26 +2604,31 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!section) {
return;
}
const before = section.circuits.map((circuit) => ({
id: circuit.id,
equipmentIdentifier: circuit.equipmentIdentifier,
}));
const assignments = buildCircuitSectionRenumberAssignments(
data?.sections ?? [],
sectionId
);
if (assignments.length === 0) {
return;
}
const label = "Bereich neu nummerieren";
await runCommand({
label: "Bereich neu nummerieren",
label,
redo: async () => {
await renumberCircuitSection(sectionId);
const result = await renumberCircuitSectionCommand(
projectId,
getExpectedProjectRevision(),
sectionId,
assignments,
label
);
applyProjectCommandResult(result);
return null;
},
undo: async () => {
// Restore prior BMKs through safe bulk endpoint to avoid transient unique collisions.
await updateSectionEquipmentIdentifiers(
sectionId,
before.map((entry) => ({
circuitId: entry.id,
equipmentIdentifier: entry.equipmentIdentifier,
}))
);
return null;
undo: async () => null,
persistent: {
undoIntent: () => null,
redoIntent: () => null,
},
});
}
+18 -12
View File
@@ -42,6 +42,9 @@ import type {
import type {
CircuitSectionsReorderSection,
} from "../../domain/models/circuit-sections-reorder-project-command.model";
import type {
CircuitSectionRenumberAssignment,
} from "../../domain/models/circuit-section-renumber-project-command.model";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -319,12 +322,6 @@ export function moveCircuitDeviceRowsToNewCircuitCommand(
);
}
export function renumberCircuitSection(sectionId: string) {
return request(`/api/circuit-sections/${sectionId}/renumber`, {
method: "POST",
});
}
export function reorderCircuitSectionCommand(
projectId: string,
expectedRevision: number,
@@ -362,14 +359,23 @@ export function reorderCircuitSectionsCommand(
);
}
export function updateSectionEquipmentIdentifiers(
export function renumberCircuitSectionCommand(
projectId: string,
expectedRevision: number,
sectionId: string,
identifiers: Array<{ circuitId: string; equipmentIdentifier: string }>
assignments: CircuitSectionRenumberAssignment[],
description: string
) {
return request(`/api/circuit-sections/${sectionId}/equipment-identifiers`, {
method: "PATCH",
body: JSON.stringify({ identifiers }),
});
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit.renumber-section",
payload: { sectionId, assignments },
},
description
);
}
export function listFloors(projectId: string) {
@@ -0,0 +1,54 @@
import type {
CircuitSectionRenumberAssignment,
} from "../../domain/models/circuit-section-renumber-project-command.model.js";
import type { CircuitTreeSectionDto } from "../types.js";
export function buildCircuitSectionRenumberAssignments(
sections: CircuitTreeSectionDto[],
sectionId: string
): CircuitSectionRenumberAssignment[] {
const targetSection = sections.find(
(section) => section.id === sectionId
);
if (!targetSection) {
throw new Error("Der Bereich wurde nicht gefunden.");
}
const identifiersOutsideSection = new Set(
sections
.filter((section) => section.id !== sectionId)
.flatMap((section) =>
section.circuits.map(
(circuit) => circuit.equipmentIdentifier
)
)
);
const assignments: CircuitSectionRenumberAssignment[] = [];
let suffix = 1;
for (const circuit of targetSection.circuits) {
let targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
while (
identifiersOutsideSection.has(targetEquipmentIdentifier)
) {
suffix += 1;
targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
}
assignments.push({
circuitId: circuit.id,
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
targetEquipmentIdentifier,
});
suffix += 1;
}
if (
assignments.every(
(assignment) =>
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
)
) {
return [];
}
return assignments;
}
@@ -0,0 +1,3 @@
import { CircuitNumberingService } from "../../domain/services/circuit-numbering.service.js";
export const circuitNumberingService = new CircuitNumberingService();
@@ -1,7 +0,0 @@
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
import { db } from "../../db/client.js";
import { CircuitSectionTransactionRepository } from "../../db/repositories/circuit-section-transaction.repository.js";
export const circuitWriteService = new CircuitWriteService({
circuitSectionTransactionStore: new CircuitSectionTransactionRepository(db),
});
@@ -1,38 +0,0 @@
import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js";
import {
updateSectionEquipmentIdentifiersSchema,
} from "../../shared/validation/circuit.schemas.js";
export async function renumberCircuitSection(req: Request, res: Response) {
const { sectionId } = req.params;
if (typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid sectionId" });
}
try {
const updatedCircuits = await circuitWriteService.renumberSection(sectionId);
return res.json({ sectionId, circuits: updatedCircuits });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to renumber section." });
}
}
export async function updateSectionEquipmentIdentifiers(req: Request, res: Response) {
const { sectionId } = req.params;
if (typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid sectionId" });
}
const parsed = updateSectionEquipmentIdentifiersSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const updatedCircuits = await circuitWriteService.updateSectionEquipmentIdentifiers(sectionId, parsed.data);
return res.json({ sectionId, circuits: updatedCircuits });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update section identifiers." });
}
}
+3 -2
View File
@@ -1,5 +1,5 @@
import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js";
import { circuitNumberingService } from "../composition/circuit-numbering-service.js";
export async function getNextCircuitIdentifier(req: Request, res: Response) {
const { sectionId } = req.params;
@@ -7,7 +7,8 @@ export async function getNextCircuitIdentifier(req: Request, res: Response) {
return res.status(400).json({ error: "Invalid sectionId" });
}
try {
const nextIdentifier = await circuitWriteService.getNextIdentifier(sectionId);
const nextIdentifier =
await circuitNumberingService.getNextIdentifier(sectionId);
return res.json({ sectionId, nextIdentifier });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to get identifier." });
-2
View File
@@ -1,6 +1,5 @@
import express from "express";
import { circuitRouter } from "./routes/circuit.routes.js";
import { circuitSectionRouter } from "./routes/circuit-section.routes.js";
import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { projectRouter } from "./routes/project.routes.js";
@@ -17,7 +16,6 @@ app.get("/health", (_req, res) => {
app.use("/api/projects", projectRouter);
app.use("/api", circuitRouter);
app.use("/api", circuitSectionRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);
@@ -1,10 +0,0 @@
import { Router } from "express";
import {
renumberCircuitSection,
updateSectionEquipmentIdentifiers,
} from "../controllers/circuit-section.controller.js";
export const circuitSectionRouter = Router();
circuitSectionRouter.post("/circuit-sections/:sectionId/renumber", renumberCircuitSection);
circuitSectionRouter.patch("/circuit-sections/:sectionId/equipment-identifiers", updateSectionEquipmentIdentifiers);
-14
View File
@@ -1,14 +0,0 @@
import { z } from "zod";
export const updateSectionEquipmentIdentifiersSchema = z.object({
identifiers: z
.array(
z.object({
circuitId: z.string().min(1),
equipmentIdentifier: z.string().min(1),
})
)
.min(1),
});
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;
@@ -0,0 +1,166 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildCircuitSectionRenumberAssignments,
} from "../src/frontend/utils/circuit-section-renumber-command.js";
import {
renumberCircuitSectionCommand,
} from "../src/frontend/utils/api.js";
import type {
CircuitTreeCircuitDto,
CircuitTreeSectionDto,
} from "../src/frontend/types.js";
function circuit(
id: string,
equipmentIdentifier: string,
sortOrder: number
): CircuitTreeCircuitDto {
return {
id,
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier,
sortOrder,
isReserve: true,
circuitTotalPower: 0,
deviceRows: [],
};
}
function section(
id: string,
prefix: string,
circuits: CircuitTreeCircuitDto[]
): CircuitTreeSectionDto {
return {
id,
key: id,
displayName: id,
prefix,
sortOrder: 10,
circuits: circuits.map((entry) => ({
...entry,
sectionId: id,
})),
};
}
describe("circuit section renumber command adapters", () => {
it("plans sequential identifiers and skips conflicts outside the section", () => {
const sections = [
section("target", "-1F", [
circuit("circuit-1", "-1F7", 10),
circuit("circuit-2", "-1F9", 20),
circuit("circuit-3", "-1F11", 30),
]),
section("other", "-2F", [
circuit("circuit-other", "-1F2", 10),
]),
];
assert.deepEqual(
buildCircuitSectionRenumberAssignments(sections, "target"),
[
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F7",
targetEquipmentIdentifier: "-1F1",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F9",
targetEquipmentIdentifier: "-1F3",
},
{
circuitId: "circuit-3",
expectedEquipmentIdentifier: "-1F11",
targetEquipmentIdentifier: "-1F4",
},
]
);
});
it("omits an already canonical section and rejects an unknown section", () => {
const sections = [
section("target", "-2F", [
circuit("circuit-1", "-2F1", 10),
circuit("circuit-2", "-2F2", 20),
]),
];
assert.deepEqual(
buildCircuitSectionRenumberAssignments(sections, "target"),
[]
);
assert.throws(
() =>
buildCircuitSectionRenumberAssignments(
sections,
"missing"
),
/nicht gefunden/
);
});
it("sends the complete assignment set through project history", async () => {
let requestBody: Record<string, unknown> | null = null;
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_input, init) => {
requestBody = 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: 1,
createdAtIso: "2026-07-25T00:00:00.000Z",
},
history: {
projectId: "project-1",
currentRevision: 1,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: "change-1",
redoChangeSetId: null,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
};
const assignments = [
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-2F9",
targetEquipmentIdentifier: "-2F1",
},
];
try {
await renumberCircuitSectionCommand(
"project-1",
0,
"section-1",
assignments,
"renumber"
);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requestBody, {
expectedRevision: 0,
description: "renumber",
command: {
schemaVersion: 1,
type: "circuit.renumber-section",
payload: {
sectionId: "section-1",
assignments,
},
},
});
});
});
@@ -1,153 +0,0 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { asc, eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitSectionTransactionRepository } from "../src/db/repositories/circuit-section-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: "Stromkreis 1",
sortOrder: 10,
isReserve: 0,
},
{
id: "circuit-2",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F2",
displayName: "Stromkreis 2",
sortOrder: 20,
isReserve: 1,
},
{
id: "circuit-3",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F3",
displayName: "Stromkreis 3",
sortOrder: 30,
isReserve: 1,
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "row-1",
circuitId: "circuit-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})
.run();
return context;
}
function listCircuits(context: DatabaseContext) {
return context.db
.select()
.from(circuits)
.orderBy(asc(circuits.id))
.all();
}
describe("circuit-section transaction repository", () => {
it("commits identifier swaps without violating the circuit-list uniqueness constraint", () => {
const context = createTestDatabase();
try {
const persisted = listCircuits(context);
const repository = new CircuitSectionTransactionRepository(context.db);
repository.updateEquipmentIdentifiers(
persisted[0].circuitListId,
[
{ id: "circuit-1", equipmentIdentifier: "-1F2" },
{ id: "circuit-2", equipmentIdentifier: "-1F1" },
],
persisted[0].sectionId
);
assert.deepEqual(
listCircuits(context).map((circuit) => circuit.equipmentIdentifier),
["-1F2", "-1F1", "-1F3"]
);
assert.equal(
context.db.select().from(circuitDeviceRows).all()[0].circuitId,
"circuit-1"
);
} finally {
context.close();
}
});
it("rolls back temporary and final identifiers when a later update fails", () => {
const context = createTestDatabase();
try {
const persisted = listCircuits(context);
context.sqlite.exec(`
CREATE TRIGGER fail_second_final_identifier
BEFORE UPDATE OF equipment_identifier ON circuits
WHEN OLD.id = 'circuit-2' AND NEW.equipment_identifier = '-1F1'
BEGIN
SELECT RAISE(ABORT, 'forced identifier failure');
END;
`);
const repository = new CircuitSectionTransactionRepository(context.db);
assert.throws(
() =>
repository.updateEquipmentIdentifiers(
persisted[0].circuitListId,
[
{ id: "circuit-1", equipmentIdentifier: "-1F2" },
{ id: "circuit-2", equipmentIdentifier: "-1F1" },
],
persisted[0].sectionId
),
/forced identifier failure/
);
assert.deepEqual(
listCircuits(context).map((circuit) => circuit.equipmentIdentifier),
["-1F1", "-1F2", "-1F3"]
);
} finally {
context.close();
}
});
});
-143
View File
@@ -1,143 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
describe("circuit write service rules", () => {
it("renumber uses safe bulk identifier update for swapped identifiers", async () => {
let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = [];
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1", prefix: "-2F" } as never;
},
} as never,
circuitRepository: {
async listBySection() {
return [
{ id: "cB", sectionId: "s1", equipmentIdentifier: "-2F2", sortOrder: 10, isReserve: 0 },
{ id: "cA", sectionId: "s1", equipmentIdentifier: "-2F1", sortOrder: 20, isReserve: 1 },
] as never[];
},
async listByCircuitList() {
return [{ id: "x1", sectionId: "s2", equipmentIdentifier: "-3F1" }] as never[];
},
} as never,
circuitSectionTransactionStore: {
updateEquipmentIdentifiers(
_listId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>
) {
safeUpdatePayload = updates;
},
} as never,
});
const result = await service.renumberSection("s1");
assert.deepEqual(safeUpdatePayload, [
{ id: "cB", equipmentIdentifier: "-2F1" },
{ id: "cA", equipmentIdentifier: "-2F2" },
]);
assert.equal(result.length, 2);
});
it("renumber shifts forward/backward and respects other sections", async () => {
let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = [];
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1", prefix: "-1F" } as never;
},
} as never,
circuitRepository: {
async listBySection() {
return [
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F5", sortOrder: 10, isReserve: 0 },
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-1F1", sortOrder: 20, isReserve: 0 },
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-1F9", sortOrder: 30, isReserve: 0 },
] as never[];
},
async listByCircuitList() {
return [{ id: "o1", sectionId: "s2", equipmentIdentifier: "-1F2" }] as never[];
},
} as never,
circuitSectionTransactionStore: {
updateEquipmentIdentifiers(
_listId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>
) {
safeUpdatePayload = updates;
},
} as never,
});
await service.renumberSection("s1");
assert.deepEqual(safeUpdatePayload, [
{ id: "c2", equipmentIdentifier: "-1F1" },
{ id: "c1", equipmentIdentifier: "-1F3" },
{ id: "c3", equipmentIdentifier: "-1F4" },
]);
});
it("renumber handles gaps without touching device rows", async () => {
let safeCalled = 0;
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1", prefix: "-1F" } as never;
},
} as never,
circuitRepository: {
async listBySection() {
return [
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 },
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F5", sortOrder: 20, isReserve: 0 },
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-1F9", sortOrder: 30, isReserve: 0 },
] as never[];
},
async listByCircuitList() {
return [] as never[];
},
} as never,
circuitSectionTransactionStore: {
updateEquipmentIdentifiers() {
safeCalled += 1;
},
} as never,
});
await service.renumberSection("s1");
assert.equal(safeCalled, 1);
});
it("validates the complete section set before safe identifier updates", async () => {
let safeCalled = false;
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1", prefix: "-1F" } as never;
},
} as never,
circuitRepository: {
async listBySection() {
return [
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 },
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 },
] as never[];
},
} as never,
circuitSectionTransactionStore: {
updateEquipmentIdentifiers() {
safeCalled = true;
},
} as never,
});
await service.updateSectionEquipmentIdentifiers("s1", {
identifiers: [
{ circuitId: "c1", equipmentIdentifier: "-1F2" },
{ circuitId: "c2", equipmentIdentifier: "-1F1" },
],
});
assert.equal(safeCalled, true);
});
});