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
|
## Undo / Redo
|
||||||
|
|
||||||
The editor's visible undo/redo stack remains session-local while renumber
|
The editor's visible undo/redo stack remains session-local and is not yet
|
||||||
operations are being migrated. Existing Circuit and CircuitDeviceRow cell
|
reconstructed after reload. All currently supported Circuit and
|
||||||
edits, standalone insertion/deletion, CircuitDeviceRow moves and Circuit
|
CircuitDeviceRow writes execute persistent project commands; their toolbar
|
||||||
reorders already execute persistent project commands; their toolbar undo/redo
|
undo/redo actions use the project-wide history endpoints. Applying a sorted
|
||||||
actions use the project-wide history endpoints. Applying a sorted view across
|
view across multiple sections is one atomic `circuit.reorder-sections` command
|
||||||
multiple sections is one atomic `circuit.reorder-sections` command and one undo
|
and one undo step. Explicit renumbering uses the collision-safe
|
||||||
step. Insertions and generated move targets use client-generated stable UUIDs,
|
`circuit.renumber-section` command and is never triggered implicitly.
|
||||||
and undo restores the same ids from complete server snapshots. The tree
|
Insertions and generated move targets use client-generated stable UUIDs, and
|
||||||
response supplies the optimistic `currentRevision`; obsolete direct field
|
undo restores the same ids from complete server snapshots. The tree response
|
||||||
PATCH, structure POST, move, reorder, Circuit and CircuitDeviceRow DELETE
|
supplies the optimistic `currentRevision`; obsolete direct field PATCH,
|
||||||
routes are removed.
|
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.
|
Reloading the page does not yet reconstruct the visible editor stack.
|
||||||
CircuitDeviceRow moves between existing circuits and moves that create one new
|
CircuitDeviceRow moves between existing circuits and moves that create one new
|
||||||
placeholder target circuit are persisted. The latter stores the complete empty
|
placeholder target circuit are persisted. The latter stores the complete empty
|
||||||
|
|||||||
@@ -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
|
contain each row's expected and target circuit plus its exact expected and
|
||||||
target sort order. This makes deletion and moves undoable without generating
|
target sort order. This makes deletion and moves undoable without generating
|
||||||
replacement identities or renumbering circuits. Existing Circuit and
|
replacement identities or renumbering circuits. Existing Circuit and
|
||||||
CircuitDeviceRow cell edits, standalone insert/delete actions and device-row
|
CircuitDeviceRow cell edits, standalone insert/delete actions, device-row
|
||||||
moves and reorders in the editor consume these endpoints. The renumber UI path
|
moves, reorders and explicit renumbering in the editor consume these endpoints.
|
||||||
is still being migrated.
|
|
||||||
|
|
||||||
`circuit-device-row.move` targets existing circuits in the same circuit list.
|
`circuit-device-row.move` targets existing circuits in the same circuit list.
|
||||||
`circuit-device-row.move-with-new-circuit` atomically creates exactly one
|
`circuit-device-row.move-with-new-circuit` atomically creates exactly one
|
||||||
@@ -176,32 +175,11 @@ Response sketch:
|
|||||||
- preview next identifier for section (`prefix + maxSuffix + 1`)
|
- preview next identifier for section (`prefix + maxSuffix + 1`)
|
||||||
|
|
||||||
Circuit and device-row field updates, standalone insertions/deletions, single
|
Circuit and device-row field updates, standalone insertions/deletions, single
|
||||||
or bulk device-row moves and circuit reorders are available only as the
|
or bulk device-row moves, circuit reorders and explicit renumbering are
|
||||||
corresponding versioned commands through
|
available only as the corresponding versioned commands through
|
||||||
`POST /projects/:projectId/commands`. There are no direct field-update PATCH,
|
`POST /projects/:projectId/commands`. There are no direct field-update PATCH,
|
||||||
structure POST, move, reorder, Circuit DELETE or CircuitDeviceRow DELETE
|
structure POST, move, reorder, renumber, identifier-restore, Circuit DELETE or
|
||||||
routes.
|
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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Removed Legacy Endpoints
|
## 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
|
UUIDs before insertion, and deletion undo restores those same ids. Device-row
|
||||||
moves and circuit reorders also use persistent project commands. Applying a
|
moves and circuit reorders also use persistent project commands. Applying a
|
||||||
sorted view across multiple sections is atomic and occupies one history step.
|
sorted view across multiple sections is atomic and occupies one history step.
|
||||||
Renumbering still uses its direct API write and session-local inverse callback
|
Explicit renumbering uses a collision-safe persistent project command and one
|
||||||
during the cutover.
|
project-history step.
|
||||||
|
|
||||||
Covered operations include:
|
Covered operations include:
|
||||||
|
|
||||||
@@ -144,5 +144,3 @@ Covered operations include:
|
|||||||
Current limitations:
|
Current limitations:
|
||||||
|
|
||||||
- the visible editor stack is still session-local and is empty after reload
|
- the visible editor stack is still session-local and is empty after reload
|
||||||
- renumber editor actions are not connected to the persistent command boundary
|
|
||||||
yet
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
# Circuit List Editor Known Limitations
|
# Circuit List Editor Known Limitations
|
||||||
|
|
||||||
- Existing Circuit and CircuitDeviceRow cell edits plus standalone
|
- All currently supported Circuit and CircuitDeviceRow editor writes use
|
||||||
insert/delete actions and CircuitDeviceRow moves use persistent project
|
persistent project commands and project-wide toolbar undo/redo, including
|
||||||
commands and project-wide toolbar undo/redo. The editor's visible stack is
|
moves, atomic multi-section reorders and explicit renumbering. The editor's
|
||||||
still session-local, is empty after reload and still contains the direct
|
visible stack is still session-local and empty after reload.
|
||||||
renumber operation during the cutover. Circuit reorder is already persistent,
|
|
||||||
including atomic multi-section sorted-order application.
|
|
||||||
- Server-side project revisions and persistent undo/redo eligibility exist for
|
- Server-side project revisions and persistent undo/redo eligibility exist for
|
||||||
the command types documented in `circuit-list-editor-api.md`; complete editor
|
the command types documented in `circuit-list-editor-api.md`; visible history
|
||||||
command coverage and history reconstruction are not complete.
|
reconstruction is not complete.
|
||||||
- Named logical project snapshots and restore are not implemented yet.
|
- Named logical project snapshots and restore are not implemented yet.
|
||||||
- No Revit/CSV/IFCGUID import and export workflow is 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.
|
- 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
|
UUID vor dem Command; Löschen und Undo bewahren diese Identität. Der Tree liefert
|
||||||
dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektweite
|
dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektweite
|
||||||
Serverhistorie. Direkte Feld-PATCH-, Struktur-POST-, Move-, Circuit- und
|
Serverhistorie. Direkte Feld-PATCH-, Struktur-POST-, Move-, Circuit- und
|
||||||
Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves und
|
Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves,
|
||||||
Stromkreis-Reorders im Grid verwenden die persistenten Kommandos bereits. Nur
|
Stromkreis-Reorders und die explizite Neunummerierung im Grid verwenden die
|
||||||
der Renumber-UI-Pfad verbleibt vorerst im sichtbaren sitzungslokalen Stack;
|
persistenten Kommandos. Der sichtbare Undo-/Redo-Stack wird nach einem Reload
|
||||||
nach einem Reload wird dieser noch nicht aus der Serverhistorie rekonstruiert.
|
noch nicht aus der Serverhistorie rekonstruiert.
|
||||||
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
|
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
|
||||||
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
|
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
|
||||||
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
|
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
|
||||||
@@ -115,6 +115,11 @@ bleiben unverändert. `circuit.renumber-section` bildet die getrennte,
|
|||||||
ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und
|
ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und
|
||||||
neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und
|
neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und
|
||||||
ändert weder Sortierung noch Gerätezeilen.
|
ä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
|
Drag-and-drop verwendet `circuit.reorder-section`. Die explizite Übernahme
|
||||||
einer sortierten Ansicht verwendet `circuit.reorder-sections`, damit alle
|
einer sortierten Ansicht verwendet `circuit.reorder-sections`, damit alle
|
||||||
betroffenen Bereiche in einer Transaktion und als ein Undo-Schritt gespeichert
|
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
|
- `circuit.renumber-section` is the separate explicit renumber operation; it
|
||||||
stores every expected/target equipment identifier, uses collision-safe
|
stores every expected/target equipment identifier, uses collision-safe
|
||||||
temporary values and restores the exact prior identifiers on undo
|
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
|
- `project-device.sync-rows` records synchronization, disconnect and reconnect
|
||||||
as one atomic multi-row operation; complete expected/target sync snapshots
|
as one atomic multi-row operation; complete expected/target sync snapshots
|
||||||
protect local values, links and override metadata against silent stale writes
|
protect local values, links and override metadata against silent stale writes
|
||||||
@@ -218,11 +221,9 @@ Completed foundation:
|
|||||||
|
|
||||||
Remaining constraints before completing persistent history:
|
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
|
- reconstruct the visible frontend history state after reload; Circuit and
|
||||||
CircuitDeviceRow field/insert/delete actions plus ProjectDevice operations
|
CircuitDeviceRow operations plus ProjectDevice operations already use
|
||||||
already use persistent commands and project-wide undo
|
persistent commands and project-wide undo
|
||||||
- extend project-scoped commands only when further project mutations are
|
- extend project-scoped commands only when further project mutations are
|
||||||
deliberately added to history; the generic versioned envelope is complete
|
deliberately added to history; the generic versioned envelope is complete
|
||||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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/CircuitDeviceRow field/insert/delete actions, device-row moves and
|
||||||
circuit reorders in the circuit-list editor are connected to this history
|
circuit reorders in the circuit-list editor are connected to this history
|
||||||
boundary. Applying sorted order across multiple sections is one atomic command.
|
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
|
Explicit renumbering is connected to the same persistent boundary. The
|
||||||
therefore remains session-local and is not reconstructed after reload.
|
editor's visible undo/redo stack remains session-local and is not reconstructed
|
||||||
|
after reload.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"start": "node dist/server/index.js",
|
||||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-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-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-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:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "tsx scripts/db-backup.ts",
|
"db:backup": "tsx scripts/db-backup.ts",
|
||||||
|
|||||||
@@ -1,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 {
|
import {
|
||||||
buildCircuitSectionReorderAssignments,
|
buildCircuitSectionReorderAssignments,
|
||||||
} from "../utils/circuit-section-reorder-command";
|
} from "../utils/circuit-section-reorder-command";
|
||||||
|
import {
|
||||||
|
buildCircuitSectionRenumberAssignments,
|
||||||
|
} from "../utils/circuit-section-renumber-command";
|
||||||
import type {
|
import type {
|
||||||
CellKey,
|
CellKey,
|
||||||
CellKind,
|
CellKind,
|
||||||
@@ -57,9 +60,8 @@ import {
|
|||||||
moveCircuitDeviceRowsToNewCircuitCommand,
|
moveCircuitDeviceRowsToNewCircuitCommand,
|
||||||
reorderCircuitSectionCommand,
|
reorderCircuitSectionCommand,
|
||||||
reorderCircuitSectionsCommand,
|
reorderCircuitSectionsCommand,
|
||||||
renumberCircuitSection,
|
renumberCircuitSectionCommand,
|
||||||
redoProjectCommand,
|
redoProjectCommand,
|
||||||
updateSectionEquipmentIdentifiers,
|
|
||||||
updateCircuitById,
|
updateCircuitById,
|
||||||
updateCircuitDeviceRowById,
|
updateCircuitDeviceRowById,
|
||||||
undoProjectCommand,
|
undoProjectCommand,
|
||||||
@@ -2592,7 +2594,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
await handleDeleteCircuit(intent.circuitId);
|
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) {
|
async function handleRenumberSection(sectionId: string) {
|
||||||
if (!confirm("Diesen Bereich neu nummerieren? Nur die Stromkreise in diesem Bereich werden geändert.")) {
|
if (!confirm("Diesen Bereich neu nummerieren? Nur die Stromkreise in diesem Bereich werden geändert.")) {
|
||||||
return;
|
return;
|
||||||
@@ -2601,26 +2604,31 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
if (!section) {
|
if (!section) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const before = section.circuits.map((circuit) => ({
|
const assignments = buildCircuitSectionRenumberAssignments(
|
||||||
id: circuit.id,
|
data?.sections ?? [],
|
||||||
equipmentIdentifier: circuit.equipmentIdentifier,
|
sectionId
|
||||||
}));
|
);
|
||||||
|
if (assignments.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = "Bereich neu nummerieren";
|
||||||
await runCommand({
|
await runCommand({
|
||||||
label: "Bereich neu nummerieren",
|
label,
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
await renumberCircuitSection(sectionId);
|
const result = await renumberCircuitSectionCommand(
|
||||||
|
projectId,
|
||||||
|
getExpectedProjectRevision(),
|
||||||
|
sectionId,
|
||||||
|
assignments,
|
||||||
|
label
|
||||||
|
);
|
||||||
|
applyProjectCommandResult(result);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
undo: async () => {
|
undo: async () => null,
|
||||||
// Restore prior BMKs through safe bulk endpoint to avoid transient unique collisions.
|
persistent: {
|
||||||
await updateSectionEquipmentIdentifiers(
|
undoIntent: () => null,
|
||||||
sectionId,
|
redoIntent: () => null,
|
||||||
before.map((entry) => ({
|
|
||||||
circuitId: entry.id,
|
|
||||||
equipmentIdentifier: entry.equipmentIdentifier,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-12
@@ -42,6 +42,9 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
CircuitSectionsReorderSection,
|
CircuitSectionsReorderSection,
|
||||||
} from "../../domain/models/circuit-sections-reorder-project-command.model";
|
} 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> {
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(url, {
|
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(
|
export function reorderCircuitSectionCommand(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
expectedRevision: number,
|
expectedRevision: number,
|
||||||
@@ -362,14 +359,23 @@ export function reorderCircuitSectionsCommand(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateSectionEquipmentIdentifiers(
|
export function renumberCircuitSectionCommand(
|
||||||
|
projectId: string,
|
||||||
|
expectedRevision: number,
|
||||||
sectionId: string,
|
sectionId: string,
|
||||||
identifiers: Array<{ circuitId: string; equipmentIdentifier: string }>
|
assignments: CircuitSectionRenumberAssignment[],
|
||||||
|
description: string
|
||||||
) {
|
) {
|
||||||
return request(`/api/circuit-sections/${sectionId}/equipment-identifiers`, {
|
return executeProjectCommand(
|
||||||
method: "PATCH",
|
projectId,
|
||||||
body: JSON.stringify({ identifiers }),
|
expectedRevision,
|
||||||
});
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.renumber-section",
|
||||||
|
payload: { sectionId, assignments },
|
||||||
|
},
|
||||||
|
description
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listFloors(projectId: string) {
|
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 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) {
|
export async function getNextCircuitIdentifier(req: Request, res: Response) {
|
||||||
const { sectionId } = req.params;
|
const { sectionId } = req.params;
|
||||||
@@ -7,7 +7,8 @@ export async function getNextCircuitIdentifier(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: "Invalid sectionId" });
|
return res.status(400).json({ error: "Invalid sectionId" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const nextIdentifier = await circuitWriteService.getNextIdentifier(sectionId);
|
const nextIdentifier =
|
||||||
|
await circuitNumberingService.getNextIdentifier(sectionId);
|
||||||
return res.json({ sectionId, nextIdentifier });
|
return res.json({ sectionId, nextIdentifier });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to get identifier." });
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to get identifier." });
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { circuitRouter } from "./routes/circuit.routes.js";
|
import { circuitRouter } from "./routes/circuit.routes.js";
|
||||||
import { circuitSectionRouter } from "./routes/circuit-section.routes.js";
|
|
||||||
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
||||||
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||||
import { projectRouter } from "./routes/project.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/projects", projectRouter);
|
||||||
app.use("/api", circuitRouter);
|
app.use("/api", circuitRouter);
|
||||||
app.use("/api", circuitSectionRouter);
|
|
||||||
app.use("/api/global-devices", globalDeviceRouter);
|
app.use("/api/global-devices", globalDeviceRouter);
|
||||||
app.use("/api/project-devices", projectDeviceRouter);
|
app.use("/api/project-devices", projectDeviceRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
import {
|
|
||||||
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