Compare commits
9 Commits
53282e8c7c
...
2eba4ea75e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eba4ea75e | |||
| 76d8d59391 | |||
| 3977a6e6e1 | |||
| e930cb75b8 | |||
| ac16cca77a | |||
| 0bc6c7372f | |||
| 2668fc2f16 | |||
| 4b4603b71b | |||
| 332dfdb5d9 |
@@ -220,15 +220,32 @@ After saving, the row becomes linked to the new project device.
|
|||||||
|
|
||||||
## Undo / Redo
|
## Undo / Redo
|
||||||
|
|
||||||
Session-local UI undo/redo exists for structural and destructive operations.
|
The editor's visible undo/redo stack remains session-local while move, reorder
|
||||||
The server already persists immutable revisions and project-wide undo/redo
|
and renumber operations are being migrated. Existing Circuit and
|
||||||
eligibility for the first commands. Public command, undo and redo endpoints
|
CircuitDeviceRow cell edits plus standalone insertion/deletion already execute
|
||||||
exist for Circuit and CircuitDeviceRow field updates as well as
|
persistent project commands; their toolbar undo/redo actions use the
|
||||||
Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and
|
project-wide history endpoints. Insertions use client-generated stable UUIDs,
|
||||||
the UI cutover remain future work. CircuitDeviceRow moves between existing
|
and deletion undo restores the same ids from complete server snapshots. The
|
||||||
circuits and moves that create one new placeholder target circuit are also
|
tree response supplies the optimistic `currentRevision`; obsolete direct field
|
||||||
persisted. The latter stores the complete empty target snapshot so undo can
|
PATCH, structure POST and CircuitDeviceRow DELETE routes are removed.
|
||||||
restore the rows and remove only the unchanged generated circuit.
|
Reloading the page does not yet reconstruct the visible editor stack.
|
||||||
|
CircuitDeviceRow moves between existing circuits and moves that create one new
|
||||||
|
placeholder target circuit are persisted. The latter stores the complete empty
|
||||||
|
target snapshot so undo can restore the rows and remove only the unchanged
|
||||||
|
generated circuit. Complete
|
||||||
|
in-section Circuit reorders are persisted separately and change sort positions
|
||||||
|
without changing equipment identifiers. Explicit complete-section renumbering
|
||||||
|
is persisted through a separate collision-safe command and is never triggered
|
||||||
|
by sorting or moving. Project-device synchronization, disconnect and reconnect
|
||||||
|
are persisted as one atomic multi-row command with complete expected/target row
|
||||||
|
snapshots, including link and override metadata. Canonical ProjectDevice field
|
||||||
|
updates are also persisted and never synchronize linked rows implicitly.
|
||||||
|
ProjectDevice insertion/deletion preserves stable device ids. Deletion captures
|
||||||
|
complete disconnected snapshots of linked rows so undo can restore only rows
|
||||||
|
that have remained unchanged. ProjectDevice create, update, delete and
|
||||||
|
global-to-project copy API/UI paths use these persistent commands and track the
|
||||||
|
returned project revision. ProjectDevice synchronization/disconnect API and UI
|
||||||
|
paths do the same; their undo action uses the project-wide history endpoint.
|
||||||
|
|
||||||
Required operations:
|
Required operations:
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
The circuit-first editor uses tree, circuit, row and project-device endpoints.
|
The circuit-first editor uses tree, circuit, row and project-device endpoints.
|
||||||
All paths below are mounted below `/api`. There is no Consumer application API.
|
All paths below are mounted below `/api`. There is no Consumer application API.
|
||||||
|
|
||||||
Project responses from `GET /projects` and `GET /projects/:projectId` include
|
Project responses from `GET /projects`, `GET /projects/:projectId` and the
|
||||||
`currentRevision`. Versioned project commands use this value for optimistic
|
circuit-tree endpoint include `currentRevision`. Versioned project commands use
|
||||||
concurrency checks.
|
this value for optimistic concurrency checks.
|
||||||
|
|
||||||
### Project Commands and History
|
### Project Commands and History
|
||||||
|
|
||||||
@@ -25,16 +25,20 @@ concurrency checks.
|
|||||||
|
|
||||||
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
|
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
|
||||||
`circuit.delete`, `circuit-device-row.update`,
|
`circuit.delete`, `circuit-device-row.update`,
|
||||||
`circuit-device-row.insert`, `circuit-device-row.delete` and
|
`circuit-device-row.insert`, `circuit-device-row.delete`,
|
||||||
`circuit-device-row.move` plus
|
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
|
||||||
`circuit-device-row.move-with-new-circuit`, all with schema version `1`. Other
|
`circuit.reorder-section`, `circuit.renumber-section` and
|
||||||
command types are rejected. Insert commands contain the complete entity or
|
`project-device.update`, `project-device.insert`, `project-device.delete` and
|
||||||
circuit block with stable ids; delete commands include the expected parent
|
`project-device.sync-rows`, all with schema version `1`. Other command types
|
||||||
identity. Circuit snapshots contain zero, one or multiple complete device
|
are rejected. Insert commands contain the complete entity or circuit block
|
||||||
rows. Move commands contain each row's expected and target circuit plus its
|
with stable ids; delete commands include the expected parent identity. Circuit
|
||||||
exact expected and target sort order. This makes deletion and moves undoable
|
snapshots contain zero, one or multiple complete device rows. Move commands
|
||||||
without generating replacement identities or renumbering circuits. The editor
|
contain each row's expected and target circuit plus its exact expected and
|
||||||
does not consume these endpoints yet.
|
target sort order. This makes deletion and moves undoable without generating
|
||||||
|
replacement identities or renumbering circuits. Existing Circuit and
|
||||||
|
CircuitDeviceRow cell edits plus standalone insert/delete actions in the editor
|
||||||
|
consume these endpoints. Move, reorder and renumber UI paths are still being
|
||||||
|
migrated.
|
||||||
|
|
||||||
`circuit-device-row.move` targets existing circuits in the same circuit list.
|
`circuit-device-row.move` 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
|
||||||
@@ -43,6 +47,58 @@ rows into it. Its inverse restores every row to its exact prior position and
|
|||||||
deletes the generated circuit only if its fields and complete row set still
|
deletes the generated circuit only if its fields and complete row set still
|
||||||
match the recorded state.
|
match the recorded state.
|
||||||
|
|
||||||
|
`circuit.reorder-section` requires one assignment for every circuit currently
|
||||||
|
in the section. Each assignment records the expected and target `sortOrder`.
|
||||||
|
The command and its inverse change no circuit field other than `sortOrder`;
|
||||||
|
equipment identifiers and complete device-row blocks remain unchanged.
|
||||||
|
|
||||||
|
`circuit.renumber-section` is an explicit operation requiring one assignment
|
||||||
|
for every circuit in the section. Assignments contain expected and target
|
||||||
|
equipment identifiers. The store rejects stale values, duplicate targets and
|
||||||
|
targets occupied by other sections, then applies swaps through collision-safe
|
||||||
|
temporary identifiers. Undo restores the exact prior identifiers; sort
|
||||||
|
positions and device rows remain unchanged.
|
||||||
|
|
||||||
|
`project-device.sync-rows` represents `synchronize`, `disconnect` and
|
||||||
|
`reconnect` operations. Every selected row carries complete expected and target
|
||||||
|
snapshots of all ProjectDevice-sync fields, the link and `overriddenFields`.
|
||||||
|
All rows, the inverse command, revision and history-stack transition commit or
|
||||||
|
roll back together. Disconnect/reconnect may only change the link; stale row
|
||||||
|
snapshots and cross-project devices or rows are rejected.
|
||||||
|
|
||||||
|
`project-device.update` changes one or multiple canonical ProjectDevice fields
|
||||||
|
and derives its inverse from the persisted device. It validates project
|
||||||
|
ownership and never writes linked `CircuitDeviceRow` values; synchronization
|
||||||
|
remains a separate explicit command.
|
||||||
|
|
||||||
|
`project-device.insert` stores the complete canonical device with a stable id.
|
||||||
|
User inserts cannot attach existing rows. `project-device.delete` captures the
|
||||||
|
complete device and every currently linked row before deletion. Its inverse
|
||||||
|
recreates the same device and reconnects only rows whose complete disconnected
|
||||||
|
snapshot still matches; device, links, revision and history transition are
|
||||||
|
atomic.
|
||||||
|
|
||||||
|
### Project Device CRUD
|
||||||
|
|
||||||
|
- `GET /project-devices/projects/:projectId`
|
||||||
|
- lists the project's reusable devices
|
||||||
|
- `POST /project-devices/projects/:projectId`
|
||||||
|
- `PUT /project-devices/projects/:projectId/:projectDeviceId`
|
||||||
|
- request: all canonical device fields plus `expectedRevision`
|
||||||
|
- response: `{ "projectDevice": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
|
- `DELETE /project-devices/projects/:projectId/:projectDeviceId`
|
||||||
|
- request: `{ "expectedRevision": 12 }`
|
||||||
|
- response: the project command result with the updated history state
|
||||||
|
- `POST /project-devices/projects/:projectId/import-global/:globalDeviceId`
|
||||||
|
- request: `{ "expectedRevision": 12 }`
|
||||||
|
- creates an independent project device with a stable UUID
|
||||||
|
- response shape matches ProjectDevice create/update
|
||||||
|
|
||||||
|
These write endpoints execute the typed `project-device.update`,
|
||||||
|
`project-device.insert` and `project-device.delete` commands. A stale revision
|
||||||
|
returns `409 PROJECT_REVISION_CONFLICT`. Updating a project device still never
|
||||||
|
synchronizes linked circuit rows implicitly.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -83,6 +139,7 @@ Response sketch:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"circuitListId": "cl_1",
|
"circuitListId": "cl_1",
|
||||||
|
"currentRevision": 12,
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
"id": "sec_1",
|
"id": "sec_1",
|
||||||
@@ -107,38 +164,17 @@ Response sketch:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Circuit CRUD
|
### Circuit Structure
|
||||||
|
|
||||||
- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits`
|
|
||||||
- create circuit in list/section
|
|
||||||
- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows`
|
|
||||||
- atomically create one circuit and one or more initial device rows
|
|
||||||
- `PATCH /circuits/:circuitId`
|
|
||||||
- update circuit-level fields (BMK, protection, cable, reserve flag, etc.)
|
|
||||||
- `DELETE /circuits/:circuitId`
|
|
||||||
- delete circuit (and related rows via DB relations)
|
|
||||||
- `GET /circuit-sections/:sectionId/next-identifier`
|
- `GET /circuit-sections/:sectionId/next-identifier`
|
||||||
- preview next identifier for section (`prefix + maxSuffix + 1`)
|
- preview next identifier for section (`prefix + maxSuffix + 1`)
|
||||||
|
|
||||||
Request sketch (`POST .../circuits`):
|
Circuit and device-row field updates, standalone insertions and standalone
|
||||||
|
deletions are available only as the corresponding versioned commands through
|
||||||
```json
|
`POST /projects/:projectId/commands`. There are no direct field-update PATCH,
|
||||||
{
|
structure POST or CircuitDeviceRow DELETE routes. The direct
|
||||||
"sectionId": "sec_1",
|
`DELETE /circuits/:circuitId` route remains temporarily for the inverse of the
|
||||||
"equipmentIdentifier": "-2F14",
|
not-yet-migrated placeholder move UI and must not be used by new features.
|
||||||
"displayName": "Sockets East",
|
|
||||||
"sortOrder": 140
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Device Row CRUD
|
|
||||||
|
|
||||||
- `POST /circuits/:circuitId/device-rows`
|
|
||||||
- create row inside a circuit
|
|
||||||
- `PATCH /circuit-device-rows/:rowId`
|
|
||||||
- update row values
|
|
||||||
- `DELETE /circuit-device-rows/:rowId`
|
|
||||||
- delete row
|
|
||||||
|
|
||||||
### Move Device Row
|
### Move Device Row
|
||||||
|
|
||||||
@@ -223,12 +259,16 @@ endpoints above.
|
|||||||
- returns all linked circuit device rows with distribution-board/circuit context and field differences
|
- returns all linked circuit device rows with distribution-board/circuit context and field differences
|
||||||
- `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize`
|
- `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize`
|
||||||
- applies only explicitly selected fields to explicitly selected linked rows
|
- applies only explicitly selected fields to explicitly selected linked rows
|
||||||
- `POST /project-devices/projects/:projectId/:projectDeviceId/restore`
|
- request: selected `rowIds`, selected `fields` and `expectedRevision`
|
||||||
- restores the captured pre-sync values for session-local undo
|
- response: updated preview, revision and project history state
|
||||||
- `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect`
|
- `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect`
|
||||||
- disconnects explicitly selected rows without changing their local values
|
- disconnects explicitly selected rows without changing their local values
|
||||||
- `POST /project-devices/projects/:projectId/:projectDeviceId/reconnect`
|
- request: selected `rowIds` and `expectedRevision`
|
||||||
- restores a disconnected link if the row has not been linked elsewhere meanwhile
|
- response: updated preview, revision and project history state
|
||||||
|
|
||||||
|
Both writes execute `project-device.sync-rows`. Undo uses the project-wide
|
||||||
|
`POST /projects/:projectId/history/undo` endpoint; reconnect is the validated
|
||||||
|
inverse command. The former direct restore/reconnect endpoints are removed.
|
||||||
|
|
||||||
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
|
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
|
||||||
project device never triggers synchronization implicitly.
|
project device never triggers synchronization implicitly.
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ Inline cells are static text by default. A cell enters edit mode by:
|
|||||||
|
|
||||||
`Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell.
|
`Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell.
|
||||||
|
|
||||||
Committed cell edits are persisted as partial database updates. Fields that were not part of the edit are not written back from an older read and therefore cannot overwrite a concurrent change to another field.
|
Committed cells on existing circuits and device rows are persisted as typed
|
||||||
|
project commands with an optimistic project revision. Only the edited fields
|
||||||
|
are included, so an older tree response cannot write unrelated fields back.
|
||||||
|
The server records the inverse in the same transaction.
|
||||||
|
|
||||||
## `selectedCell` vs `editingCell`
|
## `selectedCell` vs `editingCell`
|
||||||
|
|
||||||
@@ -119,7 +122,13 @@ Cross-section device moves show confirmation-required feedback before drop. The
|
|||||||
|
|
||||||
## Undo/Redo
|
## Undo/Redo
|
||||||
|
|
||||||
Undo/redo wraps editor operations as command objects with async `redo`/`undo` and reloads tree after each command.
|
Undo/redo wraps editor operations as command objects and reloads the tree after
|
||||||
|
each command. Existing Circuit and device-row cell edits plus standalone
|
||||||
|
insert/delete actions execute persistent project commands; their toolbar
|
||||||
|
undo/redo calls the project-wide server history. New entities receive stable
|
||||||
|
UUIDs before insertion, and deletion undo restores those same ids. Move,
|
||||||
|
reorder and renumber operations still use their existing direct API writes and
|
||||||
|
session-local inverse callbacks during the cutover.
|
||||||
|
|
||||||
Covered operations include:
|
Covered operations include:
|
||||||
|
|
||||||
@@ -132,5 +141,6 @@ Covered operations include:
|
|||||||
|
|
||||||
Current limitations:
|
Current limitations:
|
||||||
|
|
||||||
- session-local only
|
- the visible editor stack is still session-local and is empty after reload
|
||||||
- no persisted history across browser reload
|
- move, reorder and renumber editor actions are not connected to the persistent
|
||||||
|
command boundary yet
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
# Circuit List Editor Known Limitations
|
# Circuit List Editor Known Limitations
|
||||||
|
|
||||||
- Undo/redo history is session-local only.
|
- Existing Circuit and CircuitDeviceRow cell edits plus standalone
|
||||||
- Undo/redo history is not persisted across reloads or between users.
|
insert/delete actions use persistent project commands and project-wide
|
||||||
- No project revision or logical snapshot model is implemented yet.
|
toolbar undo/redo. The editor's visible stack is still session-local, is
|
||||||
|
empty after reload and still contains direct move/reorder/renumber operations
|
||||||
|
during the cutover.
|
||||||
|
- Server-side project revisions and persistent undo/redo eligibility exist for
|
||||||
|
the command types documented in `circuit-list-editor-api.md`; complete editor
|
||||||
|
command coverage and history reconstruction are not complete.
|
||||||
|
- 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.
|
||||||
- The global device library supports basic CRUD and copy operations, but has no
|
- The global device library supports basic CRUD and copy operations, but has no
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ liegt über einen Host-Mount außerhalb des Containers.
|
|||||||
- `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`
|
- `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`
|
||||||
– unterstützte Editorroute
|
– unterstützte Editorroute
|
||||||
- `src/frontend/components/circuit-tree-editor.tsx` – Editorzustand,
|
- `src/frontend/components/circuit-tree-editor.tsx` – Editorzustand,
|
||||||
Befehlsausführung, Drag-and-drop und sitzungslokales Undo/Redo
|
Befehlsausführung, Drag-and-drop und der schrittweise Historien-Cutover
|
||||||
- `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion,
|
- `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion,
|
||||||
Zellbesitz, Einfügen und Sicherheitsregeln
|
Zellbesitz, Einfügen und Sicherheitsregeln
|
||||||
- `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe
|
- `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe
|
||||||
@@ -62,7 +62,8 @@ liegt über einen Host-Mount außerhalb des Containers.
|
|||||||
6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise
|
6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise
|
||||||
Viewport soweit möglich wieder her.
|
Viewport soweit möglich wieder her.
|
||||||
|
|
||||||
Die React-Historie ist derzeit sitzungslokal. Das Datenmodell besitzt bereits
|
Der sichtbare React-Historiestapel ist während des schrittweisen Cutovers noch
|
||||||
|
sitzungslokal. Das Datenmodell besitzt
|
||||||
einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
|
einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
|
||||||
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
|
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
|
||||||
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
|
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
|
||||||
@@ -85,8 +86,15 @@ inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte
|
|||||||
wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen
|
wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen
|
||||||
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
|
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
|
||||||
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
|
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
|
||||||
Betriebsmittelkennzeichen. Das Grid verwendet diese Endpunkte noch nicht; seine
|
Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen
|
||||||
sichtbare Historie bleibt daher sitzungslokal.
|
sowie eigenständiges Einfügen und Löschen verwendet das Grid bereits über die
|
||||||
|
öffentliche Command-Grenze. Neue Circuits und Gerätezeilen erhalten ihre stabile
|
||||||
|
UUID vor dem Command; Löschen und Undo bewahren diese Identität. Der Tree liefert
|
||||||
|
dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektweite
|
||||||
|
Serverhistorie. Direkte Feld-PATCH-, Struktur-POST- und Gerätezeilen-DELETE-
|
||||||
|
Endpunkte sind entfernt. Move-, Reorder- und Renumber-UI-Pfade verbleiben
|
||||||
|
vorerst im sichtbaren sitzungslokalen Stack; nach einem Reload wird dieser noch
|
||||||
|
nicht aus der Serverhistorie rekonstruiert.
|
||||||
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
|
`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;
|
||||||
@@ -95,9 +103,39 @@ Kompositkommando `circuit-device-row.move-with-new-circuit` bildet auch das
|
|||||||
Verschieben auf einen freien Platz ab: Ein Zielstromkreis mit stabiler UUID und
|
Verschieben auf einen freien Platz ab: Ein Zielstromkreis mit stabiler UUID und
|
||||||
BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten
|
BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten
|
||||||
Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen
|
Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen
|
||||||
Felder und vollständiger Zeilenbestand unverändert sind. Circuit-Sortierung,
|
Felder und vollständiger Zeilenbestand unverändert sind.
|
||||||
Neunummerierung und Synchronisierung müssen vor der Frontend-Umstellung noch
|
`circuit.reorder-section` speichert die erwartete und neue Sortierposition
|
||||||
als persistente Commands modelliert werden.
|
jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo
|
||||||
|
ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs
|
||||||
|
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.
|
||||||
|
`project-device.sync-rows` persistiert Synchronisierung, Trennen und erneutes
|
||||||
|
Verknüpfen als atomaren Mehrzeilen-Command. Jede betroffene Zeile enthält den
|
||||||
|
vollständigen erwarteten und neuen Stand aller synchronisierbaren Felder,
|
||||||
|
einschließlich ProjectDevice-Verknüpfung und lokaler Override-Metadaten. Damit
|
||||||
|
werden stille Überschreibungen veralteter Zeilen verhindert und Undo/Redo stellt
|
||||||
|
exakt die vorherigen lokalen Werte wieder her. Die Synchronisieren- und
|
||||||
|
Trennen-Endpunkte sowie die Projektseite verwenden diesen Command mit
|
||||||
|
optimistischer Revisionsprüfung. Das dortige Rückgängig löst die persistente
|
||||||
|
projektweite Historie aus; separate direkte Restore-/Reconnect-Schreibwege
|
||||||
|
existieren nicht mehr.
|
||||||
|
`project-device.update` versioniert Änderungen an den kanonischen
|
||||||
|
Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem
|
||||||
|
gespeicherten Gerät und schreibt Geräteänderung, Revision und Historienstapel
|
||||||
|
atomar. Verknüpfte Stromkreiszeilen werden dabei bewusst nicht automatisch
|
||||||
|
synchronisiert. Der ProjectDevice-`PUT`-Endpunkt und die Projektseite verwenden
|
||||||
|
diesen Command mit optimistischer Revisionsprüfung.
|
||||||
|
`project-device.insert` und `project-device.delete` versionieren außerdem den
|
||||||
|
Lebenszyklus eines Projektgeräts mit stabiler UUID. Beim Löschen speichert das
|
||||||
|
inverse Insert den vollständigen Gerätestand sowie vollständige, nach dem
|
||||||
|
Löschen erwartete Snapshots aller zuvor verknüpften Gerätezeilen. Undo setzt
|
||||||
|
die Links nur zurück, wenn diese Zeilen weiterhin zum Projekt gehören,
|
||||||
|
unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revision
|
||||||
|
und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
|
||||||
|
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
|
||||||
|
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
|
||||||
|
|
||||||
## Projektgeräte
|
## Projektgeräte
|
||||||
|
|
||||||
@@ -129,7 +167,8 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
|||||||
|
|
||||||
## Noch nicht unterstützt
|
## Noch nicht unterstützt
|
||||||
|
|
||||||
- persistentes Undo/Redo und Projektversionen
|
- vollständiges persistentes Undo/Redo, sichtbare Historie nach Reload und
|
||||||
|
benannte Projektstände
|
||||||
- Mehrbenutzerbetrieb und Konfliktauflösung
|
- Mehrbenutzerbetrieb und Konfliktauflösung
|
||||||
- Revit-/CSV-/IFCGUID-Round-trip
|
- Revit-/CSV-/IFCGUID-Round-trip
|
||||||
- vollständige elektrische Dimensionierung
|
- vollständige elektrische Dimensionierung
|
||||||
|
|||||||
@@ -173,12 +173,20 @@ Completed foundation:
|
|||||||
to SQLite
|
to SQLite
|
||||||
- public command, undo and redo endpoints require an expected revision; undo
|
- public command, undo and redo endpoints require an expected revision; undo
|
||||||
and redo reconstruct the eligible persisted inverse or forward command
|
and redo reconstruct the eligible persisted inverse or forward command
|
||||||
|
- the circuit tree returns the project's current revision; existing Circuit
|
||||||
|
and CircuitDeviceRow cell edits use the public command endpoint, while their
|
||||||
|
toolbar undo/redo actions use project-wide persistent history
|
||||||
|
- obsolete direct Circuit and CircuitDeviceRow field-update PATCH routes are
|
||||||
|
removed so these editor writes cannot bypass revision history
|
||||||
- `circuit-device-row.insert` and `circuit-device-row.delete` preserve stable
|
- `circuit-device-row.insert` and `circuit-device-row.delete` preserve stable
|
||||||
row ids, complete deleted-row snapshots and circuit reserve state through
|
row ids, complete deleted-row snapshots and circuit reserve state through
|
||||||
atomic insert/delete, revision and stack transitions
|
atomic insert/delete, revision and stack transitions
|
||||||
- `circuit.insert` and `circuit.delete` persist a complete circuit block with
|
- `circuit.insert` and `circuit.delete` persist a complete circuit block with
|
||||||
zero, one or multiple device rows; undo restores all ids and fields without
|
zero, one or multiple device rows; undo restores all ids and fields without
|
||||||
renumbering
|
renumbering
|
||||||
|
- standalone Circuit/CircuitDeviceRow insert/delete UI paths use these commands
|
||||||
|
with client-generated stable UUIDs; obsolete direct structure POST and
|
||||||
|
CircuitDeviceRow DELETE routes are removed
|
||||||
- `circuit-device-row.move` stores exact expected and target circuit/sort
|
- `circuit-device-row.move` stores exact expected and target circuit/sort
|
||||||
positions for one or multiple rows, recalculates all affected reserve states
|
positions for one or multiple rows, recalculates all affected reserve states
|
||||||
and reconstructs a deterministic inverse across multiple source circuits
|
and reconstructs a deterministic inverse across multiple source circuits
|
||||||
@@ -186,18 +194,31 @@ Completed foundation:
|
|||||||
empty target-circuit snapshot; forward creation and all row moves share one
|
empty target-circuit snapshot; forward creation and all row moves share one
|
||||||
transaction, while undo restores the source positions and deletes only an
|
transaction, while undo restores the source positions and deletes only an
|
||||||
unchanged generated target
|
unchanged generated target
|
||||||
|
- `circuit.reorder-section` requires the complete section circuit set and
|
||||||
|
stores exact expected/target sort positions; its inverse changes no
|
||||||
|
equipment identifier and never splits a circuit block
|
||||||
|
- `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
|
||||||
|
- `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
|
||||||
|
- `project-device.update` persists canonical source-device field changes
|
||||||
|
independently and never synchronizes linked rows implicitly
|
||||||
|
- `project-device.insert` and `project-device.delete` preserve stable device
|
||||||
|
ids; delete undo restores links only from complete unchanged disconnected-row
|
||||||
|
snapshots in the same transaction
|
||||||
|
|
||||||
Remaining constraints before completing persistent history:
|
Remaining constraints before completing persistent history:
|
||||||
|
|
||||||
- extend the concrete project-scoped command union and executors beyond the
|
- route the remaining move, reorder and renumber editor writes through their
|
||||||
Circuit field/insert/delete and CircuitDeviceRow
|
existing typed persistent commands and remove the final direct mutation
|
||||||
field/insert/delete/move slices; the generic versioned command envelope is
|
routes
|
||||||
complete
|
- reconstruct the visible frontend history state after reload; Circuit and
|
||||||
- route the remaining structural, synchronization and destructive domain
|
CircuitDeviceRow field/insert/delete actions plus ProjectDevice operations
|
||||||
writes through the shared command, revision and stack transaction boundary
|
already use persistent commands and project-wide undo
|
||||||
- replace the session-local frontend command stack only after server-side
|
- extend project-scoped commands only when further project mutations are
|
||||||
command coverage is complete for structural, destructive and synchronization
|
deliberately added to history; the generic versioned envelope is complete
|
||||||
operations
|
|
||||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||||
- keep database backup/restore checks separate from project history tests
|
- keep database backup/restore checks separate from project history tests
|
||||||
|
|
||||||
|
|||||||
@@ -390,26 +390,51 @@ Implemented foundation:
|
|||||||
- a central application dispatcher reconstructs persisted commands and exposes
|
- a central application dispatcher reconstructs persisted commands and exposes
|
||||||
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
|
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
|
||||||
field updates
|
field updates
|
||||||
|
- the circuit tree exposes `currentRevision`; existing Circuit and
|
||||||
|
CircuitDeviceRow cell edits use those optimistic commands and project-wide
|
||||||
|
undo/redo instead of direct field-update PATCH routes
|
||||||
- typed CircuitDeviceRow insert/delete commands preserve complete row snapshots
|
- typed CircuitDeviceRow insert/delete commands preserve complete row snapshots
|
||||||
and stable ids; row mutation, circuit reserve state, revision and history
|
and stable ids; row mutation, circuit reserve state, revision and history
|
||||||
stack transition commit or roll back together
|
stack transition commit or roll back together
|
||||||
- typed Circuit insert/delete commands treat the circuit and all device rows as
|
- typed Circuit insert/delete commands treat the circuit and all device rows as
|
||||||
one block, preserve every stable id and never renumber neighbouring circuits
|
one block, preserve every stable id and never renumber neighbouring circuits
|
||||||
|
- standalone Circuit/CircuitDeviceRow insert/delete editor paths use those
|
||||||
|
commands with stable client-generated UUIDs and project-wide undo/redo; the
|
||||||
|
obsolete direct structure POST and CircuitDeviceRow DELETE routes are removed
|
||||||
- typed CircuitDeviceRow move commands persist exact old/new circuit and sort
|
- typed CircuitDeviceRow move commands persist exact old/new circuit and sort
|
||||||
positions for single or multi-row moves between existing circuits and derive
|
positions for single or multi-row moves between existing circuits and derive
|
||||||
affected reserve states in the same transaction
|
affected reserve states in the same transaction
|
||||||
- placeholder-target moves persist one complete generated circuit identity and
|
- placeholder-target moves persist one complete generated circuit identity and
|
||||||
all row positions atomically; undo restores every source and removes only an
|
all row positions atomically; undo restores every source and removes only an
|
||||||
unchanged generated circuit, without renumbering
|
unchanged generated circuit, without renumbering
|
||||||
|
- complete section reorders persist every expected and target circuit
|
||||||
|
`sortOrder`; undo/redo changes neither equipment identifiers nor device rows
|
||||||
|
- explicit section renumber commands persist every expected and target
|
||||||
|
equipment identifier, apply swaps collision-safely and restore exact prior
|
||||||
|
identifiers without changing sort positions or device rows
|
||||||
|
- ProjectDevice row synchronization, disconnect and reconnect persist complete
|
||||||
|
expected/target sync snapshots in one atomic multi-row command; undo restores
|
||||||
|
local values, links and override metadata without silent overwrites
|
||||||
|
- canonical ProjectDevice field updates persist independently with exact
|
||||||
|
inverses and never overwrite linked CircuitDeviceRow values implicitly
|
||||||
|
- ProjectDevice insert/delete commands preserve stable device ids and restore
|
||||||
|
previously linked rows only from complete unchanged disconnected snapshots
|
||||||
|
- ProjectDevice create, update, delete and global-to-project copy endpoints use
|
||||||
|
these commands, require `expectedRevision` and return the new history state
|
||||||
|
- ProjectDevice synchronization and disconnect endpoints also require
|
||||||
|
`expectedRevision`; their UI undo uses project-wide persistent history and
|
||||||
|
the obsolete direct restore/reconnect write paths are removed
|
||||||
- revision metadata, change-set payloads and the project counter are committed
|
- revision metadata, change-set payloads and the project counter are committed
|
||||||
in one SQLite transaction
|
in one SQLite transaction
|
||||||
- a stale expected revision produces no history writes
|
- a stale expected revision produces no history writes
|
||||||
- integration coverage verifies sequential revisions, stale-command rejection
|
- integration coverage verifies sequential revisions, stale-command rejection
|
||||||
and rollback after a forced late persistence failure
|
and rollback after a forced late persistence failure
|
||||||
|
|
||||||
The current editor operations are not connected to this history boundary yet.
|
ProjectDevice CRUD, synchronization and disconnect on the project page and
|
||||||
Undo/redo therefore remains session-local until the remaining tasks are
|
Circuit/CircuitDeviceRow field/insert/delete actions in the circuit-list editor
|
||||||
implemented.
|
are connected to this history boundary. Move, reorder and renumber UI paths are
|
||||||
|
not yet connected; the editor's visible undo/redo stack therefore remains
|
||||||
|
session-local and is not reconstructed after reload.
|
||||||
|
|
||||||
Acceptance criteria:
|
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-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db: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",
|
||||||
|
|||||||
@@ -20,9 +20,8 @@ import {
|
|||||||
listProjectDevices,
|
listProjectDevices,
|
||||||
listProjects,
|
listProjects,
|
||||||
listRooms,
|
listRooms,
|
||||||
reconnectProjectDeviceRows,
|
|
||||||
restoreProjectDeviceRows,
|
|
||||||
synchronizeProjectDeviceRows,
|
synchronizeProjectDeviceRows,
|
||||||
|
undoProjectCommand,
|
||||||
updateProjectDevice,
|
updateProjectDevice,
|
||||||
updateProjectSettings,
|
updateProjectSettings,
|
||||||
} from "../../../frontend/utils/api";
|
} from "../../../frontend/utils/api";
|
||||||
@@ -34,7 +33,6 @@ import type {
|
|||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
ProjectDeviceDto,
|
ProjectDeviceDto,
|
||||||
ProjectDeviceSyncPreviewDto,
|
ProjectDeviceSyncPreviewDto,
|
||||||
ProjectDeviceSyncRestoreRowDto,
|
|
||||||
ProjectDto,
|
ProjectDto,
|
||||||
RoomDto,
|
RoomDto,
|
||||||
} from "../../../frontend/types";
|
} from "../../../frontend/types";
|
||||||
@@ -57,9 +55,10 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
|||||||
remark: "Bemerkung",
|
remark: "Bemerkung",
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProjectDeviceUndoState =
|
type ProjectDeviceUndoState = {
|
||||||
| { kind: "synchronize"; projectDeviceId: string; rows: ProjectDeviceSyncRestoreRowDto[] }
|
projectDeviceId: string;
|
||||||
| { kind: "disconnect"; projectDeviceId: string; rowIds: string[] };
|
revisionNumber: number;
|
||||||
|
};
|
||||||
|
|
||||||
const emptyProjectDevice: CreateProjectDeviceInput = {
|
const emptyProjectDevice: CreateProjectDeviceInput = {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -176,6 +175,13 @@ export default function ProjectDetailPage() {
|
|||||||
[circuitLists]
|
[circuitLists]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function applyProjectRevision(currentRevision: number) {
|
||||||
|
setProject((current) =>
|
||||||
|
current ? { ...current, currentRevision } : current
|
||||||
|
);
|
||||||
|
setProjectDeviceUndo(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
|
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!projectId || !boardName.trim()) {
|
if (!projectId || !boardName.trim()) {
|
||||||
@@ -258,7 +264,7 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
async function handleCreateProjectDevice(event: FormEvent<HTMLFormElement>) {
|
async function handleCreateProjectDevice(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!projectId || !projectDeviceForm.name.trim()) {
|
if (!projectId || !project || !projectDeviceForm.name.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,8 +285,13 @@ export default function ProjectDetailPage() {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const created = await createProjectDevice(projectId, payload);
|
const result = await createProjectDevice(
|
||||||
setProjectDevices((current) => [...current, created]);
|
projectId,
|
||||||
|
payload,
|
||||||
|
project.currentRevision
|
||||||
|
);
|
||||||
|
setProjectDevices((current) => [...current, result.projectDevice]);
|
||||||
|
applyProjectRevision(result.history.currentRevision);
|
||||||
setProjectDeviceForm({
|
setProjectDeviceForm({
|
||||||
name: emptyProjectDevice.name,
|
name: emptyProjectDevice.name,
|
||||||
displayName: emptyProjectDevice.displayName,
|
displayName: emptyProjectDevice.displayName,
|
||||||
@@ -302,14 +313,19 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteProjectDevice(projectDeviceId: string) {
|
async function handleDeleteProjectDevice(projectDeviceId: string) {
|
||||||
if (!projectId) {
|
if (!projectId || !project) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await deleteProjectDevice(projectId, projectDeviceId);
|
const result = await deleteProjectDevice(
|
||||||
|
projectId,
|
||||||
|
projectDeviceId,
|
||||||
|
project.currentRevision
|
||||||
|
);
|
||||||
setProjectDevices((current) => current.filter((item) => item.id !== projectDeviceId));
|
setProjectDevices((current) => current.filter((item) => item.id !== projectDeviceId));
|
||||||
|
applyProjectRevision(result.history.currentRevision);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht gelöscht werden.");
|
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht gelöscht werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -322,7 +338,7 @@ export default function ProjectDetailPage() {
|
|||||||
key: "name" | "displayName" | "category",
|
key: "name" | "displayName" | "category",
|
||||||
value: string
|
value: string
|
||||||
) {
|
) {
|
||||||
if (!projectId) {
|
if (!projectId || !project) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,9 +358,19 @@ export default function ProjectDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await updateProjectDevice(projectId, device.id, payload);
|
const result = await updateProjectDevice(
|
||||||
setProjectDevices((current) => current.map((item) => (item.id === device.id ? updated : item)));
|
projectId,
|
||||||
await openProjectDeviceSyncPreview(updated.id);
|
device.id,
|
||||||
|
payload,
|
||||||
|
project.currentRevision
|
||||||
|
);
|
||||||
|
setProjectDevices((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === device.id ? result.projectDevice : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
applyProjectRevision(result.history.currentRevision);
|
||||||
|
await openProjectDeviceSyncPreview(result.projectDevice.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
|
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
|
||||||
}
|
}
|
||||||
@@ -383,7 +409,13 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSynchronizeProjectDeviceRows() {
|
async function handleSynchronizeProjectDeviceRows() {
|
||||||
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0) {
|
if (
|
||||||
|
!projectId ||
|
||||||
|
!project ||
|
||||||
|
!syncPreview ||
|
||||||
|
selectedSyncRowIds.length === 0 ||
|
||||||
|
selectedSyncFields.length === 0
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@@ -393,16 +425,17 @@ export default function ProjectDetailPage() {
|
|||||||
projectId,
|
projectId,
|
||||||
syncPreview.projectDevice.id,
|
syncPreview.projectDevice.id,
|
||||||
selectedSyncRowIds,
|
selectedSyncRowIds,
|
||||||
selectedSyncFields
|
selectedSyncFields,
|
||||||
|
project.currentRevision
|
||||||
);
|
);
|
||||||
setSyncPreview(result.preview);
|
setSyncPreview(result.preview);
|
||||||
|
applyProjectRevision(result.history.currentRevision);
|
||||||
setSelectedSyncRowIds(
|
setSelectedSyncRowIds(
|
||||||
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
|
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
|
||||||
);
|
);
|
||||||
setProjectDeviceUndo({
|
setProjectDeviceUndo({
|
||||||
kind: "synchronize",
|
|
||||||
projectDeviceId: syncPreview.projectDevice.id,
|
projectDeviceId: syncPreview.projectDevice.id,
|
||||||
rows: result.undo.rows,
|
revisionNumber: result.revision.revisionNumber,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
|
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
|
||||||
@@ -412,7 +445,12 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDisconnectProjectDeviceRows() {
|
async function handleDisconnectProjectDeviceRows() {
|
||||||
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0) {
|
if (
|
||||||
|
!projectId ||
|
||||||
|
!project ||
|
||||||
|
!syncPreview ||
|
||||||
|
selectedSyncRowIds.length === 0
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@@ -421,13 +459,19 @@ export default function ProjectDetailPage() {
|
|||||||
const result = await disconnectProjectDeviceRows(
|
const result = await disconnectProjectDeviceRows(
|
||||||
projectId,
|
projectId,
|
||||||
syncPreview.projectDevice.id,
|
syncPreview.projectDevice.id,
|
||||||
selectedSyncRowIds
|
selectedSyncRowIds,
|
||||||
|
project.currentRevision
|
||||||
);
|
);
|
||||||
await openProjectDeviceSyncPreview(syncPreview.projectDevice.id);
|
setSyncPreview(result.preview);
|
||||||
|
setSelectedSyncRowIds(
|
||||||
|
result.preview.rows
|
||||||
|
.filter((row) => row.differences.length > 0)
|
||||||
|
.map((row) => row.rowId)
|
||||||
|
);
|
||||||
|
applyProjectRevision(result.history.currentRevision);
|
||||||
setProjectDeviceUndo({
|
setProjectDeviceUndo({
|
||||||
kind: "disconnect",
|
|
||||||
projectDeviceId: syncPreview.projectDevice.id,
|
projectDeviceId: syncPreview.projectDevice.id,
|
||||||
rowIds: result.undo.rowIds,
|
revisionNumber: result.revision.revisionNumber,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
|
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
|
||||||
@@ -437,24 +481,29 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleUndoProjectDeviceOperation() {
|
async function handleUndoProjectDeviceOperation() {
|
||||||
if (!projectId || !projectDeviceUndo) {
|
if (!projectId || !project || !projectDeviceUndo) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const preview =
|
if (
|
||||||
projectDeviceUndo.kind === "synchronize"
|
project.currentRevision !==
|
||||||
? await restoreProjectDeviceRows(
|
projectDeviceUndo.revisionNumber
|
||||||
projectId,
|
) {
|
||||||
projectDeviceUndo.projectDeviceId,
|
throw new Error(
|
||||||
projectDeviceUndo.rows
|
"Eine neuere Projektänderung verhindert dieses Rückgängig."
|
||||||
)
|
|
||||||
: await reconnectProjectDeviceRows(
|
|
||||||
projectId,
|
|
||||||
projectDeviceUndo.projectDeviceId,
|
|
||||||
projectDeviceUndo.rowIds
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
const result = await undoProjectCommand(
|
||||||
|
projectId,
|
||||||
|
project.currentRevision
|
||||||
|
);
|
||||||
|
const preview = await getProjectDeviceSyncPreview(
|
||||||
|
projectId,
|
||||||
|
projectDeviceUndo.projectDeviceId
|
||||||
|
);
|
||||||
|
applyProjectRevision(result.history.currentRevision);
|
||||||
setSyncPreview(preview);
|
setSyncPreview(preview);
|
||||||
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
||||||
setProjectDeviceUndo(null);
|
setProjectDeviceUndo(null);
|
||||||
@@ -466,15 +515,20 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCopyGlobalToProject() {
|
async function handleCopyGlobalToProject() {
|
||||||
if (!projectId || !selectedGlobalDeviceId) {
|
if (!projectId || !project || !selectedGlobalDeviceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const created = await copyGlobalDeviceToProject(projectId, selectedGlobalDeviceId);
|
const result = await copyGlobalDeviceToProject(
|
||||||
setProjectDevices((current) => [...current, created]);
|
projectId,
|
||||||
|
selectedGlobalDeviceId,
|
||||||
|
project.currentRevision
|
||||||
|
);
|
||||||
|
setProjectDevices((current) => [...current, result.projectDevice]);
|
||||||
|
applyProjectRevision(result.history.currentRevision);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden.");
|
setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -7,15 +7,12 @@ import { circuits } from "../schema/circuits.js";
|
|||||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
import {
|
import {
|
||||||
toCircuitDeviceRowCreateValues,
|
toCircuitDeviceRowCreateValues,
|
||||||
toCircuitDeviceRowPatchValues,
|
|
||||||
toCircuitDeviceRowUpdateValues,
|
toCircuitDeviceRowUpdateValues,
|
||||||
type CircuitDeviceRowCreateInput,
|
type CircuitDeviceRowCreateInput,
|
||||||
type CircuitDeviceRowPatchInput,
|
|
||||||
type CircuitDeviceRowUpdateInput,
|
type CircuitDeviceRowUpdateInput,
|
||||||
} from "./circuit-device-row.persistence.js";
|
} from "./circuit-device-row.persistence.js";
|
||||||
export type {
|
export type {
|
||||||
CircuitDeviceRowCreateInput,
|
CircuitDeviceRowCreateInput,
|
||||||
CircuitDeviceRowPatchInput,
|
|
||||||
CircuitDeviceRowUpdateInput,
|
CircuitDeviceRowUpdateInput,
|
||||||
} from "./circuit-device-row.persistence.js";
|
} from "./circuit-device-row.persistence.js";
|
||||||
export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
|
export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
|
||||||
@@ -86,21 +83,6 @@ export class CircuitDeviceRowRepository {
|
|||||||
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
|
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
|
||||||
}
|
}
|
||||||
|
|
||||||
async listLinkStatesByIds(projectId: string, rowIds: string[]) {
|
|
||||||
if (rowIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return db
|
|
||||||
.select({
|
|
||||||
id: circuitDeviceRows.id,
|
|
||||||
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
|
|
||||||
})
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.innerJoin(circuits, eq(circuitDeviceRows.circuitId, circuits.id))
|
|
||||||
.innerJoin(circuitLists, eq(circuits.circuitListId, circuitLists.id))
|
|
||||||
.where(and(eq(circuitLists.projectId, projectId), inArray(circuitDeviceRows.id, rowIds)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(input: CircuitDeviceRowCreateInput) {
|
async create(input: CircuitDeviceRowCreateInput) {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
await db.insert(circuitDeviceRows).values(toCircuitDeviceRowCreateValues(id, input));
|
await db.insert(circuitDeviceRows).values(toCircuitDeviceRowCreateValues(id, input));
|
||||||
@@ -117,14 +99,6 @@ export class CircuitDeviceRowRepository {
|
|||||||
.where(eq(circuitDeviceRows.id, rowId));
|
.where(eq(circuitDeviceRows.id, rowId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFields(rowId: string, input: CircuitDeviceRowPatchInput) {
|
|
||||||
const values = toCircuitDeviceRowPatchValues(input);
|
|
||||||
if (Object.keys(values).length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await db.update(circuitDeviceRows).set(values).where(eq(circuitDeviceRows.id, rowId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(rowId: string) {
|
async delete(rowId: string) {
|
||||||
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
|
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertCircuitSectionRenumberProjectCommand,
|
||||||
|
createCircuitSectionRenumberProjectCommand,
|
||||||
|
type CircuitSectionRenumberAssignment,
|
||||||
|
} from "../../domain/models/circuit-section-renumber-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
CircuitSectionRenumberProjectCommandStore,
|
||||||
|
ExecuteCircuitSectionRenumberCommandInput,
|
||||||
|
} from "../../domain/ports/circuit-section-renumber-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
export class CircuitSectionRenumberProjectCommandRepository
|
||||||
|
implements CircuitSectionRenumberProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteCircuitSectionRenumberCommandInput) {
|
||||||
|
assertCircuitSectionRenumberProjectCommand(input.command);
|
||||||
|
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const section = tx
|
||||||
|
.select({
|
||||||
|
id: circuitSections.id,
|
||||||
|
circuitListId: circuitSections.circuitListId,
|
||||||
|
projectId: circuitLists.projectId,
|
||||||
|
})
|
||||||
|
.from(circuitSections)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuitSections.circuitListId)
|
||||||
|
)
|
||||||
|
.where(eq(circuitSections.id, input.command.payload.sectionId))
|
||||||
|
.get();
|
||||||
|
if (!section || section.projectId !== input.projectId) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section does not belong to project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionCircuits = tx
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
circuitListId: circuits.circuitListId,
|
||||||
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.sectionId, section.id))
|
||||||
|
.all();
|
||||||
|
const assignments = input.command.payload.assignments;
|
||||||
|
if (
|
||||||
|
sectionCircuits.length !== assignments.length ||
|
||||||
|
sectionCircuits.some(
|
||||||
|
(circuit) =>
|
||||||
|
circuit.circuitListId !== section.circuitListId
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit renumber must include every circuit in the section."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const circuitsById = new Map(
|
||||||
|
sectionCircuits.map((circuit) => [circuit.id, circuit])
|
||||||
|
);
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
const circuit = circuitsById.get(assignment.circuitId);
|
||||||
|
if (
|
||||||
|
!circuit ||
|
||||||
|
circuit.equipmentIdentifier !==
|
||||||
|
assignment.expectedEquipmentIdentifier
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit changed before section renumber execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listCircuits = tx
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.circuitListId, section.circuitListId))
|
||||||
|
.all();
|
||||||
|
const sectionCircuitIds = new Set(
|
||||||
|
assignments.map((assignment) => assignment.circuitId)
|
||||||
|
);
|
||||||
|
const targetIdentifiers = new Set(
|
||||||
|
assignments.map(
|
||||||
|
(assignment) => assignment.targetEquipmentIdentifier
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const conflictingCircuit = listCircuits.find(
|
||||||
|
(circuit) =>
|
||||||
|
!sectionCircuitIds.has(circuit.id) &&
|
||||||
|
targetIdentifiers.has(circuit.equipmentIdentifier)
|
||||||
|
);
|
||||||
|
if (conflictingCircuit) {
|
||||||
|
throw new Error(
|
||||||
|
"Target equipment identifier already exists in circuit list."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inverse = createCircuitSectionRenumberProjectCommand(
|
||||||
|
section.id,
|
||||||
|
assignments.map((assignment) => ({
|
||||||
|
circuitId: assignment.circuitId,
|
||||||
|
expectedEquipmentIdentifier:
|
||||||
|
assignment.targetEquipmentIdentifier,
|
||||||
|
targetEquipmentIdentifier:
|
||||||
|
assignment.expectedEquipmentIdentifier,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const temporaryIdentifiers = this.createTemporaryIdentifiers(
|
||||||
|
section.id,
|
||||||
|
assignments,
|
||||||
|
listCircuits.map((circuit) => circuit.equipmentIdentifier)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
if (
|
||||||
|
assignment.expectedEquipmentIdentifier ===
|
||||||
|
assignment.targetEquipmentIdentifier
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const temporaryIdentifier = temporaryIdentifiers.get(
|
||||||
|
assignment.circuitId
|
||||||
|
)!;
|
||||||
|
const updated = tx
|
||||||
|
.update(circuits)
|
||||||
|
.set({ equipmentIdentifier: temporaryIdentifier })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.id, assignment.circuitId),
|
||||||
|
eq(circuits.sectionId, section.id),
|
||||||
|
eq(circuits.circuitListId, section.circuitListId),
|
||||||
|
eq(
|
||||||
|
circuits.equipmentIdentifier,
|
||||||
|
assignment.expectedEquipmentIdentifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit changed during section renumber execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
if (
|
||||||
|
assignment.expectedEquipmentIdentifier ===
|
||||||
|
assignment.targetEquipmentIdentifier
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const temporaryIdentifier = temporaryIdentifiers.get(
|
||||||
|
assignment.circuitId
|
||||||
|
)!;
|
||||||
|
const updated = tx
|
||||||
|
.update(circuits)
|
||||||
|
.set({
|
||||||
|
equipmentIdentifier:
|
||||||
|
assignment.targetEquipmentIdentifier,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.id, assignment.circuitId),
|
||||||
|
eq(circuits.sectionId, section.id),
|
||||||
|
eq(circuits.circuitListId, section.circuitListId),
|
||||||
|
eq(
|
||||||
|
circuits.equipmentIdentifier,
|
||||||
|
temporaryIdentifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit changed during final section renumber execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = appendProjectRevision(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
expectedRevision: input.expectedRevision,
|
||||||
|
source: input.source,
|
||||||
|
description: input.description,
|
||||||
|
actorId: input.actorId,
|
||||||
|
forward: input.command,
|
||||||
|
inverse,
|
||||||
|
});
|
||||||
|
applyProjectHistoryTransition(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
source: input.source,
|
||||||
|
recordedChangeSetId: revision.changeSetId,
|
||||||
|
targetChangeSetId: input.historyTargetChangeSetId,
|
||||||
|
});
|
||||||
|
return { revision, inverse };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTemporaryIdentifiers(
|
||||||
|
sectionId: string,
|
||||||
|
assignments: CircuitSectionRenumberAssignment[],
|
||||||
|
occupiedIdentifiers: string[]
|
||||||
|
) {
|
||||||
|
const occupied = new Set([
|
||||||
|
...occupiedIdentifiers,
|
||||||
|
...assignments.map(
|
||||||
|
(assignment) => assignment.targetEquipmentIdentifier
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const temporaryIdentifiers = new Map<string, string>();
|
||||||
|
for (let index = 0; index < assignments.length; index += 1) {
|
||||||
|
const assignment = assignments[index];
|
||||||
|
if (
|
||||||
|
assignment.expectedEquipmentIdentifier ===
|
||||||
|
assignment.targetEquipmentIdentifier
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const base = `__tmp_renumber_command_${sectionId}_${index}`;
|
||||||
|
let candidate = base;
|
||||||
|
while (occupied.has(candidate)) {
|
||||||
|
candidate = `${candidate}_`;
|
||||||
|
}
|
||||||
|
occupied.add(candidate);
|
||||||
|
temporaryIdentifiers.set(assignment.circuitId, candidate);
|
||||||
|
}
|
||||||
|
return temporaryIdentifiers;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertCircuitSectionReorderProjectCommand,
|
||||||
|
createCircuitSectionReorderProjectCommand,
|
||||||
|
} from "../../domain/models/circuit-section-reorder-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
CircuitSectionReorderProjectCommandStore,
|
||||||
|
ExecuteCircuitSectionReorderCommandInput,
|
||||||
|
} from "../../domain/ports/circuit-section-reorder-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
export class CircuitSectionReorderProjectCommandRepository
|
||||||
|
implements CircuitSectionReorderProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteCircuitSectionReorderCommandInput) {
|
||||||
|
assertCircuitSectionReorderProjectCommand(input.command);
|
||||||
|
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const section = tx
|
||||||
|
.select({
|
||||||
|
id: circuitSections.id,
|
||||||
|
circuitListId: circuitSections.circuitListId,
|
||||||
|
projectId: circuitLists.projectId,
|
||||||
|
})
|
||||||
|
.from(circuitSections)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuitSections.circuitListId)
|
||||||
|
)
|
||||||
|
.where(eq(circuitSections.id, input.command.payload.sectionId))
|
||||||
|
.get();
|
||||||
|
if (!section || section.projectId !== input.projectId) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section does not belong to project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedCircuits = tx
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
circuitListId: circuits.circuitListId,
|
||||||
|
sortOrder: circuits.sortOrder,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.sectionId, section.id))
|
||||||
|
.all();
|
||||||
|
const assignments = input.command.payload.assignments;
|
||||||
|
if (
|
||||||
|
persistedCircuits.length !== assignments.length ||
|
||||||
|
persistedCircuits.some(
|
||||||
|
(circuit) =>
|
||||||
|
circuit.circuitListId !== section.circuitListId
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit reorder must include every circuit in the section."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const circuitsById = new Map(
|
||||||
|
persistedCircuits.map((circuit) => [circuit.id, circuit])
|
||||||
|
);
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
const circuit = circuitsById.get(assignment.circuitId);
|
||||||
|
if (
|
||||||
|
!circuit ||
|
||||||
|
circuit.sortOrder !== assignment.expectedSortOrder
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit changed before section reorder execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inverse = createCircuitSectionReorderProjectCommand(
|
||||||
|
section.id,
|
||||||
|
assignments.map((assignment) => ({
|
||||||
|
circuitId: assignment.circuitId,
|
||||||
|
expectedSortOrder: assignment.targetSortOrder,
|
||||||
|
targetSortOrder: assignment.expectedSortOrder,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
if (
|
||||||
|
assignment.expectedSortOrder === assignment.targetSortOrder
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updated = tx
|
||||||
|
.update(circuits)
|
||||||
|
.set({ sortOrder: assignment.targetSortOrder })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.id, assignment.circuitId),
|
||||||
|
eq(circuits.sectionId, section.id),
|
||||||
|
eq(
|
||||||
|
circuits.sortOrder,
|
||||||
|
assignment.expectedSortOrder
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit changed during section reorder execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = appendProjectRevision(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
expectedRevision: input.expectedRevision,
|
||||||
|
source: input.source,
|
||||||
|
description: input.description,
|
||||||
|
actorId: input.actorId,
|
||||||
|
forward: input.command,
|
||||||
|
inverse,
|
||||||
|
});
|
||||||
|
applyProjectHistoryTransition(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
source: input.source,
|
||||||
|
recordedChangeSetId: revision.changeSetId,
|
||||||
|
targetChangeSetId: input.historyTargetChangeSetId,
|
||||||
|
});
|
||||||
|
return { revision, inverse };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,18 +4,14 @@ import { db } from "../client.js";
|
|||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
import {
|
import {
|
||||||
toCircuitCreateValues,
|
toCircuitCreateValues,
|
||||||
toCircuitPatchValues,
|
|
||||||
type CircuitCreatePersistenceInput,
|
type CircuitCreatePersistenceInput,
|
||||||
type CircuitPatchPersistenceInput,
|
|
||||||
} from "./circuit.persistence.js";
|
} from "./circuit.persistence.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
CircuitCreatePersistenceInput,
|
CircuitCreatePersistenceInput,
|
||||||
CircuitPatchPersistenceInput,
|
|
||||||
} from "./circuit.persistence.js";
|
} from "./circuit.persistence.js";
|
||||||
export {
|
export {
|
||||||
toCircuitCreateValues,
|
toCircuitCreateValues,
|
||||||
toCircuitPatchValues,
|
|
||||||
} from "./circuit.persistence.js";
|
} from "./circuit.persistence.js";
|
||||||
|
|
||||||
export class CircuitRepository {
|
export class CircuitRepository {
|
||||||
@@ -84,14 +80,6 @@ export class CircuitRepository {
|
|||||||
.where(eq(circuits.id, circuitId));
|
.where(eq(circuits.id, circuitId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFields(circuitId: string, input: CircuitPatchPersistenceInput) {
|
|
||||||
const values = toCircuitPatchValues(input);
|
|
||||||
if (Object.keys(values).length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await db.update(circuits).set(values).where(eq(circuits.id, circuitId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(circuitId: string) {
|
async delete(circuitId: string) {
|
||||||
await db.delete(circuits).where(eq(circuits.id, circuitId));
|
await db.delete(circuits).where(eq(circuits.id, circuitId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertProjectDeviceUpdateProjectCommand,
|
||||||
|
createProjectDeviceUpdateProjectCommand,
|
||||||
|
type ProjectDeviceUpdateField,
|
||||||
|
type ProjectDeviceUpdatePatch,
|
||||||
|
type ProjectDeviceUpdateValues,
|
||||||
|
} from "../../domain/models/project-device-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
ExecuteProjectDeviceUpdateCommandInput,
|
||||||
|
ProjectDeviceProjectCommandStore,
|
||||||
|
} from "../../domain/ports/project-device-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
type ProjectDeviceRow = typeof projectDevices.$inferSelect;
|
||||||
|
|
||||||
|
export class ProjectDeviceProjectCommandRepository
|
||||||
|
implements ProjectDeviceProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) {
|
||||||
|
assertProjectDeviceUpdateProjectCommand(input.command);
|
||||||
|
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const current = tx
|
||||||
|
.select()
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
projectDevices.id,
|
||||||
|
input.command.payload.projectDeviceId
|
||||||
|
),
|
||||||
|
eq(projectDevices.projectId, input.projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!current) {
|
||||||
|
throw new Error(
|
||||||
|
"Project device does not belong to project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = Object.fromEntries(
|
||||||
|
input.command.payload.changes.map((change) => [
|
||||||
|
change.field,
|
||||||
|
change.value,
|
||||||
|
])
|
||||||
|
) as ProjectDeviceUpdatePatch;
|
||||||
|
const inversePatch = Object.fromEntries(
|
||||||
|
input.command.payload.changes.map((change) => [
|
||||||
|
change.field,
|
||||||
|
getProjectDeviceFieldValue(current, change.field),
|
||||||
|
])
|
||||||
|
) as ProjectDeviceUpdatePatch;
|
||||||
|
const inverse = createProjectDeviceUpdateProjectCommand(
|
||||||
|
current.id,
|
||||||
|
inversePatch
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = tx
|
||||||
|
.update(projectDevices)
|
||||||
|
.set(patch)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectDevices.id, current.id),
|
||||||
|
eq(projectDevices.projectId, input.projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Project device changed before command execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = appendProjectRevision(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
expectedRevision: input.expectedRevision,
|
||||||
|
source: input.source,
|
||||||
|
description: input.description,
|
||||||
|
actorId: input.actorId,
|
||||||
|
forward: input.command,
|
||||||
|
inverse,
|
||||||
|
});
|
||||||
|
applyProjectHistoryTransition(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
source: input.source,
|
||||||
|
recordedChangeSetId: revision.changeSetId,
|
||||||
|
targetChangeSetId: input.historyTargetChangeSetId,
|
||||||
|
});
|
||||||
|
return { revision, inverse };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectDeviceFieldValue<
|
||||||
|
TField extends ProjectDeviceUpdateField,
|
||||||
|
>(
|
||||||
|
projectDevice: ProjectDeviceRow,
|
||||||
|
field: TField
|
||||||
|
): ProjectDeviceUpdateValues[TField] {
|
||||||
|
return projectDevice[field] as ProjectDeviceUpdateValues[TField];
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertProjectDeviceRowSyncProjectCommand,
|
||||||
|
createProjectDeviceRowSyncProjectCommand,
|
||||||
|
invertProjectDeviceRowSyncOperation,
|
||||||
|
projectDeviceSyncRowSnapshotFields,
|
||||||
|
type ProjectDeviceSyncRowSnapshot,
|
||||||
|
} from "../../domain/models/project-device-row-sync-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
ExecuteProjectDeviceRowSyncCommandInput,
|
||||||
|
ProjectDeviceRowSyncProjectCommandStore,
|
||||||
|
} from "../../domain/ports/project-device-row-sync-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
||||||
|
|
||||||
|
export class ProjectDeviceRowSyncProjectCommandRepository
|
||||||
|
implements ProjectDeviceRowSyncProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteProjectDeviceRowSyncCommandInput) {
|
||||||
|
assertProjectDeviceRowSyncProjectCommand(input.command);
|
||||||
|
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const projectDevice = tx
|
||||||
|
.select({ id: projectDevices.id })
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
projectDevices.id,
|
||||||
|
input.command.payload.projectDeviceId
|
||||||
|
),
|
||||||
|
eq(projectDevices.projectId, input.projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!projectDevice) {
|
||||||
|
throw new Error(
|
||||||
|
"Project device does not belong to project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowIds = input.command.payload.rows.map(
|
||||||
|
(row) => row.rowId
|
||||||
|
);
|
||||||
|
const persistedRows = tx
|
||||||
|
.select({
|
||||||
|
row: circuitDeviceRows,
|
||||||
|
projectId: circuitLists.projectId,
|
||||||
|
})
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.innerJoin(
|
||||||
|
circuits,
|
||||||
|
eq(circuits.id, circuitDeviceRows.circuitId)
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuits.circuitListId)
|
||||||
|
)
|
||||||
|
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||||
|
.all();
|
||||||
|
if (
|
||||||
|
persistedRows.length !== rowIds.length ||
|
||||||
|
persistedRows.some(
|
||||||
|
(persisted) => persisted.projectId !== input.projectId
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"One or more sync rows do not belong to project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedById = new Map(
|
||||||
|
persistedRows.map((persisted) => [
|
||||||
|
persisted.row.id,
|
||||||
|
persisted.row,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
for (const assignment of input.command.payload.rows) {
|
||||||
|
const persisted = persistedById.get(assignment.rowId);
|
||||||
|
if (
|
||||||
|
!persisted ||
|
||||||
|
!snapshotMatchesRow(assignment.expected, persisted)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device row changed before sync execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inverse = createProjectDeviceRowSyncProjectCommand(
|
||||||
|
projectDevice.id,
|
||||||
|
invertProjectDeviceRowSyncOperation(
|
||||||
|
input.command.payload.operation
|
||||||
|
),
|
||||||
|
input.command.payload.rows.map((row) => ({
|
||||||
|
rowId: row.rowId,
|
||||||
|
expected: row.target,
|
||||||
|
target: row.expected,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const assignment of input.command.payload.rows) {
|
||||||
|
const updated = tx
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set(assignment.target)
|
||||||
|
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device row changed during sync execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = appendProjectRevision(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
expectedRevision: input.expectedRevision,
|
||||||
|
source: input.source,
|
||||||
|
description: input.description,
|
||||||
|
actorId: input.actorId,
|
||||||
|
forward: input.command,
|
||||||
|
inverse,
|
||||||
|
});
|
||||||
|
applyProjectHistoryTransition(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
source: input.source,
|
||||||
|
recordedChangeSetId: revision.changeSetId,
|
||||||
|
targetChangeSetId: input.historyTargetChangeSetId,
|
||||||
|
});
|
||||||
|
return { revision, inverse };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotMatchesRow(
|
||||||
|
snapshot: ProjectDeviceSyncRowSnapshot,
|
||||||
|
row: CircuitDeviceRow
|
||||||
|
) {
|
||||||
|
return projectDeviceSyncRowSnapshotFields.every(
|
||||||
|
(field) => snapshot[field] === row[field]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { and, eq, isNull } from "drizzle-orm";
|
|
||||||
import type {
|
|
||||||
ProjectDeviceLinkedRowValues,
|
|
||||||
ProjectDeviceRowSyncStore,
|
|
||||||
} from "../../domain/ports/project-device-row-sync.store.js";
|
|
||||||
import type { AppDatabase } from "../database-context.js";
|
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
|
||||||
import { toCircuitDeviceRowUpdateValues } from "./circuit-device-row.persistence.js";
|
|
||||||
|
|
||||||
export class ProjectDeviceRowSyncRepository implements ProjectDeviceRowSyncStore {
|
|
||||||
constructor(private readonly database: AppDatabase) {}
|
|
||||||
|
|
||||||
updateLinkedRows(
|
|
||||||
projectDeviceId: string,
|
|
||||||
changes: Array<{ rowId: string; input: ProjectDeviceLinkedRowValues }>
|
|
||||||
) {
|
|
||||||
this.database.transaction((tx) => {
|
|
||||||
for (const change of changes) {
|
|
||||||
const result = tx
|
|
||||||
.update(circuitDeviceRows)
|
|
||||||
.set(toCircuitDeviceRowUpdateValues(change.input))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuitDeviceRows.id, change.rowId),
|
|
||||||
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
if (result.changes !== 1) {
|
|
||||||
throw new Error(
|
|
||||||
"A linked row changed before the operation could be completed."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectLinkedRows(projectDeviceId: string, rowIds: string[]) {
|
|
||||||
this.database.transaction((tx) => {
|
|
||||||
for (const rowId of rowIds) {
|
|
||||||
const result = tx
|
|
||||||
.update(circuitDeviceRows)
|
|
||||||
.set({ linkedProjectDeviceId: null })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuitDeviceRows.id, rowId),
|
|
||||||
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
if (result.changes !== 1) {
|
|
||||||
throw new Error(
|
|
||||||
"A linked row changed before the operation could be completed."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
reconnectRows(projectDeviceId: string, rowIds: string[]) {
|
|
||||||
this.database.transaction((tx) => {
|
|
||||||
for (const rowId of rowIds) {
|
|
||||||
const result = tx
|
|
||||||
.update(circuitDeviceRows)
|
|
||||||
.set({ linkedProjectDeviceId: projectDeviceId })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuitDeviceRows.id, rowId),
|
|
||||||
isNull(circuitDeviceRows.linkedProjectDeviceId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
if (result.changes !== 1) {
|
|
||||||
throw new Error(
|
|
||||||
"A disconnected row changed before the operation could be undone."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
eq,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
} from "drizzle-orm";
|
||||||
|
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertProjectDeviceDeleteProjectCommand,
|
||||||
|
assertProjectDeviceInsertProjectCommand,
|
||||||
|
createProjectDeviceDeleteProjectCommand,
|
||||||
|
createProjectDeviceInsertProjectCommand,
|
||||||
|
projectDeviceDeleteCommandType,
|
||||||
|
projectDeviceInsertCommandType,
|
||||||
|
type ProjectDeviceSnapshot,
|
||||||
|
type ProjectDeviceStructureProjectCommand,
|
||||||
|
} from "../../domain/models/project-device-structure-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
ExecuteProjectDeviceStructureCommandInput,
|
||||||
|
ProjectDeviceStructureProjectCommandStore,
|
||||||
|
} from "../../domain/ports/project-device-structure-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
|
const circuitDeviceRowSnapshotFields = [
|
||||||
|
"id",
|
||||||
|
"circuitId",
|
||||||
|
"linkedProjectDeviceId",
|
||||||
|
"legacyConsumerId",
|
||||||
|
"sortOrder",
|
||||||
|
"name",
|
||||||
|
"displayName",
|
||||||
|
"phaseType",
|
||||||
|
"connectionKind",
|
||||||
|
"costGroup",
|
||||||
|
"category",
|
||||||
|
"level",
|
||||||
|
"roomId",
|
||||||
|
"roomNumberSnapshot",
|
||||||
|
"roomNameSnapshot",
|
||||||
|
"quantity",
|
||||||
|
"powerPerUnit",
|
||||||
|
"simultaneityFactor",
|
||||||
|
"cosPhi",
|
||||||
|
"remark",
|
||||||
|
"overriddenFields",
|
||||||
|
] as const satisfies readonly (keyof CircuitDeviceRowSnapshot)[];
|
||||||
|
|
||||||
|
export class ProjectDeviceStructureProjectCommandRepository
|
||||||
|
implements ProjectDeviceStructureProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteProjectDeviceStructureCommandInput) {
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const inverse = this.applyCommand(
|
||||||
|
tx,
|
||||||
|
input.projectId,
|
||||||
|
input.source,
|
||||||
|
input.command
|
||||||
|
);
|
||||||
|
const revision = appendProjectRevision(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
expectedRevision: input.expectedRevision,
|
||||||
|
source: input.source,
|
||||||
|
description: input.description,
|
||||||
|
actorId: input.actorId,
|
||||||
|
forward: input.command,
|
||||||
|
inverse,
|
||||||
|
});
|
||||||
|
applyProjectHistoryTransition(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
source: input.source,
|
||||||
|
recordedChangeSetId: revision.changeSetId,
|
||||||
|
targetChangeSetId: input.historyTargetChangeSetId,
|
||||||
|
});
|
||||||
|
return { revision, inverse };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCommand(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
source: ExecuteProjectDeviceStructureCommandInput["source"],
|
||||||
|
command: ProjectDeviceStructureProjectCommand
|
||||||
|
): ProjectDeviceStructureProjectCommand {
|
||||||
|
if (command.type === projectDeviceInsertCommandType) {
|
||||||
|
assertProjectDeviceInsertProjectCommand(command);
|
||||||
|
return this.insert(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
source,
|
||||||
|
command.payload.projectDevice,
|
||||||
|
command.payload.linkedRows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (command.type === projectDeviceDeleteCommandType) {
|
||||||
|
assertProjectDeviceDeleteProjectCommand(command);
|
||||||
|
return this.delete(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
command.payload.projectDeviceId,
|
||||||
|
command.payload.expectedProjectId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported project-device structure command.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private insert(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
source: ExecuteProjectDeviceStructureCommandInput["source"],
|
||||||
|
snapshot: ProjectDeviceSnapshot,
|
||||||
|
linkedRows: CircuitDeviceRowSnapshot[]
|
||||||
|
) {
|
||||||
|
if (snapshot.projectId !== projectId) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device snapshot does not belong to project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source === "user" && linkedRows.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
"User project-device inserts cannot reconnect existing rows."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const project = database
|
||||||
|
.select({ id: projects.id })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.get();
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Project does not exist.");
|
||||||
|
}
|
||||||
|
const existing = database
|
||||||
|
.select({ id: projectDevices.id })
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(eq(projectDevices.id, snapshot.id))
|
||||||
|
.get();
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("Project-device id already exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertRestorableRows(database, projectId, linkedRows);
|
||||||
|
database.insert(projectDevices).values(snapshot).run();
|
||||||
|
for (const row of linkedRows) {
|
||||||
|
const updated = database
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({ linkedProjectDeviceId: snapshot.id })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitDeviceRows.id, row.id),
|
||||||
|
isNull(circuitDeviceRows.linkedProjectDeviceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"A restored project-device row changed during insertion."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createProjectDeviceDeleteProjectCommand(
|
||||||
|
snapshot.id,
|
||||||
|
projectId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private delete(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
projectDeviceId: string,
|
||||||
|
expectedProjectId: string
|
||||||
|
) {
|
||||||
|
if (expectedProjectId !== projectId) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device delete project does not match command project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const projectDevice = database
|
||||||
|
.select()
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectDevices.id, projectDeviceId),
|
||||||
|
eq(projectDevices.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!projectDevice) {
|
||||||
|
throw new Error(
|
||||||
|
"Project device does not belong to project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const linkedRows = database
|
||||||
|
.select({
|
||||||
|
row: circuitDeviceRows,
|
||||||
|
projectId: circuitLists.projectId,
|
||||||
|
})
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.innerJoin(
|
||||||
|
circuits,
|
||||||
|
eq(circuits.id, circuitDeviceRows.circuitId)
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuits.circuitListId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
circuitDeviceRows.linkedProjectDeviceId,
|
||||||
|
projectDevice.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
asc(circuitDeviceRows.sortOrder),
|
||||||
|
asc(circuitDeviceRows.id)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
if (
|
||||||
|
linkedRows.some((linked) => linked.projectId !== projectId)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project device has linked rows outside its project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const inverse = createProjectDeviceInsertProjectCommand(
|
||||||
|
toProjectDeviceSnapshot(projectDevice),
|
||||||
|
linkedRows.map(({ row }) => ({
|
||||||
|
...toCircuitDeviceRowSnapshot(row),
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleted = database
|
||||||
|
.delete(projectDevices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectDevices.id, projectDevice.id),
|
||||||
|
eq(projectDevices.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (deleted.changes !== 1) {
|
||||||
|
throw new Error("Project device could not be deleted.");
|
||||||
|
}
|
||||||
|
return inverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertRestorableRows(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
snapshots: CircuitDeviceRowSnapshot[]
|
||||||
|
) {
|
||||||
|
if (snapshots.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rowIds = snapshots.map((row) => row.id);
|
||||||
|
const persistedRows = database
|
||||||
|
.select({
|
||||||
|
row: circuitDeviceRows,
|
||||||
|
projectId: circuitLists.projectId,
|
||||||
|
})
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.innerJoin(
|
||||||
|
circuits,
|
||||||
|
eq(circuits.id, circuitDeviceRows.circuitId)
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuits.circuitListId)
|
||||||
|
)
|
||||||
|
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||||
|
.all();
|
||||||
|
if (
|
||||||
|
persistedRows.length !== snapshots.length ||
|
||||||
|
persistedRows.some(
|
||||||
|
(persisted) => persisted.projectId !== projectId
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"One or more restored rows do not belong to project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const persistedById = new Map(
|
||||||
|
persistedRows.map(({ row }) => [
|
||||||
|
row.id,
|
||||||
|
toCircuitDeviceRowSnapshot(row),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
const persisted = persistedById.get(snapshot.id);
|
||||||
|
if (
|
||||||
|
!persisted ||
|
||||||
|
!circuitDeviceRowSnapshotFields.every(
|
||||||
|
(field) => persisted[field] === snapshot[field]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"A restored project-device row changed before insertion."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProjectDeviceSnapshot(
|
||||||
|
projectDevice: typeof projectDevices.$inferSelect
|
||||||
|
): ProjectDeviceSnapshot {
|
||||||
|
if (
|
||||||
|
projectDevice.phaseType !== "single_phase" &&
|
||||||
|
projectDevice.phaseType !== "three_phase"
|
||||||
|
) {
|
||||||
|
throw new Error("Persisted project-device phase type is invalid.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: projectDevice.id,
|
||||||
|
projectId: projectDevice.projectId,
|
||||||
|
name: projectDevice.name,
|
||||||
|
displayName: projectDevice.displayName,
|
||||||
|
phaseType: projectDevice.phaseType,
|
||||||
|
connectionKind: projectDevice.connectionKind,
|
||||||
|
costGroup: projectDevice.costGroup,
|
||||||
|
category: projectDevice.category,
|
||||||
|
quantity: projectDevice.quantity,
|
||||||
|
powerPerUnit: projectDevice.powerPerUnit,
|
||||||
|
simultaneityFactor: projectDevice.simultaneityFactor,
|
||||||
|
cosPhi: projectDevice.cosPhi,
|
||||||
|
remark: projectDevice.remark,
|
||||||
|
voltageV: projectDevice.voltageV,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { db } from "../client.js";
|
import { db } from "../client.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
|
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
|
||||||
import type {
|
|
||||||
CreateProjectDeviceInput,
|
|
||||||
UpdateProjectDeviceInput,
|
|
||||||
} from "../../shared/validation/project-device.schemas.js";
|
|
||||||
|
|
||||||
function withTotalPower(row: typeof projectDevices.$inferSelect) {
|
function withTotalPower(row: typeof projectDevices.$inferSelect) {
|
||||||
return {
|
return {
|
||||||
@@ -21,31 +16,6 @@ export class ProjectDeviceRepository {
|
|||||||
return rows.map(withTotalPower);
|
return rows.map(withTotalPower);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(projectId: string, input: CreateProjectDeviceInput) {
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
await db.insert(projectDevices).values({
|
|
||||||
id,
|
|
||||||
projectId,
|
|
||||||
name: input.name,
|
|
||||||
displayName: input.displayName,
|
|
||||||
phaseType: input.phaseType,
|
|
||||||
connectionKind: input.connectionKind ?? null,
|
|
||||||
costGroup: input.costGroup ?? null,
|
|
||||||
category: input.category ?? null,
|
|
||||||
quantity: input.quantity,
|
|
||||||
powerPerUnit: input.powerPerUnit,
|
|
||||||
simultaneityFactor: input.simultaneityFactor,
|
|
||||||
cosPhi: input.cosPhi ?? null,
|
|
||||||
remark: input.remark ?? null,
|
|
||||||
voltageV: input.voltageV ?? null,
|
|
||||||
});
|
|
||||||
const created = await this.findById(projectId, id);
|
|
||||||
if (!created) {
|
|
||||||
throw new Error("Failed to create project device.");
|
|
||||||
}
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(projectId: string, projectDeviceId: string) {
|
async findById(projectId: string, projectDeviceId: string) {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -57,33 +27,4 @@ export class ProjectDeviceRepository {
|
|||||||
return row ? withTotalPower(row) : null;
|
return row ? withTotalPower(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(projectId: string, projectDeviceId: string, input: UpdateProjectDeviceInput) {
|
|
||||||
await db
|
|
||||||
.update(projectDevices)
|
|
||||||
.set({
|
|
||||||
name: input.name,
|
|
||||||
displayName: input.displayName,
|
|
||||||
phaseType: input.phaseType,
|
|
||||||
connectionKind: input.connectionKind ?? null,
|
|
||||||
costGroup: input.costGroup ?? null,
|
|
||||||
category: input.category ?? null,
|
|
||||||
quantity: input.quantity,
|
|
||||||
powerPerUnit: input.powerPerUnit,
|
|
||||||
simultaneityFactor: input.simultaneityFactor,
|
|
||||||
cosPhi: input.cosPhi ?? null,
|
|
||||||
remark: input.remark ?? null,
|
|
||||||
voltageV: input.voltageV ?? null,
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(projectId: string, projectDeviceId: string) {
|
|
||||||
await db
|
|
||||||
.delete(projectDevices)
|
|
||||||
.where(
|
|
||||||
and(eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const circuitSectionRenumberCommandType =
|
||||||
|
"circuit.renumber-section" as const;
|
||||||
|
export const circuitSectionRenumberCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface CircuitSectionRenumberAssignment {
|
||||||
|
circuitId: string;
|
||||||
|
expectedEquipmentIdentifier: string;
|
||||||
|
targetEquipmentIdentifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitSectionRenumberCommandPayload {
|
||||||
|
sectionId: string;
|
||||||
|
assignments: CircuitSectionRenumberAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitSectionRenumberProjectCommand
|
||||||
|
extends SerializedProjectCommand<CircuitSectionRenumberCommandPayload> {
|
||||||
|
schemaVersion: typeof circuitSectionRenumberCommandSchemaVersion;
|
||||||
|
type: typeof circuitSectionRenumberCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitSectionRenumberProjectCommand(
|
||||||
|
sectionId: string,
|
||||||
|
assignments: CircuitSectionRenumberAssignment[]
|
||||||
|
): CircuitSectionRenumberProjectCommand {
|
||||||
|
const command: CircuitSectionRenumberProjectCommand = {
|
||||||
|
schemaVersion: circuitSectionRenumberCommandSchemaVersion,
|
||||||
|
type: circuitSectionRenumberCommandType,
|
||||||
|
payload: { sectionId, assignments },
|
||||||
|
};
|
||||||
|
assertCircuitSectionRenumberProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitSectionRenumberProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitSectionRenumberProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !==
|
||||||
|
circuitSectionRenumberCommandSchemaVersion ||
|
||||||
|
command.type !== circuitSectionRenumberCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit section renumber command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section renumber payload must be an object."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { sectionId, assignments } = command.payload;
|
||||||
|
assertNonEmptyString(sectionId, "sectionId");
|
||||||
|
if (!Array.isArray(assignments) || assignments.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section renumber command requires assignments."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const circuitIds = new Set<string>();
|
||||||
|
const expectedIdentifiers = new Set<string>();
|
||||||
|
const targetIdentifiers = new Set<string>();
|
||||||
|
let hasChangedIdentifier = false;
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
if (!isPlainObject(assignment)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section renumber command contains an invalid assignment."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertNonEmptyString(assignment.circuitId, "circuitId");
|
||||||
|
assertNonEmptyString(
|
||||||
|
assignment.expectedEquipmentIdentifier,
|
||||||
|
"expectedEquipmentIdentifier"
|
||||||
|
);
|
||||||
|
assertNonEmptyString(
|
||||||
|
assignment.targetEquipmentIdentifier,
|
||||||
|
"targetEquipmentIdentifier"
|
||||||
|
);
|
||||||
|
if (circuitIds.has(assignment.circuitId)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section renumber command contains duplicate circuit ids."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
expectedIdentifiers.has(
|
||||||
|
assignment.expectedEquipmentIdentifier
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section renumber command contains duplicate expected identifiers."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
targetIdentifiers.has(assignment.targetEquipmentIdentifier)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section renumber command contains duplicate target identifiers."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
assignment.expectedEquipmentIdentifier !==
|
||||||
|
assignment.targetEquipmentIdentifier
|
||||||
|
) {
|
||||||
|
hasChangedIdentifier = true;
|
||||||
|
}
|
||||||
|
circuitIds.add(assignment.circuitId);
|
||||||
|
expectedIdentifiers.add(
|
||||||
|
assignment.expectedEquipmentIdentifier
|
||||||
|
);
|
||||||
|
targetIdentifiers.add(assignment.targetEquipmentIdentifier);
|
||||||
|
}
|
||||||
|
if (!hasChangedIdentifier) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section renumber command must change at least one identifier."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonEmptyString(
|
||||||
|
value: unknown,
|
||||||
|
field: string
|
||||||
|
): asserts value is string {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error(`${field} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const circuitSectionReorderCommandType =
|
||||||
|
"circuit.reorder-section" as const;
|
||||||
|
export const circuitSectionReorderCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface CircuitSectionReorderAssignment {
|
||||||
|
circuitId: string;
|
||||||
|
expectedSortOrder: number;
|
||||||
|
targetSortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitSectionReorderCommandPayload {
|
||||||
|
sectionId: string;
|
||||||
|
assignments: CircuitSectionReorderAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitSectionReorderProjectCommand
|
||||||
|
extends SerializedProjectCommand<CircuitSectionReorderCommandPayload> {
|
||||||
|
schemaVersion: typeof circuitSectionReorderCommandSchemaVersion;
|
||||||
|
type: typeof circuitSectionReorderCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitSectionReorderProjectCommand(
|
||||||
|
sectionId: string,
|
||||||
|
assignments: CircuitSectionReorderAssignment[]
|
||||||
|
): CircuitSectionReorderProjectCommand {
|
||||||
|
const command: CircuitSectionReorderProjectCommand = {
|
||||||
|
schemaVersion: circuitSectionReorderCommandSchemaVersion,
|
||||||
|
type: circuitSectionReorderCommandType,
|
||||||
|
payload: { sectionId, assignments },
|
||||||
|
};
|
||||||
|
assertCircuitSectionReorderProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitSectionReorderProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitSectionReorderProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== circuitSectionReorderCommandSchemaVersion ||
|
||||||
|
command.type !== circuitSectionReorderCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit section reorder command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section reorder payload must be an object."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { sectionId, assignments } = command.payload;
|
||||||
|
assertNonEmptyString(sectionId, "sectionId");
|
||||||
|
if (!Array.isArray(assignments) || assignments.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section reorder command requires assignments."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const circuitIds = new Set<string>();
|
||||||
|
let hasChangedPosition = false;
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
if (!isPlainObject(assignment)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section reorder command contains an invalid assignment."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertNonEmptyString(assignment.circuitId, "circuitId");
|
||||||
|
assertFiniteNumber(
|
||||||
|
assignment.expectedSortOrder,
|
||||||
|
"expectedSortOrder"
|
||||||
|
);
|
||||||
|
assertFiniteNumber(
|
||||||
|
assignment.targetSortOrder,
|
||||||
|
"targetSortOrder"
|
||||||
|
);
|
||||||
|
if (circuitIds.has(assignment.circuitId)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section reorder command contains duplicate circuit ids."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
assignment.expectedSortOrder !== assignment.targetSortOrder
|
||||||
|
) {
|
||||||
|
hasChangedPosition = true;
|
||||||
|
}
|
||||||
|
circuitIds.add(assignment.circuitId);
|
||||||
|
}
|
||||||
|
if (!hasChangedPosition) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit section reorder command must change at least one position."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonEmptyString(
|
||||||
|
value: unknown,
|
||||||
|
field: string
|
||||||
|
): asserts value is string {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error(`${field} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFiniteNumber(
|
||||||
|
value: unknown,
|
||||||
|
field: string
|
||||||
|
): asserts value is number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw new Error(`${field} must be a finite number.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ export interface CircuitTreeSectionBlock {
|
|||||||
|
|
||||||
export interface CircuitTreeResponse {
|
export interface CircuitTreeResponse {
|
||||||
circuitListId: string;
|
circuitListId: string;
|
||||||
|
currentRevision: number;
|
||||||
sections: CircuitTreeSectionBlock[];
|
sections: CircuitTreeSectionBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const projectDeviceUpdateCommandType =
|
||||||
|
"project-device.update" as const;
|
||||||
|
export const projectDeviceUpdateCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface ProjectDeviceUpdateValues {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
phaseType: "single_phase" | "three_phase";
|
||||||
|
connectionKind: string | null;
|
||||||
|
costGroup: string | null;
|
||||||
|
category: string | null;
|
||||||
|
quantity: number;
|
||||||
|
powerPerUnit: number;
|
||||||
|
simultaneityFactor: number;
|
||||||
|
cosPhi: number | null;
|
||||||
|
remark: string | null;
|
||||||
|
voltageV: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectDeviceUpdateField =
|
||||||
|
keyof ProjectDeviceUpdateValues;
|
||||||
|
export type ProjectDeviceUpdatePatch =
|
||||||
|
Partial<ProjectDeviceUpdateValues>;
|
||||||
|
|
||||||
|
export const projectDeviceUpdateFields = [
|
||||||
|
"name",
|
||||||
|
"displayName",
|
||||||
|
"phaseType",
|
||||||
|
"connectionKind",
|
||||||
|
"costGroup",
|
||||||
|
"category",
|
||||||
|
"quantity",
|
||||||
|
"powerPerUnit",
|
||||||
|
"simultaneityFactor",
|
||||||
|
"cosPhi",
|
||||||
|
"remark",
|
||||||
|
"voltageV",
|
||||||
|
] as const satisfies readonly ProjectDeviceUpdateField[];
|
||||||
|
|
||||||
|
export interface ProjectDeviceUpdateFieldChange<
|
||||||
|
TField extends ProjectDeviceUpdateField = ProjectDeviceUpdateField,
|
||||||
|
> {
|
||||||
|
field: TField;
|
||||||
|
value: ProjectDeviceUpdateValues[TField];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceUpdateCommandPayload {
|
||||||
|
projectDeviceId: string;
|
||||||
|
changes: ProjectDeviceUpdateFieldChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceUpdateProjectCommand
|
||||||
|
extends SerializedProjectCommand<ProjectDeviceUpdateCommandPayload> {
|
||||||
|
schemaVersion: typeof projectDeviceUpdateCommandSchemaVersion;
|
||||||
|
type: typeof projectDeviceUpdateCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProjectDeviceUpdateProjectCommand(
|
||||||
|
projectDeviceId: string,
|
||||||
|
patch: ProjectDeviceUpdatePatch
|
||||||
|
): ProjectDeviceUpdateProjectCommand {
|
||||||
|
const command: ProjectDeviceUpdateProjectCommand = {
|
||||||
|
schemaVersion: projectDeviceUpdateCommandSchemaVersion,
|
||||||
|
type: projectDeviceUpdateCommandType,
|
||||||
|
payload: {
|
||||||
|
projectDeviceId,
|
||||||
|
changes: Object.entries(patch).map(([field, value]) => ({
|
||||||
|
field: field as ProjectDeviceUpdateField,
|
||||||
|
value,
|
||||||
|
})) as ProjectDeviceUpdateFieldChange[],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assertProjectDeviceUpdateProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertProjectDeviceUpdateProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is ProjectDeviceUpdateProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== projectDeviceUpdateCommandSchemaVersion ||
|
||||||
|
command.type !== projectDeviceUpdateCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported project-device update command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error("Project-device update payload must be an object.");
|
||||||
|
}
|
||||||
|
const { projectDeviceId, changes } = command.payload;
|
||||||
|
if (
|
||||||
|
typeof projectDeviceId !== "string" ||
|
||||||
|
!projectDeviceId.trim()
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device update command requires a project device id."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(changes) || changes.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device update command requires at least one change."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenFields = new Set<string>();
|
||||||
|
for (const change of changes) {
|
||||||
|
if (!isPlainObject(change) || typeof change.field !== "string") {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device update command contains an invalid change."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isProjectDeviceUpdateField(change.field) ||
|
||||||
|
seenFields.has(change.field)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device update command contains an invalid or duplicate field."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertProjectDeviceUpdateFieldValue(
|
||||||
|
change.field,
|
||||||
|
change.value
|
||||||
|
);
|
||||||
|
seenFields.add(change.field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertProjectDeviceUpdateFieldValue(
|
||||||
|
field: ProjectDeviceUpdateField,
|
||||||
|
value: unknown
|
||||||
|
) {
|
||||||
|
if (field === "name" || field === "displayName") {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error(`${field} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (field === "phaseType") {
|
||||||
|
if (value !== "single_phase" && value !== "three_phase") {
|
||||||
|
throw new Error("phaseType is invalid.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
field === "quantity" ||
|
||||||
|
field === "powerPerUnit" ||
|
||||||
|
field === "simultaneityFactor"
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
typeof value !== "number" ||
|
||||||
|
!Number.isFinite(value) ||
|
||||||
|
value < 0 ||
|
||||||
|
(field === "simultaneityFactor" && value > 1)
|
||||||
|
) {
|
||||||
|
throw new Error(`${field} is outside its allowed range.`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (field === "cosPhi") {
|
||||||
|
if (
|
||||||
|
value !== null &&
|
||||||
|
(typeof value !== "number" ||
|
||||||
|
!Number.isFinite(value) ||
|
||||||
|
value < 0 ||
|
||||||
|
value > 1)
|
||||||
|
) {
|
||||||
|
throw new Error("cosPhi must be between zero and one or null.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (field === "voltageV") {
|
||||||
|
if (
|
||||||
|
value !== null &&
|
||||||
|
(typeof value !== "number" ||
|
||||||
|
!Number.isFinite(value) ||
|
||||||
|
value <= 0)
|
||||||
|
) {
|
||||||
|
throw new Error("voltageV must be positive or null.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value !== null && typeof value !== "string") {
|
||||||
|
throw new Error(`${field} must be a string or null.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProjectDeviceUpdateField(
|
||||||
|
value: string
|
||||||
|
): value is ProjectDeviceUpdateField {
|
||||||
|
return projectDeviceUpdateFields.includes(
|
||||||
|
value as ProjectDeviceUpdateField
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import {
|
||||||
|
assertCircuitDeviceRowUpdateProjectCommand,
|
||||||
|
circuitDeviceRowUpdateCommandSchemaVersion,
|
||||||
|
circuitDeviceRowUpdateCommandType,
|
||||||
|
type CircuitDeviceRowUpdateField,
|
||||||
|
type CircuitDeviceRowUpdateValues,
|
||||||
|
} from "./circuit-device-row-project-command.model.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const projectDeviceRowSyncCommandType =
|
||||||
|
"project-device.sync-rows" as const;
|
||||||
|
export const projectDeviceRowSyncCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export const projectDeviceSyncRowSnapshotFields = [
|
||||||
|
"linkedProjectDeviceId",
|
||||||
|
"name",
|
||||||
|
"displayName",
|
||||||
|
"phaseType",
|
||||||
|
"connectionKind",
|
||||||
|
"costGroup",
|
||||||
|
"category",
|
||||||
|
"quantity",
|
||||||
|
"powerPerUnit",
|
||||||
|
"simultaneityFactor",
|
||||||
|
"cosPhi",
|
||||||
|
"remark",
|
||||||
|
"overriddenFields",
|
||||||
|
] as const satisfies readonly CircuitDeviceRowUpdateField[];
|
||||||
|
|
||||||
|
export type ProjectDeviceSyncRowSnapshot = Pick<
|
||||||
|
CircuitDeviceRowUpdateValues,
|
||||||
|
(typeof projectDeviceSyncRowSnapshotFields)[number]
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ProjectDeviceRowSyncOperation =
|
||||||
|
| "synchronize"
|
||||||
|
| "disconnect"
|
||||||
|
| "reconnect";
|
||||||
|
|
||||||
|
export interface ProjectDeviceRowSyncAssignment {
|
||||||
|
rowId: string;
|
||||||
|
expected: ProjectDeviceSyncRowSnapshot;
|
||||||
|
target: ProjectDeviceSyncRowSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceRowSyncCommandPayload {
|
||||||
|
projectDeviceId: string;
|
||||||
|
operation: ProjectDeviceRowSyncOperation;
|
||||||
|
rows: ProjectDeviceRowSyncAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceRowSyncProjectCommand
|
||||||
|
extends SerializedProjectCommand<ProjectDeviceRowSyncCommandPayload> {
|
||||||
|
schemaVersion: typeof projectDeviceRowSyncCommandSchemaVersion;
|
||||||
|
type: typeof projectDeviceRowSyncCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProjectDeviceRowSyncProjectCommand(
|
||||||
|
projectDeviceId: string,
|
||||||
|
operation: ProjectDeviceRowSyncOperation,
|
||||||
|
rows: ProjectDeviceRowSyncAssignment[]
|
||||||
|
): ProjectDeviceRowSyncProjectCommand {
|
||||||
|
const command: ProjectDeviceRowSyncProjectCommand = {
|
||||||
|
schemaVersion: projectDeviceRowSyncCommandSchemaVersion,
|
||||||
|
type: projectDeviceRowSyncCommandType,
|
||||||
|
payload: { projectDeviceId, operation, rows },
|
||||||
|
};
|
||||||
|
assertProjectDeviceRowSyncProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertProjectDeviceRowSyncProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is ProjectDeviceRowSyncProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== projectDeviceRowSyncCommandSchemaVersion ||
|
||||||
|
command.type !== projectDeviceRowSyncCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported project-device row sync command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device row sync payload must be an object."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { projectDeviceId, operation, rows } = command.payload;
|
||||||
|
assertNonEmptyString(projectDeviceId, "projectDeviceId");
|
||||||
|
if (
|
||||||
|
operation !== "synchronize" &&
|
||||||
|
operation !== "disconnect" &&
|
||||||
|
operation !== "reconnect"
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device row sync operation is invalid."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device row sync command requires at least one row."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowIds = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!isPlainObject(row)) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device row sync command contains an invalid row."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertNonEmptyString(row.rowId, "rowId");
|
||||||
|
if (rowIds.has(row.rowId)) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device row sync command contains duplicate row ids."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertProjectDeviceSyncRowSnapshot(row.expected);
|
||||||
|
assertProjectDeviceSyncRowSnapshot(row.target);
|
||||||
|
const expected =
|
||||||
|
row.expected as ProjectDeviceSyncRowSnapshot;
|
||||||
|
const target = row.target as ProjectDeviceSyncRowSnapshot;
|
||||||
|
if (snapshotsEqual(expected, target)) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device row sync command contains a no-op row."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertOperationTransition(
|
||||||
|
operation,
|
||||||
|
projectDeviceId,
|
||||||
|
expected,
|
||||||
|
target
|
||||||
|
);
|
||||||
|
rowIds.add(row.rowId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invertProjectDeviceRowSyncOperation(
|
||||||
|
operation: ProjectDeviceRowSyncOperation
|
||||||
|
): ProjectDeviceRowSyncOperation {
|
||||||
|
if (operation === "disconnect") {
|
||||||
|
return "reconnect";
|
||||||
|
}
|
||||||
|
if (operation === "reconnect") {
|
||||||
|
return "disconnect";
|
||||||
|
}
|
||||||
|
return "synchronize";
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertProjectDeviceSyncRowSnapshot(snapshot: unknown) {
|
||||||
|
if (!isPlainObject(snapshot)) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device sync row snapshot must be an object."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const snapshotKeys = Object.keys(snapshot);
|
||||||
|
if (
|
||||||
|
snapshotKeys.length !== projectDeviceSyncRowSnapshotFields.length ||
|
||||||
|
projectDeviceSyncRowSnapshotFields.some(
|
||||||
|
(field) =>
|
||||||
|
!Object.prototype.hasOwnProperty.call(snapshot, field)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device sync row snapshot must contain every sync field."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertCircuitDeviceRowUpdateProjectCommand({
|
||||||
|
schemaVersion: circuitDeviceRowUpdateCommandSchemaVersion,
|
||||||
|
type: circuitDeviceRowUpdateCommandType,
|
||||||
|
payload: {
|
||||||
|
rowId: "__sync_snapshot_validation__",
|
||||||
|
changes: projectDeviceSyncRowSnapshotFields.map((field) => ({
|
||||||
|
field,
|
||||||
|
value: snapshot[field],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertOperationTransition(
|
||||||
|
operation: ProjectDeviceRowSyncOperation,
|
||||||
|
projectDeviceId: string,
|
||||||
|
expected: ProjectDeviceSyncRowSnapshot,
|
||||||
|
target: ProjectDeviceSyncRowSnapshot
|
||||||
|
) {
|
||||||
|
if (operation === "synchronize") {
|
||||||
|
if (
|
||||||
|
expected.linkedProjectDeviceId !== projectDeviceId ||
|
||||||
|
target.linkedProjectDeviceId !== projectDeviceId
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Synchronized rows must remain linked to the selected project device."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedLink =
|
||||||
|
operation === "disconnect" ? projectDeviceId : null;
|
||||||
|
const targetLink =
|
||||||
|
operation === "disconnect" ? null : projectDeviceId;
|
||||||
|
if (
|
||||||
|
expected.linkedProjectDeviceId !== expectedLink ||
|
||||||
|
target.linkedProjectDeviceId !== targetLink
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device link transition does not match the operation."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!snapshotsEqualWithoutLink(expected, target)) {
|
||||||
|
throw new Error(
|
||||||
|
"Disconnect and reconnect must not change local row values."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotsEqual(
|
||||||
|
left: ProjectDeviceSyncRowSnapshot,
|
||||||
|
right: ProjectDeviceSyncRowSnapshot
|
||||||
|
) {
|
||||||
|
return projectDeviceSyncRowSnapshotFields.every(
|
||||||
|
(field) => left[field] === right[field]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotsEqualWithoutLink(
|
||||||
|
left: ProjectDeviceSyncRowSnapshot,
|
||||||
|
right: ProjectDeviceSyncRowSnapshot
|
||||||
|
) {
|
||||||
|
return projectDeviceSyncRowSnapshotFields
|
||||||
|
.filter((field) => field !== "linkedProjectDeviceId")
|
||||||
|
.every((field) => left[field] === right[field]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonEmptyString(
|
||||||
|
value: unknown,
|
||||||
|
field: string
|
||||||
|
): asserts value is string {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error(`${field} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
assertCircuitDeviceRowInsertProjectCommand,
|
||||||
|
circuitDeviceRowInsertCommandType,
|
||||||
|
circuitDeviceRowStructureCommandSchemaVersion,
|
||||||
|
type CircuitDeviceRowSnapshot,
|
||||||
|
} from "./circuit-device-row-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertProjectDeviceUpdateProjectCommand,
|
||||||
|
projectDeviceUpdateCommandSchemaVersion,
|
||||||
|
projectDeviceUpdateCommandType,
|
||||||
|
projectDeviceUpdateFields,
|
||||||
|
type ProjectDeviceUpdateValues,
|
||||||
|
} from "./project-device-project-command.model.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const projectDeviceInsertCommandType =
|
||||||
|
"project-device.insert" as const;
|
||||||
|
export const projectDeviceDeleteCommandType =
|
||||||
|
"project-device.delete" as const;
|
||||||
|
export const projectDeviceStructureCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface ProjectDeviceSnapshot
|
||||||
|
extends ProjectDeviceUpdateValues {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceInsertCommandPayload {
|
||||||
|
projectDevice: ProjectDeviceSnapshot;
|
||||||
|
linkedRows: CircuitDeviceRowSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceDeleteCommandPayload {
|
||||||
|
projectDeviceId: string;
|
||||||
|
expectedProjectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceInsertProjectCommand
|
||||||
|
extends SerializedProjectCommand<ProjectDeviceInsertCommandPayload> {
|
||||||
|
schemaVersion: typeof projectDeviceStructureCommandSchemaVersion;
|
||||||
|
type: typeof projectDeviceInsertCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceDeleteProjectCommand
|
||||||
|
extends SerializedProjectCommand<ProjectDeviceDeleteCommandPayload> {
|
||||||
|
schemaVersion: typeof projectDeviceStructureCommandSchemaVersion;
|
||||||
|
type: typeof projectDeviceDeleteCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectDeviceStructureProjectCommand =
|
||||||
|
| ProjectDeviceInsertProjectCommand
|
||||||
|
| ProjectDeviceDeleteProjectCommand;
|
||||||
|
|
||||||
|
export function createProjectDeviceInsertProjectCommand(
|
||||||
|
projectDevice: ProjectDeviceSnapshot,
|
||||||
|
linkedRows: CircuitDeviceRowSnapshot[] = []
|
||||||
|
): ProjectDeviceInsertProjectCommand {
|
||||||
|
const command: ProjectDeviceInsertProjectCommand = {
|
||||||
|
schemaVersion: projectDeviceStructureCommandSchemaVersion,
|
||||||
|
type: projectDeviceInsertCommandType,
|
||||||
|
payload: { projectDevice, linkedRows },
|
||||||
|
};
|
||||||
|
assertProjectDeviceInsertProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProjectDeviceDeleteProjectCommand(
|
||||||
|
projectDeviceId: string,
|
||||||
|
expectedProjectId: string
|
||||||
|
): ProjectDeviceDeleteProjectCommand {
|
||||||
|
const command: ProjectDeviceDeleteProjectCommand = {
|
||||||
|
schemaVersion: projectDeviceStructureCommandSchemaVersion,
|
||||||
|
type: projectDeviceDeleteCommandType,
|
||||||
|
payload: { projectDeviceId, expectedProjectId },
|
||||||
|
};
|
||||||
|
assertProjectDeviceDeleteProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertProjectDeviceInsertProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is ProjectDeviceInsertProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== projectDeviceStructureCommandSchemaVersion ||
|
||||||
|
command.type !== projectDeviceInsertCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported project-device insert command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error("Project-device insert payload must be an object.");
|
||||||
|
}
|
||||||
|
const { projectDevice, linkedRows } = command.payload;
|
||||||
|
if (!isPlainObject(projectDevice)) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device insert command requires a project device."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertNonEmptyString(projectDevice.id, "projectDevice.id");
|
||||||
|
assertNonEmptyString(
|
||||||
|
projectDevice.projectId,
|
||||||
|
"projectDevice.projectId"
|
||||||
|
);
|
||||||
|
assertProjectDeviceUpdateProjectCommand({
|
||||||
|
schemaVersion: projectDeviceUpdateCommandSchemaVersion,
|
||||||
|
type: projectDeviceUpdateCommandType,
|
||||||
|
payload: {
|
||||||
|
projectDeviceId: projectDevice.id,
|
||||||
|
changes: projectDeviceUpdateFields.map((field) => ({
|
||||||
|
field,
|
||||||
|
value: projectDevice[field],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Array.isArray(linkedRows)) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device insert linkedRows must be an array."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rowIds = new Set<string>();
|
||||||
|
for (const row of linkedRows) {
|
||||||
|
assertCircuitDeviceRowInsertProjectCommand({
|
||||||
|
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
|
||||||
|
type: circuitDeviceRowInsertCommandType,
|
||||||
|
payload: { row },
|
||||||
|
});
|
||||||
|
if (row.linkedProjectDeviceId !== null) {
|
||||||
|
throw new Error(
|
||||||
|
"Restored project-device rows must currently be disconnected."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rowIds.has(row.id)) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device insert command contains duplicate row ids."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rowIds.add(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertProjectDeviceDeleteProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is ProjectDeviceDeleteProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== projectDeviceStructureCommandSchemaVersion ||
|
||||||
|
command.type !== projectDeviceDeleteCommandType
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported project-device delete command.");
|
||||||
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error("Project-device delete payload must be an object.");
|
||||||
|
}
|
||||||
|
assertNonEmptyString(
|
||||||
|
command.payload.projectDeviceId,
|
||||||
|
"projectDeviceId"
|
||||||
|
);
|
||||||
|
assertNonEmptyString(
|
||||||
|
command.payload.expectedProjectId,
|
||||||
|
"expectedProjectId"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonEmptyString(value: unknown, field: string) {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error(`${field} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { CircuitSectionRenumberProjectCommand } from "../models/circuit-section-renumber-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteCircuitSectionRenumberCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: CircuitSectionRenumberProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedCircuitSectionRenumberCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: CircuitSectionRenumberProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitSectionRenumberProjectCommandStore {
|
||||||
|
execute(
|
||||||
|
input: ExecuteCircuitSectionRenumberCommandInput
|
||||||
|
): ExecutedCircuitSectionRenumberCommand;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { CircuitSectionReorderProjectCommand } from "../models/circuit-section-reorder-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteCircuitSectionReorderCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: CircuitSectionReorderProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedCircuitSectionReorderCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: CircuitSectionReorderProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitSectionReorderProjectCommandStore {
|
||||||
|
execute(
|
||||||
|
input: ExecuteCircuitSectionReorderCommandInput
|
||||||
|
): ExecutedCircuitSectionReorderCommand;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ProjectDeviceUpdateProjectCommand } from "../models/project-device-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteProjectDeviceUpdateCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: ProjectDeviceUpdateProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedProjectDeviceUpdateCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: ProjectDeviceUpdateProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceProjectCommandStore {
|
||||||
|
executeUpdate(
|
||||||
|
input: ExecuteProjectDeviceUpdateCommandInput
|
||||||
|
): ExecutedProjectDeviceUpdateCommand;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ProjectDeviceRowSyncProjectCommand } from "../models/project-device-row-sync-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteProjectDeviceRowSyncCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: ProjectDeviceRowSyncProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedProjectDeviceRowSyncCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: ProjectDeviceRowSyncProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceRowSyncProjectCommandStore {
|
||||||
|
execute(
|
||||||
|
input: ExecuteProjectDeviceRowSyncCommandInput
|
||||||
|
): ExecutedProjectDeviceRowSyncCommand;
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
export interface ProjectDeviceLinkedRowValues {
|
|
||||||
linkedProjectDeviceId?: string;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
phaseType?: string;
|
|
||||||
connectionKind?: string;
|
|
||||||
costGroup?: string;
|
|
||||||
category?: string;
|
|
||||||
level?: string;
|
|
||||||
roomId?: string;
|
|
||||||
roomNumberSnapshot?: string;
|
|
||||||
roomNameSnapshot?: string;
|
|
||||||
quantity: number;
|
|
||||||
powerPerUnit: number;
|
|
||||||
simultaneityFactor: number;
|
|
||||||
cosPhi?: number;
|
|
||||||
remark?: string;
|
|
||||||
overriddenFields?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectDeviceRowSyncStore {
|
|
||||||
updateLinkedRows(
|
|
||||||
projectDeviceId: string,
|
|
||||||
changes: Array<{ rowId: string; input: ProjectDeviceLinkedRowValues }>
|
|
||||||
): void;
|
|
||||||
disconnectLinkedRows(projectDeviceId: string, rowIds: string[]): void;
|
|
||||||
reconnectRows(projectDeviceId: string, rowIds: string[]): void;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ProjectDeviceStructureProjectCommand } from "../models/project-device-structure-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteProjectDeviceStructureCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: ProjectDeviceStructureProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedProjectDeviceStructureCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: ProjectDeviceStructureProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceStructureProjectCommandStore {
|
||||||
|
execute(
|
||||||
|
input: ExecuteProjectDeviceStructureCommandInput
|
||||||
|
): ExecutedProjectDeviceStructureCommand;
|
||||||
|
}
|
||||||
@@ -1,51 +1,37 @@
|
|||||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||||
import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js";
|
import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js";
|
||||||
import type { CircuitSectionTransactionStore } from "../ports/circuit-section-transaction.store.js";
|
import type { CircuitSectionTransactionStore } from "../ports/circuit-section-transaction.store.js";
|
||||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
|
||||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
|
||||||
import type {
|
import type {
|
||||||
CreateCircuitDeviceRowInput,
|
|
||||||
CreateCircuitInput,
|
|
||||||
CreateCircuitWithDeviceRowsInput,
|
|
||||||
MoveCircuitDeviceRowInput,
|
MoveCircuitDeviceRowInput,
|
||||||
MoveCircuitDeviceRowsBulkInput,
|
MoveCircuitDeviceRowsBulkInput,
|
||||||
ReorderSectionCircuitsInput,
|
ReorderSectionCircuitsInput,
|
||||||
UpdateSectionEquipmentIdentifiersInput,
|
UpdateSectionEquipmentIdentifiersInput,
|
||||||
UpdateCircuitDeviceRowInput,
|
|
||||||
UpdateCircuitInput,
|
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
} from "../../shared/validation/circuit.schemas.js";
|
||||||
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
||||||
import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js";
|
|
||||||
|
|
||||||
export class CircuitWriteService {
|
export class CircuitWriteService {
|
||||||
private readonly circuitRepository: CircuitRepository;
|
private readonly circuitRepository: CircuitRepository;
|
||||||
private readonly circuitSectionRepository: CircuitSectionRepository;
|
private readonly circuitSectionRepository: CircuitSectionRepository;
|
||||||
private readonly circuitListRepository: CircuitListRepository;
|
|
||||||
private readonly deviceRowRepository: CircuitDeviceRowRepository;
|
private readonly deviceRowRepository: CircuitDeviceRowRepository;
|
||||||
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
||||||
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
||||||
private readonly projectDeviceRepository: ProjectDeviceRepository;
|
|
||||||
private readonly numberingService: CircuitNumberingService;
|
private readonly numberingService: CircuitNumberingService;
|
||||||
|
|
||||||
constructor(deps?: {
|
constructor(deps?: {
|
||||||
circuitRepository?: CircuitRepository;
|
circuitRepository?: CircuitRepository;
|
||||||
circuitSectionRepository?: CircuitSectionRepository;
|
circuitSectionRepository?: CircuitSectionRepository;
|
||||||
circuitListRepository?: CircuitListRepository;
|
|
||||||
deviceRowRepository?: CircuitDeviceRowRepository;
|
deviceRowRepository?: CircuitDeviceRowRepository;
|
||||||
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
||||||
circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
||||||
projectDeviceRepository?: ProjectDeviceRepository;
|
|
||||||
numberingService?: CircuitNumberingService;
|
numberingService?: CircuitNumberingService;
|
||||||
}) {
|
}) {
|
||||||
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
|
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
|
||||||
this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository();
|
this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository();
|
||||||
this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository();
|
|
||||||
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
||||||
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
|
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
|
||||||
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
|
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
|
||||||
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
|
||||||
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,36 +47,6 @@ export class CircuitWriteService {
|
|||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforces BMK uniqueness inside one circuit list.
|
|
||||||
private async assertUniqueEquipmentIdentifier(
|
|
||||||
circuitListId: string,
|
|
||||||
equipmentIdentifier: string,
|
|
||||||
excludeCircuitId?: string
|
|
||||||
) {
|
|
||||||
const exists = await this.circuitRepository.existsByEquipmentIdentifier(
|
|
||||||
circuitListId,
|
|
||||||
equipmentIdentifier,
|
|
||||||
excludeCircuitId
|
|
||||||
);
|
|
||||||
if (exists) {
|
|
||||||
throw new Error("Duplicate equipmentIdentifier in circuit list.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validates linked project-device id against owning project of the circuit list.
|
|
||||||
private async assertValidLinkedProjectDeviceForProject(
|
|
||||||
projectId: string,
|
|
||||||
linkedProjectDeviceId?: string
|
|
||||||
) {
|
|
||||||
if (!linkedProjectDeviceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const device = await this.projectDeviceRepository.findById(projectId, linkedProjectDeviceId);
|
|
||||||
if (!device) {
|
|
||||||
throw new Error("Invalid linked project device id.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDeviceRowTransactionStore() {
|
private getDeviceRowTransactionStore() {
|
||||||
if (!this.deviceRowTransactionStore) {
|
if (!this.deviceRowTransactionStore) {
|
||||||
throw new Error("Circuit device-row transactions are not configured.");
|
throw new Error("Circuit device-row transactions are not configured.");
|
||||||
@@ -105,107 +61,6 @@ export class CircuitWriteService {
|
|||||||
return this.circuitSectionTransactionStore;
|
return this.circuitSectionTransactionStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) {
|
|
||||||
if (!linkedProjectDeviceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const circuit = await this.circuitRepository.findById(circuitId);
|
|
||||||
if (!circuit) {
|
|
||||||
throw new Error("Invalid circuit id.");
|
|
||||||
}
|
|
||||||
const list = await this.circuitListRepository.findByIdByListIdOnly(circuit.circuitListId);
|
|
||||||
if (!list) {
|
|
||||||
throw new Error("Circuit list not found.");
|
|
||||||
}
|
|
||||||
await this.assertValidLinkedProjectDeviceForProject(list.projectId, linkedProjectDeviceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createCircuit(projectId: string, circuitListId: string, input: CreateCircuitInput) {
|
|
||||||
const list = await this.circuitListRepository.findById(projectId, circuitListId);
|
|
||||||
if (!list) {
|
|
||||||
throw new Error("Circuit list not found in project.");
|
|
||||||
}
|
|
||||||
await this.assertSectionInList(input.sectionId, circuitListId);
|
|
||||||
await this.assertUniqueEquipmentIdentifier(circuitListId, input.equipmentIdentifier);
|
|
||||||
const id = await this.circuitRepository.create({
|
|
||||||
circuitListId,
|
|
||||||
sectionId: input.sectionId,
|
|
||||||
equipmentIdentifier: input.equipmentIdentifier,
|
|
||||||
displayName: input.displayName,
|
|
||||||
sortOrder: input.sortOrder,
|
|
||||||
protectionType: input.protectionType,
|
|
||||||
protectionRatedCurrent: input.protectionRatedCurrent,
|
|
||||||
protectionCharacteristic: input.protectionCharacteristic,
|
|
||||||
cableType: input.cableType,
|
|
||||||
cableCrossSection: input.cableCrossSection,
|
|
||||||
cableLength: input.cableLength,
|
|
||||||
rcdAssignment: input.rcdAssignment,
|
|
||||||
terminalDesignation: input.terminalDesignation,
|
|
||||||
voltage: input.voltage,
|
|
||||||
controlRequirement: input.controlRequirement,
|
|
||||||
status: input.status,
|
|
||||||
isReserve: input.isReserve ?? true,
|
|
||||||
remark: input.remark,
|
|
||||||
});
|
|
||||||
return this.circuitRepository.findById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createCircuitWithDeviceRows(
|
|
||||||
projectId: string,
|
|
||||||
circuitListId: string,
|
|
||||||
input: CreateCircuitWithDeviceRowsInput
|
|
||||||
) {
|
|
||||||
const list = await this.circuitListRepository.findById(projectId, circuitListId);
|
|
||||||
if (!list) {
|
|
||||||
throw new Error("Circuit list not found in project.");
|
|
||||||
}
|
|
||||||
await this.assertSectionInList(input.circuit.sectionId, circuitListId);
|
|
||||||
await this.assertUniqueEquipmentIdentifier(
|
|
||||||
circuitListId,
|
|
||||||
input.circuit.equipmentIdentifier
|
|
||||||
);
|
|
||||||
for (const row of input.deviceRows) {
|
|
||||||
await this.assertValidLinkedProjectDeviceForProject(
|
|
||||||
list.projectId,
|
|
||||||
row.linkedProjectDeviceId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = this.getDeviceRowTransactionStore().createCircuitWithDeviceRows({
|
|
||||||
circuit: {
|
|
||||||
circuitListId,
|
|
||||||
...input.circuit,
|
|
||||||
},
|
|
||||||
deviceRows: input.deviceRows,
|
|
||||||
});
|
|
||||||
const circuit = await this.circuitRepository.findById(created.circuitId);
|
|
||||||
const deviceRows = await Promise.all(
|
|
||||||
created.rowIds.map((rowId) => this.deviceRowRepository.findById(rowId))
|
|
||||||
);
|
|
||||||
if (!circuit || deviceRows.some((row) => !row)) {
|
|
||||||
throw new Error("Der angelegte Stromkreis konnte nicht geladen werden.");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
circuit,
|
|
||||||
deviceRows,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateCircuit(circuitId: string, input: UpdateCircuitInput) {
|
|
||||||
const current = await this.circuitRepository.findById(circuitId);
|
|
||||||
if (!current) {
|
|
||||||
throw new Error("Invalid circuit id.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sectionId = input.sectionId ?? current.sectionId;
|
|
||||||
const equipmentIdentifier = input.equipmentIdentifier ?? current.equipmentIdentifier;
|
|
||||||
await this.assertSectionInList(sectionId, current.circuitListId);
|
|
||||||
await this.assertUniqueEquipmentIdentifier(current.circuitListId, equipmentIdentifier, circuitId);
|
|
||||||
|
|
||||||
await this.circuitRepository.updateFields(circuitId, input);
|
|
||||||
return this.circuitRepository.findById(circuitId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteCircuit(circuitId: string) {
|
async deleteCircuit(circuitId: string) {
|
||||||
const current = await this.circuitRepository.findById(circuitId);
|
const current = await this.circuitRepository.findById(circuitId);
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -214,67 +69,6 @@ export class CircuitWriteService {
|
|||||||
await this.circuitRepository.delete(circuitId);
|
await this.circuitRepository.delete(circuitId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDeviceRow(circuitId: string, input: CreateCircuitDeviceRowInput) {
|
|
||||||
const circuit = await this.circuitRepository.findById(circuitId);
|
|
||||||
if (!circuit) {
|
|
||||||
throw new Error("Invalid circuit id.");
|
|
||||||
}
|
|
||||||
await this.assertValidLinkedProjectDevice(circuitId, input.linkedProjectDeviceId);
|
|
||||||
|
|
||||||
const rowId = this.getDeviceRowTransactionStore().createInCircuit({
|
|
||||||
circuitId,
|
|
||||||
linkedProjectDeviceId: input.linkedProjectDeviceId,
|
|
||||||
sortOrder: input.sortOrder,
|
|
||||||
name: input.name,
|
|
||||||
displayName: input.displayName,
|
|
||||||
phaseType: input.phaseType,
|
|
||||||
connectionKind: input.connectionKind,
|
|
||||||
costGroup: input.costGroup,
|
|
||||||
category: input.category,
|
|
||||||
level: input.level,
|
|
||||||
roomId: input.roomId,
|
|
||||||
roomNumberSnapshot: input.roomNumberSnapshot,
|
|
||||||
roomNameSnapshot: input.roomNameSnapshot,
|
|
||||||
quantity: input.quantity,
|
|
||||||
powerPerUnit: input.powerPerUnit,
|
|
||||||
simultaneityFactor: input.simultaneityFactor,
|
|
||||||
cosPhi: input.cosPhi,
|
|
||||||
remark: input.remark,
|
|
||||||
overriddenFields: input.overriddenFields,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.deviceRowRepository.findById(rowId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateDeviceRow(rowId: string, input: UpdateCircuitDeviceRowInput) {
|
|
||||||
const current = await this.deviceRowRepository.findById(rowId);
|
|
||||||
if (!current) {
|
|
||||||
throw new Error("Invalid device row id.");
|
|
||||||
}
|
|
||||||
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
|
||||||
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
|
|
||||||
current,
|
|
||||||
input
|
|
||||||
);
|
|
||||||
await this.deviceRowRepository.updateFields(rowId, {
|
|
||||||
...input,
|
|
||||||
...(overriddenFields !== undefined ? { overriddenFields } : {}),
|
|
||||||
});
|
|
||||||
return this.deviceRowRepository.findById(rowId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteDeviceRow(rowId: string) {
|
|
||||||
const current = await this.deviceRowRepository.findById(rowId);
|
|
||||||
if (!current) {
|
|
||||||
throw new Error("Invalid device row id.");
|
|
||||||
}
|
|
||||||
const circuit = await this.circuitRepository.findById(current.circuitId);
|
|
||||||
if (!circuit) {
|
|
||||||
throw new Error("Invalid circuit id.");
|
|
||||||
}
|
|
||||||
this.getDeviceRowTransactionStore().deleteFromCircuit(rowId, circuit.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) {
|
async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) {
|
||||||
const row = await this.deviceRowRepository.findById(rowId);
|
const row = await this.deviceRowRepository.findById(rowId);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ import {
|
|||||||
assertCircuitUpdateProjectCommand,
|
assertCircuitUpdateProjectCommand,
|
||||||
circuitUpdateCommandType,
|
circuitUpdateCommandType,
|
||||||
} from "../models/circuit-project-command.model.js";
|
} from "../models/circuit-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitSectionReorderProjectCommand,
|
||||||
|
circuitSectionReorderCommandType,
|
||||||
|
} from "../models/circuit-section-reorder-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitSectionRenumberProjectCommand,
|
||||||
|
circuitSectionRenumberCommandType,
|
||||||
|
} from "../models/circuit-section-renumber-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
assertCircuitDeleteProjectCommand,
|
assertCircuitDeleteProjectCommand,
|
||||||
assertCircuitInsertProjectCommand,
|
assertCircuitInsertProjectCommand,
|
||||||
@@ -29,10 +37,26 @@ import {
|
|||||||
assertSerializedProjectCommand,
|
assertSerializedProjectCommand,
|
||||||
type SerializedProjectCommand,
|
type SerializedProjectCommand,
|
||||||
} from "../models/project-command.model.js";
|
} from "../models/project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertProjectDeviceRowSyncProjectCommand,
|
||||||
|
projectDeviceRowSyncCommandType,
|
||||||
|
} from "../models/project-device-row-sync-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertProjectDeviceUpdateProjectCommand,
|
||||||
|
projectDeviceUpdateCommandType,
|
||||||
|
} from "../models/project-device-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertProjectDeviceDeleteProjectCommand,
|
||||||
|
assertProjectDeviceInsertProjectCommand,
|
||||||
|
projectDeviceDeleteCommandType,
|
||||||
|
projectDeviceInsertCommandType,
|
||||||
|
} from "../models/project-device-structure-project-command.model.js";
|
||||||
import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js";
|
import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js";
|
||||||
import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js";
|
import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js";
|
||||||
import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
|
import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
|
||||||
import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
|
import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
|
||||||
|
import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
|
||||||
|
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
|
||||||
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
|
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
|
||||||
import type {
|
import type {
|
||||||
ExecuteProjectHistoryCommandInput,
|
ExecuteProjectHistoryCommandInput,
|
||||||
@@ -44,6 +68,9 @@ import type {
|
|||||||
ProjectHistoryDirection,
|
ProjectHistoryDirection,
|
||||||
ProjectHistoryStore,
|
ProjectHistoryStore,
|
||||||
} from "../ports/project-history.store.js";
|
} from "../ports/project-history.store.js";
|
||||||
|
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
|
||||||
|
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
|
||||||
|
import type { ProjectDeviceStructureProjectCommandStore } from "../ports/project-device-structure-project-command.store.js";
|
||||||
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
|
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
|
||||||
|
|
||||||
interface DispatchProjectCommandInput {
|
interface DispatchProjectCommandInput {
|
||||||
@@ -63,6 +90,11 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
|
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
|
||||||
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
|
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
|
||||||
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
|
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
|
||||||
|
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
|
||||||
|
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
|
||||||
|
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
|
||||||
|
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
|
||||||
|
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
|
||||||
private readonly historyStore: ProjectHistoryStore
|
private readonly historyStore: ProjectHistoryStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -186,6 +218,48 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
|
case circuitSectionReorderCommandType: {
|
||||||
|
assertCircuitSectionReorderProjectCommand(input.command);
|
||||||
|
return this.circuitSectionReorderStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitSectionRenumberCommandType: {
|
||||||
|
assertCircuitSectionRenumberProjectCommand(input.command);
|
||||||
|
return this.circuitSectionRenumberStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case projectDeviceRowSyncCommandType: {
|
||||||
|
assertProjectDeviceRowSyncProjectCommand(input.command);
|
||||||
|
return this.projectDeviceRowSyncStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case projectDeviceUpdateCommandType: {
|
||||||
|
assertProjectDeviceUpdateProjectCommand(input.command);
|
||||||
|
return this.projectDeviceStore.executeUpdate({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case projectDeviceInsertCommandType: {
|
||||||
|
assertProjectDeviceInsertProjectCommand(input.command);
|
||||||
|
return this.projectDeviceStructureStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case projectDeviceDeleteCommandType: {
|
||||||
|
assertProjectDeviceDeleteProjectCommand(input.command);
|
||||||
|
return this.projectDeviceStructureStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unsupported project command type: ${input.command.type}.`
|
`Unsupported project command type: ${input.command.type}.`
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||||
import type { ProjectDeviceRowSyncStore } from "../ports/project-device-row-sync.store.js";
|
import {
|
||||||
|
createProjectDeviceRowSyncProjectCommand,
|
||||||
|
projectDeviceSyncRowSnapshotFields,
|
||||||
|
type ProjectDeviceRowSyncProjectCommand,
|
||||||
|
type ProjectDeviceSyncRowSnapshot,
|
||||||
|
} from "../models/project-device-row-sync-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
projectDeviceSyncFields,
|
projectDeviceSyncFields,
|
||||||
type ProjectDeviceSyncField,
|
type ProjectDeviceSyncField,
|
||||||
@@ -20,20 +25,9 @@ type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProj
|
|||||||
|
|
||||||
type SyncDependencies = {
|
type SyncDependencies = {
|
||||||
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
|
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
|
||||||
deviceRowRepository: Pick<
|
deviceRowRepository: Pick<CircuitDeviceRowRepository, "listLinkedByProjectDevice">;
|
||||||
CircuitDeviceRowRepository,
|
|
||||||
| "listLinkedByProjectDevice"
|
|
||||||
| "listLinkStatesByIds"
|
|
||||||
>;
|
|
||||||
deviceRowSyncStore: ProjectDeviceRowSyncStore;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ProjectDeviceSyncRestoreRow {
|
|
||||||
rowId: string;
|
|
||||||
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
|
|
||||||
overriddenFields: ProjectDeviceSyncField[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
|
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
|
||||||
return projectDevice[field];
|
return projectDevice[field];
|
||||||
}
|
}
|
||||||
@@ -56,19 +50,10 @@ function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
|
|||||||
export class ProjectDeviceSyncService {
|
export class ProjectDeviceSyncService {
|
||||||
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"];
|
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"];
|
||||||
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"];
|
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"];
|
||||||
private readonly deviceRowSyncStore?: SyncDependencies["deviceRowSyncStore"];
|
|
||||||
|
|
||||||
constructor(deps?: Partial<SyncDependencies>) {
|
constructor(deps?: Partial<SyncDependencies>) {
|
||||||
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
||||||
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
||||||
this.deviceRowSyncStore = deps?.deviceRowSyncStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDeviceRowSyncStore() {
|
|
||||||
if (!this.deviceRowSyncStore) {
|
|
||||||
throw new Error("Project-device row synchronization is not configured.");
|
|
||||||
}
|
|
||||||
return this.deviceRowSyncStore;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPreview(projectId: string, projectDeviceId: string) {
|
async getPreview(projectId: string, projectDeviceId: string) {
|
||||||
@@ -100,12 +85,12 @@ export class ProjectDeviceSyncService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async synchronize(
|
async createSynchronizeCommand(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
projectDeviceId: string,
|
projectDeviceId: string,
|
||||||
rowIds: string[],
|
rowIds: string[],
|
||||||
fields: ProjectDeviceSyncField[]
|
fields: ProjectDeviceSyncField[]
|
||||||
) {
|
): Promise<ProjectDeviceRowSyncProjectCommand> {
|
||||||
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
|
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
|
||||||
if (!projectDevice) {
|
if (!projectDevice) {
|
||||||
throw new Error("Project device not found.");
|
throw new Error("Project device not found.");
|
||||||
@@ -113,81 +98,60 @@ export class ProjectDeviceSyncService {
|
|||||||
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
||||||
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
||||||
const selectedFields = [...new Set(fields)];
|
const selectedFields = [...new Set(fields)];
|
||||||
const undoRows: ProjectDeviceSyncRestoreRow[] = selectedRows.map((row) => ({
|
const assignments = selectedRows.flatMap((row) => {
|
||||||
rowId: row.id,
|
const expected = toSyncSnapshot(row);
|
||||||
values: Object.fromEntries(selectedFields.map((field) => [field, row[field] ?? null])),
|
const target = { ...expected };
|
||||||
overriddenFields: parseOverriddenFields(row.overriddenFields),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const changes = [];
|
|
||||||
for (const row of selectedRows) {
|
|
||||||
const next = this.copyRow(row);
|
|
||||||
for (const field of selectedFields) {
|
for (const field of selectedFields) {
|
||||||
Object.assign(next, { [field]: sourceValue(projectDevice, field) ?? undefined });
|
Object.assign(target, {
|
||||||
|
[field]: sourceValue(projectDevice, field) ?? null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const remainingOverrides = parseOverriddenFields(row.overriddenFields).filter(
|
const remainingOverrides = parseOverriddenFields(row.overriddenFields).filter(
|
||||||
(field) => !selectedFields.includes(field)
|
(field) => !selectedFields.includes(field)
|
||||||
);
|
);
|
||||||
next.overriddenFields = serializeOverriddenFields(remainingOverrides);
|
target.overriddenFields =
|
||||||
changes.push({ rowId: row.id, input: next });
|
serializeOverriddenFields(remainingOverrides) ?? null;
|
||||||
}
|
return snapshotsEqual(expected, target)
|
||||||
this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes);
|
? []
|
||||||
|
: [{ rowId: row.id, expected, target }];
|
||||||
return {
|
|
||||||
preview: await this.getPreview(projectId, projectDeviceId),
|
|
||||||
undo: { rows: undoRows },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async disconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
|
|
||||||
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
|
||||||
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
|
||||||
const disconnectedRowIds = selectedRows.map((row) => row.id);
|
|
||||||
|
|
||||||
this.getDeviceRowSyncStore().disconnectLinkedRows(
|
|
||||||
projectDeviceId,
|
|
||||||
disconnectedRowIds
|
|
||||||
);
|
|
||||||
|
|
||||||
return { disconnectedRowIds, undo: { rowIds: disconnectedRowIds } };
|
|
||||||
}
|
|
||||||
|
|
||||||
async restore(
|
|
||||||
projectId: string,
|
|
||||||
projectDeviceId: string,
|
|
||||||
restoreRows: ProjectDeviceSyncRestoreRow[]
|
|
||||||
) {
|
|
||||||
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
|
||||||
const selectedRows = this.resolveSelectedRows(linkedRows, restoreRows.map((row) => row.rowId));
|
|
||||||
const restoreById = new Map(restoreRows.map((row) => [row.rowId, row]));
|
|
||||||
const changes = selectedRows.map((row) => {
|
|
||||||
const restore = restoreById.get(row.id)!;
|
|
||||||
const next = this.copyRow(row);
|
|
||||||
for (const [field, value] of Object.entries(restore.values) as Array<
|
|
||||||
[ProjectDeviceSyncField, string | number | null]
|
|
||||||
>) {
|
|
||||||
Object.assign(next, { [field]: value ?? undefined });
|
|
||||||
}
|
|
||||||
next.overriddenFields = serializeOverriddenFields(restore.overriddenFields);
|
|
||||||
return { rowId: row.id, input: next };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes);
|
return createProjectDeviceRowSyncProjectCommand(
|
||||||
return this.getPreview(projectId, projectDeviceId);
|
projectDeviceId,
|
||||||
|
"synchronize",
|
||||||
|
assignments
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
|
async createDisconnectCommand(
|
||||||
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
|
projectId: string,
|
||||||
|
projectDeviceId: string,
|
||||||
|
rowIds: string[]
|
||||||
|
): Promise<ProjectDeviceRowSyncProjectCommand> {
|
||||||
|
const projectDevice = await this.projectDeviceRepository.findById(
|
||||||
|
projectId,
|
||||||
|
projectDeviceId
|
||||||
|
);
|
||||||
if (!projectDevice) {
|
if (!projectDevice) {
|
||||||
throw new Error("Project device not found.");
|
throw new Error("Project device not found.");
|
||||||
}
|
}
|
||||||
const uniqueRowIds = [...new Set(rowIds)];
|
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
||||||
const linkStates = await this.deviceRowRepository.listLinkStatesByIds(projectId, uniqueRowIds);
|
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
||||||
if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) {
|
return createProjectDeviceRowSyncProjectCommand(
|
||||||
throw new Error("One or more rows cannot be reconnected.");
|
projectDeviceId,
|
||||||
}
|
"disconnect",
|
||||||
this.getDeviceRowSyncStore().reconnectRows(projectDeviceId, uniqueRowIds);
|
selectedRows.map((row) => {
|
||||||
return this.getPreview(projectId, projectDeviceId);
|
const expected = toSyncSnapshot(row);
|
||||||
|
return {
|
||||||
|
rowId: row.id,
|
||||||
|
expected,
|
||||||
|
target: {
|
||||||
|
...expected,
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {
|
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {
|
||||||
@@ -200,25 +164,31 @@ export class ProjectDeviceSyncService {
|
|||||||
return selectedRows;
|
return selectedRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
private copyRow(row: LinkedRow) {
|
}
|
||||||
|
|
||||||
|
function toSyncSnapshot(row: LinkedRow): ProjectDeviceSyncRowSnapshot {
|
||||||
return {
|
return {
|
||||||
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
|
linkedProjectDeviceId: row.linkedProjectDeviceId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
phaseType: row.phaseType ?? undefined,
|
phaseType: row.phaseType,
|
||||||
connectionKind: row.connectionKind ?? undefined,
|
connectionKind: row.connectionKind,
|
||||||
costGroup: row.costGroup ?? undefined,
|
costGroup: row.costGroup,
|
||||||
category: row.category ?? undefined,
|
category: row.category,
|
||||||
level: row.level ?? undefined,
|
|
||||||
roomId: row.roomId ?? undefined,
|
|
||||||
roomNumberSnapshot: row.roomNumberSnapshot ?? undefined,
|
|
||||||
roomNameSnapshot: row.roomNameSnapshot ?? undefined,
|
|
||||||
quantity: row.quantity,
|
quantity: row.quantity,
|
||||||
powerPerUnit: row.powerPerUnit,
|
powerPerUnit: row.powerPerUnit,
|
||||||
simultaneityFactor: row.simultaneityFactor,
|
simultaneityFactor: row.simultaneityFactor,
|
||||||
cosPhi: row.cosPhi ?? undefined,
|
cosPhi: row.cosPhi,
|
||||||
remark: row.remark ?? undefined,
|
remark: row.remark,
|
||||||
overriddenFields: row.overriddenFields ?? undefined,
|
overriddenFields: row.overriddenFields,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function snapshotsEqual(
|
||||||
|
left: ProjectDeviceSyncRowSnapshot,
|
||||||
|
right: ProjectDeviceSyncRowSnapshot
|
||||||
|
) {
|
||||||
|
return projectDeviceSyncRowSnapshotFields.every(
|
||||||
|
(field) => left[field] === right[field]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+8
-20
@@ -92,6 +92,11 @@ export interface ProjectDeviceDto {
|
|||||||
voltageV: number | null;
|
voltageV: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceCommandResultDto
|
||||||
|
extends ProjectCommandResultDto {
|
||||||
|
projectDevice: ProjectDeviceDto;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateFloorInput {
|
export interface CreateFloorInput {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
@@ -160,24 +165,9 @@ export interface ProjectDeviceSyncPreviewDto {
|
|||||||
rows: ProjectDeviceSyncRowDto[];
|
rows: ProjectDeviceSyncRowDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectDeviceSyncRestoreRowDto {
|
export interface ProjectDeviceSyncCommandResultDto
|
||||||
rowId: string;
|
extends ProjectCommandResultDto {
|
||||||
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
|
|
||||||
overriddenFields: ProjectDeviceSyncField[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectDeviceSyncResultDto {
|
|
||||||
preview: ProjectDeviceSyncPreviewDto;
|
preview: ProjectDeviceSyncPreviewDto;
|
||||||
undo: {
|
|
||||||
rows: ProjectDeviceSyncRestoreRowDto[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectDeviceDisconnectResultDto {
|
|
||||||
disconnectedRowIds: string[];
|
|
||||||
undo: {
|
|
||||||
rowIds: string[];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CircuitTreeDeviceRowDto {
|
export interface CircuitTreeDeviceRowDto {
|
||||||
@@ -250,6 +240,7 @@ export interface CircuitTreeMigrationReportDto {
|
|||||||
|
|
||||||
export interface CircuitTreeResponseDto {
|
export interface CircuitTreeResponseDto {
|
||||||
circuitListId: string;
|
circuitListId: string;
|
||||||
|
currentRevision: number;
|
||||||
sections: CircuitTreeSectionDto[];
|
sections: CircuitTreeSectionDto[];
|
||||||
migrationReport?: CircuitTreeMigrationReportDto;
|
migrationReport?: CircuitTreeMigrationReportDto;
|
||||||
}
|
}
|
||||||
@@ -274,8 +265,6 @@ export interface CreateCircuitInputDto {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateCircuitInputDto = Partial<CreateCircuitInputDto>;
|
|
||||||
|
|
||||||
export interface CreateCircuitDeviceRowInputDto {
|
export interface CreateCircuitDeviceRowInputDto {
|
||||||
linkedProjectDeviceId?: string;
|
linkedProjectDeviceId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -297,5 +286,4 @@ export interface CreateCircuitDeviceRowInputDto {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateCircuitDeviceRowInputDto = Partial<CreateCircuitDeviceRowInputDto>;
|
|
||||||
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
|
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
|
||||||
|
|||||||
+150
-97
@@ -9,23 +9,30 @@ import type {
|
|||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
ProjectCommandDto,
|
ProjectCommandDto,
|
||||||
ProjectCommandResultDto,
|
ProjectCommandResultDto,
|
||||||
|
ProjectDeviceCommandResultDto,
|
||||||
ProjectDeviceDto,
|
ProjectDeviceDto,
|
||||||
ProjectHistoryStateDto,
|
ProjectHistoryStateDto,
|
||||||
ProjectDeviceDisconnectResultDto,
|
ProjectDeviceSyncCommandResultDto,
|
||||||
ProjectDeviceSyncPreviewDto,
|
ProjectDeviceSyncPreviewDto,
|
||||||
ProjectDeviceSyncRestoreRowDto,
|
|
||||||
ProjectDeviceSyncResultDto,
|
|
||||||
ProjectDto,
|
ProjectDto,
|
||||||
RoomDto,
|
RoomDto,
|
||||||
CircuitTreeResponseDto,
|
CircuitTreeResponseDto,
|
||||||
CircuitTreeCircuitDto,
|
|
||||||
CircuitTreeDeviceRowDto,
|
|
||||||
CreateCircuitInputDto,
|
|
||||||
UpdateCircuitInputDto,
|
|
||||||
CreateCircuitDeviceRowInputDto,
|
|
||||||
UpdateCircuitDeviceRowInputDto,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
||||||
|
import {
|
||||||
|
createCircuitUpdateProjectCommand,
|
||||||
|
type CircuitUpdatePatch,
|
||||||
|
} from "../../domain/models/circuit-project-command.model";
|
||||||
|
import {
|
||||||
|
createCircuitDeviceRowUpdateProjectCommand,
|
||||||
|
type CircuitDeviceRowUpdatePatch,
|
||||||
|
} from "../../domain/models/circuit-device-row-project-command.model";
|
||||||
|
import type {
|
||||||
|
CircuitDeviceRowSnapshot,
|
||||||
|
} from "../../domain/models/circuit-device-row-structure-project-command.model";
|
||||||
|
import type {
|
||||||
|
CircuitSnapshot,
|
||||||
|
} from "../../domain/models/circuit-structure-project-command.model";
|
||||||
|
|
||||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -154,38 +161,55 @@ export function getCircuitTree(projectId: string, circuitListId: string) {
|
|||||||
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
|
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCircuit(projectId: string, circuitListId: string, input: CreateCircuitInputDto) {
|
export function updateCircuitById(
|
||||||
return request(`/api/projects/${projectId}/circuit-lists/${circuitListId}/circuits`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCircuitWithDeviceRows(
|
|
||||||
projectId: string,
|
projectId: string,
|
||||||
circuitListId: string,
|
expectedRevision: number,
|
||||||
input: {
|
circuitId: string,
|
||||||
circuit: CreateCircuitInputDto;
|
input: CircuitUpdatePatch
|
||||||
deviceRows: CreateCircuitDeviceRowInputDto[];
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
return request<{
|
return executeProjectCommand(
|
||||||
circuit: CircuitTreeCircuitDto;
|
projectId,
|
||||||
deviceRows: CircuitTreeDeviceRowDto[];
|
expectedRevision,
|
||||||
}>(
|
createCircuitUpdateProjectCommand(circuitId, input),
|
||||||
`/api/projects/${projectId}/circuit-lists/${circuitListId}/circuits-with-device-rows`,
|
"Stromkreisfeld bearbeiten"
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateCircuitById(circuitId: string, input: UpdateCircuitInputDto) {
|
export function insertCircuitCommand(
|
||||||
return request(`/api/circuits/${circuitId}`, {
|
projectId: string,
|
||||||
method: "PATCH",
|
expectedRevision: number,
|
||||||
body: JSON.stringify(input),
|
circuit: CircuitSnapshot,
|
||||||
});
|
description: string
|
||||||
|
) {
|
||||||
|
return executeProjectCommand(
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.insert",
|
||||||
|
payload: { circuit },
|
||||||
|
},
|
||||||
|
description
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCircuitCommand(
|
||||||
|
projectId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
circuitId: string,
|
||||||
|
expectedCircuitListId: string,
|
||||||
|
description: string
|
||||||
|
) {
|
||||||
|
return executeProjectCommand(
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit.delete",
|
||||||
|
payload: { circuitId, expectedCircuitListId },
|
||||||
|
},
|
||||||
|
description
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteCircuitById(circuitId: string) {
|
export function deleteCircuitById(circuitId: string) {
|
||||||
@@ -200,24 +224,55 @@ export function getNextCircuitIdentifier(sectionId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCircuitDeviceRow(circuitId: string, input: CreateCircuitDeviceRowInputDto) {
|
export function updateCircuitDeviceRowById(
|
||||||
return request(`/api/circuits/${circuitId}/device-rows`, {
|
projectId: string,
|
||||||
method: "POST",
|
expectedRevision: number,
|
||||||
body: JSON.stringify(input),
|
rowId: string,
|
||||||
});
|
input: CircuitDeviceRowUpdatePatch
|
||||||
|
) {
|
||||||
|
return executeProjectCommand(
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
createCircuitDeviceRowUpdateProjectCommand(rowId, input),
|
||||||
|
"Gerätezeilenfeld bearbeiten"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateCircuitDeviceRowById(rowId: string, input: UpdateCircuitDeviceRowInputDto) {
|
export function insertCircuitDeviceRowCommand(
|
||||||
return request(`/api/circuit-device-rows/${rowId}`, {
|
projectId: string,
|
||||||
method: "PATCH",
|
expectedRevision: number,
|
||||||
body: JSON.stringify(input),
|
row: CircuitDeviceRowSnapshot,
|
||||||
});
|
description: string
|
||||||
|
) {
|
||||||
|
return executeProjectCommand(
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit-device-row.insert",
|
||||||
|
payload: { row },
|
||||||
|
},
|
||||||
|
description
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteCircuitDeviceRowById(rowId: string) {
|
export function deleteCircuitDeviceRowCommand(
|
||||||
return request<void>(`/api/circuit-device-rows/${rowId}`, {
|
projectId: string,
|
||||||
method: "DELETE",
|
expectedRevision: number,
|
||||||
});
|
rowId: string,
|
||||||
|
expectedCircuitId: string,
|
||||||
|
description: string
|
||||||
|
) {
|
||||||
|
return executeProjectCommand(
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "circuit-device-row.delete",
|
||||||
|
payload: { rowId, expectedCircuitId },
|
||||||
|
},
|
||||||
|
description
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveCircuitDeviceRowById(
|
export function moveCircuitDeviceRowById(
|
||||||
@@ -322,34 +377,58 @@ export function listProjectDevices(projectId: string) {
|
|||||||
return request<ProjectDeviceDto[]>(`/api/project-devices/projects/${projectId}`);
|
return request<ProjectDeviceDto[]>(`/api/project-devices/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProjectDevice(projectId: string, input: CreateProjectDeviceInput) {
|
export function createProjectDevice(
|
||||||
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}`, {
|
projectId: string,
|
||||||
|
input: CreateProjectDeviceInput,
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return request<ProjectDeviceCommandResultDto>(`/api/project-devices/projects/${projectId}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify({ ...input, expectedRevision }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateProjectDevice(
|
export function updateProjectDevice(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
projectDeviceId: string,
|
projectDeviceId: string,
|
||||||
input: CreateProjectDeviceInput
|
input: CreateProjectDeviceInput,
|
||||||
|
expectedRevision: number
|
||||||
) {
|
) {
|
||||||
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, {
|
return request<ProjectDeviceCommandResultDto>(
|
||||||
|
`/api/project-devices/projects/${projectId}/${projectDeviceId}`,
|
||||||
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify({ ...input, expectedRevision }),
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteProjectDevice(projectId: string, projectDeviceId: string) {
|
export function deleteProjectDevice(
|
||||||
return request<void>(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, {
|
projectId: string,
|
||||||
|
projectDeviceId: string,
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return request<ProjectCommandResultDto>(
|
||||||
|
`/api/project-devices/projects/${projectId}/${projectDeviceId}`,
|
||||||
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
body: JSON.stringify({ expectedRevision }),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function copyGlobalDeviceToProject(projectId: string, globalDeviceId: string) {
|
export function copyGlobalDeviceToProject(
|
||||||
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}/import-global/${globalDeviceId}`, {
|
projectId: string,
|
||||||
|
globalDeviceId: string,
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return request<ProjectDeviceCommandResultDto>(
|
||||||
|
`/api/project-devices/projects/${projectId}/import-global/${globalDeviceId}`,
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
body: JSON.stringify({ expectedRevision }),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProjectDeviceSyncPreview(projectId: string, projectDeviceId: string) {
|
export function getProjectDeviceSyncPreview(projectId: string, projectDeviceId: string) {
|
||||||
@@ -362,13 +441,14 @@ export function synchronizeProjectDeviceRows(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
projectDeviceId: string,
|
projectDeviceId: string,
|
||||||
rowIds: string[],
|
rowIds: string[],
|
||||||
fields: ProjectDeviceSyncField[]
|
fields: ProjectDeviceSyncField[],
|
||||||
|
expectedRevision: number
|
||||||
) {
|
) {
|
||||||
return request<ProjectDeviceSyncResultDto>(
|
return request<ProjectDeviceSyncCommandResultDto>(
|
||||||
`/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`,
|
`/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ rowIds, fields }),
|
body: JSON.stringify({ rowIds, fields, expectedRevision }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -376,41 +456,14 @@ export function synchronizeProjectDeviceRows(
|
|||||||
export function disconnectProjectDeviceRows(
|
export function disconnectProjectDeviceRows(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
projectDeviceId: string,
|
projectDeviceId: string,
|
||||||
rowIds: string[]
|
rowIds: string[],
|
||||||
|
expectedRevision: number
|
||||||
) {
|
) {
|
||||||
return request<ProjectDeviceDisconnectResultDto>(
|
return request<ProjectDeviceSyncCommandResultDto>(
|
||||||
`/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`,
|
`/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ rowIds }),
|
body: JSON.stringify({ rowIds, expectedRevision }),
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function restoreProjectDeviceRows(
|
|
||||||
projectId: string,
|
|
||||||
projectDeviceId: string,
|
|
||||||
rows: ProjectDeviceSyncRestoreRowDto[]
|
|
||||||
) {
|
|
||||||
return request<ProjectDeviceSyncPreviewDto>(
|
|
||||||
`/api/project-devices/projects/${projectId}/${projectDeviceId}/restore`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ rows }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reconnectProjectDeviceRows(
|
|
||||||
projectId: string,
|
|
||||||
projectDeviceId: string,
|
|
||||||
rowIds: string[]
|
|
||||||
) {
|
|
||||||
return request<ProjectDeviceSyncPreviewDto>(
|
|
||||||
`/api/project-devices/projects/${projectId}/${projectDeviceId}/reconnect`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ rowIds }),
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
|
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
|
||||||
|
import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js";
|
||||||
|
import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js";
|
||||||
|
|
||||||
export type CellKey =
|
export type CellKey =
|
||||||
| "equipmentIdentifier"
|
| "equipmentIdentifier"
|
||||||
@@ -189,6 +191,78 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildCircuitEditPatch(
|
||||||
|
key: CellKey,
|
||||||
|
draft: string
|
||||||
|
): CircuitUpdatePatch {
|
||||||
|
if (key === "protectionSummary" || key === "cableSummary") {
|
||||||
|
return { remark: draft.trim() || null };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
key === "protectionRatedCurrent" ||
|
||||||
|
key === "cableLength" ||
|
||||||
|
key === "voltage"
|
||||||
|
) {
|
||||||
|
return { [key]: parseNumeric(key, draft) ?? null };
|
||||||
|
}
|
||||||
|
if (key === "isReserve") {
|
||||||
|
const normalized = draft.trim().toLowerCase();
|
||||||
|
return {
|
||||||
|
isReserve: ["1", "true", "yes", "ja"].includes(normalized),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (key === "equipmentIdentifier") {
|
||||||
|
return { equipmentIdentifier: draft.trim() };
|
||||||
|
}
|
||||||
|
return { [key]: draft.trim() || null } as CircuitUpdatePatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDeviceRowEditPatch(
|
||||||
|
key: CellKey,
|
||||||
|
draft: string
|
||||||
|
): CircuitDeviceRowUpdatePatch {
|
||||||
|
if (
|
||||||
|
key === "quantity" ||
|
||||||
|
key === "powerPerUnit" ||
|
||||||
|
key === "simultaneityFactor"
|
||||||
|
) {
|
||||||
|
const value = parseNumeric(key, draft);
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error("Dieses Zahlenfeld darf nicht leer sein.");
|
||||||
|
}
|
||||||
|
return { [key]: value };
|
||||||
|
}
|
||||||
|
if (key === "cosPhi") {
|
||||||
|
return { cosPhi: parseNumeric(key, draft) ?? null };
|
||||||
|
}
|
||||||
|
if (key === "roomSummary") {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return {
|
||||||
|
roomNumberSnapshot: null,
|
||||||
|
roomNameSnapshot: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const firstSpace = trimmed.indexOf(" ");
|
||||||
|
return firstSpace > 0
|
||||||
|
? {
|
||||||
|
roomNumberSnapshot: trimmed.slice(0, firstSpace).trim(),
|
||||||
|
roomNameSnapshot: trimmed.slice(firstSpace + 1).trim() || null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
roomNumberSnapshot: trimmed,
|
||||||
|
roomNameSnapshot: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (key === "technicalName") {
|
||||||
|
return { name: draft.trim() };
|
||||||
|
}
|
||||||
|
if (key === "displayName") {
|
||||||
|
return { displayName: draft.trim() };
|
||||||
|
}
|
||||||
|
return { [key]: draft.trim() || null } as CircuitDeviceRowUpdatePatch;
|
||||||
|
}
|
||||||
|
|
||||||
export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): GridValue {
|
export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): GridValue {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "technicalName": return device.name;
|
case "technicalName": return device.name;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type {
|
||||||
|
CircuitDeviceRowSnapshot,
|
||||||
|
} from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
CircuitSnapshot,
|
||||||
|
} from "../../domain/models/circuit-structure-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
CreateCircuitDeviceRowInputDto,
|
||||||
|
CreateCircuitInputDto,
|
||||||
|
} from "../types.js";
|
||||||
|
|
||||||
|
export function buildCircuitDeviceRowInsertSnapshot(input: {
|
||||||
|
id: string;
|
||||||
|
circuitId: string;
|
||||||
|
values: CreateCircuitDeviceRowInputDto;
|
||||||
|
defaultSortOrder: number;
|
||||||
|
}): CircuitDeviceRowSnapshot {
|
||||||
|
const { id, circuitId, values, defaultSortOrder } = input;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
circuitId,
|
||||||
|
linkedProjectDeviceId: values.linkedProjectDeviceId ?? null,
|
||||||
|
legacyConsumerId: null,
|
||||||
|
sortOrder: values.sortOrder ?? defaultSortOrder,
|
||||||
|
name: values.name,
|
||||||
|
displayName: values.displayName,
|
||||||
|
phaseType: values.phaseType ?? null,
|
||||||
|
connectionKind: values.connectionKind ?? null,
|
||||||
|
costGroup: values.costGroup ?? null,
|
||||||
|
category: values.category ?? null,
|
||||||
|
level: values.level ?? null,
|
||||||
|
roomId: values.roomId ?? null,
|
||||||
|
roomNumberSnapshot: values.roomNumberSnapshot ?? null,
|
||||||
|
roomNameSnapshot: values.roomNameSnapshot ?? null,
|
||||||
|
quantity: values.quantity,
|
||||||
|
powerPerUnit: values.powerPerUnit,
|
||||||
|
simultaneityFactor: values.simultaneityFactor,
|
||||||
|
cosPhi: values.cosPhi ?? null,
|
||||||
|
remark: values.remark ?? null,
|
||||||
|
overriddenFields: values.overriddenFields ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCircuitInsertSnapshot(input: {
|
||||||
|
id: string;
|
||||||
|
circuitListId: string;
|
||||||
|
values: CreateCircuitInputDto;
|
||||||
|
deviceRows: CircuitDeviceRowSnapshot[];
|
||||||
|
}): CircuitSnapshot {
|
||||||
|
const { id, circuitListId, values, deviceRows } = input;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
circuitListId,
|
||||||
|
sectionId: values.sectionId,
|
||||||
|
equipmentIdentifier: values.equipmentIdentifier,
|
||||||
|
displayName: values.displayName ?? null,
|
||||||
|
sortOrder: values.sortOrder,
|
||||||
|
protectionType: values.protectionType ?? null,
|
||||||
|
protectionRatedCurrent: values.protectionRatedCurrent ?? null,
|
||||||
|
protectionCharacteristic: values.protectionCharacteristic ?? null,
|
||||||
|
cableType: values.cableType ?? null,
|
||||||
|
cableCrossSection: values.cableCrossSection ?? null,
|
||||||
|
cableLength: values.cableLength ?? null,
|
||||||
|
rcdAssignment: values.rcdAssignment ?? null,
|
||||||
|
terminalDesignation: values.terminalDesignation ?? null,
|
||||||
|
voltage: values.voltage ?? null,
|
||||||
|
controlRequirement: values.controlRequirement ?? null,
|
||||||
|
status: values.status ?? null,
|
||||||
|
isReserve: deviceRows.length === 0,
|
||||||
|
remark: values.remark ?? null,
|
||||||
|
deviceRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,8 +3,13 @@ import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/
|
|||||||
import { CircuitDeviceRowMoveProjectCommandRepository } from "../../db/repositories/circuit-device-row-move-project-command.repository.js";
|
import { CircuitDeviceRowMoveProjectCommandRepository } from "../../db/repositories/circuit-device-row-move-project-command.repository.js";
|
||||||
import { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-project-command.repository.js";
|
import { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-project-command.repository.js";
|
||||||
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
|
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
|
||||||
|
import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||||
|
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||||
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
|
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
|
||||||
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
|
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
|
||||||
|
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
|
||||||
|
import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js";
|
||||||
|
import { ProjectDeviceStructureProjectCommandRepository } from "../../db/repositories/project-device-structure-project-command.repository.js";
|
||||||
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
||||||
|
|
||||||
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||||
@@ -16,6 +21,16 @@ export const circuitDeviceRowMoveProjectCommandStore =
|
|||||||
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
||||||
export const circuitStructureProjectCommandStore =
|
export const circuitStructureProjectCommandStore =
|
||||||
new CircuitStructureProjectCommandRepository(db);
|
new CircuitStructureProjectCommandRepository(db);
|
||||||
|
export const circuitSectionReorderProjectCommandStore =
|
||||||
|
new CircuitSectionReorderProjectCommandRepository(db);
|
||||||
|
export const circuitSectionRenumberProjectCommandStore =
|
||||||
|
new CircuitSectionRenumberProjectCommandRepository(db);
|
||||||
|
export const projectDeviceStructureProjectCommandStore =
|
||||||
|
new ProjectDeviceStructureProjectCommandRepository(db);
|
||||||
|
export const projectDeviceProjectCommandStore =
|
||||||
|
new ProjectDeviceProjectCommandRepository(db);
|
||||||
|
export const projectDeviceRowSyncProjectCommandStore =
|
||||||
|
new ProjectDeviceRowSyncProjectCommandRepository(db);
|
||||||
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
||||||
export const projectCommandService = new ProjectCommandService(
|
export const projectCommandService = new ProjectCommandService(
|
||||||
circuitProjectCommandStore,
|
circuitProjectCommandStore,
|
||||||
@@ -23,5 +38,10 @@ export const projectCommandService = new ProjectCommandService(
|
|||||||
circuitDeviceRowStructureProjectCommandStore,
|
circuitDeviceRowStructureProjectCommandStore,
|
||||||
circuitDeviceRowMoveProjectCommandStore,
|
circuitDeviceRowMoveProjectCommandStore,
|
||||||
circuitStructureProjectCommandStore,
|
circuitStructureProjectCommandStore,
|
||||||
|
circuitSectionReorderProjectCommandStore,
|
||||||
|
circuitSectionRenumberProjectCommandStore,
|
||||||
|
projectDeviceStructureProjectCommandStore,
|
||||||
|
projectDeviceProjectCommandStore,
|
||||||
|
projectDeviceRowSyncProjectCommandStore,
|
||||||
projectHistoryStore
|
projectHistoryStore
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import { db } from "../../db/client.js";
|
|
||||||
import { ProjectDeviceRowSyncRepository } from "../../db/repositories/project-device-row-sync.repository.js";
|
|
||||||
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js";
|
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js";
|
||||||
|
|
||||||
export const projectDeviceSyncService = new ProjectDeviceSyncService({
|
export const projectDeviceSyncService = new ProjectDeviceSyncService();
|
||||||
deviceRowSyncStore: new ProjectDeviceRowSyncRepository(db),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,64 +1,10 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { circuitWriteService } from "../composition/circuit-write-service.js";
|
import { circuitWriteService } from "../composition/circuit-write-service.js";
|
||||||
import {
|
import {
|
||||||
createCircuitDeviceRowSchema,
|
|
||||||
moveCircuitDeviceRowsBulkSchema,
|
moveCircuitDeviceRowsBulkSchema,
|
||||||
moveCircuitDeviceRowSchema,
|
moveCircuitDeviceRowSchema,
|
||||||
updateCircuitDeviceRowSchema,
|
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
} from "../../shared/validation/circuit.schemas.js";
|
||||||
|
|
||||||
export async function createCircuitDeviceRow(req: Request, res: Response) {
|
|
||||||
const { circuitId } = req.params;
|
|
||||||
if (typeof circuitId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid circuitId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = createCircuitDeviceRowSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = await circuitWriteService.createDeviceRow(circuitId, parsed.data);
|
|
||||||
return res.status(201).json(created);
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to create device row." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateCircuitDeviceRow(req: Request, res: Response) {
|
|
||||||
const { rowId } = req.params;
|
|
||||||
if (typeof rowId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid rowId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = updateCircuitDeviceRowSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await circuitWriteService.updateDeviceRow(rowId, parsed.data);
|
|
||||||
return res.json(updated);
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update device row." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteCircuitDeviceRow(req: Request, res: Response) {
|
|
||||||
const { rowId } = req.params;
|
|
||||||
if (typeof rowId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid rowId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await circuitWriteService.deleteDeviceRow(rowId);
|
|
||||||
return res.status(204).send();
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to delete device row." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function moveCircuitDeviceRow(req: Request, res: Response) {
|
export async function moveCircuitDeviceRow(req: Request, res: Response) {
|
||||||
const { rowId } = req.params;
|
const { rowId } = req.params;
|
||||||
if (typeof rowId !== "string") {
|
if (typeof rowId !== "string") {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
|||||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||||
|
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
||||||
import {
|
import {
|
||||||
calculateCircuitTotalPower,
|
calculateCircuitTotalPower,
|
||||||
calculateRowTotalPower,
|
calculateRowTotalPower,
|
||||||
@@ -13,6 +14,7 @@ const circuitListRepository = new CircuitListRepository();
|
|||||||
const circuitSectionRepository = new CircuitSectionRepository();
|
const circuitSectionRepository = new CircuitSectionRepository();
|
||||||
const circuitRepository = new CircuitRepository();
|
const circuitRepository = new CircuitRepository();
|
||||||
const circuitDeviceRowRepository = new CircuitDeviceRowRepository();
|
const circuitDeviceRowRepository = new CircuitDeviceRowRepository();
|
||||||
|
const projectRepository = new ProjectRepository();
|
||||||
|
|
||||||
export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
|
export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
|
||||||
if (!(error instanceof Error)) {
|
if (!(error instanceof Error)) {
|
||||||
@@ -35,6 +37,10 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
if (!list) {
|
if (!list) {
|
||||||
return res.status(404).json({ error: "Circuit list not found" });
|
return res.status(404).json({ error: "Circuit list not found" });
|
||||||
}
|
}
|
||||||
|
const project = await projectRepository.findById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
||||||
@@ -52,6 +58,7 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
|
|
||||||
const tree: CircuitTreeResponse = {
|
const tree: CircuitTreeResponse = {
|
||||||
circuitListId,
|
circuitListId,
|
||||||
|
currentRevision: project.currentRevision,
|
||||||
sections: sections.map((section) => ({
|
sections: sections.map((section) => ({
|
||||||
id: section.id,
|
id: section.id,
|
||||||
key: section.key,
|
key: section.key,
|
||||||
@@ -123,6 +130,7 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
if (isMissingCircuitTreeSchemaError(error)) {
|
if (isMissingCircuitTreeSchemaError(error)) {
|
||||||
return res.json({
|
return res.json({
|
||||||
circuitListId,
|
circuitListId,
|
||||||
|
currentRevision: project.currentRevision,
|
||||||
sections: [],
|
sections: [],
|
||||||
warning:
|
warning:
|
||||||
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
||||||
|
|||||||
@@ -1,76 +1,5 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { circuitWriteService } from "../composition/circuit-write-service.js";
|
import { circuitWriteService } from "../composition/circuit-write-service.js";
|
||||||
import {
|
|
||||||
createCircuitSchema,
|
|
||||||
createCircuitWithDeviceRowsSchema,
|
|
||||||
updateCircuitSchema,
|
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
|
||||||
|
|
||||||
export async function createCircuit(req: Request, res: Response) {
|
|
||||||
const { projectId, circuitListId } = req.params;
|
|
||||||
if (typeof projectId !== "string" || typeof circuitListId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid parameters" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = createCircuitSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = await circuitWriteService.createCircuit(projectId, circuitListId, parsed.data);
|
|
||||||
return res.status(201).json(created);
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to create circuit." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createCircuitWithDeviceRows(req: Request, res: Response) {
|
|
||||||
const { projectId, circuitListId } = req.params;
|
|
||||||
if (typeof projectId !== "string" || typeof circuitListId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Ungültige Parameter." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = createCircuitWithDeviceRowsSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = await circuitWriteService.createCircuitWithDeviceRows(
|
|
||||||
projectId,
|
|
||||||
circuitListId,
|
|
||||||
parsed.data
|
|
||||||
);
|
|
||||||
return res.status(201).json(created);
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Stromkreis und Gerätezeilen konnten nicht erstellt werden.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateCircuit(req: Request, res: Response) {
|
|
||||||
const { circuitId } = req.params;
|
|
||||||
if (typeof circuitId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid circuitId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = updateCircuitSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await circuitWriteService.updateCircuit(circuitId, parsed.data);
|
|
||||||
return res.json(updated);
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update circuit." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteCircuit(req: Request, res: Response) {
|
export async function deleteCircuit(req: Request, res: Response) {
|
||||||
const { circuitId } = req.params;
|
const { circuitId } = req.params;
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
||||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||||
import { projectDeviceSyncService } from "../composition/project-device-sync-service.js";
|
import { createProjectDeviceUpdateProjectCommand } from "../../domain/models/project-device-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
createProjectDeviceSchema,
|
createProjectDeviceDeleteProjectCommand,
|
||||||
|
createProjectDeviceInsertProjectCommand,
|
||||||
|
type ProjectDeviceSnapshot,
|
||||||
|
} from "../../domain/models/project-device-structure-project-command.model.js";
|
||||||
|
import { projectDeviceSyncService } from "../composition/project-device-sync-service.js";
|
||||||
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
|
import {
|
||||||
|
createProjectDeviceCommandSchema,
|
||||||
disconnectProjectDeviceRowsSchema,
|
disconnectProjectDeviceRowsSchema,
|
||||||
reconnectProjectDeviceRowsSchema,
|
projectDeviceStructureCommandSchema,
|
||||||
restoreProjectDeviceRowsSchema,
|
|
||||||
synchronizeProjectDeviceRowsSchema,
|
synchronizeProjectDeviceRowsSchema,
|
||||||
updateProjectDeviceSchema,
|
updateProjectDeviceCommandSchema,
|
||||||
} from "../../shared/validation/project-device.schemas.js";
|
} from "../../shared/validation/project-device.schemas.js";
|
||||||
|
import type { CreateProjectDeviceInput } from "../../shared/validation/project-device.schemas.js";
|
||||||
|
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||||
|
|
||||||
const globalDeviceRepository = new GlobalDeviceRepository();
|
const globalDeviceRepository = new GlobalDeviceRepository();
|
||||||
const projectDeviceRepository = new ProjectDeviceRepository();
|
const projectDeviceRepository = new ProjectDeviceRepository();
|
||||||
@@ -27,12 +36,28 @@ export async function createProjectDevice(req: Request, res: Response) {
|
|||||||
if (typeof projectId !== "string") {
|
if (typeof projectId !== "string") {
|
||||||
return res.status(400).json({ error: "Invalid projectId" });
|
return res.status(400).json({ error: "Invalid projectId" });
|
||||||
}
|
}
|
||||||
const parsed = createProjectDeviceSchema.safeParse(req.body);
|
const parsed = createProjectDeviceCommandSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
const created = await projectDeviceRepository.create(projectId, parsed.data);
|
const { expectedRevision, ...input } = parsed.data;
|
||||||
return res.status(201).json(created);
|
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), input);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
description: "Projektgerät anlegen",
|
||||||
|
command: createProjectDeviceInsertProjectCommand(projectDevice),
|
||||||
|
});
|
||||||
|
const created = await projectDeviceRepository.findById(projectId, projectDevice.id);
|
||||||
|
if (!created) {
|
||||||
|
throw new Error("Created project device could not be loaded.");
|
||||||
|
}
|
||||||
|
return res.status(201).json({ ...result, projectDevice: created });
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProjectDevice(req: Request, res: Response) {
|
export async function updateProjectDevice(req: Request, res: Response) {
|
||||||
@@ -41,19 +66,32 @@ export async function updateProjectDevice(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: "Invalid parameters" });
|
return res.status(400).json({ error: "Invalid parameters" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = updateProjectDeviceSchema.safeParse(req.body);
|
const parsed = updateProjectDeviceCommandSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
|
const { expectedRevision, ...input } = parsed.data;
|
||||||
|
|
||||||
await projectDeviceRepository.update(projectId, projectDeviceId, parsed.data);
|
try {
|
||||||
|
const result = projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
description: "Projektgerät bearbeiten",
|
||||||
|
command: createProjectDeviceUpdateProjectCommand(
|
||||||
|
projectDeviceId,
|
||||||
|
toProjectDeviceValues(input)
|
||||||
|
),
|
||||||
|
});
|
||||||
// Linked rows are synchronized only through the explicit review flow.
|
// Linked rows are synchronized only through the explicit review flow.
|
||||||
// Updating a project device must never silently overwrite local circuit-list values.
|
// Updating a project device must never silently overwrite local circuit-list values.
|
||||||
const row = await projectDeviceRepository.findById(projectId, projectDeviceId);
|
const projectDevice = await projectDeviceRepository.findById(projectId, projectDeviceId);
|
||||||
if (!row) {
|
if (!projectDevice) {
|
||||||
return res.status(404).json({ error: "Project device not found" });
|
throw new Error("Updated project device could not be loaded.");
|
||||||
|
}
|
||||||
|
return res.json({ ...result, projectDevice });
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
}
|
}
|
||||||
return res.json(row);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteProjectDevice(req: Request, res: Response) {
|
export async function deleteProjectDevice(req: Request, res: Response) {
|
||||||
@@ -62,8 +100,23 @@ export async function deleteProjectDevice(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: "Invalid parameters" });
|
return res.status(400).json({ error: "Invalid parameters" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await projectDeviceRepository.delete(projectId, projectDeviceId);
|
const parsed = projectDeviceStructureCommandSchema.safeParse(req.body);
|
||||||
return res.status(204).send();
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return res.json(
|
||||||
|
projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision: parsed.data.expectedRevision,
|
||||||
|
description: "Projektgerät löschen",
|
||||||
|
command: createProjectDeviceDeleteProjectCommand(projectDeviceId, projectId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
||||||
@@ -71,13 +124,17 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
|||||||
if (typeof projectId !== "string" || typeof globalDeviceId !== "string") {
|
if (typeof projectId !== "string" || typeof globalDeviceId !== "string") {
|
||||||
return res.status(400).json({ error: "Invalid parameters" });
|
return res.status(400).json({ error: "Invalid parameters" });
|
||||||
}
|
}
|
||||||
|
const parsed = projectDeviceStructureCommandSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
const source = await globalDeviceRepository.findById(globalDeviceId);
|
const source = await globalDeviceRepository.findById(globalDeviceId);
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return res.status(404).json({ error: "Global device not found" });
|
return res.status(404).json({ error: "Global device not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await projectDeviceRepository.create(projectId, {
|
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), {
|
||||||
name: source.name,
|
name: source.name,
|
||||||
displayName: source.displayName,
|
displayName: source.displayName,
|
||||||
phaseType: source.phaseCount === 3 ? "three_phase" : "single_phase",
|
phaseType: source.phaseCount === 3 ? "three_phase" : "single_phase",
|
||||||
@@ -90,7 +147,50 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
|||||||
voltageV: source.voltageV ?? undefined,
|
voltageV: source.voltageV ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(201).json(created);
|
try {
|
||||||
|
const result = projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision: parsed.data.expectedRevision,
|
||||||
|
description: "Globales Gerät ins Projekt übernehmen",
|
||||||
|
command: createProjectDeviceInsertProjectCommand(projectDevice),
|
||||||
|
});
|
||||||
|
const created = await projectDeviceRepository.findById(projectId, projectDevice.id);
|
||||||
|
if (!created) {
|
||||||
|
throw new Error("Imported project device could not be loaded.");
|
||||||
|
}
|
||||||
|
return res.status(201).json({ ...result, projectDevice: created });
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProjectDeviceSnapshot(
|
||||||
|
projectId: string,
|
||||||
|
id: string,
|
||||||
|
input: CreateProjectDeviceInput
|
||||||
|
): ProjectDeviceSnapshot {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
projectId,
|
||||||
|
...toProjectDeviceValues(input),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProjectDeviceValues(input: CreateProjectDeviceInput) {
|
||||||
|
return {
|
||||||
|
name: input.name,
|
||||||
|
displayName: input.displayName,
|
||||||
|
phaseType: input.phaseType,
|
||||||
|
connectionKind: input.connectionKind ?? null,
|
||||||
|
costGroup: input.costGroup ?? null,
|
||||||
|
category: input.category ?? null,
|
||||||
|
quantity: input.quantity,
|
||||||
|
powerPerUnit: input.powerPerUnit,
|
||||||
|
simultaneityFactor: input.simultaneityFactor,
|
||||||
|
cosPhi: input.cosPhi ?? null,
|
||||||
|
remark: input.remark ?? null,
|
||||||
|
voltageV: input.voltageV ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProjectDeviceSyncPreview(req: Request, res: Response) {
|
export async function getProjectDeviceSyncPreview(req: Request, res: Response) {
|
||||||
@@ -116,32 +216,25 @@ export async function synchronizeProjectDeviceRows(req: Request, res: Response)
|
|||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await projectDeviceSyncService.synchronize(
|
const command = await projectDeviceSyncService.createSynchronizeCommand(
|
||||||
projectId,
|
projectId,
|
||||||
projectDeviceId,
|
projectDeviceId,
|
||||||
parsed.data.rowIds,
|
parsed.data.rowIds,
|
||||||
parsed.data.fields
|
parsed.data.fields
|
||||||
);
|
);
|
||||||
return res.json(result);
|
const result = projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision: parsed.data.expectedRevision,
|
||||||
|
description: "Projektgerät in Stromkreiszeilen synchronisieren",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
const preview = await projectDeviceSyncService.getPreview(
|
||||||
|
projectId,
|
||||||
|
projectDeviceId
|
||||||
|
);
|
||||||
|
return res.json({ ...result, preview });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to synchronize rows." });
|
return respondWithProjectCommandError(error, res);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function restoreProjectDeviceRows(req: Request, res: Response) {
|
|
||||||
const { projectId, projectDeviceId } = req.params;
|
|
||||||
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid parameters" });
|
|
||||||
}
|
|
||||||
const parsed = restoreProjectDeviceRowsSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const preview = await projectDeviceSyncService.restore(projectId, projectDeviceId, parsed.data.rows);
|
|
||||||
return res.json(preview);
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to restore rows." });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,26 +248,23 @@ export async function disconnectProjectDeviceRows(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await projectDeviceSyncService.disconnect(projectId, projectDeviceId, parsed.data.rowIds);
|
const command = await projectDeviceSyncService.createDisconnectCommand(
|
||||||
return res.json(result);
|
projectId,
|
||||||
|
projectDeviceId,
|
||||||
|
parsed.data.rowIds
|
||||||
|
);
|
||||||
|
const result = projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision: parsed.data.expectedRevision,
|
||||||
|
description: "Projektgerät-Verknüpfungen trennen",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
const preview = await projectDeviceSyncService.getPreview(
|
||||||
|
projectId,
|
||||||
|
projectDeviceId
|
||||||
|
);
|
||||||
|
return res.json({ ...result, preview });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to disconnect rows." });
|
return respondWithProjectCommandError(error, res);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reconnectProjectDeviceRows(req: Request, res: Response) {
|
|
||||||
const { projectId, projectDeviceId } = req.params;
|
|
||||||
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid parameters" });
|
|
||||||
}
|
|
||||||
const parsed = reconnectProjectDeviceRowsSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const preview = await projectDeviceSyncService.reconnect(projectId, projectDeviceId, parsed.data.rowIds);
|
|
||||||
return res.json(preview);
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to reconnect rows." });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
deleteCircuitDeviceRow,
|
|
||||||
moveCircuitDeviceRowsBulk,
|
moveCircuitDeviceRowsBulk,
|
||||||
moveCircuitDeviceRow,
|
moveCircuitDeviceRow,
|
||||||
updateCircuitDeviceRow,
|
|
||||||
} from "../controllers/circuit-device-row.controller.js";
|
} from "../controllers/circuit-device-row.controller.js";
|
||||||
|
|
||||||
export const circuitDeviceRowRouter = Router();
|
export const circuitDeviceRowRouter = Router();
|
||||||
|
|
||||||
circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk);
|
circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk);
|
||||||
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow);
|
|
||||||
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
|
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
|
||||||
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
|
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
createCircuit,
|
|
||||||
createCircuitWithDeviceRows,
|
|
||||||
deleteCircuit,
|
deleteCircuit,
|
||||||
getNextCircuitIdentifier,
|
getNextCircuitIdentifier,
|
||||||
updateCircuit,
|
|
||||||
} from "../controllers/circuit.controller.js";
|
} from "../controllers/circuit.controller.js";
|
||||||
import { createCircuitDeviceRow } from "../controllers/circuit-device-row.controller.js";
|
|
||||||
|
|
||||||
export const circuitRouter = Router();
|
export const circuitRouter = Router();
|
||||||
|
|
||||||
circuitRouter.post("/projects/:projectId/circuit-lists/:circuitListId/circuits", createCircuit);
|
|
||||||
circuitRouter.post(
|
|
||||||
"/projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows",
|
|
||||||
createCircuitWithDeviceRows
|
|
||||||
);
|
|
||||||
circuitRouter.patch("/circuits/:circuitId", updateCircuit);
|
|
||||||
circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
|
circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
|
||||||
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
|
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
|
||||||
circuitRouter.post("/circuits/:circuitId/device-rows", createCircuitDeviceRow);
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
disconnectProjectDeviceRows,
|
disconnectProjectDeviceRows,
|
||||||
getProjectDeviceSyncPreview,
|
getProjectDeviceSyncPreview,
|
||||||
listProjectDevicesByProject,
|
listProjectDevicesByProject,
|
||||||
reconnectProjectDeviceRows,
|
|
||||||
restoreProjectDeviceRows,
|
|
||||||
synchronizeProjectDeviceRows,
|
synchronizeProjectDeviceRows,
|
||||||
updateProjectDevice,
|
updateProjectDevice,
|
||||||
} from "../controllers/project-device.controller.js";
|
} from "../controllers/project-device.controller.js";
|
||||||
@@ -19,8 +17,6 @@ projectDeviceRouter.post("/projects/:projectId", createProjectDevice);
|
|||||||
projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject);
|
projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject);
|
||||||
projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview);
|
projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview);
|
||||||
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/synchronize", synchronizeProjectDeviceRows);
|
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/synchronize", synchronizeProjectDeviceRows);
|
||||||
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/restore", restoreProjectDeviceRows);
|
|
||||||
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/disconnect", disconnectProjectDeviceRows);
|
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/disconnect", disconnectProjectDeviceRows);
|
||||||
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/reconnect", reconnectProjectDeviceRows);
|
|
||||||
projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice);
|
projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice);
|
||||||
projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice);
|
projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice);
|
||||||
|
|||||||
@@ -1,60 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const createCircuitSchema = z.object({
|
|
||||||
sectionId: z.string().min(1),
|
|
||||||
equipmentIdentifier: z.string().min(1),
|
|
||||||
displayName: z.string().optional(),
|
|
||||||
sortOrder: z.number(),
|
|
||||||
protectionType: z.string().optional(),
|
|
||||||
protectionRatedCurrent: z.number().min(0).optional(),
|
|
||||||
protectionCharacteristic: z.string().optional(),
|
|
||||||
cableType: z.string().optional(),
|
|
||||||
cableCrossSection: z.string().optional(),
|
|
||||||
cableLength: z.number().min(0).optional(),
|
|
||||||
rcdAssignment: z.string().optional(),
|
|
||||||
terminalDesignation: z.string().optional(),
|
|
||||||
voltage: z.number().positive().optional(),
|
|
||||||
controlRequirement: z.string().optional(),
|
|
||||||
status: z.string().optional(),
|
|
||||||
isReserve: z.boolean().optional(),
|
|
||||||
remark: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateCircuitSchema = createCircuitSchema.partial().extend({
|
|
||||||
sectionId: z.string().min(1).optional(),
|
|
||||||
equipmentIdentifier: z.string().min(1).optional(),
|
|
||||||
sortOrder: z.number().optional(),
|
|
||||||
isReserve: z.boolean().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createCircuitDeviceRowSchema = z.object({
|
|
||||||
linkedProjectDeviceId: z.string().min(1).optional(),
|
|
||||||
name: z.string().min(1),
|
|
||||||
displayName: z.string().min(1),
|
|
||||||
phaseType: z.string().optional(),
|
|
||||||
connectionKind: z.string().optional(),
|
|
||||||
costGroup: z.string().optional(),
|
|
||||||
category: z.string().optional(),
|
|
||||||
level: z.string().optional(),
|
|
||||||
roomId: z.string().min(1).optional(),
|
|
||||||
roomNumberSnapshot: z.string().optional(),
|
|
||||||
roomNameSnapshot: z.string().optional(),
|
|
||||||
quantity: z.number().min(0),
|
|
||||||
powerPerUnit: z.number().min(0),
|
|
||||||
simultaneityFactor: z.number().min(0),
|
|
||||||
cosPhi: z.number().positive().optional(),
|
|
||||||
remark: z.string().optional(),
|
|
||||||
overriddenFields: z.string().optional(),
|
|
||||||
sortOrder: z.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createCircuitWithDeviceRowsSchema = z.object({
|
|
||||||
circuit: createCircuitSchema,
|
|
||||||
deviceRows: z.array(createCircuitDeviceRowSchema).min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateCircuitDeviceRowSchema = createCircuitDeviceRowSchema.partial();
|
|
||||||
|
|
||||||
export const moveCircuitDeviceRowSchema = z
|
export const moveCircuitDeviceRowSchema = z
|
||||||
.object({
|
.object({
|
||||||
targetCircuitId: z.string().min(1).optional(),
|
targetCircuitId: z.string().min(1).optional(),
|
||||||
@@ -97,11 +42,6 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({
|
|||||||
.min(1),
|
.min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateCircuitInput = z.infer<typeof createCircuitSchema>;
|
|
||||||
export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>;
|
|
||||||
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
|
|
||||||
export type CreateCircuitWithDeviceRowsInput = z.infer<typeof createCircuitWithDeviceRowsSchema>;
|
|
||||||
export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>;
|
|
||||||
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
|
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
|
||||||
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
|
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
|
||||||
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
|
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const expectedProjectRevisionSchema = z.number().int().nonnegative();
|
||||||
|
|
||||||
const projectCommandEnvelopeSchema = z.object({
|
const projectCommandEnvelopeSchema = z.object({
|
||||||
schemaVersion: z.number().int().positive(),
|
schemaVersion: z.number().int().positive(),
|
||||||
type: z.string().trim().min(1),
|
type: z.string().trim().min(1),
|
||||||
@@ -7,11 +9,11 @@ const projectCommandEnvelopeSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const executeProjectCommandSchema = z.object({
|
export const executeProjectCommandSchema = z.object({
|
||||||
expectedRevision: z.number().int().nonnegative(),
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
description: z.string().trim().min(1).max(500).optional(),
|
description: z.string().trim().min(1).max(500).optional(),
|
||||||
command: projectCommandEnvelopeSchema,
|
command: projectCommandEnvelopeSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const executeProjectHistoryCommandSchema = z.object({
|
export const executeProjectHistoryCommandSchema = z.object({
|
||||||
expectedRevision: z.number().int().nonnegative(),
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { projectDeviceSyncFields } from "../constants/project-device-sync-fields.js";
|
import { projectDeviceSyncFields } from "../constants/project-device-sync-fields.js";
|
||||||
|
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||||
|
|
||||||
export const createProjectDeviceSchema = z.object({
|
export const createProjectDeviceSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -19,42 +20,30 @@ export const createProjectDeviceSchema = z.object({
|
|||||||
|
|
||||||
export const updateProjectDeviceSchema = createProjectDeviceSchema;
|
export const updateProjectDeviceSchema = createProjectDeviceSchema;
|
||||||
|
|
||||||
|
export const createProjectDeviceCommandSchema =
|
||||||
|
createProjectDeviceSchema.extend({
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateProjectDeviceCommandSchema =
|
||||||
|
updateProjectDeviceSchema.extend({
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectDeviceStructureCommandSchema = z.object({
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export const synchronizeProjectDeviceRowsSchema = z.object({
|
export const synchronizeProjectDeviceRowsSchema = z.object({
|
||||||
rowIds: z.array(z.string().min(1)).min(1),
|
rowIds: z.array(z.string().min(1)).min(1),
|
||||||
fields: z.array(z.enum(projectDeviceSyncFields)).min(1),
|
fields: z.array(z.enum(projectDeviceSyncFields)).min(1),
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const disconnectProjectDeviceRowsSchema = z.object({
|
export const disconnectProjectDeviceRowsSchema = z.object({
|
||||||
rowIds: z.array(z.string().min(1)).min(1),
|
rowIds: z.array(z.string().min(1)).min(1),
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectDeviceRestoreValuesSchema = z.object({
|
|
||||||
name: z.string().optional(),
|
|
||||||
displayName: z.string().optional(),
|
|
||||||
phaseType: z.enum(["single_phase", "three_phase"]).optional(),
|
|
||||||
connectionKind: z.string().nullable().optional(),
|
|
||||||
costGroup: z.string().nullable().optional(),
|
|
||||||
category: z.string().nullable().optional(),
|
|
||||||
quantity: z.number().min(0).optional(),
|
|
||||||
powerPerUnit: z.number().min(0).optional(),
|
|
||||||
simultaneityFactor: z.number().min(0).max(1).optional(),
|
|
||||||
cosPhi: z.number().min(0).max(1).nullable().optional(),
|
|
||||||
remark: z.string().nullable().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const restoreProjectDeviceRowsSchema = z.object({
|
|
||||||
rows: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
rowId: z.string().min(1),
|
|
||||||
values: projectDeviceRestoreValuesSchema,
|
|
||||||
overriddenFields: z.array(z.enum(projectDeviceSyncFields)),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const reconnectProjectDeviceRowsSchema = disconnectProjectDeviceRowsSchema;
|
|
||||||
|
|
||||||
export type CreateProjectDeviceInput = z.infer<typeof createProjectDeviceSchema>;
|
export type CreateProjectDeviceInput = z.infer<typeof createProjectDeviceSchema>;
|
||||||
export type UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>;
|
export type UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
|
|||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import {
|
import {
|
||||||
allColumns,
|
allColumns,
|
||||||
|
buildCircuitEditPatch,
|
||||||
|
buildDeviceRowEditPatch,
|
||||||
getBlockSortValue,
|
getBlockSortValue,
|
||||||
getCellKind,
|
getCellKind,
|
||||||
getCircuitValue,
|
getCircuitValue,
|
||||||
@@ -85,4 +87,38 @@ describe("circuit grid model", () => {
|
|||||||
assert.equal(parseNumeric("quantity", ""), undefined);
|
assert.equal(parseNumeric("quantity", ""), undefined);
|
||||||
assert.throws(() => parseNumeric("quantity", "two"), /Ungültiger Zahlenwert/);
|
assert.throws(() => parseNumeric("quantity", "two"), /Ungültiger Zahlenwert/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds nullable circuit command patches from grid drafts", () => {
|
||||||
|
assert.deepEqual(buildCircuitEditPatch("voltage", ""), {
|
||||||
|
voltage: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
buildCircuitEditPatch("controlRequirement", " DALI "),
|
||||||
|
{ controlRequirement: "DALI" }
|
||||||
|
);
|
||||||
|
assert.deepEqual(buildCircuitEditPatch("isReserve", "ja"), {
|
||||||
|
isReserve: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds device command patches with explicit clearing semantics", () => {
|
||||||
|
assert.deepEqual(buildDeviceRowEditPatch("roomSummary", ""), {
|
||||||
|
roomNumberSnapshot: null,
|
||||||
|
roomNameSnapshot: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
buildDeviceRowEditPatch("roomSummary", "1.01 Büro"),
|
||||||
|
{
|
||||||
|
roomNumberSnapshot: "1.01",
|
||||||
|
roomNameSnapshot: "Büro",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.deepEqual(buildDeviceRowEditPatch("technicalName", " Leuchte "), {
|
||||||
|
name: "Leuchte",
|
||||||
|
});
|
||||||
|
assert.throws(
|
||||||
|
() => buildDeviceRowEditPatch("quantity", ""),
|
||||||
|
/darf nicht leer sein/
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ function createTestFixture(): TestFixture {
|
|||||||
displayName: "Licht Bestand",
|
displayName: "Licht Bestand",
|
||||||
sortOrder: 10,
|
sortOrder: 10,
|
||||||
protectionRatedCurrent: 10,
|
protectionRatedCurrent: 10,
|
||||||
|
voltage: 230,
|
||||||
|
controlRequirement: "none",
|
||||||
isReserve: 0,
|
isReserve: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -111,6 +113,8 @@ describe("circuit project-command repository", () => {
|
|||||||
const command = createCircuitUpdateProjectCommand("circuit-1", {
|
const command = createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
displayName: null,
|
displayName: null,
|
||||||
protectionRatedCurrent: 16,
|
protectionRatedCurrent: 16,
|
||||||
|
voltage: 400,
|
||||||
|
controlRequirement: "DALI",
|
||||||
isReserve: true,
|
isReserve: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,6 +129,8 @@ describe("circuit project-command repository", () => {
|
|||||||
const circuit = getCircuit(fixture.context);
|
const circuit = getCircuit(fixture.context);
|
||||||
assert.equal(circuit.displayName, null);
|
assert.equal(circuit.displayName, null);
|
||||||
assert.equal(circuit.protectionRatedCurrent, 16);
|
assert.equal(circuit.protectionRatedCurrent, 16);
|
||||||
|
assert.equal(circuit.voltage, 400);
|
||||||
|
assert.equal(circuit.controlRequirement, "DALI");
|
||||||
assert.equal(circuit.isReserve, 1);
|
assert.equal(circuit.isReserve, 1);
|
||||||
assert.equal(executed.revision.revisionNumber, 1);
|
assert.equal(executed.revision.revisionNumber, 1);
|
||||||
assert.deepEqual(executed.inverse.payload, {
|
assert.deepEqual(executed.inverse.payload, {
|
||||||
@@ -132,6 +138,8 @@ describe("circuit project-command repository", () => {
|
|||||||
changes: [
|
changes: [
|
||||||
{ field: "displayName", value: "Licht Bestand" },
|
{ field: "displayName", value: "Licht Bestand" },
|
||||||
{ field: "protectionRatedCurrent", value: 10 },
|
{ field: "protectionRatedCurrent", value: 10 },
|
||||||
|
{ field: "voltage", value: 230 },
|
||||||
|
{ field: "controlRequirement", value: "none" },
|
||||||
{ field: "isReserve", value: false },
|
{ field: "isReserve", value: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,454 @@
|
|||||||
|
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 { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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 { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
|
||||||
|
|
||||||
|
interface TestFixture {
|
||||||
|
context: DatabaseContext;
|
||||||
|
sectionId: string;
|
||||||
|
foreignSectionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestDatabase(): TestFixture {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values([
|
||||||
|
{ id: "project-1", name: "Test project" },
|
||||||
|
{ id: "project-2", name: "Other project" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
const boards = new DistributionBoardRepository(context.db);
|
||||||
|
const board = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-1",
|
||||||
|
"UV-01"
|
||||||
|
);
|
||||||
|
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-2",
|
||||||
|
"UV-02"
|
||||||
|
);
|
||||||
|
const ownSections = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.orderBy(asc(circuitSections.sortOrder))
|
||||||
|
.all();
|
||||||
|
const foreignSection = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, foreignBoard.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(ownSections[0]);
|
||||||
|
assert.ok(ownSections[1]);
|
||||||
|
assert.ok(foreignSection);
|
||||||
|
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: ownSections[0].id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-2",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: ownSections[0].id,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-3",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: ownSections[0].id,
|
||||||
|
equipmentIdentifier: "-1F3",
|
||||||
|
sortOrder: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-other-section",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: ownSections[1].id,
|
||||||
|
equipmentIdentifier: "-2F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-foreign",
|
||||||
|
circuitListId: foreignBoard.id,
|
||||||
|
sectionId: foreignSection.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.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,
|
||||||
|
sectionId: ownSections[0].id,
|
||||||
|
foreignSectionId: foreignSection.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSectionCircuitState(
|
||||||
|
context: DatabaseContext,
|
||||||
|
sectionId: string
|
||||||
|
) {
|
||||||
|
return context.db
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||||
|
sortOrder: circuits.sortOrder,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.sectionId, sectionId))
|
||||||
|
.orderBy(asc(circuits.id))
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSwapCommand(sectionId: string) {
|
||||||
|
return createCircuitSectionRenumberProjectCommand(sectionId, [
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedEquipmentIdentifier: "-1F1",
|
||||||
|
targetEquipmentIdentifier: "-1F2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedEquipmentIdentifier: "-1F2",
|
||||||
|
targetEquipmentIdentifier: "-1F1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-3",
|
||||||
|
expectedEquipmentIdentifier: "-1F3",
|
||||||
|
targetEquipmentIdentifier: "-1F3",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit section renumber project-command repository", () => {
|
||||||
|
it("swaps identifiers and supports persisted undo and redo", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new CircuitSectionRenumberProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const command = createSwapCommand(fixture.sectionId);
|
||||||
|
const renumbered = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
getSectionCircuitState(
|
||||||
|
fixture.context,
|
||||||
|
fixture.sectionId
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-2",
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-3",
|
||||||
|
equipmentIdentifier: "-1F3",
|
||||||
|
sortOrder: 30,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get()?.circuitId,
|
||||||
|
"circuit-1"
|
||||||
|
);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
renumbered.revision.changeSetId,
|
||||||
|
command: renumbered.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
getSectionCircuitState(
|
||||||
|
fixture.context,
|
||||||
|
fixture.sectionId
|
||||||
|
).map((circuit) => circuit.equipmentIdentifier),
|
||||||
|
["-1F1", "-1F2", "-1F3"]
|
||||||
|
);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
renumbered.revision.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
new ProjectHistoryRepository(fixture.context.db).getState(
|
||||||
|
"project-1"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 3,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: renumbered.revision.changeSetId,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects incomplete, stale and foreign-section assignments", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new CircuitSectionRenumberProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitSectionRenumberProjectCommand(
|
||||||
|
fixture.sectionId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedEquipmentIdentifier: "-1F1",
|
||||||
|
targetEquipmentIdentifier: "-1F2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedEquipmentIdentifier: "-1F2",
|
||||||
|
targetEquipmentIdentifier: "-1F1",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/every circuit/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitSectionRenumberProjectCommand(
|
||||||
|
fixture.sectionId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedEquipmentIdentifier: "-1F999",
|
||||||
|
targetEquipmentIdentifier: "-1F2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedEquipmentIdentifier: "-1F2",
|
||||||
|
targetEquipmentIdentifier: "-1F1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-3",
|
||||||
|
expectedEquipmentIdentifier: "-1F3",
|
||||||
|
targetEquipmentIdentifier: "-1F3",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/changed before/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitSectionRenumberProjectCommand(
|
||||||
|
fixture.foreignSectionId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-foreign",
|
||||||
|
expectedEquipmentIdentifier: "-1F1",
|
||||||
|
targetEquipmentIdentifier: "-1F2",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/does not belong to project/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all()
|
||||||
|
.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects target identifiers used by another section", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new CircuitSectionRenumberProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitSectionRenumberProjectCommand(
|
||||||
|
fixture.sectionId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedEquipmentIdentifier: "-1F1",
|
||||||
|
targetEquipmentIdentifier: "-2F1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedEquipmentIdentifier: "-1F2",
|
||||||
|
targetEquipmentIdentifier: "-1F1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-3",
|
||||||
|
expectedEquipmentIdentifier: "-1F3",
|
||||||
|
targetEquipmentIdentifier: "-1F2",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/already exists in circuit list/
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
getSectionCircuitState(
|
||||||
|
fixture.context,
|
||||||
|
fixture.sectionId
|
||||||
|
).map((circuit) => circuit.equipmentIdentifier),
|
||||||
|
["-1F1", "-1F2", "-1F3"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back temporary and final identifiers for late history failures", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
fixture.context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_renumber_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced renumber history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const store =
|
||||||
|
new CircuitSectionRenumberProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createSwapCommand(fixture.sectionId),
|
||||||
|
}),
|
||||||
|
/forced renumber history failure/
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
getSectionCircuitState(
|
||||||
|
fixture.context,
|
||||||
|
fixture.sectionId
|
||||||
|
).map((circuit) => circuit.equipmentIdentifier),
|
||||||
|
["-1F1", "-1F2", "-1F3"]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all()
|
||||||
|
.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back renumbering for a stale project revision", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new CircuitSectionRenumberProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createSwapCommand(fixture.sectionId),
|
||||||
|
}),
|
||||||
|
/at revision 0, expected 1/
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
getSectionCircuitState(
|
||||||
|
fixture.context,
|
||||||
|
fixture.sectionId
|
||||||
|
).map((circuit) => circuit.equipmentIdentifier),
|
||||||
|
["-1F1", "-1F2", "-1F3"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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 { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
|
||||||
|
|
||||||
|
interface TestFixture {
|
||||||
|
context: DatabaseContext;
|
||||||
|
sectionId: string;
|
||||||
|
foreignSectionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestDatabase(): TestFixture {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values([
|
||||||
|
{ id: "project-1", name: "Test project" },
|
||||||
|
{ id: "project-2", name: "Other project" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
const boards = new DistributionBoardRepository(context.db);
|
||||||
|
const board = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-1",
|
||||||
|
"UV-01"
|
||||||
|
);
|
||||||
|
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-2",
|
||||||
|
"UV-02"
|
||||||
|
);
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.get();
|
||||||
|
const foreignSection = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, foreignBoard.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(section);
|
||||||
|
assert.ok(foreignSection);
|
||||||
|
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-2",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-3",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F3",
|
||||||
|
sortOrder: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-foreign",
|
||||||
|
circuitListId: foreignBoard.id,
|
||||||
|
sectionId: foreignSection.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.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,
|
||||||
|
sectionId: section.id,
|
||||||
|
foreignSectionId: foreignSection.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCircuitState(context: DatabaseContext) {
|
||||||
|
return context.db
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||||
|
sortOrder: circuits.sortOrder,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.all()
|
||||||
|
.filter((circuit) => circuit.id !== "circuit-foreign")
|
||||||
|
.sort((left, right) => left.id.localeCompare(right.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReorderCommand(sectionId: string) {
|
||||||
|
return createCircuitSectionReorderProjectCommand(sectionId, [
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetSortOrder: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-3",
|
||||||
|
expectedSortOrder: 30,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit section reorder project-command repository", () => {
|
||||||
|
it("reorders a complete section and supports persisted undo and redo", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new CircuitSectionReorderProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const command = createReorderCommand(fixture.sectionId);
|
||||||
|
const reordered = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(getCircuitState(fixture.context), [
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-2",
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
sortOrder: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-3",
|
||||||
|
equipmentIdentifier: "-1F3",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get()?.circuitId,
|
||||||
|
"circuit-1"
|
||||||
|
);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
reordered.revision.changeSetId,
|
||||||
|
command: reordered.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
getCircuitState(fixture.context).map(
|
||||||
|
(circuit) => circuit.sortOrder
|
||||||
|
),
|
||||||
|
[10, 20, 30]
|
||||||
|
);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
reordered.revision.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
new ProjectHistoryRepository(fixture.context.db).getState(
|
||||||
|
"project-1"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 3,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: reordered.revision.changeSetId,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects incomplete, stale and foreign-section reorders", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new CircuitSectionReorderProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitSectionReorderProjectCommand(
|
||||||
|
fixture.sectionId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/every circuit/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitSectionReorderProjectCommand(
|
||||||
|
fixture.sectionId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 999,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetSortOrder: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-3",
|
||||||
|
expectedSortOrder: 30,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/changed before/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitSectionReorderProjectCommand(
|
||||||
|
fixture.foreignSectionId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-foreign",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/does not belong to project/
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
getCircuitState(fixture.context).map(
|
||||||
|
(circuit) => circuit.sortOrder
|
||||||
|
),
|
||||||
|
[10, 20, 30]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all()
|
||||||
|
.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back all sort positions for late history failures", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
fixture.context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_reorder_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced reorder history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const store =
|
||||||
|
new CircuitSectionReorderProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createReorderCommand(fixture.sectionId),
|
||||||
|
}),
|
||||||
|
/forced reorder history failure/
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
getCircuitState(fixture.context).map(
|
||||||
|
(circuit) => circuit.sortOrder
|
||||||
|
),
|
||||||
|
[10, 20, 30]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all()
|
||||||
|
.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the reorder for a stale project revision", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new CircuitSectionReorderProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createReorderCommand(fixture.sectionId),
|
||||||
|
}),
|
||||||
|
/at revision 0, expected 1/
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
getCircuitState(fixture.context).map(
|
||||||
|
(circuit) => circuit.sortOrder
|
||||||
|
),
|
||||||
|
[10, 20, 30]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
buildCircuitDeviceRowInsertSnapshot,
|
||||||
|
buildCircuitInsertSnapshot,
|
||||||
|
} from "../src/frontend/utils/circuit-structure-command.js";
|
||||||
|
import {
|
||||||
|
deleteCircuitCommand,
|
||||||
|
deleteCircuitDeviceRowCommand,
|
||||||
|
insertCircuitCommand,
|
||||||
|
insertCircuitDeviceRowCommand,
|
||||||
|
} from "../src/frontend/utils/api.js";
|
||||||
|
|
||||||
|
describe("circuit structure command adapters", () => {
|
||||||
|
it("builds a complete row snapshot without legacy identities", () => {
|
||||||
|
const row = buildCircuitDeviceRowInsertSnapshot({
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
defaultSortOrder: 20,
|
||||||
|
values: {
|
||||||
|
linkedProjectDeviceId: "device-1",
|
||||||
|
name: "Steckdose",
|
||||||
|
displayName: "Steckdose Büro",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.4,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(row, {
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: "device-1",
|
||||||
|
legacyConsumerId: null,
|
||||||
|
sortOrder: 20,
|
||||||
|
name: "Steckdose",
|
||||||
|
displayName: "Steckdose Büro",
|
||||||
|
phaseType: null,
|
||||||
|
connectionKind: null,
|
||||||
|
costGroup: null,
|
||||||
|
category: null,
|
||||||
|
level: null,
|
||||||
|
roomId: null,
|
||||||
|
roomNumberSnapshot: null,
|
||||||
|
roomNameSnapshot: null,
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.4,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: null,
|
||||||
|
remark: null,
|
||||||
|
overriddenFields: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives reserve state from the complete device-row snapshot", () => {
|
||||||
|
const reserve = buildCircuitInsertSnapshot({
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
values: {
|
||||||
|
sectionId: "section-1",
|
||||||
|
equipmentIdentifier: "-2F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
isReserve: false,
|
||||||
|
voltage: 230,
|
||||||
|
controlRequirement: "DALI",
|
||||||
|
},
|
||||||
|
deviceRows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(reserve.isReserve, true);
|
||||||
|
assert.equal(reserve.displayName, null);
|
||||||
|
assert.equal(reserve.voltage, 230);
|
||||||
|
assert.equal(reserve.controlRequirement, "DALI");
|
||||||
|
|
||||||
|
const row = buildCircuitDeviceRowInsertSnapshot({
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: reserve.id,
|
||||||
|
defaultSortOrder: 10,
|
||||||
|
values: {
|
||||||
|
name: "Gerät",
|
||||||
|
displayName: "Gerät",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const populated = buildCircuitInsertSnapshot({
|
||||||
|
id: reserve.id,
|
||||||
|
circuitListId: reserve.circuitListId,
|
||||||
|
values: {
|
||||||
|
sectionId: reserve.sectionId,
|
||||||
|
equipmentIdentifier: reserve.equipmentIdentifier,
|
||||||
|
sortOrder: reserve.sortOrder,
|
||||||
|
isReserve: true,
|
||||||
|
},
|
||||||
|
deviceRows: [row],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(populated.isReserve, false);
|
||||||
|
assert.deepEqual(populated.deviceRows, [row]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends structure envelopes through the project command route", async () => {
|
||||||
|
const requests: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
requests.push({
|
||||||
|
url: String(input),
|
||||||
|
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
revision: {
|
||||||
|
revisionId: "revision-1",
|
||||||
|
changeSetId: "change-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
revisionNumber: requests.length,
|
||||||
|
createdAtIso: "2026-07-25T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: requests.length,
|
||||||
|
undoDepth: requests.length,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: "change-1",
|
||||||
|
redoChangeSetId: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = buildCircuitDeviceRowInsertSnapshot({
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
defaultSortOrder: 10,
|
||||||
|
values: {
|
||||||
|
name: "Gerät",
|
||||||
|
displayName: "Gerät",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const circuit = buildCircuitInsertSnapshot({
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
values: {
|
||||||
|
sectionId: "section-1",
|
||||||
|
equipmentIdentifier: "-2F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
deviceRows: [row],
|
||||||
|
});
|
||||||
|
|
||||||
|
await insertCircuitCommand("project-1", 0, circuit, "insert circuit");
|
||||||
|
await deleteCircuitCommand(
|
||||||
|
"project-1",
|
||||||
|
1,
|
||||||
|
circuit.id,
|
||||||
|
circuit.circuitListId,
|
||||||
|
"delete circuit"
|
||||||
|
);
|
||||||
|
await insertCircuitDeviceRowCommand(
|
||||||
|
"project-1",
|
||||||
|
2,
|
||||||
|
row,
|
||||||
|
"insert row"
|
||||||
|
);
|
||||||
|
await deleteCircuitDeviceRowCommand(
|
||||||
|
"project-1",
|
||||||
|
3,
|
||||||
|
row.id,
|
||||||
|
row.circuitId,
|
||||||
|
"delete row"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
requests.map((request) => request.url),
|
||||||
|
Array(4).fill("/api/projects/project-1/commands")
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
requests.map((request) => {
|
||||||
|
const command = request.body.command as {
|
||||||
|
schemaVersion: number;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
request.body.expectedRevision,
|
||||||
|
command.schemaVersion,
|
||||||
|
command.type,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
[0, 1, "circuit.insert"],
|
||||||
|
[1, 1, "circuit.delete"],
|
||||||
|
[2, 1, "circuit-device-row.insert"],
|
||||||
|
[3, 1, "circuit-device-row.delete"],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,304 +1,8 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
|
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
|
||||||
import { createCircuitSchema } from "../src/shared/validation/circuit.schemas.js";
|
|
||||||
|
|
||||||
describe("circuit write service rules", () => {
|
describe("circuit write service rules", () => {
|
||||||
it("accepts future sizing inputs without calculating sizing suggestions", () => {
|
|
||||||
const parsed = createCircuitSchema.safeParse({
|
|
||||||
sectionId: "s1",
|
|
||||||
equipmentIdentifier: "-1F1",
|
|
||||||
sortOrder: 10,
|
|
||||||
voltage: 230,
|
|
||||||
controlRequirement: "DALI",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(parsed.success, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes voltage and control requirements through circuit updates", async () => {
|
|
||||||
let updatePayload: Record<string, unknown> = {};
|
|
||||||
const circuit = {
|
|
||||||
id: "c1",
|
|
||||||
circuitListId: "l1",
|
|
||||||
sectionId: "s1",
|
|
||||||
equipmentIdentifier: "-1F1",
|
|
||||||
sortOrder: 10,
|
|
||||||
isReserve: 0,
|
|
||||||
voltage: 230,
|
|
||||||
controlRequirement: "none",
|
|
||||||
};
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
circuitSectionRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "s1", circuitListId: "l1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async findById() {
|
|
||||||
return circuit as never;
|
|
||||||
},
|
|
||||||
async existsByEquipmentIdentifier() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
async updateFields(_id: string, payload: Record<string, unknown>) {
|
|
||||||
updatePayload = payload;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.updateCircuit("c1", { voltage: 400, controlRequirement: "DALI" });
|
|
||||||
|
|
||||||
assert.deepEqual(updatePayload, {
|
|
||||||
voltage: 400,
|
|
||||||
controlRequirement: "DALI",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects duplicate equipment identifiers in same circuit list", async () => {
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
circuitListRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "list1", projectId: "p1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitSectionRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "sec1", circuitListId: "list1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async existsByEquipmentIdentifier() {
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
() =>
|
|
||||||
service.createCircuit("p1", "list1", {
|
|
||||||
sectionId: "sec1",
|
|
||||||
equipmentIdentifier: "-2F1",
|
|
||||||
sortOrder: 10,
|
|
||||||
}),
|
|
||||||
/Duplicate equipmentIdentifier/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects section and list mismatch", async () => {
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
circuitListRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "list1", projectId: "p1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitSectionRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "sec1", circuitListId: "other" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async existsByEquipmentIdentifier() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
() =>
|
|
||||||
service.createCircuit("p1", "list1", {
|
|
||||||
sectionId: "sec1",
|
|
||||||
equipmentIdentifier: "-2F1",
|
|
||||||
sortOrder: 10,
|
|
||||||
}),
|
|
||||||
/Section does not belong to circuit list/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleting last device row keeps circuit and sets reserve", async () => {
|
|
||||||
let transactionalDelete: { rowId: string; circuitId: string } | undefined;
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
deviceRowRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "r1", circuitId: "c1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
deviceRowTransactionStore: {
|
|
||||||
deleteFromCircuit(rowId: string, circuitId: string) {
|
|
||||||
transactionalDelete = { rowId, circuitId };
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async findById() {
|
|
||||||
return {
|
|
||||||
id: "c1",
|
|
||||||
sectionId: "s1",
|
|
||||||
circuitListId: "l1",
|
|
||||||
equipmentIdentifier: "-2F1",
|
|
||||||
sortOrder: 10,
|
|
||||||
isReserve: 0,
|
|
||||||
} as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.deleteDeviceRow("r1");
|
|
||||||
assert.deepEqual(transactionalDelete, { rowId: "r1", circuitId: "c1" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creating device row in reserve circuit clears reserve status", async () => {
|
|
||||||
let transactionalCreate:
|
|
||||||
| {
|
|
||||||
circuitId: string;
|
|
||||||
sortOrder?: number;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
circuitRepository: {
|
|
||||||
async findById() {
|
|
||||||
return {
|
|
||||||
id: "c1",
|
|
||||||
sectionId: "s1",
|
|
||||||
circuitListId: "l1",
|
|
||||||
equipmentIdentifier: "-2F1",
|
|
||||||
sortOrder: 10,
|
|
||||||
isReserve: 1,
|
|
||||||
} as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
deviceRowRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "row1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
deviceRowTransactionStore: {
|
|
||||||
createInCircuit(input: typeof transactionalCreate) {
|
|
||||||
transactionalCreate = input;
|
|
||||||
return "row1";
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitListRepository: {} as never,
|
|
||||||
circuitSectionRepository: {} as never,
|
|
||||||
projectDeviceRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "pd1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.createDeviceRow("c1", {
|
|
||||||
name: "Load",
|
|
||||||
displayName: "Load",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 1,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
});
|
|
||||||
assert.deepEqual(transactionalCreate, {
|
|
||||||
circuitId: "c1",
|
|
||||||
linkedProjectDeviceId: undefined,
|
|
||||||
sortOrder: undefined,
|
|
||||||
name: "Load",
|
|
||||||
displayName: "Load",
|
|
||||||
phaseType: undefined,
|
|
||||||
connectionKind: undefined,
|
|
||||||
costGroup: undefined,
|
|
||||||
category: undefined,
|
|
||||||
level: undefined,
|
|
||||||
roomId: undefined,
|
|
||||||
roomNumberSnapshot: undefined,
|
|
||||||
roomNameSnapshot: undefined,
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 1,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
cosPhi: undefined,
|
|
||||||
remark: undefined,
|
|
||||||
overriddenFields: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a circuit and all first device rows through one repository command", async () => {
|
|
||||||
let transactionalCreate: {
|
|
||||||
circuit: { circuitListId: string; sectionId: string; equipmentIdentifier: string };
|
|
||||||
deviceRows: Array<{ linkedProjectDeviceId?: string; name: string }>;
|
|
||||||
} | undefined;
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
circuitListRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "l1", projectId: "p1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitSectionRepository: {
|
|
||||||
async findById() {
|
|
||||||
return { id: "s1", circuitListId: "l1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
circuitRepository: {
|
|
||||||
async existsByEquipmentIdentifier() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
async findById() {
|
|
||||||
return { id: "c-new", circuitListId: "l1", sectionId: "s1" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
projectDeviceRepository: {
|
|
||||||
async findById(projectId: string, deviceId: string) {
|
|
||||||
return projectId === "p1" && deviceId === "pd1" ? ({ id: "pd1" } as never) : null;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
deviceRowRepository: {
|
|
||||||
async findById(rowId: string) {
|
|
||||||
return { id: rowId, circuitId: "c-new" } as never;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
deviceRowTransactionStore: {
|
|
||||||
createCircuitWithDeviceRows(input: typeof transactionalCreate) {
|
|
||||||
transactionalCreate = input;
|
|
||||||
return { circuitId: "c-new", rowIds: ["r1", "r2"] };
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
const created = await service.createCircuitWithDeviceRows("p1", "l1", {
|
|
||||||
circuit: {
|
|
||||||
sectionId: "s1",
|
|
||||||
equipmentIdentifier: "-2F1",
|
|
||||||
displayName: "Steckdosen",
|
|
||||||
sortOrder: 10,
|
|
||||||
},
|
|
||||||
deviceRows: [
|
|
||||||
{
|
|
||||||
linkedProjectDeviceId: "pd1",
|
|
||||||
name: "Socket",
|
|
||||||
displayName: "Steckdose",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.2,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Manual",
|
|
||||||
displayName: "Manuell",
|
|
||||||
quantity: 2,
|
|
||||||
powerPerUnit: 0.1,
|
|
||||||
simultaneityFactor: 0.8,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(transactionalCreate?.circuit.circuitListId, "l1");
|
|
||||||
assert.equal(transactionalCreate?.circuit.sectionId, "s1");
|
|
||||||
assert.equal(transactionalCreate?.circuit.equipmentIdentifier, "-2F1");
|
|
||||||
assert.deepEqual(
|
|
||||||
transactionalCreate?.deviceRows.map((row) => row.name),
|
|
||||||
["Socket", "Manual"]
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
created.deviceRows.map((row) => row?.id),
|
|
||||||
["r1", "r2"]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renumber uses safe bulk identifier update for swapped identifiers", async () => {
|
it("renumber uses safe bulk identifier update for swapped identifiers", async () => {
|
||||||
let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = [];
|
let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = [];
|
||||||
const service = new CircuitWriteService({
|
const service = new CircuitWriteService({
|
||||||
@@ -741,36 +445,4 @@ describe("circuit write service rules", () => {
|
|||||||
assert.equal(safeCalled, true);
|
assert.equal(safeCalled, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks local edits on linked device rows as overridden fields", async () => {
|
|
||||||
let updatePayload: Record<string, unknown> = {};
|
|
||||||
const current = {
|
|
||||||
id: "r1",
|
|
||||||
circuitId: "c1",
|
|
||||||
linkedProjectDeviceId: "pd1",
|
|
||||||
name: "Luminaire",
|
|
||||||
displayName: "Local name",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.1,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
sortOrder: 10,
|
|
||||||
overriddenFields: null,
|
|
||||||
};
|
|
||||||
const service = new CircuitWriteService({
|
|
||||||
deviceRowRepository: {
|
|
||||||
async findById() {
|
|
||||||
return current as never;
|
|
||||||
},
|
|
||||||
async updateFields(_rowId: string, input: Record<string, unknown>) {
|
|
||||||
updatePayload = input;
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.updateDeviceRow("r1", { quantity: 2 });
|
|
||||||
|
|
||||||
assert.deepEqual(updatePayload, {
|
|
||||||
quantity: 2,
|
|
||||||
overriddenFields: "[\"quantity\"]",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,27 @@ import {
|
|||||||
createCircuitDeleteProjectCommand,
|
createCircuitDeleteProjectCommand,
|
||||||
createCircuitInsertProjectCommand,
|
createCircuitInsertProjectCommand,
|
||||||
} from "../src/domain/models/circuit-structure-project-command.model.js";
|
} from "../src/domain/models/circuit-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitSectionReorderProjectCommand,
|
||||||
|
createCircuitSectionReorderProjectCommand,
|
||||||
|
} from "../src/domain/models/circuit-section-reorder-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitSectionRenumberProjectCommand,
|
||||||
|
createCircuitSectionRenumberProjectCommand,
|
||||||
|
} from "../src/domain/models/circuit-section-renumber-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertProjectDeviceRowSyncProjectCommand,
|
||||||
|
createProjectDeviceRowSyncProjectCommand,
|
||||||
|
type ProjectDeviceSyncRowSnapshot,
|
||||||
|
} from "../src/domain/models/project-device-row-sync-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertProjectDeviceUpdateProjectCommand,
|
||||||
|
createProjectDeviceUpdateProjectCommand,
|
||||||
|
} from "../src/domain/models/project-device-project-command.model.js";
|
||||||
|
import {
|
||||||
|
createProjectDeviceDeleteProjectCommand,
|
||||||
|
createProjectDeviceInsertProjectCommand,
|
||||||
|
} from "../src/domain/models/project-device-structure-project-command.model.js";
|
||||||
|
|
||||||
describe("serialized project commands", () => {
|
describe("serialized project commands", () => {
|
||||||
it("round-trips a versioned command envelope", () => {
|
it("round-trips a versioned command envelope", () => {
|
||||||
@@ -183,6 +204,242 @@ describe("circuit device-row update project commands", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("project-device row sync project commands", () => {
|
||||||
|
const linkedSnapshot: ProjectDeviceSyncRowSnapshot = {
|
||||||
|
linkedProjectDeviceId: "project-device-1",
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Lokaler Name",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: null,
|
||||||
|
costGroup: null,
|
||||||
|
category: "lighting",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: 0.9,
|
||||||
|
remark: null,
|
||||||
|
overriddenFields: '["displayName"]',
|
||||||
|
};
|
||||||
|
|
||||||
|
it("captures complete expected and target snapshots for synchronization", () => {
|
||||||
|
const command = createProjectDeviceRowSyncProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"synchronize",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expected: linkedSnapshot,
|
||||||
|
target: {
|
||||||
|
...linkedSnapshot,
|
||||||
|
displayName: "Projektgerät",
|
||||||
|
overriddenFields: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertProjectDeviceRowSyncProjectCommand(command)
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
command.payload.rows[0]?.target.displayName,
|
||||||
|
"Projektgerät"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("permits link-only disconnects and rejects invalid sync transitions", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
createProjectDeviceRowSyncProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"disconnect",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expected: linkedSnapshot,
|
||||||
|
target: {
|
||||||
|
...linkedSnapshot,
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createProjectDeviceRowSyncProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"disconnect",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expected: linkedSnapshot,
|
||||||
|
target: {
|
||||||
|
...linkedSnapshot,
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
quantity: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
/must not change local row values/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createProjectDeviceRowSyncProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"synchronize",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expected: linkedSnapshot,
|
||||||
|
target: linkedSnapshot,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
/no-op row/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project-device update project commands", () => {
|
||||||
|
it("creates typed canonical field changes with null clearing", () => {
|
||||||
|
const command = createProjectDeviceUpdateProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
{
|
||||||
|
displayName: "Flurbeleuchtung",
|
||||||
|
cosPhi: null,
|
||||||
|
voltageV: 230,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertProjectDeviceUpdateProjectCommand(command)
|
||||||
|
);
|
||||||
|
assert.deepEqual(command.payload.changes, [
|
||||||
|
{ field: "displayName", value: "Flurbeleuchtung" },
|
||||||
|
{ field: "cosPhi", value: null },
|
||||||
|
{ field: "voltageV", value: 230 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty, duplicate and invalid canonical values", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createProjectDeviceUpdateProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
{}
|
||||||
|
),
|
||||||
|
/at least one change/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
assertProjectDeviceUpdateProjectCommand({
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "project-device.update",
|
||||||
|
payload: {
|
||||||
|
projectDeviceId: "project-device-1",
|
||||||
|
changes: [
|
||||||
|
{ field: "quantity", value: 1 },
|
||||||
|
{ field: "quantity", value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/duplicate field/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createProjectDeviceUpdateProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
{ simultaneityFactor: 1.1 }
|
||||||
|
),
|
||||||
|
/outside its allowed range/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project-device structure project commands", () => {
|
||||||
|
const projectDevice = {
|
||||||
|
id: "project-device-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Office lighting",
|
||||||
|
phaseType: "single_phase" as const,
|
||||||
|
connectionKind: "fixed",
|
||||||
|
costGroup: "440",
|
||||||
|
category: "lighting",
|
||||||
|
quantity: 4,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
remark: "DALI",
|
||||||
|
voltageV: 230,
|
||||||
|
};
|
||||||
|
const disconnectedRow = {
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
legacyConsumerId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Local lighting",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: "fixed",
|
||||||
|
costGroup: "440",
|
||||||
|
category: "lighting",
|
||||||
|
level: null,
|
||||||
|
roomId: null,
|
||||||
|
roomNumberSnapshot: null,
|
||||||
|
roomNameSnapshot: null,
|
||||||
|
quantity: 4,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
remark: "DALI",
|
||||||
|
overriddenFields: '["displayName"]',
|
||||||
|
};
|
||||||
|
|
||||||
|
it("captures complete device and disconnected-row restore snapshots", () => {
|
||||||
|
const insert = createProjectDeviceInsertProjectCommand(
|
||||||
|
projectDevice,
|
||||||
|
[disconnectedRow]
|
||||||
|
);
|
||||||
|
const remove = createProjectDeviceDeleteProjectCommand(
|
||||||
|
projectDevice.id,
|
||||||
|
projectDevice.projectId
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(insert.payload, {
|
||||||
|
projectDevice,
|
||||||
|
linkedRows: [disconnectedRow],
|
||||||
|
});
|
||||||
|
assert.deepEqual(remove.payload, {
|
||||||
|
projectDeviceId: "project-device-1",
|
||||||
|
expectedProjectId: "project-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid device values and rows that are already linked", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createProjectDeviceInsertProjectCommand({
|
||||||
|
...projectDevice,
|
||||||
|
quantity: -1,
|
||||||
|
}),
|
||||||
|
/outside its allowed range/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createProjectDeviceInsertProjectCommand(projectDevice, [
|
||||||
|
{
|
||||||
|
...disconnectedRow,
|
||||||
|
linkedProjectDeviceId: "project-device-1",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
/must currently be disconnected/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("circuit device-row structure project commands", () => {
|
describe("circuit device-row structure project commands", () => {
|
||||||
const row = {
|
const row = {
|
||||||
id: "row-1",
|
id: "row-1",
|
||||||
@@ -519,3 +776,115 @@ describe("circuit structure project commands", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("circuit section reorder project commands", () => {
|
||||||
|
it("captures every expected and target sort position", () => {
|
||||||
|
const command = createCircuitSectionReorderProjectCommand(
|
||||||
|
"section-1",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(command.payload.assignments.length, 2);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertCircuitSectionReorderProjectCommand(command)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty, duplicate and complete no-op assignments", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => createCircuitSectionReorderProjectCommand("", []),
|
||||||
|
/sectionId/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitSectionReorderProjectCommand("section-1", [
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
/duplicate circuit ids/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitSectionReorderProjectCommand("section-1", [
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
/change at least one position/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("circuit section renumber project commands", () => {
|
||||||
|
it("captures every expected and target equipment identifier", () => {
|
||||||
|
const command = createCircuitSectionRenumberProjectCommand(
|
||||||
|
"section-1",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedEquipmentIdentifier: "-1F1",
|
||||||
|
targetEquipmentIdentifier: "-1F2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedEquipmentIdentifier: "-1F2",
|
||||||
|
targetEquipmentIdentifier: "-1F1",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(command.payload.assignments.length, 2);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertCircuitSectionRenumberProjectCommand(command)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate and complete no-op assignments", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitSectionRenumberProjectCommand("section-1", [
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedEquipmentIdentifier: "-1F1",
|
||||||
|
targetEquipmentIdentifier: "-1F2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedEquipmentIdentifier: "-1F2",
|
||||||
|
targetEquipmentIdentifier: "-1F2",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
/duplicate target identifiers/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitSectionRenumberProjectCommand("section-1", [
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedEquipmentIdentifier: "-1F1",
|
||||||
|
targetEquipmentIdentifier: "-1F1",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
/change at least one identifier/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,13 +11,19 @@ import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories
|
|||||||
import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-project-command.repository.js";
|
import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-project-command.repository.js";
|
||||||
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
|
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
|
||||||
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
|
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
|
||||||
|
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||||
|
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
|
import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js";
|
||||||
|
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
|
||||||
|
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||||
|
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
|
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
|
||||||
@@ -29,7 +35,12 @@ import {
|
|||||||
} from "../src/domain/models/circuit-device-row-move-project-command.model.js";
|
} from "../src/domain/models/circuit-device-row-move-project-command.model.js";
|
||||||
import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
|
import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
|
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
|
||||||
|
import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
|
||||||
|
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
|
||||||
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
|
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
|
||||||
|
import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/project-device-row-sync-project-command.model.js";
|
||||||
|
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
|
||||||
|
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js";
|
||||||
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
|
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
|
||||||
|
|
||||||
function createTestDatabase(): DatabaseContext {
|
function createTestDatabase(): DatabaseContext {
|
||||||
@@ -84,6 +95,11 @@ function createService(context: DatabaseContext) {
|
|||||||
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
|
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
|
||||||
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
|
new CircuitDeviceRowMoveProjectCommandRepository(context.db),
|
||||||
new CircuitStructureProjectCommandRepository(context.db),
|
new CircuitStructureProjectCommandRepository(context.db),
|
||||||
|
new CircuitSectionReorderProjectCommandRepository(context.db),
|
||||||
|
new CircuitSectionRenumberProjectCommandRepository(context.db),
|
||||||
|
new ProjectDeviceStructureProjectCommandRepository(context.db),
|
||||||
|
new ProjectDeviceProjectCommandRepository(context.db),
|
||||||
|
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
|
||||||
new ProjectHistoryRepository(context.db)
|
new ProjectHistoryRepository(context.db)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -171,6 +187,199 @@ describe("project command service", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dispatches project-device row synchronization and its inverse", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.db
|
||||||
|
.insert(projectDevices)
|
||||||
|
.values({
|
||||||
|
id: "project-device-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Office lighting",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
quantity: 4,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({
|
||||||
|
linkedProjectDeviceId: "project-device-1",
|
||||||
|
overriddenFields: '["displayName"]',
|
||||||
|
})
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.run();
|
||||||
|
const expected = {
|
||||||
|
linkedProjectDeviceId: "project-device-1",
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
phaseType: null,
|
||||||
|
connectionKind: null,
|
||||||
|
costGroup: null,
|
||||||
|
category: null,
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: null,
|
||||||
|
remark: null,
|
||||||
|
overriddenFields: '["displayName"]',
|
||||||
|
};
|
||||||
|
const service = createService(context);
|
||||||
|
const synchronized = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createProjectDeviceRowSyncProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"synchronize",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-1",
|
||||||
|
expected,
|
||||||
|
target: {
|
||||||
|
...expected,
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Office lighting",
|
||||||
|
quantity: 4,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
overriddenFields: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(synchronized.history.undoDepth, 1);
|
||||||
|
assert.equal(getRowQuantity(context), 4);
|
||||||
|
|
||||||
|
const undone = createService(context).undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.equal(undone.history.redoDepth, 1);
|
||||||
|
assert.equal(getRowQuantity(context), 1);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get()?.overriddenFields,
|
||||||
|
'["displayName"]'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches project-device updates and their persisted inverse", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.db
|
||||||
|
.insert(projectDevices)
|
||||||
|
.values({
|
||||||
|
id: "project-device-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Office lighting",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
quantity: 4,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
const service = createService(context);
|
||||||
|
const updated = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createProjectDeviceUpdateProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
{
|
||||||
|
displayName: "Flurbeleuchtung",
|
||||||
|
powerPerUnit: 0.05,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(updated.history.undoDepth, 1);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(eq(projectDevices.id, "project-device-1"))
|
||||||
|
.get()?.displayName,
|
||||||
|
"Flurbeleuchtung"
|
||||||
|
);
|
||||||
|
|
||||||
|
const undone = createService(context).undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.equal(undone.history.redoDepth, 1);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(eq(projectDevices.id, "project-device-1"))
|
||||||
|
.get()?.displayName,
|
||||||
|
"Office lighting"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches project-device insertion and deletion", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const service = createService(context);
|
||||||
|
const inserted = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createProjectDeviceInsertProjectCommand({
|
||||||
|
id: "project-device-new",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Socket",
|
||||||
|
displayName: "Office socket",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: null,
|
||||||
|
costGroup: "440",
|
||||||
|
category: "socket",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 0.5,
|
||||||
|
cosPhi: null,
|
||||||
|
remark: null,
|
||||||
|
voltageV: 230,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(inserted.history.undoDepth, 1);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(eq(projectDevices.id, "project-device-new"))
|
||||||
|
.get()?.displayName,
|
||||||
|
"Office socket"
|
||||||
|
);
|
||||||
|
|
||||||
|
const undone = createService(context).undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.equal(undone.history.redoDepth, 1);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(eq(projectDevices.id, "project-device-new"))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects stale undo and preserves the domain value and stack", () => {
|
it("rejects stale undo and preserves the domain value and stack", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
@@ -475,6 +684,185 @@ describe("project command service", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dispatches complete section reorders without changing identifiers", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const firstCircuit = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.get();
|
||||||
|
assert.ok(firstCircuit);
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "circuit-2",
|
||||||
|
circuitListId: firstCircuit.circuitListId,
|
||||||
|
sectionId: firstCircuit.sectionId,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
sortOrder: 20,
|
||||||
|
isReserve: 1,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const reordered = createService(context).executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createCircuitSectionReorderProjectCommand(
|
||||||
|
firstCircuit.sectionId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetSortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(reordered.history.undoDepth, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||||
|
sortOrder: circuits.sortOrder,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.all()
|
||||||
|
.sort((left, right) => left.id.localeCompare(right.id)),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-2",
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
createService(context).undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({ id: circuits.id, sortOrder: circuits.sortOrder })
|
||||||
|
.from(circuits)
|
||||||
|
.all()
|
||||||
|
.sort((left, right) => left.id.localeCompare(right.id)),
|
||||||
|
[
|
||||||
|
{ id: "circuit-1", sortOrder: 10 },
|
||||||
|
{ id: "circuit-2", sortOrder: 20 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches explicit section renumbering and its inverse", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const firstCircuit = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.get();
|
||||||
|
assert.ok(firstCircuit);
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "circuit-2",
|
||||||
|
circuitListId: firstCircuit.circuitListId,
|
||||||
|
sectionId: firstCircuit.sectionId,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
sortOrder: 20,
|
||||||
|
isReserve: 1,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const renumbered = createService(context).executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createCircuitSectionRenumberProjectCommand(
|
||||||
|
firstCircuit.sectionId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedEquipmentIdentifier: "-1F1",
|
||||||
|
targetEquipmentIdentifier: "-1F2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedEquipmentIdentifier: "-1F2",
|
||||||
|
targetEquipmentIdentifier: "-1F1",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(renumbered.history.undoDepth, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||||
|
sortOrder: circuits.sortOrder,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.all()
|
||||||
|
.sort((left, right) => left.id.localeCompare(right.id)),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-2",
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
createService(context).undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.all()
|
||||||
|
.sort((left, right) => left.id.localeCompare(right.id)),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-2",
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects unavailable history directions without writing a revision", () => {
|
it("rejects unavailable history directions without writing a revision", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
|
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||||
|
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.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" },
|
||||||
|
{ id: "project-2", name: "Other project" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(projectDevices)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "project-device-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Office lighting",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: "fixed",
|
||||||
|
costGroup: "440",
|
||||||
|
category: "lighting",
|
||||||
|
quantity: 4,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
remark: "DALI",
|
||||||
|
voltageV: 230,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "project-device-foreign",
|
||||||
|
projectId: "project-2",
|
||||||
|
name: "Foreign device",
|
||||||
|
displayName: "Foreign device",
|
||||||
|
phaseType: "three_phase",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectDevice(
|
||||||
|
context: DatabaseContext,
|
||||||
|
projectDeviceId = "project-device-1"
|
||||||
|
) {
|
||||||
|
const device = context.db
|
||||||
|
.select()
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(eq(projectDevices.id, projectDeviceId))
|
||||||
|
.get();
|
||||||
|
assert.ok(device);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project-device project-command repository", () => {
|
||||||
|
it("updates canonical fields and supports persisted undo and redo", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new ProjectDeviceProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
const command = createProjectDeviceUpdateProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
{
|
||||||
|
displayName: "Flurbeleuchtung",
|
||||||
|
phaseType: "three_phase",
|
||||||
|
connectionKind: null,
|
||||||
|
powerPerUnit: 0.05,
|
||||||
|
cosPhi: null,
|
||||||
|
voltageV: 400,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const updated = store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
displayName: getProjectDevice(context).displayName,
|
||||||
|
phaseType: getProjectDevice(context).phaseType,
|
||||||
|
connectionKind: getProjectDevice(context).connectionKind,
|
||||||
|
powerPerUnit: getProjectDevice(context).powerPerUnit,
|
||||||
|
cosPhi: getProjectDevice(context).cosPhi,
|
||||||
|
voltageV: getProjectDevice(context).voltageV,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: "Flurbeleuchtung",
|
||||||
|
phaseType: "three_phase",
|
||||||
|
connectionKind: null,
|
||||||
|
powerPerUnit: 0.05,
|
||||||
|
cosPhi: null,
|
||||||
|
voltageV: 400,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: updated.revision.changeSetId,
|
||||||
|
command: updated.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
displayName: getProjectDevice(context).displayName,
|
||||||
|
phaseType: getProjectDevice(context).phaseType,
|
||||||
|
connectionKind: getProjectDevice(context).connectionKind,
|
||||||
|
powerPerUnit: getProjectDevice(context).powerPerUnit,
|
||||||
|
cosPhi: getProjectDevice(context).cosPhi,
|
||||||
|
voltageV: getProjectDevice(context).voltageV,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: "Office lighting",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: "fixed",
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
voltageV: 230,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: updated.revision.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
new ProjectHistoryRepository(context.db).getState(
|
||||||
|
"project-1"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 3,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: updated.revision.changeSetId,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects project devices from another project", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new ProjectDeviceProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceUpdateProjectCommand(
|
||||||
|
"project-device-foreign",
|
||||||
|
{ displayName: "Not allowed" }
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/does not belong to project/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectDevice(
|
||||||
|
context,
|
||||||
|
"project-device-foreign"
|
||||||
|
).displayName,
|
||||||
|
"Foreign device"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the update for a stale project revision", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new ProjectDeviceProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceUpdateProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
{ displayName: "First change" }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceUpdateProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
{ displayName: "Stale change" }
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error instanceof ProjectRevisionConflictError &&
|
||||||
|
error.actualRevision === 1
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectDevice(context).displayName,
|
||||||
|
"First change"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the update after a late history failure", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_project_device_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced project-device history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const store = new ProjectDeviceProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceUpdateProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
{
|
||||||
|
displayName: "Must roll back",
|
||||||
|
quantity: 9,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/forced project-device history failure/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectDevice(context).displayName,
|
||||||
|
"Office lighting"
|
||||||
|
);
|
||||||
|
assert.equal(getProjectDevice(context).quantity, 4);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
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 { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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 { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import {
|
||||||
|
createProjectDeviceRowSyncProjectCommand,
|
||||||
|
type ProjectDeviceSyncRowSnapshot,
|
||||||
|
} from "../src/domain/models/project-device-row-sync-project-command.model.js";
|
||||||
|
|
||||||
|
interface TestFixture {
|
||||||
|
context: DatabaseContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestDatabase(): TestFixture {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values([
|
||||||
|
{ id: "project-1", name: "Test project" },
|
||||||
|
{ id: "project-2", name: "Other project" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
const boards = new DistributionBoardRepository(context.db);
|
||||||
|
const board = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-1",
|
||||||
|
"UV-01"
|
||||||
|
);
|
||||||
|
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-2",
|
||||||
|
"UV-02"
|
||||||
|
);
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.get();
|
||||||
|
const foreignSection = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, foreignBoard.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(section);
|
||||||
|
assert.ok(foreignSection);
|
||||||
|
|
||||||
|
context.db
|
||||||
|
.insert(projectDevices)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "project-device-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Office lighting",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
quantity: 4,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
remark: "DALI",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "project-device-foreign",
|
||||||
|
projectId: "project-2",
|
||||||
|
name: "Foreign device",
|
||||||
|
displayName: "Foreign device",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-foreign",
|
||||||
|
circuitListId: foreignBoard.id,
|
||||||
|
sectionId: foreignSection.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: "project-device-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Old luminaire",
|
||||||
|
displayName: "Local row 1",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
category: "lighting",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.03,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
overriddenFields: '["displayName","quantity"]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "row-2",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: "project-device-1",
|
||||||
|
sortOrder: 20,
|
||||||
|
name: "Old luminaire",
|
||||||
|
displayName: "Local row 2",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
category: "lighting",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.03,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
overriddenFields: '["displayName"]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "row-foreign",
|
||||||
|
circuitId: "circuit-foreign",
|
||||||
|
linkedProjectDeviceId: "project-device-foreign",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Foreign device",
|
||||||
|
displayName: "Foreign device",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
return { context };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRow(context: DatabaseContext, rowId: string) {
|
||||||
|
const row = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, rowId))
|
||||||
|
.get();
|
||||||
|
assert.ok(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(
|
||||||
|
context: DatabaseContext,
|
||||||
|
rowId: string
|
||||||
|
): ProjectDeviceSyncRowSnapshot {
|
||||||
|
const row = getRow(context, rowId);
|
||||||
|
return {
|
||||||
|
linkedProjectDeviceId: row.linkedProjectDeviceId,
|
||||||
|
name: row.name,
|
||||||
|
displayName: row.displayName,
|
||||||
|
phaseType: row.phaseType,
|
||||||
|
connectionKind: row.connectionKind,
|
||||||
|
costGroup: row.costGroup,
|
||||||
|
category: row.category,
|
||||||
|
quantity: row.quantity,
|
||||||
|
powerPerUnit: row.powerPerUnit,
|
||||||
|
simultaneityFactor: row.simultaneityFactor,
|
||||||
|
cosPhi: row.cosPhi,
|
||||||
|
remark: row.remark,
|
||||||
|
overriddenFields: row.overriddenFields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCommand(context: DatabaseContext) {
|
||||||
|
return createProjectDeviceRowSyncProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"synchronize",
|
||||||
|
["row-1", "row-2"].map((rowId) => {
|
||||||
|
const expected = snapshot(context, rowId);
|
||||||
|
return {
|
||||||
|
rowId,
|
||||||
|
expected,
|
||||||
|
target: {
|
||||||
|
...expected,
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Office lighting",
|
||||||
|
quantity: 4,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
remark: "DALI",
|
||||||
|
overriddenFields: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listOwnRows(context: DatabaseContext) {
|
||||||
|
return context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, "circuit-1"))
|
||||||
|
.orderBy(asc(circuitDeviceRows.id))
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project-device row sync project-command repository", () => {
|
||||||
|
it("synchronizes multiple rows and supports persisted undo and redo", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new ProjectDeviceRowSyncProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const command = syncCommand(fixture.context);
|
||||||
|
const synchronized = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
listOwnRows(fixture.context).map((row) => [
|
||||||
|
row.displayName,
|
||||||
|
row.quantity,
|
||||||
|
row.overriddenFields,
|
||||||
|
row.sortOrder,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
["Office lighting", 4, null, 10],
|
||||||
|
["Office lighting", 4, null, 20],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
synchronized.revision.changeSetId,
|
||||||
|
command: synchronized.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
listOwnRows(fixture.context).map((row) => [
|
||||||
|
row.displayName,
|
||||||
|
row.quantity,
|
||||||
|
row.overriddenFields,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
["Local row 1", 1, '["displayName","quantity"]'],
|
||||||
|
["Local row 2", 2, '["displayName"]'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
synchronized.revision.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
new ProjectHistoryRepository(fixture.context.db).getState(
|
||||||
|
"project-1"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 3,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: synchronized.revision.changeSetId,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disconnects and reconnects rows without changing local values", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new ProjectDeviceRowSyncProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const before = listOwnRows(fixture.context);
|
||||||
|
const command = createProjectDeviceRowSyncProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"disconnect",
|
||||||
|
["row-1", "row-2"].map((rowId) => {
|
||||||
|
const expected = snapshot(fixture.context, rowId);
|
||||||
|
return {
|
||||||
|
rowId,
|
||||||
|
expected,
|
||||||
|
target: {
|
||||||
|
...expected,
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const disconnected = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
listOwnRows(fixture.context).map((row) => [
|
||||||
|
row.linkedProjectDeviceId,
|
||||||
|
row.displayName,
|
||||||
|
row.quantity,
|
||||||
|
]),
|
||||||
|
before.map((row) => [null, row.displayName, row.quantity])
|
||||||
|
);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
disconnected.revision.changeSetId,
|
||||||
|
command: disconnected.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
listOwnRows(fixture.context).map(
|
||||||
|
(row) => row.linkedProjectDeviceId
|
||||||
|
),
|
||||||
|
["project-device-1", "project-device-1"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale snapshots and cross-project device or row access", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new ProjectDeviceRowSyncProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const staleCommand = syncCommand(fixture.context);
|
||||||
|
fixture.context.db
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({ displayName: "Changed elsewhere" })
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.run();
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: staleCommand,
|
||||||
|
}),
|
||||||
|
/changed before sync execution/
|
||||||
|
);
|
||||||
|
|
||||||
|
const expected = snapshot(fixture.context, "row-foreign");
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceRowSyncProjectCommand(
|
||||||
|
"project-device-foreign",
|
||||||
|
"disconnect",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-foreign",
|
||||||
|
expected,
|
||||||
|
target: {
|
||||||
|
...expected,
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/does not belong to project/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceRowSyncProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"disconnect",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
rowId: "row-foreign",
|
||||||
|
expected: {
|
||||||
|
...expected,
|
||||||
|
linkedProjectDeviceId: "project-device-1",
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
...expected,
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/rows do not belong to project/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all()
|
||||||
|
.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back every row and revision after a late history failure", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
fixture.context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_sync_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced sync history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const store = new ProjectDeviceRowSyncProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: syncCommand(fixture.context),
|
||||||
|
}),
|
||||||
|
/forced sync history failure/
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
listOwnRows(fixture.context).map((row) => [
|
||||||
|
row.displayName,
|
||||||
|
row.quantity,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
["Local row 1", 1],
|
||||||
|
["Local row 2", 2],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all()
|
||||||
|
.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back synchronized rows for a stale project revision", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new ProjectDeviceRowSyncProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: syncCommand(fixture.context),
|
||||||
|
}),
|
||||||
|
/at revision 0, expected 1/
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
listOwnRows(fixture.context).map((row) => [
|
||||||
|
row.displayName,
|
||||||
|
row.quantity,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
["Local row 1", 1],
|
||||||
|
["Local row 2", 2],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
||||||
import {
|
|
||||||
createDatabaseContext,
|
|
||||||
type DatabaseContext,
|
|
||||||
} from "../src/db/database-context.js";
|
|
||||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
|
||||||
import { ProjectDeviceRowSyncRepository } from "../src/db/repositories/project-device-row-sync.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 { projectDevices } from "../src/db/schema/project-devices.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: "Beleuchtung",
|
|
||||||
sortOrder: 10,
|
|
||||||
isReserve: 0,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
context.db
|
|
||||||
.insert(projectDevices)
|
|
||||||
.values({
|
|
||||||
id: "project-device-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "Luminaire",
|
|
||||||
displayName: "Office lighting",
|
|
||||||
phaseType: "single_phase",
|
|
||||||
quantity: 4,
|
|
||||||
powerPerUnit: 0.04,
|
|
||||||
simultaneityFactor: 0.8,
|
|
||||||
installedPowerPerUnitKw: 0.04,
|
|
||||||
demandFactor: 0.8,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
context.db
|
|
||||||
.insert(circuitDeviceRows)
|
|
||||||
.values([
|
|
||||||
{
|
|
||||||
id: "row-1",
|
|
||||||
circuitId: "circuit-1",
|
|
||||||
linkedProjectDeviceId: "project-device-1",
|
|
||||||
sortOrder: 10,
|
|
||||||
name: "Old luminaire",
|
|
||||||
displayName: "Local row 1",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.03,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "row-2",
|
|
||||||
circuitId: "circuit-1",
|
|
||||||
linkedProjectDeviceId: "project-device-1",
|
|
||||||
sortOrder: 20,
|
|
||||||
name: "Old luminaire",
|
|
||||||
displayName: "Local row 2",
|
|
||||||
quantity: 2,
|
|
||||||
powerPerUnit: 0.03,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
.run();
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
function synchronizedValues(displayName: string) {
|
|
||||||
return {
|
|
||||||
linkedProjectDeviceId: "project-device-1",
|
|
||||||
name: "Luminaire",
|
|
||||||
displayName,
|
|
||||||
phaseType: "single_phase",
|
|
||||||
quantity: 4,
|
|
||||||
powerPerUnit: 0.04,
|
|
||||||
simultaneityFactor: 0.8,
|
|
||||||
cosPhi: 0.95,
|
|
||||||
remark: "DALI",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function listRows(context: DatabaseContext) {
|
|
||||||
return context.db
|
|
||||||
.select()
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.all()
|
|
||||||
.sort((left, right) => left.id.localeCompare(right.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("project-device row sync repository", () => {
|
|
||||||
it("commits synchronized values for all selected linked rows", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const repository = new ProjectDeviceRowSyncRepository(context.db);
|
|
||||||
|
|
||||||
repository.updateLinkedRows("project-device-1", [
|
|
||||||
{ rowId: "row-1", input: synchronizedValues("Synced row 1") },
|
|
||||||
{ rowId: "row-2", input: synchronizedValues("Synced row 2") },
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
listRows(context).map((row) => [
|
|
||||||
row.displayName,
|
|
||||||
row.quantity,
|
|
||||||
row.powerPerUnit,
|
|
||||||
row.simultaneityFactor,
|
|
||||||
]),
|
|
||||||
[
|
|
||||||
["Synced row 1", 4, 0.04, 0.8],
|
|
||||||
["Synced row 2", 4, 0.04, 0.8],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back earlier synchronized values when a later row update fails", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
context.sqlite.exec(`
|
|
||||||
CREATE TRIGGER fail_second_sync_update
|
|
||||||
BEFORE UPDATE ON circuit_device_rows
|
|
||||||
WHEN OLD.id = 'row-2' AND NEW.display_name = 'Synced'
|
|
||||||
BEGIN
|
|
||||||
SELECT RAISE(ABORT, 'forced sync failure');
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
const repository = new ProjectDeviceRowSyncRepository(context.db);
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
repository.updateLinkedRows("project-device-1", [
|
|
||||||
{ rowId: "row-1", input: synchronizedValues("Synced") },
|
|
||||||
{ rowId: "row-2", input: synchronizedValues("Synced") },
|
|
||||||
]),
|
|
||||||
/forced sync failure/
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
listRows(context).map((row) => [row.displayName, row.quantity]),
|
|
||||||
[
|
|
||||||
["Local row 1", 1],
|
|
||||||
["Local row 2", 2],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disconnects all selected rows without changing local values", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const repository = new ProjectDeviceRowSyncRepository(context.db);
|
|
||||||
|
|
||||||
repository.disconnectLinkedRows("project-device-1", ["row-1", "row-2"]);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
listRows(context).map((row) => [
|
|
||||||
row.linkedProjectDeviceId,
|
|
||||||
row.displayName,
|
|
||||||
row.quantity,
|
|
||||||
]),
|
|
||||||
[
|
|
||||||
[null, "Local row 1", 1],
|
|
||||||
[null, "Local row 2", 2],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back earlier disconnects when a later row update fails", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
context.sqlite.exec(`
|
|
||||||
CREATE TRIGGER fail_second_disconnect
|
|
||||||
BEFORE UPDATE ON circuit_device_rows
|
|
||||||
WHEN OLD.id = 'row-2' AND NEW.linked_project_device_id IS NULL
|
|
||||||
BEGIN
|
|
||||||
SELECT RAISE(ABORT, 'forced disconnect failure');
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
const repository = new ProjectDeviceRowSyncRepository(context.db);
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
repository.disconnectLinkedRows("project-device-1", ["row-1", "row-2"]),
|
|
||||||
/forced disconnect failure/
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
listRows(context).map((row) => row.linkedProjectDeviceId),
|
|
||||||
["project-device-1", "project-device-1"]
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reconnects all selected disconnected rows", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
context.db.update(circuitDeviceRows).set({ linkedProjectDeviceId: null }).run();
|
|
||||||
const repository = new ProjectDeviceRowSyncRepository(context.db);
|
|
||||||
|
|
||||||
repository.reconnectRows("project-device-1", ["row-1", "row-2"]);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
listRows(context).map((row) => row.linkedProjectDeviceId),
|
|
||||||
["project-device-1", "project-device-1"]
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back earlier reconnects when a later row update fails", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
context.db.update(circuitDeviceRows).set({ linkedProjectDeviceId: null }).run();
|
|
||||||
context.sqlite.exec(`
|
|
||||||
CREATE TRIGGER fail_second_reconnect
|
|
||||||
BEFORE UPDATE ON circuit_device_rows
|
|
||||||
WHEN OLD.id = 'row-2' AND NEW.linked_project_device_id = 'project-device-1'
|
|
||||||
BEGIN
|
|
||||||
SELECT RAISE(ABORT, 'forced reconnect failure');
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
const repository = new ProjectDeviceRowSyncRepository(context.db);
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() => repository.reconnectRows("project-device-1", ["row-1", "row-2"]),
|
|
||||||
/forced reconnect failure/
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
listRows(context).map((row) => row.linkedProjectDeviceId),
|
|
||||||
[null, null]
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { createProjectDeviceSchema } from "../src/shared/validation/project-device.schemas.js";
|
import {
|
||||||
|
createProjectDeviceCommandSchema,
|
||||||
|
createProjectDeviceSchema,
|
||||||
|
disconnectProjectDeviceRowsSchema,
|
||||||
|
projectDeviceStructureCommandSchema,
|
||||||
|
synchronizeProjectDeviceRowsSchema,
|
||||||
|
updateProjectDeviceCommandSchema,
|
||||||
|
} from "../src/shared/validation/project-device.schemas.js";
|
||||||
|
|
||||||
describe("project device circuit-first schema", () => {
|
describe("project device circuit-first schema", () => {
|
||||||
it("accepts circuit device fields", () => {
|
it("accepts circuit device fields", () => {
|
||||||
@@ -33,4 +40,78 @@ describe("project device circuit-first schema", () => {
|
|||||||
|
|
||||||
assert.equal(result.success, false);
|
assert.equal(result.success, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires a project revision for create and update commands", () => {
|
||||||
|
const device = {
|
||||||
|
name: "E-Line Pro",
|
||||||
|
displayName: "Bürobeleuchtung",
|
||||||
|
phaseType: "single_phase" as const,
|
||||||
|
quantity: 6,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(createProjectDeviceCommandSchema.safeParse(device).success, false);
|
||||||
|
assert.equal(updateProjectDeviceCommandSchema.safeParse(device).success, false);
|
||||||
|
assert.equal(
|
||||||
|
createProjectDeviceCommandSchema.safeParse({
|
||||||
|
...device,
|
||||||
|
expectedRevision: 4,
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
updateProjectDeviceCommandSchema.safeParse({
|
||||||
|
...device,
|
||||||
|
expectedRevision: 4,
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts only non-negative integer revisions for structure commands", () => {
|
||||||
|
assert.equal(
|
||||||
|
projectDeviceStructureCommandSchema.safeParse({
|
||||||
|
expectedRevision: 0,
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
projectDeviceStructureCommandSchema.safeParse({
|
||||||
|
expectedRevision: -1,
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
projectDeviceStructureCommandSchema.safeParse({
|
||||||
|
expectedRevision: 1.5,
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the current revision for synchronization writes", () => {
|
||||||
|
assert.equal(
|
||||||
|
synchronizeProjectDeviceRowsSchema.safeParse({
|
||||||
|
rowIds: ["row-1"],
|
||||||
|
fields: ["quantity"],
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
synchronizeProjectDeviceRowsSchema.safeParse({
|
||||||
|
rowIds: ["row-1"],
|
||||||
|
fields: ["quantity"],
|
||||||
|
expectedRevision: 7,
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
disconnectProjectDeviceRowsSchema.safeParse({
|
||||||
|
rowIds: ["row-1"],
|
||||||
|
expectedRevision: 7,
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
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 { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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 { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import {
|
||||||
|
createProjectDeviceDeleteProjectCommand,
|
||||||
|
createProjectDeviceInsertProjectCommand,
|
||||||
|
type ProjectDeviceSnapshot,
|
||||||
|
} from "../src/domain/models/project-device-structure-project-command.model.js";
|
||||||
|
|
||||||
|
function newProjectDeviceSnapshot(): ProjectDeviceSnapshot {
|
||||||
|
return {
|
||||||
|
id: "project-device-new",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Socket",
|
||||||
|
displayName: "Office socket",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: null,
|
||||||
|
costGroup: "440",
|
||||||
|
category: "socket",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 0.5,
|
||||||
|
cosPhi: null,
|
||||||
|
remark: null,
|
||||||
|
voltageV: 230,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
{ id: "project-2", name: "Other project" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
const boards = new DistributionBoardRepository(context.db);
|
||||||
|
const board = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-1",
|
||||||
|
"UV-01"
|
||||||
|
);
|
||||||
|
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-2",
|
||||||
|
"UV-02"
|
||||||
|
);
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.get();
|
||||||
|
const foreignSection = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, foreignBoard.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(section);
|
||||||
|
assert.ok(foreignSection);
|
||||||
|
|
||||||
|
context.db
|
||||||
|
.insert(projectDevices)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "project-device-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Office lighting",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: "fixed",
|
||||||
|
costGroup: "440",
|
||||||
|
category: "lighting",
|
||||||
|
quantity: 4,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
remark: "DALI",
|
||||||
|
voltageV: 230,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "project-device-foreign",
|
||||||
|
projectId: "project-2",
|
||||||
|
name: "Foreign device",
|
||||||
|
displayName: "Foreign device",
|
||||||
|
phaseType: "three_phase",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-foreign",
|
||||||
|
circuitListId: foreignBoard.id,
|
||||||
|
sectionId: foreignSection.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: "project-device-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Local row 1",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.03,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
overriddenFields: '["displayName","quantity"]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "row-2",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: "project-device-1",
|
||||||
|
sortOrder: 20,
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Local row 2",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.03,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
overriddenFields: '["displayName"]',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectDevice(
|
||||||
|
context: DatabaseContext,
|
||||||
|
id: string
|
||||||
|
) {
|
||||||
|
return context.db
|
||||||
|
.select()
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(eq(projectDevices.id, id))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkedRowState(context: DatabaseContext) {
|
||||||
|
return context.db
|
||||||
|
.select({
|
||||||
|
id: circuitDeviceRows.id,
|
||||||
|
linkedProjectDeviceId:
|
||||||
|
circuitDeviceRows.linkedProjectDeviceId,
|
||||||
|
displayName: circuitDeviceRows.displayName,
|
||||||
|
quantity: circuitDeviceRows.quantity,
|
||||||
|
overriddenFields: circuitDeviceRows.overriddenFields,
|
||||||
|
})
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, "circuit-1"))
|
||||||
|
.orderBy(asc(circuitDeviceRows.id))
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project-device structure project-command repository", () => {
|
||||||
|
it("inserts a stable project device and supports undo and redo", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new ProjectDeviceStructureProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
const command = createProjectDeviceInsertProjectCommand(
|
||||||
|
newProjectDeviceSnapshot()
|
||||||
|
);
|
||||||
|
const inserted = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
getProjectDevice(context, "project-device-new")
|
||||||
|
?.displayName,
|
||||||
|
"Office socket"
|
||||||
|
);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
inserted.revision.changeSetId,
|
||||||
|
command: inserted.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
getProjectDevice(context, "project-device-new"),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
inserted.revision.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
new ProjectHistoryRepository(context.db).getState(
|
||||||
|
"project-1"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 3,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: inserted.revision.changeSetId,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a device and restores its exact linked rows", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new ProjectDeviceStructureProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
const beforeRows = getLinkedRowState(context);
|
||||||
|
const command = createProjectDeviceDeleteProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"project-1"
|
||||||
|
);
|
||||||
|
const deleted = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
getProjectDevice(context, "project-device-1"),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
getLinkedRowState(context).map(
|
||||||
|
(row) => row.linkedProjectDeviceId
|
||||||
|
),
|
||||||
|
[null, null]
|
||||||
|
);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
deleted.revision.changeSetId,
|
||||||
|
command: deleted.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
getProjectDevice(context, "project-device-1")
|
||||||
|
?.displayName,
|
||||||
|
"Office lighting"
|
||||||
|
);
|
||||||
|
assert.deepEqual(getLinkedRowState(context), beforeRows);
|
||||||
|
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
deleted.revision.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
getLinkedRowState(context).map(
|
||||||
|
(row) => row.linkedProjectDeviceId
|
||||||
|
),
|
||||||
|
[null, null]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsafe user links, duplicate ids and foreign deletes", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new ProjectDeviceStructureProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
const disconnectedRow = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get();
|
||||||
|
assert.ok(disconnectedRow);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceInsertProjectCommand(
|
||||||
|
newProjectDeviceSnapshot(),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...disconnectedRow,
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/cannot reconnect existing rows/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceInsertProjectCommand({
|
||||||
|
...newProjectDeviceSnapshot(),
|
||||||
|
id: "project-device-1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/id already exists/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceDeleteProjectCommand(
|
||||||
|
"project-device-foreign",
|
||||||
|
"project-1"
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/does not belong to project/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses undo when a disconnected row changed", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new ProjectDeviceStructureProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
const deleted = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceDeleteProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"project-1"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({ displayName: "Changed while disconnected" })
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId:
|
||||||
|
deleted.revision.changeSetId,
|
||||||
|
command: deleted.inverse,
|
||||||
|
}),
|
||||||
|
/row changed before insertion/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectDevice(context, "project-device-1"),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
getLinkedRowState(context).map(
|
||||||
|
(row) => row.linkedProjectDeviceId
|
||||||
|
),
|
||||||
|
[null, null]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back deletion, disconnected links and history together", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_project_device_structure_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced project-device structure history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const store =
|
||||||
|
new ProjectDeviceStructureProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
const beforeRows = getLinkedRowState(context);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceDeleteProjectCommand(
|
||||||
|
"project-device-1",
|
||||||
|
"project-1"
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/forced project-device structure history failure/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectDevice(context, "project-device-1")
|
||||||
|
?.displayName,
|
||||||
|
"Office lighting"
|
||||||
|
);
|
||||||
|
assert.deepEqual(getLinkedRowState(context), beforeRows);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back insertion for a stale project revision", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store =
|
||||||
|
new ProjectDeviceStructureProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectDeviceInsertProjectCommand(
|
||||||
|
newProjectDeviceSnapshot()
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/at revision 0, expected 1/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectDevice(context, "project-device-new"),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ function projectDevice() {
|
|||||||
totalPower: 0.192,
|
totalPower: 0.192,
|
||||||
cosPhi: 0.95,
|
cosPhi: 0.95,
|
||||||
remark: "DALI",
|
remark: "DALI",
|
||||||
|
voltageV: 230,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +56,6 @@ function linkedRow() {
|
|||||||
|
|
||||||
function createService() {
|
function createService() {
|
||||||
const rows = [linkedRow()];
|
const rows = [linkedRow()];
|
||||||
const updates: Array<Record<string, unknown>> = [];
|
|
||||||
let transactionCalls = 0;
|
|
||||||
const service = new ProjectDeviceSyncService({
|
const service = new ProjectDeviceSyncService({
|
||||||
projectDeviceRepository: {
|
projectDeviceRepository: {
|
||||||
async findById() {
|
async findById() {
|
||||||
@@ -65,99 +64,104 @@ function createService() {
|
|||||||
},
|
},
|
||||||
deviceRowRepository: {
|
deviceRowRepository: {
|
||||||
async listLinkedByProjectDevice() {
|
async listLinkedByProjectDevice() {
|
||||||
return rows.filter((row) => row.linkedProjectDeviceId === "pd1") as never;
|
return rows as never;
|
||||||
},
|
},
|
||||||
async listLinkStatesByIds() {
|
|
||||||
return rows.map((row) => ({ id: row.id, linkedProjectDeviceId: row.linkedProjectDeviceId })) as never;
|
|
||||||
},
|
},
|
||||||
} as never,
|
|
||||||
deviceRowSyncStore: {
|
|
||||||
updateLinkedRows(_projectDeviceId, changes) {
|
|
||||||
transactionCalls += 1;
|
|
||||||
for (const change of changes) {
|
|
||||||
updates.push({ rowId: change.rowId, ...change.input });
|
|
||||||
Object.assign(rows.find((row) => row.id === change.rowId)!, change.input);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
disconnectLinkedRows(_projectDeviceId, rowIds) {
|
|
||||||
transactionCalls += 1;
|
|
||||||
for (const rowId of rowIds) {
|
|
||||||
const row = rows.find((entry) => entry.id === rowId)!;
|
|
||||||
updates.push({ rowId, linkedProjectDeviceId: undefined, displayName: row.displayName, quantity: row.quantity });
|
|
||||||
Object.assign(row, { linkedProjectDeviceId: null });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
reconnectRows(projectDeviceId, rowIds) {
|
|
||||||
transactionCalls += 1;
|
|
||||||
for (const rowId of rowIds) {
|
|
||||||
Object.assign(rows.find((row) => row.id === rowId)!, { linkedProjectDeviceId: projectDeviceId });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
});
|
});
|
||||||
return { service, rows, updates, transactionCalls: () => transactionCalls };
|
return { service, rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("project device synchronization", () => {
|
describe("project device synchronization", () => {
|
||||||
it("shows field differences and local overrides without changing rows", async () => {
|
it("shows field differences and local overrides without changing rows", async () => {
|
||||||
const { service, updates } = createService();
|
const { service, rows } = createService();
|
||||||
|
const before = structuredClone(rows);
|
||||||
const preview = await service.getPreview("p1", "pd1");
|
const preview = await service.getPreview("p1", "pd1");
|
||||||
|
|
||||||
assert.equal(updates.length, 0);
|
assert.deepEqual(rows, before);
|
||||||
assert.equal(preview.rows[0].equipmentIdentifier, "-1F1");
|
assert.equal(preview.rows[0].equipmentIdentifier, "-1F1");
|
||||||
assert.equal(preview.rows[0].differences.some((difference) => difference.field === "quantity"), true);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
preview.rows[0].differences.find((difference) => difference.field === "displayName")?.isOverridden,
|
preview.rows[0].differences.some(
|
||||||
|
(difference) => difference.field === "quantity"
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
preview.rows[0].differences.find(
|
||||||
|
(difference) => difference.field === "displayName"
|
||||||
|
)?.isOverridden,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("synchronizes only explicitly selected fields", async () => {
|
it("creates a deterministic command for selected synchronization fields", async () => {
|
||||||
const { service, rows, transactionCalls } = createService();
|
const { service, rows } = createService();
|
||||||
await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
|
const before = structuredClone(rows);
|
||||||
|
const command = await service.createSynchronizeCommand(
|
||||||
|
"p1",
|
||||||
|
"pd1",
|
||||||
|
["row1"],
|
||||||
|
["quantity", "powerPerUnit"]
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(rows[0].quantity, 6);
|
assert.deepEqual(rows, before);
|
||||||
assert.equal(rows[0].powerPerUnit, 0.04);
|
assert.equal(command.payload.operation, "synchronize");
|
||||||
assert.equal(rows[0].displayName, "Local display name");
|
assert.equal(command.payload.rows[0].expected.quantity, 2);
|
||||||
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName"]);
|
assert.equal(command.payload.rows[0].target.quantity, 6);
|
||||||
assert.equal(transactionCalls(), 1);
|
assert.equal(command.payload.rows[0].target.powerPerUnit, 0.04);
|
||||||
|
assert.equal(
|
||||||
|
command.payload.rows[0].target.displayName,
|
||||||
|
"Local display name"
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
JSON.parse(command.payload.rows[0].target.overriddenFields ?? "[]"),
|
||||||
|
["displayName"]
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects rows that are not linked to the selected project device", async () => {
|
it("rejects rows that are not linked to the selected project device", async () => {
|
||||||
const { service } = createService();
|
const { service } = createService();
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => service.synchronize("p1", "pd1", ["other-row"], ["quantity"]),
|
() =>
|
||||||
|
service.createSynchronizeCommand(
|
||||||
|
"p1",
|
||||||
|
"pd1",
|
||||||
|
["other-row"],
|
||||||
|
["quantity"]
|
||||||
|
),
|
||||||
/not linked/
|
/not linked/
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disconnects selected rows without changing local values", async () => {
|
it("creates a link-only disconnect command", async () => {
|
||||||
const { service, updates } = createService();
|
const { service } = createService();
|
||||||
await service.disconnect("p1", "pd1", ["row1"]);
|
const command = await service.createDisconnectCommand(
|
||||||
|
"p1",
|
||||||
|
"pd1",
|
||||||
|
["row1"]
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(updates[0].linkedProjectDeviceId, undefined);
|
const assignment = command.payload.rows[0];
|
||||||
assert.equal(updates[0].displayName, "Local display name");
|
assert.equal(command.payload.operation, "disconnect");
|
||||||
assert.equal(updates[0].quantity, 2);
|
assert.equal(assignment.expected.linkedProjectDeviceId, "pd1");
|
||||||
|
assert.equal(assignment.target.linkedProjectDeviceId, null);
|
||||||
|
assert.equal(
|
||||||
|
assignment.target.displayName,
|
||||||
|
assignment.expected.displayName
|
||||||
|
);
|
||||||
|
assert.equal(assignment.target.quantity, assignment.expected.quantity);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores synchronized values and override markers", async () => {
|
it("does not create no-op synchronization commands", async () => {
|
||||||
const { service, rows } = createService();
|
const { service } = createService();
|
||||||
const result = await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
await service.restore("p1", "pd1", result.undo.rows);
|
service.createSynchronizeCommand(
|
||||||
|
"p1",
|
||||||
assert.equal(rows[0].quantity, 2);
|
"pd1",
|
||||||
assert.equal(rows[0].powerPerUnit, 0.03);
|
["row1"],
|
||||||
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName", "quantity"]);
|
["category"]
|
||||||
|
),
|
||||||
|
/at least one row/
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reconnects rows only while they remain disconnected", async () => {
|
|
||||||
const { service, rows } = createService();
|
|
||||||
const result = await service.disconnect("p1", "pd1", ["row1"]);
|
|
||||||
|
|
||||||
await service.reconnect("p1", "pd1", result.undo.rowIds);
|
|
||||||
|
|
||||||
assert.equal(rows[0].linkedProjectDeviceId, "pd1");
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user