Persist section renumbering
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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." });
|
||||
}
|
||||
}
|
||||
@@ -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." });
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user