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
|
||||
|
||||
Session-local UI undo/redo exists for structural and destructive operations.
|
||||
The server already persists immutable revisions and project-wide undo/redo
|
||||
eligibility for the first commands. Public command, undo and redo endpoints
|
||||
exist for Circuit and CircuitDeviceRow field updates as well as
|
||||
Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and
|
||||
the UI cutover remain future work. CircuitDeviceRow moves between existing
|
||||
circuits and moves that create one new placeholder target circuit are also
|
||||
persisted. The latter stores the complete empty target snapshot so undo can
|
||||
restore the rows and remove only the unchanged generated circuit.
|
||||
The editor's visible undo/redo stack remains session-local while move, reorder
|
||||
and renumber operations are being migrated. Existing Circuit and
|
||||
CircuitDeviceRow cell edits plus standalone insertion/deletion already execute
|
||||
persistent project commands; their toolbar undo/redo actions use the
|
||||
project-wide history endpoints. Insertions use client-generated stable UUIDs,
|
||||
and deletion undo restores the same ids from complete server snapshots. The
|
||||
tree response supplies the optimistic `currentRevision`; obsolete direct field
|
||||
PATCH, structure POST and CircuitDeviceRow DELETE routes are removed.
|
||||
Reloading the page does not yet reconstruct the visible editor stack.
|
||||
CircuitDeviceRow moves between existing circuits and moves that create one new
|
||||
placeholder target circuit are persisted. The latter stores the complete empty
|
||||
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:
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
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.
|
||||
|
||||
Project responses from `GET /projects` and `GET /projects/:projectId` include
|
||||
`currentRevision`. Versioned project commands use this value for optimistic
|
||||
concurrency checks.
|
||||
Project responses from `GET /projects`, `GET /projects/:projectId` and the
|
||||
circuit-tree endpoint include `currentRevision`. Versioned project commands use
|
||||
this value for optimistic concurrency checks.
|
||||
|
||||
### Project Commands and History
|
||||
|
||||
@@ -25,16 +25,20 @@ concurrency checks.
|
||||
|
||||
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
|
||||
`circuit.delete`, `circuit-device-row.update`,
|
||||
`circuit-device-row.insert`, `circuit-device-row.delete` and
|
||||
`circuit-device-row.move` plus
|
||||
`circuit-device-row.move-with-new-circuit`, all with schema version `1`. Other
|
||||
command types are rejected. Insert commands contain the complete entity or
|
||||
circuit block with stable ids; delete commands include the expected parent
|
||||
identity. Circuit snapshots contain zero, one or multiple complete device
|
||||
rows. Move commands contain each row's expected and target circuit plus its
|
||||
exact expected and target sort order. This makes deletion and moves undoable
|
||||
without generating replacement identities or renumbering circuits. The editor
|
||||
does not consume these endpoints yet.
|
||||
`circuit-device-row.insert`, `circuit-device-row.delete`,
|
||||
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
|
||||
`circuit.reorder-section`, `circuit.renumber-section` and
|
||||
`project-device.update`, `project-device.insert`, `project-device.delete` and
|
||||
`project-device.sync-rows`, all with schema version `1`. Other command types
|
||||
are rejected. Insert commands contain the complete entity or circuit block
|
||||
with stable ids; delete commands include the expected parent identity. Circuit
|
||||
snapshots contain zero, one or multiple complete device rows. Move commands
|
||||
contain each row's expected and target circuit plus its exact expected and
|
||||
target sort order. This makes deletion and moves undoable without generating
|
||||
replacement identities or renumbering circuits. Existing Circuit and
|
||||
CircuitDeviceRow cell edits 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-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
|
||||
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:
|
||||
|
||||
```json
|
||||
@@ -83,6 +139,7 @@ Response sketch:
|
||||
```json
|
||||
{
|
||||
"circuitListId": "cl_1",
|
||||
"currentRevision": 12,
|
||||
"sections": [
|
||||
{
|
||||
"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`
|
||||
- preview next identifier for section (`prefix + maxSuffix + 1`)
|
||||
|
||||
Request sketch (`POST .../circuits`):
|
||||
|
||||
```json
|
||||
{
|
||||
"sectionId": "sec_1",
|
||||
"equipmentIdentifier": "-2F14",
|
||||
"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
|
||||
Circuit and device-row field updates, standalone insertions and standalone
|
||||
deletions are available only as the corresponding versioned commands through
|
||||
`POST /projects/:projectId/commands`. There are no direct field-update PATCH,
|
||||
structure POST or CircuitDeviceRow DELETE routes. The direct
|
||||
`DELETE /circuits/:circuitId` route remains temporarily for the inverse of the
|
||||
not-yet-migrated placeholder move UI and must not be used by new features.
|
||||
|
||||
### Move Device Row
|
||||
|
||||
@@ -223,12 +259,16 @@ endpoints above.
|
||||
- returns all linked circuit device rows with distribution-board/circuit context and field differences
|
||||
- `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize`
|
||||
- applies only explicitly selected fields to explicitly selected linked rows
|
||||
- `POST /project-devices/projects/:projectId/:projectDeviceId/restore`
|
||||
- restores the captured pre-sync values for session-local undo
|
||||
- request: selected `rowIds`, selected `fields` and `expectedRevision`
|
||||
- response: updated preview, revision and project history state
|
||||
- `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect`
|
||||
- disconnects explicitly selected rows without changing their local values
|
||||
- `POST /project-devices/projects/:projectId/:projectDeviceId/reconnect`
|
||||
- restores a disconnected link if the row has not been linked elsewhere meanwhile
|
||||
- request: selected `rowIds` and `expectedRevision`
|
||||
- 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
|
||||
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.
|
||||
|
||||
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`
|
||||
|
||||
@@ -119,7 +122,13 @@ Cross-section device moves show confirmation-required feedback before drop. The
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -132,5 +141,6 @@ Covered operations include:
|
||||
|
||||
Current limitations:
|
||||
|
||||
- session-local only
|
||||
- no persisted history across browser reload
|
||||
- the visible editor stack is still session-local and is empty after 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
|
||||
|
||||
- Undo/redo history is session-local only.
|
||||
- Undo/redo history is not persisted across reloads or between users.
|
||||
- No project revision or logical snapshot model is implemented yet.
|
||||
- Existing Circuit and CircuitDeviceRow cell edits plus standalone
|
||||
insert/delete actions use persistent project commands and project-wide
|
||||
toolbar undo/redo. The editor's visible stack is still session-local, is
|
||||
empty after reload and still contains 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.
|
||||
- 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
|
||||
|
||||
@@ -41,7 +41,7 @@ liegt über einen Host-Mount außerhalb des Containers.
|
||||
- `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`
|
||||
– unterstützte Editorroute
|
||||
- `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,
|
||||
Zellbesitz, Einfügen und Sicherheitsregeln
|
||||
- `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
|
||||
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-
|
||||
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
|
||||
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
|
||||
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
|
||||
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
|
||||
Betriebsmittelkennzeichen. Das Grid verwendet diese Endpunkte noch nicht; seine
|
||||
sichtbare Historie bleibt daher sitzungslokal.
|
||||
Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen
|
||||
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
|
||||
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
|
||||
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
|
||||
BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten
|
||||
Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen
|
||||
Felder und vollständiger Zeilenbestand unverändert sind. Circuit-Sortierung,
|
||||
Neunummerierung und Synchronisierung müssen vor der Frontend-Umstellung noch
|
||||
als persistente Commands modelliert werden.
|
||||
Felder und vollständiger Zeilenbestand unverändert sind.
|
||||
`circuit.reorder-section` speichert die erwartete und neue Sortierposition
|
||||
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
|
||||
|
||||
@@ -129,7 +167,8 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
||||
|
||||
## 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
|
||||
- Revit-/CSV-/IFCGUID-Round-trip
|
||||
- vollständige elektrische Dimensionierung
|
||||
|
||||
@@ -173,12 +173,20 @@ Completed foundation:
|
||||
to SQLite
|
||||
- public command, undo and redo endpoints require an expected revision; undo
|
||||
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
|
||||
row ids, complete deleted-row snapshots and circuit reserve state through
|
||||
atomic insert/delete, revision and stack transitions
|
||||
- `circuit.insert` and `circuit.delete` persist a complete circuit block with
|
||||
zero, one or multiple device rows; undo restores all ids and fields without
|
||||
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
|
||||
positions for one or multiple rows, recalculates all affected reserve states
|
||||
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
|
||||
transaction, while undo restores the source positions and deletes only an
|
||||
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:
|
||||
|
||||
- extend the concrete project-scoped command union and executors beyond the
|
||||
Circuit field/insert/delete and CircuitDeviceRow
|
||||
field/insert/delete/move slices; the generic versioned command envelope is
|
||||
complete
|
||||
- route the remaining structural, synchronization and destructive domain
|
||||
writes through the shared command, revision and stack transaction boundary
|
||||
- replace the session-local frontend command stack only after server-side
|
||||
command coverage is complete for structural, destructive and synchronization
|
||||
operations
|
||||
- route the remaining move, reorder and renumber editor writes through their
|
||||
existing typed persistent commands and remove the final direct mutation
|
||||
routes
|
||||
- reconstruct the visible frontend history state after reload; Circuit and
|
||||
CircuitDeviceRow field/insert/delete actions plus ProjectDevice operations
|
||||
already use persistent commands and project-wide undo
|
||||
- extend project-scoped commands only when further project mutations are
|
||||
deliberately added to history; the generic versioned envelope is complete
|
||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||
- 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
|
||||
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
|
||||
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
|
||||
and stable ids; row mutation, circuit reserve state, revision and history
|
||||
stack transition commit or roll back together
|
||||
- typed Circuit insert/delete commands treat the circuit and all device rows as
|
||||
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
|
||||
positions for single or multi-row moves between existing circuits and derive
|
||||
affected reserve states in the same transaction
|
||||
- placeholder-target moves persist one complete generated circuit identity and
|
||||
all row positions atomically; undo restores every source and removes only an
|
||||
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
|
||||
in one SQLite transaction
|
||||
- a stale expected revision produces no history writes
|
||||
- integration coverage verifies sequential revisions, stale-command rejection
|
||||
and rollback after a forced late persistence failure
|
||||
|
||||
The current editor operations are not connected to this history boundary yet.
|
||||
Undo/redo therefore remains session-local until the remaining tasks are
|
||||
implemented.
|
||||
ProjectDevice CRUD, synchronization and disconnect on the project page and
|
||||
Circuit/CircuitDeviceRow field/insert/delete actions in the circuit-list editor
|
||||
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:
|
||||
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@
|
||||
"build:api": "tsc -p tsconfig.json",
|
||||
"build:web": "next build",
|
||||
"start": "node dist/server/index.js",
|
||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-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-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-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:migrate": "drizzle-kit migrate",
|
||||
"db:backup": "tsx scripts/db-backup.ts",
|
||||
|
||||
@@ -20,9 +20,8 @@ import {
|
||||
listProjectDevices,
|
||||
listProjects,
|
||||
listRooms,
|
||||
reconnectProjectDeviceRows,
|
||||
restoreProjectDeviceRows,
|
||||
synchronizeProjectDeviceRows,
|
||||
undoProjectCommand,
|
||||
updateProjectDevice,
|
||||
updateProjectSettings,
|
||||
} from "../../../frontend/utils/api";
|
||||
@@ -34,7 +33,6 @@ import type {
|
||||
GlobalDeviceDto,
|
||||
ProjectDeviceDto,
|
||||
ProjectDeviceSyncPreviewDto,
|
||||
ProjectDeviceSyncRestoreRowDto,
|
||||
ProjectDto,
|
||||
RoomDto,
|
||||
} from "../../../frontend/types";
|
||||
@@ -57,9 +55,10 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||||
remark: "Bemerkung",
|
||||
};
|
||||
|
||||
type ProjectDeviceUndoState =
|
||||
| { kind: "synchronize"; projectDeviceId: string; rows: ProjectDeviceSyncRestoreRowDto[] }
|
||||
| { kind: "disconnect"; projectDeviceId: string; rowIds: string[] };
|
||||
type ProjectDeviceUndoState = {
|
||||
projectDeviceId: string;
|
||||
revisionNumber: number;
|
||||
};
|
||||
|
||||
const emptyProjectDevice: CreateProjectDeviceInput = {
|
||||
name: "",
|
||||
@@ -176,6 +175,13 @@ export default function ProjectDetailPage() {
|
||||
[circuitLists]
|
||||
);
|
||||
|
||||
function applyProjectRevision(currentRevision: number) {
|
||||
setProject((current) =>
|
||||
current ? { ...current, currentRevision } : current
|
||||
);
|
||||
setProjectDeviceUndo(null);
|
||||
}
|
||||
|
||||
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!projectId || !boardName.trim()) {
|
||||
@@ -258,7 +264,7 @@ export default function ProjectDetailPage() {
|
||||
|
||||
async function handleCreateProjectDevice(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!projectId || !projectDeviceForm.name.trim()) {
|
||||
if (!projectId || !project || !projectDeviceForm.name.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -279,8 +285,13 @@ export default function ProjectDetailPage() {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await createProjectDevice(projectId, payload);
|
||||
setProjectDevices((current) => [...current, created]);
|
||||
const result = await createProjectDevice(
|
||||
projectId,
|
||||
payload,
|
||||
project.currentRevision
|
||||
);
|
||||
setProjectDevices((current) => [...current, result.projectDevice]);
|
||||
applyProjectRevision(result.history.currentRevision);
|
||||
setProjectDeviceForm({
|
||||
name: emptyProjectDevice.name,
|
||||
displayName: emptyProjectDevice.displayName,
|
||||
@@ -302,14 +313,19 @@ export default function ProjectDetailPage() {
|
||||
}
|
||||
|
||||
async function handleDeleteProjectDevice(projectDeviceId: string) {
|
||||
if (!projectId) {
|
||||
if (!projectId || !project) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteProjectDevice(projectId, projectDeviceId);
|
||||
const result = await deleteProjectDevice(
|
||||
projectId,
|
||||
projectDeviceId,
|
||||
project.currentRevision
|
||||
);
|
||||
setProjectDevices((current) => current.filter((item) => item.id !== projectDeviceId));
|
||||
applyProjectRevision(result.history.currentRevision);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht gelöscht werden.");
|
||||
} finally {
|
||||
@@ -322,7 +338,7 @@ export default function ProjectDetailPage() {
|
||||
key: "name" | "displayName" | "category",
|
||||
value: string
|
||||
) {
|
||||
if (!projectId) {
|
||||
if (!projectId || !project) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -342,9 +358,19 @@ export default function ProjectDetailPage() {
|
||||
};
|
||||
|
||||
try {
|
||||
const updated = await updateProjectDevice(projectId, device.id, payload);
|
||||
setProjectDevices((current) => current.map((item) => (item.id === device.id ? updated : item)));
|
||||
await openProjectDeviceSyncPreview(updated.id);
|
||||
const result = await updateProjectDevice(
|
||||
projectId,
|
||||
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) {
|
||||
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
|
||||
}
|
||||
@@ -383,7 +409,13 @@ export default function ProjectDetailPage() {
|
||||
}
|
||||
|
||||
async function handleSynchronizeProjectDeviceRows() {
|
||||
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0) {
|
||||
if (
|
||||
!projectId ||
|
||||
!project ||
|
||||
!syncPreview ||
|
||||
selectedSyncRowIds.length === 0 ||
|
||||
selectedSyncFields.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
@@ -393,16 +425,17 @@ export default function ProjectDetailPage() {
|
||||
projectId,
|
||||
syncPreview.projectDevice.id,
|
||||
selectedSyncRowIds,
|
||||
selectedSyncFields
|
||||
selectedSyncFields,
|
||||
project.currentRevision
|
||||
);
|
||||
setSyncPreview(result.preview);
|
||||
applyProjectRevision(result.history.currentRevision);
|
||||
setSelectedSyncRowIds(
|
||||
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
|
||||
);
|
||||
setProjectDeviceUndo({
|
||||
kind: "synchronize",
|
||||
projectDeviceId: syncPreview.projectDevice.id,
|
||||
rows: result.undo.rows,
|
||||
revisionNumber: result.revision.revisionNumber,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
|
||||
@@ -412,7 +445,12 @@ export default function ProjectDetailPage() {
|
||||
}
|
||||
|
||||
async function handleDisconnectProjectDeviceRows() {
|
||||
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0) {
|
||||
if (
|
||||
!projectId ||
|
||||
!project ||
|
||||
!syncPreview ||
|
||||
selectedSyncRowIds.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
@@ -421,13 +459,19 @@ export default function ProjectDetailPage() {
|
||||
const result = await disconnectProjectDeviceRows(
|
||||
projectId,
|
||||
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({
|
||||
kind: "disconnect",
|
||||
projectDeviceId: syncPreview.projectDevice.id,
|
||||
rowIds: result.undo.rowIds,
|
||||
revisionNumber: result.revision.revisionNumber,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
|
||||
@@ -437,24 +481,29 @@ export default function ProjectDetailPage() {
|
||||
}
|
||||
|
||||
async function handleUndoProjectDeviceOperation() {
|
||||
if (!projectId || !projectDeviceUndo) {
|
||||
if (!projectId || !project || !projectDeviceUndo) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const preview =
|
||||
projectDeviceUndo.kind === "synchronize"
|
||||
? await restoreProjectDeviceRows(
|
||||
projectId,
|
||||
projectDeviceUndo.projectDeviceId,
|
||||
projectDeviceUndo.rows
|
||||
)
|
||||
: await reconnectProjectDeviceRows(
|
||||
projectId,
|
||||
projectDeviceUndo.projectDeviceId,
|
||||
projectDeviceUndo.rowIds
|
||||
);
|
||||
if (
|
||||
project.currentRevision !==
|
||||
projectDeviceUndo.revisionNumber
|
||||
) {
|
||||
throw new Error(
|
||||
"Eine neuere Projektänderung verhindert dieses Rückgängig."
|
||||
);
|
||||
}
|
||||
const result = await undoProjectCommand(
|
||||
projectId,
|
||||
project.currentRevision
|
||||
);
|
||||
const preview = await getProjectDeviceSyncPreview(
|
||||
projectId,
|
||||
projectDeviceUndo.projectDeviceId
|
||||
);
|
||||
applyProjectRevision(result.history.currentRevision);
|
||||
setSyncPreview(preview);
|
||||
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
||||
setProjectDeviceUndo(null);
|
||||
@@ -466,15 +515,20 @@ export default function ProjectDetailPage() {
|
||||
}
|
||||
|
||||
async function handleCopyGlobalToProject() {
|
||||
if (!projectId || !selectedGlobalDeviceId) {
|
||||
if (!projectId || !project || !selectedGlobalDeviceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await copyGlobalDeviceToProject(projectId, selectedGlobalDeviceId);
|
||||
setProjectDevices((current) => [...current, created]);
|
||||
const result = await copyGlobalDeviceToProject(
|
||||
projectId,
|
||||
selectedGlobalDeviceId,
|
||||
project.currentRevision
|
||||
);
|
||||
setProjectDevices((current) => [...current, result.projectDevice]);
|
||||
applyProjectRevision(result.history.currentRevision);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden.");
|
||||
} finally {
|
||||
|
||||
@@ -7,15 +7,12 @@ import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import {
|
||||
toCircuitDeviceRowCreateValues,
|
||||
toCircuitDeviceRowPatchValues,
|
||||
toCircuitDeviceRowUpdateValues,
|
||||
type CircuitDeviceRowCreateInput,
|
||||
type CircuitDeviceRowPatchInput,
|
||||
type CircuitDeviceRowUpdateInput,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
export type {
|
||||
CircuitDeviceRowCreateInput,
|
||||
CircuitDeviceRowPatchInput,
|
||||
CircuitDeviceRowUpdateInput,
|
||||
} 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));
|
||||
}
|
||||
|
||||
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) {
|
||||
const id = crypto.randomUUID();
|
||||
await db.insert(circuitDeviceRows).values(toCircuitDeviceRowCreateValues(id, input));
|
||||
@@ -117,14 +99,6 @@ export class CircuitDeviceRowRepository {
|
||||
.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) {
|
||||
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 {
|
||||
toCircuitCreateValues,
|
||||
toCircuitPatchValues,
|
||||
type CircuitCreatePersistenceInput,
|
||||
type CircuitPatchPersistenceInput,
|
||||
} from "./circuit.persistence.js";
|
||||
|
||||
export type {
|
||||
CircuitCreatePersistenceInput,
|
||||
CircuitPatchPersistenceInput,
|
||||
} from "./circuit.persistence.js";
|
||||
export {
|
||||
toCircuitCreateValues,
|
||||
toCircuitPatchValues,
|
||||
} from "./circuit.persistence.js";
|
||||
|
||||
export class CircuitRepository {
|
||||
@@ -84,14 +80,6 @@ export class CircuitRepository {
|
||||
.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) {
|
||||
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 { db } from "../client.js";
|
||||
import { projectDevices } from "../schema/project-devices.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) {
|
||||
return {
|
||||
@@ -21,31 +16,6 @@ export class ProjectDeviceRepository {
|
||||
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) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
@@ -57,33 +27,4 @@ export class ProjectDeviceRepository {
|
||||
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 {
|
||||
circuitListId: string;
|
||||
currentRevision: number;
|
||||
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 type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-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 { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||
import type {
|
||||
CreateCircuitDeviceRowInput,
|
||||
CreateCircuitInput,
|
||||
CreateCircuitWithDeviceRowsInput,
|
||||
MoveCircuitDeviceRowInput,
|
||||
MoveCircuitDeviceRowsBulkInput,
|
||||
ReorderSectionCircuitsInput,
|
||||
UpdateSectionEquipmentIdentifiersInput,
|
||||
UpdateCircuitDeviceRowInput,
|
||||
UpdateCircuitInput,
|
||||
} from "../../shared/validation/circuit.schemas.js";
|
||||
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
||||
import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js";
|
||||
|
||||
export class CircuitWriteService {
|
||||
private readonly circuitRepository: CircuitRepository;
|
||||
private readonly circuitSectionRepository: CircuitSectionRepository;
|
||||
private readonly circuitListRepository: CircuitListRepository;
|
||||
private readonly deviceRowRepository: CircuitDeviceRowRepository;
|
||||
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
||||
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
||||
private readonly projectDeviceRepository: ProjectDeviceRepository;
|
||||
private readonly numberingService: CircuitNumberingService;
|
||||
|
||||
constructor(deps?: {
|
||||
circuitRepository?: CircuitRepository;
|
||||
circuitSectionRepository?: CircuitSectionRepository;
|
||||
circuitListRepository?: CircuitListRepository;
|
||||
deviceRowRepository?: CircuitDeviceRowRepository;
|
||||
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
||||
circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
||||
projectDeviceRepository?: ProjectDeviceRepository;
|
||||
numberingService?: CircuitNumberingService;
|
||||
}) {
|
||||
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
|
||||
this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository();
|
||||
this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository();
|
||||
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
||||
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
|
||||
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
|
||||
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
||||
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
||||
}
|
||||
|
||||
@@ -61,36 +47,6 @@ export class CircuitWriteService {
|
||||
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() {
|
||||
if (!this.deviceRowTransactionStore) {
|
||||
throw new Error("Circuit device-row transactions are not configured.");
|
||||
@@ -105,107 +61,6 @@ export class CircuitWriteService {
|
||||
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) {
|
||||
const current = await this.circuitRepository.findById(circuitId);
|
||||
if (!current) {
|
||||
@@ -214,67 +69,6 @@ export class CircuitWriteService {
|
||||
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) {
|
||||
const row = await this.deviceRowRepository.findById(rowId);
|
||||
if (!row) {
|
||||
|
||||
@@ -19,6 +19,14 @@ import {
|
||||
assertCircuitUpdateProjectCommand,
|
||||
circuitUpdateCommandType,
|
||||
} 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 {
|
||||
assertCircuitDeleteProjectCommand,
|
||||
assertCircuitInsertProjectCommand,
|
||||
@@ -29,10 +37,26 @@ import {
|
||||
assertSerializedProjectCommand,
|
||||
type SerializedProjectCommand,
|
||||
} 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 { 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 { 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 {
|
||||
ExecuteProjectHistoryCommandInput,
|
||||
@@ -44,6 +68,9 @@ import type {
|
||||
ProjectHistoryDirection,
|
||||
ProjectHistoryStore,
|
||||
} 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";
|
||||
|
||||
interface DispatchProjectCommandInput {
|
||||
@@ -63,6 +90,11 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
|
||||
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
|
||||
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
|
||||
) {}
|
||||
|
||||
@@ -186,6 +218,48 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
command: input.command,
|
||||
}).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:
|
||||
throw new Error(
|
||||
`Unsupported project command type: ${input.command.type}.`
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.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 {
|
||||
projectDeviceSyncFields,
|
||||
type ProjectDeviceSyncField,
|
||||
@@ -20,20 +25,9 @@ type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProj
|
||||
|
||||
type SyncDependencies = {
|
||||
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
|
||||
deviceRowRepository: Pick<
|
||||
CircuitDeviceRowRepository,
|
||||
| "listLinkedByProjectDevice"
|
||||
| "listLinkStatesByIds"
|
||||
>;
|
||||
deviceRowSyncStore: ProjectDeviceRowSyncStore;
|
||||
deviceRowRepository: Pick<CircuitDeviceRowRepository, "listLinkedByProjectDevice">;
|
||||
};
|
||||
|
||||
export interface ProjectDeviceSyncRestoreRow {
|
||||
rowId: string;
|
||||
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
|
||||
overriddenFields: ProjectDeviceSyncField[];
|
||||
}
|
||||
|
||||
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
|
||||
return projectDevice[field];
|
||||
}
|
||||
@@ -56,19 +50,10 @@ function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
|
||||
export class ProjectDeviceSyncService {
|
||||
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"];
|
||||
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"];
|
||||
private readonly deviceRowSyncStore?: SyncDependencies["deviceRowSyncStore"];
|
||||
|
||||
constructor(deps?: Partial<SyncDependencies>) {
|
||||
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
||||
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) {
|
||||
@@ -100,12 +85,12 @@ export class ProjectDeviceSyncService {
|
||||
};
|
||||
}
|
||||
|
||||
async synchronize(
|
||||
async createSynchronizeCommand(
|
||||
projectId: string,
|
||||
projectDeviceId: string,
|
||||
rowIds: string[],
|
||||
fields: ProjectDeviceSyncField[]
|
||||
) {
|
||||
): Promise<ProjectDeviceRowSyncProjectCommand> {
|
||||
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
|
||||
if (!projectDevice) {
|
||||
throw new Error("Project device not found.");
|
||||
@@ -113,81 +98,60 @@ export class ProjectDeviceSyncService {
|
||||
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
||||
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
||||
const selectedFields = [...new Set(fields)];
|
||||
const undoRows: ProjectDeviceSyncRestoreRow[] = selectedRows.map((row) => ({
|
||||
rowId: row.id,
|
||||
values: Object.fromEntries(selectedFields.map((field) => [field, row[field] ?? null])),
|
||||
overriddenFields: parseOverriddenFields(row.overriddenFields),
|
||||
}));
|
||||
|
||||
const changes = [];
|
||||
for (const row of selectedRows) {
|
||||
const next = this.copyRow(row);
|
||||
const assignments = selectedRows.flatMap((row) => {
|
||||
const expected = toSyncSnapshot(row);
|
||||
const target = { ...expected };
|
||||
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(
|
||||
(field) => !selectedFields.includes(field)
|
||||
);
|
||||
next.overriddenFields = serializeOverriddenFields(remainingOverrides);
|
||||
changes.push({ rowId: row.id, input: next });
|
||||
}
|
||||
this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes);
|
||||
|
||||
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 };
|
||||
target.overriddenFields =
|
||||
serializeOverriddenFields(remainingOverrides) ?? null;
|
||||
return snapshotsEqual(expected, target)
|
||||
? []
|
||||
: [{ rowId: row.id, expected, target }];
|
||||
});
|
||||
|
||||
this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes);
|
||||
return this.getPreview(projectId, projectDeviceId);
|
||||
return createProjectDeviceRowSyncProjectCommand(
|
||||
projectDeviceId,
|
||||
"synchronize",
|
||||
assignments
|
||||
);
|
||||
}
|
||||
|
||||
async reconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
|
||||
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
|
||||
async createDisconnectCommand(
|
||||
projectId: string,
|
||||
projectDeviceId: string,
|
||||
rowIds: string[]
|
||||
): Promise<ProjectDeviceRowSyncProjectCommand> {
|
||||
const projectDevice = await this.projectDeviceRepository.findById(
|
||||
projectId,
|
||||
projectDeviceId
|
||||
);
|
||||
if (!projectDevice) {
|
||||
throw new Error("Project device not found.");
|
||||
}
|
||||
const uniqueRowIds = [...new Set(rowIds)];
|
||||
const linkStates = await this.deviceRowRepository.listLinkStatesByIds(projectId, uniqueRowIds);
|
||||
if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) {
|
||||
throw new Error("One or more rows cannot be reconnected.");
|
||||
}
|
||||
this.getDeviceRowSyncStore().reconnectRows(projectDeviceId, uniqueRowIds);
|
||||
return this.getPreview(projectId, projectDeviceId);
|
||||
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
||||
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
||||
return createProjectDeviceRowSyncProjectCommand(
|
||||
projectDeviceId,
|
||||
"disconnect",
|
||||
selectedRows.map((row) => {
|
||||
const expected = toSyncSnapshot(row);
|
||||
return {
|
||||
rowId: row.id,
|
||||
expected,
|
||||
target: {
|
||||
...expected,
|
||||
linkedProjectDeviceId: null,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {
|
||||
@@ -200,25 +164,31 @@ export class ProjectDeviceSyncService {
|
||||
return selectedRows;
|
||||
}
|
||||
|
||||
private copyRow(row: LinkedRow) {
|
||||
return {
|
||||
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
|
||||
name: row.name,
|
||||
displayName: row.displayName,
|
||||
phaseType: row.phaseType ?? undefined,
|
||||
connectionKind: row.connectionKind ?? undefined,
|
||||
costGroup: row.costGroup ?? undefined,
|
||||
category: row.category ?? undefined,
|
||||
level: row.level ?? undefined,
|
||||
roomId: row.roomId ?? undefined,
|
||||
roomNumberSnapshot: row.roomNumberSnapshot ?? undefined,
|
||||
roomNameSnapshot: row.roomNameSnapshot ?? undefined,
|
||||
quantity: row.quantity,
|
||||
powerPerUnit: row.powerPerUnit,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi ?? undefined,
|
||||
remark: row.remark ?? undefined,
|
||||
overriddenFields: row.overriddenFields ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toSyncSnapshot(row: LinkedRow): ProjectDeviceSyncRowSnapshot {
|
||||
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 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;
|
||||
}
|
||||
|
||||
export interface ProjectDeviceCommandResultDto
|
||||
extends ProjectCommandResultDto {
|
||||
projectDevice: ProjectDeviceDto;
|
||||
}
|
||||
|
||||
export interface CreateFloorInput {
|
||||
name: string;
|
||||
}
|
||||
@@ -160,24 +165,9 @@ export interface ProjectDeviceSyncPreviewDto {
|
||||
rows: ProjectDeviceSyncRowDto[];
|
||||
}
|
||||
|
||||
export interface ProjectDeviceSyncRestoreRowDto {
|
||||
rowId: string;
|
||||
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
|
||||
overriddenFields: ProjectDeviceSyncField[];
|
||||
}
|
||||
|
||||
export interface ProjectDeviceSyncResultDto {
|
||||
export interface ProjectDeviceSyncCommandResultDto
|
||||
extends ProjectCommandResultDto {
|
||||
preview: ProjectDeviceSyncPreviewDto;
|
||||
undo: {
|
||||
rows: ProjectDeviceSyncRestoreRowDto[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProjectDeviceDisconnectResultDto {
|
||||
disconnectedRowIds: string[];
|
||||
undo: {
|
||||
rowIds: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface CircuitTreeDeviceRowDto {
|
||||
@@ -250,6 +240,7 @@ export interface CircuitTreeMigrationReportDto {
|
||||
|
||||
export interface CircuitTreeResponseDto {
|
||||
circuitListId: string;
|
||||
currentRevision: number;
|
||||
sections: CircuitTreeSectionDto[];
|
||||
migrationReport?: CircuitTreeMigrationReportDto;
|
||||
}
|
||||
@@ -274,8 +265,6 @@ export interface CreateCircuitInputDto {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export type UpdateCircuitInputDto = Partial<CreateCircuitInputDto>;
|
||||
|
||||
export interface CreateCircuitDeviceRowInputDto {
|
||||
linkedProjectDeviceId?: string;
|
||||
name: string;
|
||||
@@ -297,5 +286,4 @@ export interface CreateCircuitDeviceRowInputDto {
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export type UpdateCircuitDeviceRowInputDto = Partial<CreateCircuitDeviceRowInputDto>;
|
||||
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
|
||||
|
||||
+153
-100
@@ -9,23 +9,30 @@ import type {
|
||||
GlobalDeviceDto,
|
||||
ProjectCommandDto,
|
||||
ProjectCommandResultDto,
|
||||
ProjectDeviceCommandResultDto,
|
||||
ProjectDeviceDto,
|
||||
ProjectHistoryStateDto,
|
||||
ProjectDeviceDisconnectResultDto,
|
||||
ProjectDeviceSyncCommandResultDto,
|
||||
ProjectDeviceSyncPreviewDto,
|
||||
ProjectDeviceSyncRestoreRowDto,
|
||||
ProjectDeviceSyncResultDto,
|
||||
ProjectDto,
|
||||
RoomDto,
|
||||
CircuitTreeResponseDto,
|
||||
CircuitTreeCircuitDto,
|
||||
CircuitTreeDeviceRowDto,
|
||||
CreateCircuitInputDto,
|
||||
UpdateCircuitInputDto,
|
||||
CreateCircuitDeviceRowInputDto,
|
||||
UpdateCircuitDeviceRowInputDto,
|
||||
} from "../types";
|
||||
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> {
|
||||
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`);
|
||||
}
|
||||
|
||||
export function createCircuit(projectId: string, circuitListId: string, input: CreateCircuitInputDto) {
|
||||
return request(`/api/projects/${projectId}/circuit-lists/${circuitListId}/circuits`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function createCircuitWithDeviceRows(
|
||||
export function updateCircuitById(
|
||||
projectId: string,
|
||||
circuitListId: string,
|
||||
input: {
|
||||
circuit: CreateCircuitInputDto;
|
||||
deviceRows: CreateCircuitDeviceRowInputDto[];
|
||||
}
|
||||
expectedRevision: number,
|
||||
circuitId: string,
|
||||
input: CircuitUpdatePatch
|
||||
) {
|
||||
return request<{
|
||||
circuit: CircuitTreeCircuitDto;
|
||||
deviceRows: CircuitTreeDeviceRowDto[];
|
||||
}>(
|
||||
`/api/projects/${projectId}/circuit-lists/${circuitListId}/circuits-with-device-rows`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
}
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
createCircuitUpdateProjectCommand(circuitId, input),
|
||||
"Stromkreisfeld bearbeiten"
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCircuitById(circuitId: string, input: UpdateCircuitInputDto) {
|
||||
return request(`/api/circuits/${circuitId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export function insertCircuitCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
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) {
|
||||
@@ -200,24 +224,55 @@ export function getNextCircuitIdentifier(sectionId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCircuitDeviceRow(circuitId: string, input: CreateCircuitDeviceRowInputDto) {
|
||||
return request(`/api/circuits/${circuitId}/device-rows`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export function updateCircuitDeviceRowById(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
rowId: string,
|
||||
input: CircuitDeviceRowUpdatePatch
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
createCircuitDeviceRowUpdateProjectCommand(rowId, input),
|
||||
"Gerätezeilenfeld bearbeiten"
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCircuitDeviceRowById(rowId: string, input: UpdateCircuitDeviceRowInputDto) {
|
||||
return request(`/api/circuit-device-rows/${rowId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export function insertCircuitDeviceRowCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
row: CircuitDeviceRowSnapshot,
|
||||
description: string
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-device-row.insert",
|
||||
payload: { row },
|
||||
},
|
||||
description
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCircuitDeviceRowById(rowId: string) {
|
||||
return request<void>(`/api/circuit-device-rows/${rowId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
export function deleteCircuitDeviceRowCommand(
|
||||
projectId: string,
|
||||
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(
|
||||
@@ -322,34 +377,58 @@ export function listProjectDevices(projectId: string) {
|
||||
return request<ProjectDeviceDto[]>(`/api/project-devices/projects/${projectId}`);
|
||||
}
|
||||
|
||||
export function createProjectDevice(projectId: string, input: CreateProjectDeviceInput) {
|
||||
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}`, {
|
||||
export function createProjectDevice(
|
||||
projectId: string,
|
||||
input: CreateProjectDeviceInput,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectDeviceCommandResultDto>(`/api/project-devices/projects/${projectId}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
body: JSON.stringify({ ...input, expectedRevision }),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProjectDevice(
|
||||
projectId: string,
|
||||
projectDeviceId: string,
|
||||
input: CreateProjectDeviceInput
|
||||
input: CreateProjectDeviceInput,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return request<ProjectDeviceCommandResultDto>(
|
||||
`/api/project-devices/projects/${projectId}/${projectDeviceId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ ...input, expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteProjectDevice(projectId: string, projectDeviceId: string) {
|
||||
return request<void>(`/api/project-devices/projects/${projectId}/${projectDeviceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
export function deleteProjectDevice(
|
||||
projectId: string,
|
||||
projectDeviceId: string,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectCommandResultDto>(
|
||||
`/api/project-devices/projects/${projectId}/${projectDeviceId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function copyGlobalDeviceToProject(projectId: string, globalDeviceId: string) {
|
||||
return request<ProjectDeviceDto>(`/api/project-devices/projects/${projectId}/import-global/${globalDeviceId}`, {
|
||||
method: "POST",
|
||||
});
|
||||
export function copyGlobalDeviceToProject(
|
||||
projectId: string,
|
||||
globalDeviceId: string,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectDeviceCommandResultDto>(
|
||||
`/api/project-devices/projects/${projectId}/import-global/${globalDeviceId}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function getProjectDeviceSyncPreview(projectId: string, projectDeviceId: string) {
|
||||
@@ -362,13 +441,14 @@ export function synchronizeProjectDeviceRows(
|
||||
projectId: string,
|
||||
projectDeviceId: string,
|
||||
rowIds: string[],
|
||||
fields: ProjectDeviceSyncField[]
|
||||
fields: ProjectDeviceSyncField[],
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectDeviceSyncResultDto>(
|
||||
return request<ProjectDeviceSyncCommandResultDto>(
|
||||
`/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rowIds, fields }),
|
||||
body: JSON.stringify({ rowIds, fields, expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -376,41 +456,14 @@ export function synchronizeProjectDeviceRows(
|
||||
export function disconnectProjectDeviceRows(
|
||||
projectId: string,
|
||||
projectDeviceId: string,
|
||||
rowIds: string[]
|
||||
rowIds: string[],
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectDeviceDisconnectResultDto>(
|
||||
return request<ProjectDeviceSyncCommandResultDto>(
|
||||
`/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rowIds }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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 }),
|
||||
body: JSON.stringify({ rowIds, expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 =
|
||||
| "equipmentIdentifier"
|
||||
@@ -189,6 +191,78 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine
|
||||
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 {
|
||||
switch (key) {
|
||||
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 { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-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 { 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";
|
||||
|
||||
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||
@@ -16,6 +21,16 @@ export const circuitDeviceRowMoveProjectCommandStore =
|
||||
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
||||
export const circuitStructureProjectCommandStore =
|
||||
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 projectCommandService = new ProjectCommandService(
|
||||
circuitProjectCommandStore,
|
||||
@@ -23,5 +38,10 @@ export const projectCommandService = new ProjectCommandService(
|
||||
circuitDeviceRowStructureProjectCommandStore,
|
||||
circuitDeviceRowMoveProjectCommandStore,
|
||||
circuitStructureProjectCommandStore,
|
||||
circuitSectionReorderProjectCommandStore,
|
||||
circuitSectionRenumberProjectCommandStore,
|
||||
projectDeviceStructureProjectCommandStore,
|
||||
projectDeviceProjectCommandStore,
|
||||
projectDeviceRowSyncProjectCommandStore,
|
||||
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";
|
||||
|
||||
export const projectDeviceSyncService = new ProjectDeviceSyncService({
|
||||
deviceRowSyncStore: new ProjectDeviceRowSyncRepository(db),
|
||||
});
|
||||
export const projectDeviceSyncService = new ProjectDeviceSyncService();
|
||||
|
||||
@@ -1,64 +1,10 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { circuitWriteService } from "../composition/circuit-write-service.js";
|
||||
import {
|
||||
createCircuitDeviceRowSchema,
|
||||
moveCircuitDeviceRowsBulkSchema,
|
||||
moveCircuitDeviceRowSchema,
|
||||
updateCircuitDeviceRowSchema,
|
||||
} 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) {
|
||||
const { rowId } = req.params;
|
||||
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 { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
||||
import {
|
||||
calculateCircuitTotalPower,
|
||||
calculateRowTotalPower,
|
||||
@@ -13,6 +14,7 @@ const circuitListRepository = new CircuitListRepository();
|
||||
const circuitSectionRepository = new CircuitSectionRepository();
|
||||
const circuitRepository = new CircuitRepository();
|
||||
const circuitDeviceRowRepository = new CircuitDeviceRowRepository();
|
||||
const projectRepository = new ProjectRepository();
|
||||
|
||||
export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
@@ -35,6 +37,10 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
if (!list) {
|
||||
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 {
|
||||
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
||||
@@ -52,6 +58,7 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
|
||||
const tree: CircuitTreeResponse = {
|
||||
circuitListId,
|
||||
currentRevision: project.currentRevision,
|
||||
sections: sections.map((section) => ({
|
||||
id: section.id,
|
||||
key: section.key,
|
||||
@@ -123,6 +130,7 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
if (isMissingCircuitTreeSchemaError(error)) {
|
||||
return res.json({
|
||||
circuitListId,
|
||||
currentRevision: project.currentRevision,
|
||||
sections: [],
|
||||
warning:
|
||||
"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 { 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) {
|
||||
const { circuitId } = req.params;
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import { GlobalDeviceRepository } from "../../db/repositories/global-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 {
|
||||
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,
|
||||
reconnectProjectDeviceRowsSchema,
|
||||
restoreProjectDeviceRowsSchema,
|
||||
projectDeviceStructureCommandSchema,
|
||||
synchronizeProjectDeviceRowsSchema,
|
||||
updateProjectDeviceSchema,
|
||||
updateProjectDeviceCommandSchema,
|
||||
} 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 projectDeviceRepository = new ProjectDeviceRepository();
|
||||
@@ -27,12 +36,28 @@ export async function createProjectDevice(req: Request, res: Response) {
|
||||
if (typeof projectId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid projectId" });
|
||||
}
|
||||
const parsed = createProjectDeviceSchema.safeParse(req.body);
|
||||
const parsed = createProjectDeviceCommandSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const created = await projectDeviceRepository.create(projectId, parsed.data);
|
||||
return res.status(201).json(created);
|
||||
const { expectedRevision, ...input } = parsed.data;
|
||||
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) {
|
||||
@@ -41,19 +66,32 @@ export async function updateProjectDevice(req: Request, res: Response) {
|
||||
return res.status(400).json({ error: "Invalid parameters" });
|
||||
}
|
||||
|
||||
const parsed = updateProjectDeviceSchema.safeParse(req.body);
|
||||
const parsed = updateProjectDeviceCommandSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const { expectedRevision, ...input } = parsed.data;
|
||||
|
||||
await projectDeviceRepository.update(projectId, projectDeviceId, parsed.data);
|
||||
// Linked rows are synchronized only through the explicit review flow.
|
||||
// Updating a project device must never silently overwrite local circuit-list values.
|
||||
const row = await projectDeviceRepository.findById(projectId, projectDeviceId);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: "Project device not found" });
|
||||
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.
|
||||
// Updating a project device must never silently overwrite local circuit-list values.
|
||||
const projectDevice = await projectDeviceRepository.findById(projectId, projectDeviceId);
|
||||
if (!projectDevice) {
|
||||
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) {
|
||||
@@ -62,8 +100,23 @@ export async function deleteProjectDevice(req: Request, res: Response) {
|
||||
return res.status(400).json({ error: "Invalid parameters" });
|
||||
}
|
||||
|
||||
await projectDeviceRepository.delete(projectId, projectDeviceId);
|
||||
return res.status(204).send();
|
||||
const parsed = projectDeviceStructureCommandSchema.safeParse(req.body);
|
||||
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) {
|
||||
@@ -71,13 +124,17 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
||||
if (typeof projectId !== "string" || typeof globalDeviceId !== "string") {
|
||||
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);
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: "Global device not found" });
|
||||
}
|
||||
|
||||
const created = await projectDeviceRepository.create(projectId, {
|
||||
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), {
|
||||
name: source.name,
|
||||
displayName: source.displayName,
|
||||
phaseType: source.phaseCount === 3 ? "three_phase" : "single_phase",
|
||||
@@ -90,7 +147,50 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
||||
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) {
|
||||
@@ -116,32 +216,25 @@ export async function synchronizeProjectDeviceRows(req: Request, res: Response)
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
try {
|
||||
const result = await projectDeviceSyncService.synchronize(
|
||||
const command = await projectDeviceSyncService.createSynchronizeCommand(
|
||||
projectId,
|
||||
projectDeviceId,
|
||||
parsed.data.rowIds,
|
||||
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) {
|
||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to synchronize rows." });
|
||||
}
|
||||
}
|
||||
|
||||
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." });
|
||||
return respondWithProjectCommandError(error, res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,26 +248,23 @@ export async function disconnectProjectDeviceRows(req: Request, res: Response) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
try {
|
||||
const result = await projectDeviceSyncService.disconnect(projectId, projectDeviceId, parsed.data.rowIds);
|
||||
return res.json(result);
|
||||
const command = await projectDeviceSyncService.createDisconnectCommand(
|
||||
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) {
|
||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to disconnect rows." });
|
||||
}
|
||||
}
|
||||
|
||||
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." });
|
||||
return respondWithProjectCommandError(error, res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import {
|
||||
deleteCircuitDeviceRow,
|
||||
moveCircuitDeviceRowsBulk,
|
||||
moveCircuitDeviceRow,
|
||||
updateCircuitDeviceRow,
|
||||
} from "../controllers/circuit-device-row.controller.js";
|
||||
|
||||
export const circuitDeviceRowRouter = Router();
|
||||
|
||||
circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk);
|
||||
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow);
|
||||
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
|
||||
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { Router } from "express";
|
||||
import {
|
||||
createCircuit,
|
||||
createCircuitWithDeviceRows,
|
||||
deleteCircuit,
|
||||
getNextCircuitIdentifier,
|
||||
updateCircuit,
|
||||
} from "../controllers/circuit.controller.js";
|
||||
import { createCircuitDeviceRow } from "../controllers/circuit-device-row.controller.js";
|
||||
|
||||
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.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
|
||||
circuitRouter.post("/circuits/:circuitId/device-rows", createCircuitDeviceRow);
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
disconnectProjectDeviceRows,
|
||||
getProjectDeviceSyncPreview,
|
||||
listProjectDevicesByProject,
|
||||
reconnectProjectDeviceRows,
|
||||
restoreProjectDeviceRows,
|
||||
synchronizeProjectDeviceRows,
|
||||
updateProjectDevice,
|
||||
} from "../controllers/project-device.controller.js";
|
||||
@@ -19,8 +17,6 @@ projectDeviceRouter.post("/projects/:projectId", createProjectDevice);
|
||||
projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject);
|
||||
projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview);
|
||||
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/reconnect", reconnectProjectDeviceRows);
|
||||
projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice);
|
||||
projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice);
|
||||
|
||||
@@ -1,60 +1,5 @@
|
||||
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
|
||||
.object({
|
||||
targetCircuitId: z.string().min(1).optional(),
|
||||
@@ -97,11 +42,6 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({
|
||||
.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 MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
|
||||
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const expectedProjectRevisionSchema = z.number().int().nonnegative();
|
||||
|
||||
const projectCommandEnvelopeSchema = z.object({
|
||||
schemaVersion: z.number().int().positive(),
|
||||
type: z.string().trim().min(1),
|
||||
@@ -7,11 +9,11 @@ const projectCommandEnvelopeSchema = z.object({
|
||||
});
|
||||
|
||||
export const executeProjectCommandSchema = z.object({
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
description: z.string().trim().min(1).max(500).optional(),
|
||||
command: projectCommandEnvelopeSchema,
|
||||
});
|
||||
|
||||
export const executeProjectHistoryCommandSchema = z.object({
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { projectDeviceSyncFields } from "../constants/project-device-sync-fields.js";
|
||||
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||
|
||||
export const createProjectDeviceSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -19,42 +20,30 @@ export const createProjectDeviceSchema = z.object({
|
||||
|
||||
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({
|
||||
rowIds: z.array(z.string().min(1)).min(1),
|
||||
fields: z.array(z.enum(projectDeviceSyncFields)).min(1),
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
});
|
||||
|
||||
export const disconnectProjectDeviceRowsSchema = z.object({
|
||||
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 UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>;
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
allColumns,
|
||||
buildCircuitEditPatch,
|
||||
buildDeviceRowEditPatch,
|
||||
getBlockSortValue,
|
||||
getCellKind,
|
||||
getCircuitValue,
|
||||
@@ -85,4 +87,38 @@ describe("circuit grid model", () => {
|
||||
assert.equal(parseNumeric("quantity", ""), undefined);
|
||||
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",
|
||||
sortOrder: 10,
|
||||
protectionRatedCurrent: 10,
|
||||
voltage: 230,
|
||||
controlRequirement: "none",
|
||||
isReserve: 0,
|
||||
},
|
||||
{
|
||||
@@ -111,6 +113,8 @@ describe("circuit project-command repository", () => {
|
||||
const command = createCircuitUpdateProjectCommand("circuit-1", {
|
||||
displayName: null,
|
||||
protectionRatedCurrent: 16,
|
||||
voltage: 400,
|
||||
controlRequirement: "DALI",
|
||||
isReserve: true,
|
||||
});
|
||||
|
||||
@@ -125,6 +129,8 @@ describe("circuit project-command repository", () => {
|
||||
const circuit = getCircuit(fixture.context);
|
||||
assert.equal(circuit.displayName, null);
|
||||
assert.equal(circuit.protectionRatedCurrent, 16);
|
||||
assert.equal(circuit.voltage, 400);
|
||||
assert.equal(circuit.controlRequirement, "DALI");
|
||||
assert.equal(circuit.isReserve, 1);
|
||||
assert.equal(executed.revision.revisionNumber, 1);
|
||||
assert.deepEqual(executed.inverse.payload, {
|
||||
@@ -132,6 +138,8 @@ describe("circuit project-command repository", () => {
|
||||
changes: [
|
||||
{ field: "displayName", value: "Licht Bestand" },
|
||||
{ field: "protectionRatedCurrent", value: 10 },
|
||||
{ field: "voltage", value: 230 },
|
||||
{ field: "controlRequirement", value: "none" },
|
||||
{ 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 { describe, it } from "node:test";
|
||||
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
|
||||
import { createCircuitSchema } from "../src/shared/validation/circuit.schemas.js";
|
||||
|
||||
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 () => {
|
||||
let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = [];
|
||||
const service = new CircuitWriteService({
|
||||
@@ -741,36 +445,4 @@ describe("circuit write service rules", () => {
|
||||
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,
|
||||
createCircuitInsertProjectCommand,
|
||||
} 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", () => {
|
||||
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", () => {
|
||||
const row = {
|
||||
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 { 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 { 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 { DistributionBoardRepository } from "../src/db/repositories/distribution-board.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 { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.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 { projects } from "../src/db/schema/projects.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";
|
||||
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 { 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 { 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";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
@@ -84,6 +95,11 @@ function createService(context: DatabaseContext) {
|
||||
new CircuitDeviceRowStructureProjectCommandRepository(context.db),
|
||||
new CircuitDeviceRowMoveProjectCommandRepository(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)
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
const context = createTestDatabase();
|
||||
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", () => {
|
||||
const context = createTestDatabase();
|
||||
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 { 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", () => {
|
||||
it("accepts circuit device fields", () => {
|
||||
@@ -33,4 +40,78 @@ describe("project device circuit-first schema", () => {
|
||||
|
||||
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,
|
||||
cosPhi: 0.95,
|
||||
remark: "DALI",
|
||||
voltageV: 230,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,8 +56,6 @@ function linkedRow() {
|
||||
|
||||
function createService() {
|
||||
const rows = [linkedRow()];
|
||||
const updates: Array<Record<string, unknown>> = [];
|
||||
let transactionCalls = 0;
|
||||
const service = new ProjectDeviceSyncService({
|
||||
projectDeviceRepository: {
|
||||
async findById() {
|
||||
@@ -65,99 +64,104 @@ function createService() {
|
||||
},
|
||||
deviceRowRepository: {
|
||||
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", () => {
|
||||
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");
|
||||
|
||||
assert.equal(updates.length, 0);
|
||||
assert.deepEqual(rows, before);
|
||||
assert.equal(preview.rows[0].equipmentIdentifier, "-1F1");
|
||||
assert.equal(preview.rows[0].differences.some((difference) => difference.field === "quantity"), true);
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
it("synchronizes only explicitly selected fields", async () => {
|
||||
const { service, rows, transactionCalls } = createService();
|
||||
await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
|
||||
it("creates a deterministic command for selected synchronization fields", async () => {
|
||||
const { service, rows } = createService();
|
||||
const before = structuredClone(rows);
|
||||
const command = await service.createSynchronizeCommand(
|
||||
"p1",
|
||||
"pd1",
|
||||
["row1"],
|
||||
["quantity", "powerPerUnit"]
|
||||
);
|
||||
|
||||
assert.equal(rows[0].quantity, 6);
|
||||
assert.equal(rows[0].powerPerUnit, 0.04);
|
||||
assert.equal(rows[0].displayName, "Local display name");
|
||||
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName"]);
|
||||
assert.equal(transactionCalls(), 1);
|
||||
assert.deepEqual(rows, before);
|
||||
assert.equal(command.payload.operation, "synchronize");
|
||||
assert.equal(command.payload.rows[0].expected.quantity, 2);
|
||||
assert.equal(command.payload.rows[0].target.quantity, 6);
|
||||
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 () => {
|
||||
const { service } = createService();
|
||||
await assert.rejects(
|
||||
() => service.synchronize("p1", "pd1", ["other-row"], ["quantity"]),
|
||||
() =>
|
||||
service.createSynchronizeCommand(
|
||||
"p1",
|
||||
"pd1",
|
||||
["other-row"],
|
||||
["quantity"]
|
||||
),
|
||||
/not linked/
|
||||
);
|
||||
});
|
||||
|
||||
it("disconnects selected rows without changing local values", async () => {
|
||||
const { service, updates } = createService();
|
||||
await service.disconnect("p1", "pd1", ["row1"]);
|
||||
it("creates a link-only disconnect command", async () => {
|
||||
const { service } = createService();
|
||||
const command = await service.createDisconnectCommand(
|
||||
"p1",
|
||||
"pd1",
|
||||
["row1"]
|
||||
);
|
||||
|
||||
assert.equal(updates[0].linkedProjectDeviceId, undefined);
|
||||
assert.equal(updates[0].displayName, "Local display name");
|
||||
assert.equal(updates[0].quantity, 2);
|
||||
const assignment = command.payload.rows[0];
|
||||
assert.equal(command.payload.operation, "disconnect");
|
||||
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 () => {
|
||||
const { service, rows } = createService();
|
||||
const result = await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
|
||||
|
||||
await service.restore("p1", "pd1", result.undo.rows);
|
||||
|
||||
assert.equal(rows[0].quantity, 2);
|
||||
assert.equal(rows[0].powerPerUnit, 0.03);
|
||||
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName", "quantity"]);
|
||||
it("does not create no-op synchronization commands", async () => {
|
||||
const { service } = createService();
|
||||
await assert.rejects(
|
||||
() =>
|
||||
service.createSynchronizeCommand(
|
||||
"p1",
|
||||
"pd1",
|
||||
["row1"],
|
||||
["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