Compare commits
41 Commits
2eba4ea75e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cfd4778305 | |||
| fac6c9350f | |||
| 9ea081179d | |||
| dcb303284c | |||
| 602ae6da0b | |||
| 1b2ca84258 | |||
| 096ba8aa1c | |||
| 084103bf54 | |||
| b1a11397b3 | |||
| 194bc9c0b1 | |||
| 0180f768ac | |||
| a04c2c04d4 | |||
| edf245a21c | |||
| feec814dc5 | |||
| da8115fe1d | |||
| 5fa2a701dc | |||
| d77cfbeb49 | |||
| e1fc81a083 | |||
| d7b98de56a | |||
| 859fa4a42a | |||
| c1be85e408 | |||
| 0cedeaa959 | |||
| 4789e01aac | |||
| 214ad728cd | |||
| e4d22caf09 | |||
| 0f306d9a90 | |||
| 266bdb4749 | |||
| d23e7d990c | |||
| ac465a1cc0 | |||
| 1cf26a932e | |||
| 1a77eedaa8 | |||
| 59d442593f | |||
| b2763f72d5 | |||
| d00ae30bda | |||
| 384a5769ab | |||
| 7c670fece3 | |||
| 5a0c9019af | |||
| 4b47bc2bcc | |||
| c45afd0981 | |||
| abcb468807 | |||
| 08a2775a88 |
@@ -21,6 +21,24 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
|
||||
`src/frontend/components/circuit-grid-*.ts` modules.
|
||||
- Critical multi-write commands use injected transaction repositories with real
|
||||
SQLite commit/rollback tests.
|
||||
- All supported runtime project-command stores, including full snapshot
|
||||
restoration, share
|
||||
`src/db/repositories/project-command-transaction.persistence.ts` for the
|
||||
atomic domain-write, revision and history transition boundary. Its applied
|
||||
forward-command variant preserves derived CircuitDeviceRow override metadata.
|
||||
- Low-level `appendProjectRevision` persistence is adapter-internal and tested
|
||||
directly; do not reintroduce a standalone runtime revision repository.
|
||||
- Runtime domain services receive narrow reader/store dependencies explicitly;
|
||||
concrete SQLite repositories are instantiated only under `src/server/composition`.
|
||||
- General application repositories also require an explicit `AppDatabase`;
|
||||
`src/server/composition/application-repositories.ts` owns their runtime
|
||||
instances, and controllers never import the global SQLite client.
|
||||
- The upgrade-only legacy migration service follows the same dependency rule;
|
||||
its concrete repositories are instantiated only in
|
||||
`scripts/db-migrate-legacy-consumers.ts`.
|
||||
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
|
||||
repositories expose only active reads. Runtime writes belong in typed command
|
||||
repositories; direct integration fixtures belong under `tests/support`.
|
||||
|
||||
The supported runtime model is Circuit-First. The former Consumer UI and API are
|
||||
removed. Retained `consumers` rows, migration mappings and reports are upgrade-only
|
||||
@@ -220,15 +238,34 @@ After saving, the row becomes linked to the new project device.
|
||||
|
||||
## Undo / Redo
|
||||
|
||||
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.
|
||||
The editor reads undo/redo eligibility from the project-wide server history on
|
||||
initial load and after every tree reload. Undo/redo therefore remains available
|
||||
after a page refresh or application restart. All currently supported Circuit
|
||||
and CircuitDeviceRow writes execute persistent project commands. Applying a
|
||||
sorted view across multiple sections is one atomic `circuit.reorder-sections`
|
||||
command and one undo step. Explicit renumbering uses the collision-safe
|
||||
`circuit.renumber-section` command and is never triggered implicitly.
|
||||
Immutable revision metadata is available through the paginated
|
||||
`GET /api/projects/:projectId/history/revisions` endpoint; it does not expose
|
||||
stored command payloads.
|
||||
Named logical snapshots can be created and listed through project-scoped API
|
||||
endpoints. Their schema-versioned payload contains the complete supported
|
||||
project runtime state and a SHA-256, excludes global/upgrade-only data and does
|
||||
not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||
project data atomically and records a new `restore` revision with a complete
|
||||
inverse command. Restore can therefore be undone and redone after a restart.
|
||||
The central revision boundary creates an automatic logical snapshot after each
|
||||
25 new revisions and retains only the newest 12 automatic snapshots per
|
||||
project. Named snapshots are never removed by this retention policy.
|
||||
The project page exposes persistent project-wide Undo/Redo in the header of an
|
||||
initially collapsed German snapshot/timeline UI, with explicit restore
|
||||
confirmation and cursor-based loading of older revision metadata.
|
||||
Insertions and generated move targets use client-generated stable UUIDs, and
|
||||
undo restores the same ids from complete server snapshots. The tree response
|
||||
supplies the optimistic `currentRevision`; obsolete direct field PATCH,
|
||||
structure POST, move, reorder, renumber, identifier-restore, Circuit and
|
||||
CircuitDeviceRow DELETE routes are removed.
|
||||
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
|
||||
@@ -246,6 +283,24 @@ 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.
|
||||
Project settings use the persistent `project.update-settings` command. Project
|
||||
metadata, both voltage defaults and the enabled distribution-board supply types
|
||||
change in one revision and Undo/Redo restores them together; the project PUT
|
||||
route requires `expectedRevision`. The system catalog is `AV`, `SV`, `EV`,
|
||||
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
|
||||
cannot be disabled.
|
||||
Distribution-board setup uses `distribution-board.insert` with a complete
|
||||
stable snapshot of the board, circuit list and four default sections. Its
|
||||
inverse removes only the same unchanged and still-empty structure; the POST
|
||||
route requires `expectedRevision` and returns the updated history state.
|
||||
Distribution-board floor assignment and a project-enabled supply type use
|
||||
`distribution-board.update`; both values are snapshot/export fields and one
|
||||
persistent undo step.
|
||||
Floor and room setup use `project-floor.insert` and `project-room.insert` with
|
||||
complete stable snapshots. Their inverses remove only unchanged records and
|
||||
reject floors with assigned rooms/distribution boards or rooms referenced by
|
||||
device rows or retained upgrade data. Both POST routes require `expectedRevision` and return
|
||||
the updated history state.
|
||||
|
||||
Required operations:
|
||||
|
||||
@@ -287,7 +342,7 @@ Users must be able to override sizing suggestions.
|
||||
- Never edit an already applied migration.
|
||||
- Back up an existing database before applying a new migration.
|
||||
- Inspect generated SQL; it must contain only the intended schema change.
|
||||
- Keep database backups separate from future logical project snapshots.
|
||||
- Keep database backups separate from logical project snapshots.
|
||||
- Do not import the global SQLite singleton into domain services.
|
||||
- Keep synchronous SQLite transaction behavior inside persistence adapters.
|
||||
- Preserve stable UUIDs and explicit transaction boundaries for a later
|
||||
@@ -295,8 +350,6 @@ Users must be able to override sizing suggestions.
|
||||
|
||||
## Current Deferred Work
|
||||
|
||||
- complete persistent project command coverage and the undo/redo UI cutover
|
||||
- named logical snapshots and restore
|
||||
- Revit/CSV/IFCGUID round-trip
|
||||
- full electrical sizing
|
||||
- multi-user/PostgreSQL operation
|
||||
@@ -317,6 +370,7 @@ For a normal code change run the relevant focused tests plus:
|
||||
- `npm test`
|
||||
- `npm run build:api`
|
||||
- `npm run build:web`
|
||||
- `npm run typecheck:scripts`
|
||||
- `npx tsc --noEmit -p tsconfig.next.json`
|
||||
|
||||
Use a concise imperative commit message for each completed, verified work package.
|
||||
|
||||
@@ -6,8 +6,8 @@ Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
||||
|
||||
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
|
||||
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
|
||||
persistentes Undo/Redo, Projektversionen und der Revit-/IFCGUID-Datenaustausch sind
|
||||
noch nicht implementiert.
|
||||
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
|
||||
implementiert.
|
||||
|
||||
## Unterstützter Arbeitsablauf
|
||||
|
||||
@@ -18,7 +18,9 @@ noch nicht implementiert.
|
||||
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
|
||||
- komplette Stromkreisblöcke filtern und sortieren
|
||||
- BMKs stabil halten und nur auf ausdrücklichen Befehl neu nummerieren
|
||||
- Änderungen innerhalb der aktuellen Editorsitzung rückgängig machen und wiederholen
|
||||
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
|
||||
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
|
||||
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
|
||||
|
||||
## Technik
|
||||
|
||||
|
||||
@@ -27,8 +27,10 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
||||
## Zukunftsarchitektur
|
||||
|
||||
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
||||
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md)
|
||||
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
|
||||
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
||||
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
|
||||
|
||||
## Fachliche Referenzspezifikation
|
||||
|
||||
|
||||
+106
-81
@@ -14,6 +14,16 @@ this value for optimistic concurrency checks.
|
||||
- `GET /projects/:projectId/history`
|
||||
- returns `currentRevision`, `undoDepth`, `redoDepth` and the current top
|
||||
change-set id for both persistent stacks
|
||||
- `GET /projects/:projectId/history/revisions`
|
||||
- returns revision metadata in descending `revisionNumber` order
|
||||
- optional query: `limit` (default `25`, maximum `100`) and exclusive
|
||||
`beforeRevision`
|
||||
- returns `projectId`, `currentRevision`, `revisions` and
|
||||
`nextBeforeRevision`; use the latter as the next page's
|
||||
`beforeRevision`
|
||||
- each revision contains ids, number, timestamp, actor, source, description,
|
||||
command type and payload schema version
|
||||
- stored forward/inverse command payloads are intentionally not exposed
|
||||
- `POST /projects/:projectId/commands`
|
||||
- executes a supported versioned command as a new user revision
|
||||
- body: `{ "expectedRevision": 0, "command": { ... } }`
|
||||
@@ -22,23 +32,50 @@ this value for optimistic concurrency checks.
|
||||
- body: `{ "expectedRevision": 1 }`
|
||||
- executes the eligible inverse or forward command as a new auditable
|
||||
revision
|
||||
- `GET /projects/:projectId/snapshots`
|
||||
- lists named and automatic logical snapshot metadata, including `kind`,
|
||||
without returning payload JSON
|
||||
- `POST /projects/:projectId/snapshots`
|
||||
- body:
|
||||
`{ "expectedRevision": 12, "name": "Vor Ausschreibung", "description": "Optional" }`
|
||||
- atomically captures the complete project-scoped runtime state at the
|
||||
expected revision
|
||||
- returns `201` with source revision, schema version, SHA-256 and creation
|
||||
metadata
|
||||
- snapshot creation does not increment the project revision or alter
|
||||
undo/redo stacks
|
||||
- duplicate names and stale revisions return `409`
|
||||
- the central revision boundary additionally creates `automatic` snapshots
|
||||
after 25 further revisions and retains the newest 12 per project; named
|
||||
snapshots are never removed by this policy
|
||||
- `POST /projects/:projectId/snapshots/:snapshotId/restore`
|
||||
- body: `{ "expectedRevision": 13 }`
|
||||
- verifies the stored payload checksum and atomically restores the complete
|
||||
supported project state as revision `14` with source `restore`
|
||||
- stores the complete pre-restore state as the inverse command, so project
|
||||
Undo/Redo can revert or repeat the restore after a restart
|
||||
- stale revisions or state checks return `409`; unknown snapshots return
|
||||
`404`
|
||||
- clients cannot submit arbitrary `project.restore-state` payloads through
|
||||
the generic command endpoint
|
||||
|
||||
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
|
||||
`circuit.delete`, `circuit-device-row.update`,
|
||||
`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
|
||||
`circuit.reorder-section`, `circuit.reorder-sections`,
|
||||
`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
|
||||
`project-device.sync-rows` as well as `project.update-settings`, 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.
|
||||
CircuitDeviceRow cell edits, standalone insert/delete actions, device-row
|
||||
moves, reorders and explicit renumbering in the editor consume these endpoints.
|
||||
|
||||
`circuit-device-row.move` targets existing circuits in the same circuit list.
|
||||
`circuit-device-row.move-with-new-circuit` atomically creates exactly one
|
||||
@@ -52,6 +89,11 @@ 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.reorder-sections` contains one complete assignment block per affected
|
||||
section. All blocks are validated before any write and commit or roll back
|
||||
together as one revision and one undo step. The editor uses it when applying a
|
||||
sorted view that changes multiple sections.
|
||||
|
||||
`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
|
||||
@@ -99,6 +141,25 @@ These write endpoints execute the typed `project-device.update`,
|
||||
returns `409 PROJECT_REVISION_CONFLICT`. Updating a project device still never
|
||||
synchronizes linked circuit rows implicitly.
|
||||
|
||||
### Project Settings
|
||||
|
||||
- `PUT /projects/:projectId`
|
||||
- request:
|
||||
`{ "expectedRevision": 12, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }`
|
||||
- executes `project.update-settings` as one atomic revision
|
||||
- response:
|
||||
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
|
||||
- persistent Undo/Redo restores metadata, voltage values and the enabled
|
||||
distribution-board supply types together; project-device and circuit
|
||||
voltages are recalculated from the restored project settings in the same
|
||||
transaction
|
||||
- project-device and circuit voltage are derived values and are not accepted
|
||||
as editable frontend fields
|
||||
- at least one of `AV`, `SV`, `EV`, `USV`, `MSR`, `SiBe` must be enabled;
|
||||
a supply type currently used by a distribution board cannot be disabled
|
||||
- unchanged values are rejected without creating a revision; a stale
|
||||
revision returns `409 PROJECT_REVISION_CONFLICT`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
@@ -127,7 +188,41 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||
### Distribution Board Setup
|
||||
|
||||
- `POST /projects/:projectId/distribution-boards`
|
||||
- atomically creates the distribution board, its circuit list and all default circuit sections
|
||||
- body:
|
||||
`{ "name": "UV-02", "floorId": "floor_1", "supplyType": "SV", "expectedRevision": 12 }`
|
||||
- `floorId` may be `null`; a non-null floor must belong to the project
|
||||
- `supplyType` is one of `AV`, `SV`, `EV`, `USV`, `MSR` or `SiBe` and must
|
||||
be enabled in the project settings
|
||||
- executes `distribution-board.insert` with stable ids for the distribution
|
||||
board, its circuit list and all four default circuit sections
|
||||
- response:
|
||||
`{ "distributionBoard": { ... }, "revision": { ... }, "history": { ... } }`
|
||||
- persistent Undo removes only the unchanged and still-empty generated
|
||||
structure; Redo restores the same ids
|
||||
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
||||
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
||||
- body:
|
||||
`{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }`
|
||||
- executes `distribution-board.update`; floor and supply type are restored
|
||||
together by persistent Undo/Redo
|
||||
|
||||
### Project Floors and Rooms
|
||||
|
||||
- `POST /projects/:projectId/floors`
|
||||
- body: `{ "name": "EG", "expectedRevision": 13 }`
|
||||
- executes `project-floor.insert` with a stable floor id
|
||||
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }`
|
||||
- `POST /projects/:projectId/rooms`
|
||||
- body:
|
||||
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }`
|
||||
- executes `project-room.insert` with a stable room id; `floorId` is optional
|
||||
and must belong to the project when present
|
||||
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
|
||||
- Persistent Undo removes only unchanged, unreferenced records. A floor with
|
||||
assigned rooms or distribution boards and a room referenced by device rows
|
||||
or retained upgrade data are rejected instead of silently clearing foreign
|
||||
keys.
|
||||
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
|
||||
|
||||
### Tree Endpoint
|
||||
|
||||
@@ -169,82 +264,12 @@ Response sketch:
|
||||
- `GET /circuit-sections/:sectionId/next-identifier`
|
||||
- preview next identifier for section (`prefix + maxSuffix + 1`)
|
||||
|
||||
Circuit and device-row field updates, standalone insertions and standalone
|
||||
deletions are available only as the corresponding versioned commands through
|
||||
Circuit and device-row field updates, standalone insertions/deletions, single
|
||||
or bulk device-row moves, circuit reorders and explicit renumbering are
|
||||
available only as the corresponding versioned commands through
|
||||
`POST /projects/:projectId/commands`. There are no direct field-update PATCH,
|
||||
structure POST 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
|
||||
|
||||
- `PATCH /circuit-device-rows/:rowId/move`
|
||||
- Purpose: move one row to existing circuit or to newly created circuit in target section.
|
||||
|
||||
Request sketch:
|
||||
|
||||
```json
|
||||
{
|
||||
"targetCircuitId": "cir_target"
|
||||
}
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```json
|
||||
{
|
||||
"targetSectionId": "sec_target",
|
||||
"createNewCircuit": true
|
||||
}
|
||||
```
|
||||
|
||||
### Bulk Move Device Rows
|
||||
|
||||
- `PATCH /circuit-device-rows/move-bulk`
|
||||
- Purpose: move multiple rows in one command flow.
|
||||
|
||||
Request sketch:
|
||||
|
||||
```json
|
||||
{
|
||||
"rowIds": ["row_1", "row_2"],
|
||||
"targetCircuitId": "cir_target"
|
||||
}
|
||||
```
|
||||
|
||||
### Reorder Circuits
|
||||
|
||||
- `PATCH /circuit-sections/:sectionId/circuits/reorder`
|
||||
- Purpose: persist explicit circuit order within one section.
|
||||
|
||||
Request sketch:
|
||||
|
||||
```json
|
||||
{
|
||||
"orderedCircuitIds": ["cir_2", "cir_1", "cir_3"]
|
||||
}
|
||||
```
|
||||
|
||||
### Renumber Section
|
||||
|
||||
- `POST /circuit-sections/:sectionId/renumber`
|
||||
- Purpose: explicit renumbering by section prefix; never implicit on move/sort.
|
||||
|
||||
### Safe Equipment Identifier Update
|
||||
|
||||
- `PATCH /circuit-sections/:sectionId/equipment-identifiers`
|
||||
- Purpose: apply explicit per-circuit identifiers safely even with unique constraints.
|
||||
|
||||
Request sketch:
|
||||
|
||||
```json
|
||||
{
|
||||
"identifiers": [
|
||||
{ "circuitId": "cir_1", "equipmentIdentifier": "-2F1" },
|
||||
{ "circuitId": "cir_2", "equipmentIdentifier": "-2F2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
structure POST, move, reorder, renumber, identifier-restore, Circuit DELETE or
|
||||
CircuitDeviceRow DELETE routes.
|
||||
|
||||
## Removed Legacy Endpoints
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ The pure grid modules have no React state and are covered by focused unit tests.
|
||||
- `cosPhi`
|
||||
- room snapshots
|
||||
- category/cost group and related row attributes
|
||||
- canonical `phaseType` values `single_phase` or `three_phase`; the
|
||||
frontend always presents these as `1-phasig` or `3-phasig` and edits
|
||||
them through a selection control
|
||||
- `ProjectDevice`
|
||||
- Reusable device template entity at project level.
|
||||
- Can be linked to `CircuitDeviceRow` entries, with copied display values on insert.
|
||||
|
||||
@@ -122,13 +122,16 @@ Cross-section device moves show confirmation-required feedback before drop. The
|
||||
|
||||
## Undo/Redo
|
||||
|
||||
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.
|
||||
Editor operations execute persistent project commands and reload the tree after
|
||||
each command. On initial load and every reload, the editor reads undo/redo
|
||||
eligibility from the project-wide server history and verifies that tree and
|
||||
history belong to the same project revision. Undo/redo therefore remains
|
||||
available after a page refresh. New entities receive stable UUIDs before
|
||||
insertion, and deletion undo restores those same ids. Device-row moves and
|
||||
circuit reorders also use persistent project commands. Applying a sorted view
|
||||
across multiple sections is atomic and occupies one history step. Explicit
|
||||
renumbering uses a collision-safe persistent project command and one
|
||||
project-history step.
|
||||
|
||||
Covered operations include:
|
||||
|
||||
@@ -139,8 +142,7 @@ Covered operations include:
|
||||
- renumber and identifier update flows
|
||||
- apply sorted order
|
||||
|
||||
Current limitations:
|
||||
|
||||
- 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
|
||||
The editor toolbar exposes availability through its Undo/Redo buttons. The
|
||||
project page provides the same persistent project-wide controls in the header
|
||||
of its version card, including after a reload. Named restore points and the
|
||||
revision timeline remain inside the expandable part of that card.
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Circuit List Editor Known Limitations
|
||||
|
||||
- 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.
|
||||
- All currently supported Circuit and CircuitDeviceRow editor writes use
|
||||
persistent project commands and project-wide toolbar undo/redo, including
|
||||
moves, atomic multi-section reorders and explicit renumbering. Undo/redo
|
||||
eligibility is restored from server history after a page reload.
|
||||
- The project page exposes the paginated revision timeline; the circuit editor
|
||||
itself currently exposes only the next eligible Undo/Redo actions.
|
||||
- Named and automatic logical project snapshots can be listed and restored
|
||||
through the project page with persistent Undo/Redo. Automatic snapshots are
|
||||
created every 25 revisions and only their newest 12 entries are retained.
|
||||
- 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
|
||||
|
||||
+198
-25
@@ -47,6 +47,8 @@ liegt über einen Host-Mount außerhalb des Containers.
|
||||
- `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe
|
||||
- `src/server/index.ts` und `src/server/routes/` – API-Komposition
|
||||
- `src/domain/services/` – fachliche Command- und Synchronisierungsregeln
|
||||
- `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten
|
||||
SQLite-Repositories
|
||||
- `src/db/repositories/` – Abfragen, Persistenzmapper und Transaktionsadapter
|
||||
- `src/db/schema/` und `src/db/migrations/` – SQLite-Schema und Migrationen
|
||||
|
||||
@@ -62,28 +64,53 @@ 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.
|
||||
|
||||
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
|
||||
Der Editor besitzt keinen sitzungslokalen Undo-/Redo-Stapel mehr. Beim initialen
|
||||
Laden und nach jedem Tree-Reload liest er den persistenten History-Status und
|
||||
gleicht dessen Revision mit `currentRevision` des Trees ab. Das Datenmodell
|
||||
besitzt einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
|
||||
Tabellen. Eine direkt getestete Persistence-Funktion schreibt diese
|
||||
Historienmetadaten innerhalb der zentralen Command-Transaktion optimistisch und
|
||||
atomar fort. Vorwärts- und Rückwärtskommandos besitzen einen
|
||||
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
|
||||
einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und
|
||||
bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze
|
||||
angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren interne
|
||||
Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und
|
||||
Revision gemeinsam committen beziehungsweise zurückrollen können.
|
||||
einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten
|
||||
Circuit-, Gerätezeilen-, Projektgeräte-, Projektstruktur- und
|
||||
Projekteinstellungsänderungen
|
||||
verwenden typisierte Command-Stores, die Fachänderung, automatisch erzeugtes
|
||||
inverses Kommando, Revision und Historienstapel gemeinsam committen
|
||||
beziehungsweise zurückrollen.
|
||||
Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
|
||||
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite,
|
||||
persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
|
||||
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über
|
||||
`GET /api/projects/:projectId/history` lesbar. Ein zentraler Dispatcher führt
|
||||
die unterstützten Typen `circuit.update` und `circuit-device-row.update` über
|
||||
öffentliche Command-, Undo- und Redo-Endpunkte aus. Zusätzlich sind
|
||||
`circuit-device-row.insert` und `circuit-device-row.delete` als atomare
|
||||
Strukturkommandos vorhanden. Beim Löschen wird die vollständige Zeile im
|
||||
inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte
|
||||
wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen
|
||||
`GET /api/projects/:projectId/history` lesbar. Die unveränderliche,
|
||||
absteigend paginierte Revisions-Timeline ist ohne Befehls-Payloads über
|
||||
`GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler
|
||||
Dispatcher führt die nachfolgend beschriebenen typisierten Kommandos über
|
||||
öffentliche Command-, Undo- und Redo-Endpunkte aus.
|
||||
Alle unterstützten Runtime-Project-Command-Stores einschließlich der
|
||||
vollständigen Snapshot-Wiederherstellung verwenden dabei
|
||||
`project-command-transaction.persistence.ts` als gemeinsame äußere
|
||||
Transaktionsgrenze. Sie führt Fachänderung, Revisions-Append und
|
||||
History-Transition in fester Reihenfolge innerhalb derselben SQLite-Transaktion
|
||||
aus. Für CircuitDeviceRow-Feldänderungen übernimmt eine typisierte Variante den
|
||||
während der Fachänderung um Override-Metadaten ergänzten Forward-Command. Die
|
||||
fachlichen Validierungs-, Forward- und Inversenregeln bleiben im jeweiligen
|
||||
Store.
|
||||
Die Low-Level-Funktion `appendProjectRevision` bleibt ein internes Detail dieser
|
||||
Persistenzgrenze und wird direkt mit einer realen SQLite-Transaktion getestet.
|
||||
Ein eigenständiges Runtime-Revisions-Repository existiert nicht.
|
||||
`ProjectDeviceSyncService` und `CircuitNumberingService` kennen nur schmale,
|
||||
fachlich benannte Reader-Interfaces. Ihre SQLite-Repositories werden
|
||||
ausschließlich in `src/server/composition/` erzeugt und injiziert.
|
||||
Auch die allgemeinen Projekt-, Geräte-, Raum-, Geschoss- und Circuit-
|
||||
Repositories verlangen einen expliziten `AppDatabase`-Kontext. Ihre
|
||||
Anwendungsinstanzen werden zentral in
|
||||
`src/server/composition/application-repositories.ts` erzeugt; Controller
|
||||
importieren weder den globalen SQLite-Client noch konkrete Repository-Klassen.
|
||||
`circuit-device-row.insert` und `circuit-device-row.delete` sind atomare
|
||||
Strukturkommandos. Beim Löschen wird die vollständige Zeile im 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. Bestehende Circuit- und Gerätezeilen-Zelländerungen
|
||||
@@ -91,10 +118,49 @@ sowie eigenständiges Einfügen und Löschen verwendet das Grid bereits über di
|
||||
ö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.
|
||||
Serverhistorie. Direkte Feld-PATCH-, Struktur-POST-, Move-, Circuit- und
|
||||
Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves,
|
||||
Stromkreis-Reorders und die explizite Neunummerierung im Grid verwenden die
|
||||
persistenten Kommandos. Die Toolbar leitet ihre Undo-/Redo-Verfügbarkeit direkt
|
||||
aus den serverseitigen Stack-Tiefen ab, sodass ein Reload die Bedienbarkeit
|
||||
nicht unterbricht.
|
||||
|
||||
Benannte logische Projektstände werden in `project_snapshots` getrennt von
|
||||
Datenbank-Backups gespeichert. `POST /api/projects/:projectId/snapshots`
|
||||
erzeugt bei passender erwarteter Revision transaktional einen vollständigen,
|
||||
schema-versionierten Projektzustand mit SHA-256-Prüfwert. Enthalten sind
|
||||
Projekteinstellungen, Verteiler, Stromkreislisten, Bereiche, Stromkreise und
|
||||
Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte,
|
||||
Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots.
|
||||
Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel.
|
||||
`kind` unterscheidet benannte und automatische Stände. Die zentrale
|
||||
Revisionspersistenz erzeugt nach jeweils 25 weiteren Projektänderungen
|
||||
transaktional einen automatischen Stand. Pro Projekt bleiben die neuesten 12
|
||||
automatischen Stände erhalten; ältere automatische Stände werden in derselben
|
||||
Transaktion entfernt. Benannte Stände und die unveränderliche Revisionshistorie
|
||||
sind von dieser Aufbewahrung ausdrücklich ausgeschlossen.
|
||||
`POST /api/projects/:projectId/snapshots/:snapshotId/restore` prüft Payload,
|
||||
Prüfsumme, erwartete Revision und den unmittelbar zuvor gelesenen
|
||||
Projektzustand. Der Restore ersetzt alle unterstützten Projektdaten in einer
|
||||
Transaktion und schreibt dabei eine neue Revision mit Quelle `restore` sowie
|
||||
ein vollständiges inverses Kommando. Undo und Redo können deshalb auch einen
|
||||
Restore nach einem Neustart exakt zurücknehmen oder wiederholen. Kompatible
|
||||
Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie
|
||||
nicht Bestandteil des logischen Snapshots sind.
|
||||
Die Projektseite bindet diese APIs in einem einklappbaren Bereich
|
||||
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
|
||||
benennen, jeden aufgeführten benannten oder automatischen Stand nach
|
||||
expliziter Bestätigung wiederherstellen und die paginierte Revisions-Timeline
|
||||
mit deutschen Quellen- und Änderungsbezeichnungen lesen. Die Snapshot-Liste
|
||||
ordnet jede positive `sourceRevision` serverseitig ihren unveränderlichen
|
||||
Revisionsmetadaten zu. Dadurch zeigt auch ein älterer automatischer Stand die
|
||||
auslösende Änderung, ohne eine zweite frei formulierte Beschreibung zu
|
||||
speichern oder von den zuletzt paginiert geladenen Timeline-Einträgen
|
||||
abzuhängen. Revision null wird als Projektstart dargestellt.
|
||||
Projektweites Rückgängig/Wiederholen bleibt im Kopf dieses Bereichs auch im
|
||||
eingeklappten Zustand erreichbar. Die Verfügbarkeit stammt direkt aus den
|
||||
persistierten Server-Stacks; nach einer Historienaktion oder einem Restore lädt
|
||||
die Seite sämtliche Projektdaten neu.
|
||||
`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;
|
||||
@@ -104,6 +170,29 @@ 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.
|
||||
Der Editor erzeugt die vollständigen Move-Zuweisungen aus dem geladenen Tree,
|
||||
vergibt für neue Ziele vor dem Kommando eine stabile UUID und führt das
|
||||
Toolbar-Undo/Redo über die projektweite Historie aus.
|
||||
Die Projektgerätepalette belegt keine permanente Layoutspalte mehr. Sie wird
|
||||
über die Editor-Toolbar als überlagernder Drawer geöffnet, während das
|
||||
Stromkreis-Grid standardmäßig die gesamte verfügbare Breite nutzt. Auswahl,
|
||||
Schnelleinfügen und die vorhandenen Drag-and-drop-Payloads bleiben im Drawer
|
||||
unverändert verfügbar; während eines aktiven Projektgeräte-Drags kann er nicht
|
||||
geschlossen werden. Der Drawer liegt am rechten Fensterrand und verdeckt damit
|
||||
nicht die führenden BMK- und Anzeigenamenspalten. Das Grid zeigt Geräte- und
|
||||
Stromkreisleistungen in einer gemeinsamen Spalte `Gesamtsumme`: Gerätezeilen
|
||||
verwenden `rowTotalPower`, Stromkreis-Sammelzeilen `circuitTotalPower`.
|
||||
Zahlen werden ausschließlich für die Anzeige deutsch und begrenzt formatiert;
|
||||
gespeicherte Werte und Bearbeitungsentwürfe behalten ihre volle Genauigkeit.
|
||||
Jeder Abschnitt liefert und zeigt seine aufsummierte Stromkreisleistung. Die
|
||||
Stromkreisliste zeigt außerdem die ungefilterte Gesamtleistung des Verteilers,
|
||||
den am Verteiler gespeicherten Gleichzeitigkeitsfaktor und die daraus
|
||||
abgeleitete Gesamtleistung unter Berücksichtigung dieses Faktors. Sortierung
|
||||
und Filter verändern diese fachlichen Summen nicht.
|
||||
Reihenfolge und Sichtbarkeit der Grid-Spalten sind reine UI-Präferenzen. Sie
|
||||
werden im Browser unter einem projektspezifischen Schlüssel gespeichert und
|
||||
deshalb beim Wechsel zwischen Verteilern desselben Projekts wiederverwendet,
|
||||
ohne Projektrevisionen oder fachliche Snapshots zu erzeugen.
|
||||
`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
|
||||
@@ -111,6 +200,15 @@ bleiben unverändert. `circuit.renumber-section` bildet die getrennte,
|
||||
ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und
|
||||
neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und
|
||||
ändert weder Sortierung noch Gerätezeilen.
|
||||
Die Bereichsaktion erzeugt die vollständigen erwarteten und neuen BMKs aus
|
||||
Präfix und aktueller Stromkreisreihenfolge und verwendet das persistente
|
||||
Kommando für Toolbar-Undo/Redo. Die früheren direkten Renumber- und
|
||||
Identifier-Restore-Endpunkte sowie ihr separater Transaktionsadapter sind
|
||||
entfernt.
|
||||
Drag-and-drop verwendet `circuit.reorder-section`. Die explizite Übernahme
|
||||
einer sortierten Ansicht verwendet `circuit.reorder-sections`, damit alle
|
||||
betroffenen Bereiche in einer Transaktion und als ein Undo-Schritt gespeichert
|
||||
werden. Direkte Reorder-Endpunkte existieren nicht mehr.
|
||||
`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,
|
||||
@@ -118,8 +216,9 @@ 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
|
||||
optimistischer Revisionsprüfung. Rückgängig/Wiederholen erfolgt dort
|
||||
einheitlich über die persistente projektweite Historie; der frühere
|
||||
sitzungslokale Spezial-Undo und 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
|
||||
@@ -136,6 +235,70 @@ unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revisi
|
||||
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.
|
||||
`project.update-settings` versioniert Projektname, interne und externe
|
||||
Projektnummer, Bauherr, Beschreibung, beide Standardspannungen sowie die im
|
||||
Projekt freigeschalteten Verteiler-Netzarten als eine atomare Änderung. Der
|
||||
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
|
||||
eine Netzart muss aktiv bleiben und eine bereits von einer Verteilung
|
||||
verwendete Netzart kann nicht deaktiviert werden. Die Projektseite bearbeitet diese Angaben in einem
|
||||
beschrifteten Einstellungsmodal statt in einer permanenten Formularkarte. Der
|
||||
Store leitet das inverse Kommando aus dem gespeicherten Projekt ab und schreibt
|
||||
Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId`
|
||||
verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
|
||||
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
|
||||
Kommando- und Snapshot-Versionen vor dieser Erweiterung bleiben les- und
|
||||
ausführbar; fehlende Metadaten werden dabei als `null` behandelt.
|
||||
Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte
|
||||
Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals
|
||||
statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare,
|
||||
fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen
|
||||
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
||||
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
||||
damit Änderungen weiterhin niemals still synchronisiert werden.
|
||||
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
||||
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
||||
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
||||
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
|
||||
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
||||
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
||||
Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu
|
||||
zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
|
||||
Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen
|
||||
innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die
|
||||
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
||||
`POST /api/projects/import`, der ausschließlich eine neue Kopie anlegt und
|
||||
deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das
|
||||
Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den
|
||||
projektgebundenen Endpunkt beschränkt.
|
||||
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
||||
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
||||
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
||||
`distribution-board.delete` ist die persistierte Inverse und entfernt nur den
|
||||
vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
|
||||
Projektseite übergeben die erwartete Projektrevision; der frühere direkte
|
||||
Controller-Schreibweg ist entfernt. Verteilungen besitzen eine optionale
|
||||
Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und
|
||||
nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
|
||||
Freigabe der Netzart in den Projekteinstellungen.
|
||||
`distribution-board.update` versioniert Etage, Netzart und den
|
||||
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
||||
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
||||
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
|
||||
Snapshot-Schema 6 und der portable Projekttransfer enthalten diesen Wert;
|
||||
Schema 5 und älter werden mit dem neutralen Faktor `1` hochgestuft.
|
||||
Snapshot-Schema 4 enthält bereits Etage und Netzart; Schema 1/2 sowie
|
||||
gespeicherte Version-1-Strukturcommands werden ohne erfundene Zuordnung
|
||||
hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als
|
||||
Projektauswahl hochgestuft.
|
||||
`project-floor.insert` und `project-room.insert` versionieren die Anlage von
|
||||
Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden
|
||||
jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur
|
||||
entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
|
||||
Raum wird nur entfernt,
|
||||
solange weder eine CircuitDeviceRow noch ein aufbewahrter Upgrade-Datensatz auf
|
||||
ihn verweist. Beide POST-Endpunkte verlangen `expectedRevision`, liefern den
|
||||
aktualisierten Historienstand und besitzen keinen direkten Create-Schreibweg
|
||||
mehr.
|
||||
|
||||
## Projektgeräte
|
||||
|
||||
@@ -158,8 +321,20 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
||||
- `npm run db:migrate` wendet Drizzle-Migrationen an.
|
||||
- `npm run db:verify:circuit-schema` prüft erforderliche und entfernte Spalten.
|
||||
- `npm run db:backup` erzeugt ein konsistentes und verifiziertes Online-Backup.
|
||||
- `npm run typecheck:scripts` prüft alle TypeScript-Wartungs- und
|
||||
Upgrade-Skripte mit ihren Anwendungspfaden, ohne Code zu erzeugen.
|
||||
- Angewendete Migrationen werden niemals nachträglich verändert.
|
||||
- `db:migrate:legacy-consumers` ist Upgrade-Werkzeug, kein Anwendungspfad.
|
||||
Der fachliche Migrationsdienst kennt nur schmale Reader-/Store-Ports unter
|
||||
`src/domain/ports`; konkrete SQLite-Repositories werden ausschließlich im
|
||||
CLI-Skript zusammengesetzt.
|
||||
- Allgemeine Circuit-, Gerätezeilen-, CircuitList- und DistributionBoard-
|
||||
Repositories stellen im Anwendungspfad nur noch benötigte Leseabfragen bereit.
|
||||
Fachliche Schreibvorgänge liegen in den typisierten Command-Repositories;
|
||||
Upgrade-Schreibvorgänge bleiben in expliziten Migrationsadaptern.
|
||||
- Die vollständige Verteilungs-Testfixture liegt unter
|
||||
`tests/support/distribution-board-fixture.ts` und ist kein exportierter
|
||||
Produktions-Schreibweg.
|
||||
|
||||
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
||||
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
||||
@@ -167,8 +342,6 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
||||
|
||||
## Noch nicht unterstützt
|
||||
|
||||
- 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
|
||||
|
||||
@@ -70,6 +70,7 @@ Beispiel `Preserve circuit blocks during filtering`.
|
||||
npm test
|
||||
npm run build:api
|
||||
npm run build:web
|
||||
npm run typecheck:scripts
|
||||
npx tsc --noEmit -p tsconfig.next.json
|
||||
git diff --check
|
||||
```
|
||||
@@ -83,6 +84,9 @@ Invoke-WebRequest http://localhost:3000/health
|
||||
```
|
||||
|
||||
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.
|
||||
Testaufbau, der vollständige Projektstrukturen direkt einfügt, gehört nach
|
||||
`tests/support`. Produktions-Repositories dürfen nicht nur für Testfixtures
|
||||
erneut direkte Schreibmethoden erhalten.
|
||||
|
||||
## Migrationen
|
||||
|
||||
|
||||
@@ -130,27 +130,8 @@ Expected response shape:
|
||||
|
||||
If migrations were not applied, endpoint may return an empty fallback with a warning.
|
||||
|
||||
## Dev-only visual test helper (multi-device circuit)
|
||||
## Visual verification
|
||||
|
||||
Add one extra manual device row to an existing circuit:
|
||||
|
||||
```bash
|
||||
npm run dev:add-manual-circuit-row -- <circuitId>
|
||||
```
|
||||
|
||||
Default inserted values:
|
||||
- `name`: `Test sub device`
|
||||
- `displayName`: `Beleuchtung WC`
|
||||
- `phaseType`: `single_phase`
|
||||
- `quantity`: `1`
|
||||
- `powerPerUnit`: `0.05`
|
||||
- `simultaneityFactor`: `1`
|
||||
- `cosPhi`: `1`
|
||||
|
||||
The script prints the created row id.
|
||||
|
||||
Delete the test row again:
|
||||
|
||||
```bash
|
||||
npm run dev:delete-circuit-row -- <rowId>
|
||||
```
|
||||
Manual device rows and multi-device circuits can be created directly in the
|
||||
circuit-list editor. The former development scripts for direct database writes
|
||||
were removed because they bypassed project revisions and persistent undo/redo.
|
||||
|
||||
@@ -60,7 +60,9 @@ Full event sourcing is not required. Current normalized tables remain the primar
|
||||
- After a page reload, the client requests the current revision and eligible history actions.
|
||||
- Commands include an expected project revision. A stale command is rejected instead of silently overwriting a newer project state.
|
||||
|
||||
The current session-local frontend command stack is transitional. Editor modularization must separate command descriptions from React callbacks so commands can later be sent to and reconstructed by the server.
|
||||
The circuit-list editor uses the server state directly for operation
|
||||
eligibility. React callbacks only initiate forward commands and provide
|
||||
best-effort selection hints after the tree reload.
|
||||
|
||||
## Snapshot Storage
|
||||
|
||||
@@ -73,6 +75,43 @@ Project history must use logical project data, not copies of the SQLite database
|
||||
|
||||
Large snapshots may later be compressed or stored in object storage, with metadata and checksums retained in the database.
|
||||
|
||||
Implemented named-snapshot foundation:
|
||||
|
||||
- `project_snapshots` stores project ownership, source revision, schema
|
||||
version, name, optional description, logical payload, SHA-256 and creation
|
||||
metadata
|
||||
- snapshot creation requires the expected current revision and reads plus
|
||||
stores the complete project state in one SQLite transaction
|
||||
- the versioned payload contains project settings, distribution boards,
|
||||
circuit lists, sections, circuits with device rows, project devices, floors
|
||||
and rooms while excluding global and upgrade-only data
|
||||
- snapshot creation/listing does not create a project revision or affect
|
||||
undo/redo eligibility
|
||||
- public Create/List endpoints expose metadata but not the logical payload
|
||||
- restore verifies the stored checksum and a hash of the complete current
|
||||
state before replacing supported project data in one transaction
|
||||
- restore records a new `restore` revision containing both the selected target
|
||||
state and the complete pre-restore inverse; undo/redo therefore remains
|
||||
available after application restarts
|
||||
- compatible upgrade-only Consumer links, mappings and reports are preserved
|
||||
separately and are never imported into the logical snapshot payload
|
||||
- arbitrary restore payloads are rejected by the generic user-command
|
||||
endpoint; only server-stored snapshots can initiate a forward restore
|
||||
- the project page provides an initially collapsed snapshot/timeline panel,
|
||||
explicit inline restore confirmation and cursor-based loading of older
|
||||
revisions; a successful restore reloads all project read models
|
||||
|
||||
Implemented automatic-snapshot policy:
|
||||
|
||||
- `project_snapshots.kind` distinguishes `named` from `automatic`
|
||||
- the central revision boundary captures the complete post-command state after
|
||||
every 25 additional project revisions
|
||||
- the newest 12 automatic snapshots per project are retained
|
||||
- retention never removes named snapshots, immutable revisions or physical
|
||||
database backups
|
||||
- automatic capture and retention run inside the same transaction as the
|
||||
triggering project revision
|
||||
|
||||
## Revit / CSV / IFCGUID Round-Trip Direction
|
||||
|
||||
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
|
||||
@@ -168,6 +207,9 @@ Completed foundation:
|
||||
a forced stack failure rolls all of them back
|
||||
- `GET /api/projects/:projectId/history` exposes current revision, stack depths
|
||||
and top change-set ids
|
||||
- `GET /api/projects/:projectId/history/revisions` exposes a descending,
|
||||
cursor-paginated metadata timeline without leaking persisted forward or
|
||||
inverse command payloads
|
||||
- a central application dispatcher executes the supported `circuit.update`
|
||||
and `circuit-device-row.update` envelopes without coupling the domain service
|
||||
to SQLite
|
||||
@@ -194,12 +236,25 @@ 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
|
||||
- the circuit-list editor executes existing-target and generated-target row
|
||||
moves through these commands; obsolete direct move and Circuit DELETE routes
|
||||
plus their separate transaction adapter are removed
|
||||
- `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.reorder-sections` applies the same invariants to every affected
|
||||
section from a sorted view in one transaction and one history entry
|
||||
- circuit drag-and-drop and sorted-order application use these commands; the
|
||||
obsolete direct reorder route and transaction method are removed
|
||||
- `circuit.renumber-section` is the separate explicit renumber operation; it
|
||||
stores every expected/target equipment identifier, uses collision-safe
|
||||
temporary values and restores the exact prior identifiers on undo
|
||||
- the editor's explicit renumber action uses this command; obsolete direct
|
||||
renumber and identifier-restore routes plus their transaction adapter are
|
||||
removed
|
||||
- the circuit-list editor loads project-wide undo/redo eligibility together
|
||||
with the tree, retries revision mismatches and keeps Undo/Redo available after
|
||||
page reloads without a session-local command stack
|
||||
- `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
|
||||
@@ -208,23 +263,32 @@ Completed foundation:
|
||||
- `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
|
||||
- `project.update-settings` persists both project voltage defaults, its exact
|
||||
inverse, revision and history transition atomically; the project page and
|
||||
existing PUT route require the current expected revision; the same
|
||||
transaction derives every project-device and circuit voltage again, so
|
||||
Undo/Redo cannot leave mixed voltage states
|
||||
- `distribution-board.insert` persists the complete generated board, circuit
|
||||
list and default-section structure with stable ids; its delete inverse
|
||||
refuses changed or populated structures and the project-page POST tracks the
|
||||
returned revision
|
||||
- `project-floor.insert` and `project-room.insert` persist complete location
|
||||
records with stable ids; their inverses reject changed or referenced records
|
||||
so Undo never clears room assignments or device-row links implicitly
|
||||
- the floor and room POST routes require the current expected revision and the
|
||||
project page tracks the returned history state
|
||||
|
||||
Remaining constraints before completing persistent history:
|
||||
Remaining constraints before completing project version history:
|
||||
|
||||
- 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
|
||||
- all current normal project-scoped runtime writes are behind the revision
|
||||
boundary; extend it whenever further project mutations are introduced
|
||||
- project creation remains initialization at revision zero, while global device
|
||||
templates remain outside any single project history
|
||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||
- keep database backup/restore checks separate from project history tests
|
||||
|
||||
## Deferred Decisions
|
||||
|
||||
- exact snapshot frequency and retention policy
|
||||
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
||||
- branch visualization after undo followed by new edits
|
||||
- user/role model and actor attribution
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
# Kontext für die Revit-Anforderungsplanung
|
||||
|
||||
## Zweck dieser Datei
|
||||
|
||||
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei
|
||||
helfen, gemeinsam mit dem Anwender die fachlichen und technischen Anforderungen
|
||||
für einen zukünftigen Revit-Datenaustausch zu klären.
|
||||
|
||||
Die Revit-Schnittstelle ist **noch nicht implementiert**. Das LLM soll zunächst
|
||||
den tatsächlichen Arbeitsablauf erfragen, Unklarheiten sichtbar machen und
|
||||
daraus eine neue, überprüfbare Arbeitsanweisung erstellen. Es soll weder ein
|
||||
Dateiformat noch ein Datenbankschema oder eine UI ungefragt festlegen.
|
||||
|
||||
## Produkt und fachliches Modell
|
||||
|
||||
Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
|
||||
Ihr Kern ist ein tabellenähnlicher Stromkreislisten-Editor für Verteilungen.
|
||||
|
||||
Das unterstützte Laufzeitmodell ist:
|
||||
|
||||
```text
|
||||
Project
|
||||
├── Floor
|
||||
│ └── Room
|
||||
├── ProjectDevice
|
||||
└── DistributionBoard
|
||||
└── CircuitList
|
||||
└── CircuitSection
|
||||
└── Circuit
|
||||
└── CircuitDeviceRow
|
||||
```
|
||||
|
||||
Wichtige fachliche Grenzen:
|
||||
|
||||
- Ein `Circuit` ist nicht dasselbe wie eine Gerätezeile.
|
||||
- Ein Stromkreis kann null, eine oder mehrere `CircuitDeviceRow` enthalten.
|
||||
- BMK (`equipmentIdentifier`), Schutz- und Kabeldaten gehören zum Stromkreis.
|
||||
- Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi, Raum, Kostengruppe
|
||||
und Kategorie gehören zur Gerätezeile.
|
||||
- Ein `ProjectDevice` ist eine wiederverwendbare, projektbezogene Vorlage.
|
||||
- Eine Gerätezeile kann mit einem Projektgerät verknüpft sein, behält aber
|
||||
lokale Werte und ausdrücklich erfasste Abweichungen.
|
||||
- Änderungen eines Projektgeräts überschreiben verknüpfte Zeilen niemals
|
||||
automatisch. Eine Synchronisierung benötigt Vorschau und Benutzerauswahl.
|
||||
- Manuelle, nicht mit einem Projektgerät verknüpfte Gerätezeilen sind regulär
|
||||
erlaubt.
|
||||
- Bestehende BMKs und stabile UUIDs dürfen nicht automatisch verändert werden.
|
||||
- Neunummerierung ist immer eine ausdrückliche Benutzeraktion.
|
||||
- `IFCGUID` darf nicht als BMK oder als Ersatz für eine interne UUID verwendet
|
||||
werden.
|
||||
|
||||
Für einen späteren Revit-Austausch besonders relevante Daten sind:
|
||||
|
||||
- Projekt: Name, interne/externe Projektnummer, Bauherr, Beschreibung,
|
||||
Projektspannungen.
|
||||
- Geschoss und Raum: interne UUID, Name beziehungsweise Raumnummer und
|
||||
Raumname.
|
||||
- Verteilung: interne UUID, Name, Etage, Netzart und Gleichzeitigkeitsfaktor.
|
||||
- Stromkreis: interne UUID, BMK, Anzeigename, Bereich, Schutz-, Kabel-,
|
||||
Spannungs-, Steuerungs-, Status- und Bemerkungsdaten.
|
||||
- Gerätezeile: interne UUID, optionale Projektgeräteverknüpfung, Name,
|
||||
Anzeigename, Phasenart, Anschlussart, Kostengruppe, Kategorie, Geschoss,
|
||||
Raum, Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi und Bemerkung.
|
||||
- Projektgerät: interne UUID sowie die wiederverwendbaren Gerätewerte.
|
||||
|
||||
Die Phasenart ist fachlich `1-phasig` oder `3-phasig`. Die Spannung wird daraus
|
||||
und aus den Projekteinstellungen abgeleitet; freie Sonderspannungen sind im
|
||||
aktuellen Produkt nicht vorgesehen.
|
||||
|
||||
## Aktuelle technische Architektur
|
||||
|
||||
- Frontend: Next.js App Router, React und TypeScript unter `src/app` und
|
||||
`src/frontend`.
|
||||
- API: Express und Zod unter `src/server`.
|
||||
- Fachregeln und typisierte Commands: `src/domain`.
|
||||
- Persistenz: SQLite, Drizzle und Repository-Adapter unter `src/db`.
|
||||
- Laufzeitkomposition konkreter Repositories:
|
||||
`src/server/composition`.
|
||||
- Lokaler Betrieb: Browser/Next.js auf Port 3001, Express-API auf Port 3000,
|
||||
SQLite-Datei unter `data/leistungsbilanz.db`.
|
||||
- Docker Compose wird für die lokale Entwicklung unterstützt.
|
||||
- PostgreSQL und Mehrbenutzerbetrieb sind noch nicht umgesetzt.
|
||||
|
||||
Der zentrale Editor liegt unter:
|
||||
|
||||
```text
|
||||
src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx
|
||||
src/frontend/components/circuit-tree-editor.tsx
|
||||
```
|
||||
|
||||
Frontend-Aktionen werden als validierte API-Commands ausgeführt. Fachregeln
|
||||
liegen in Domain-Services; konkrete SQLite-Zugriffe bleiben in
|
||||
Persistenzadaptern. Neue Revit-Fachlogik darf deshalb nicht direkt von
|
||||
`better-sqlite3`, React-Komponenten oder globalen Datenbankinstanzen abhängen.
|
||||
|
||||
## Revisionen, Undo/Redo und Sicherungspunkte
|
||||
|
||||
Alle normalen projektbezogenen Änderungen laufen über eine gemeinsame
|
||||
Command- und Transaktionsgrenze:
|
||||
|
||||
```text
|
||||
Fachänderung + inverses Kommando + Revision + Undo/Redo-Übergang
|
||||
```
|
||||
|
||||
Diese Bestandteile werden atomar geschrieben oder vollständig zurückgerollt.
|
||||
Jedes Projekt besitzt eine monoton steigende `currentRevision`. Schreibbefehle
|
||||
geben die erwartete Revision mit, damit veraltete Clients keine neueren
|
||||
Änderungen überschreiben.
|
||||
|
||||
Undo/Redo ist projektweit persistent und funktioniert nach Reload oder
|
||||
Anwendungsneustart. Zusätzlich gibt es:
|
||||
|
||||
- eine unveränderliche Revisions-Timeline;
|
||||
- benannte und automatische logische Projektsnapshots;
|
||||
- wiederherstellbare Snapshots mit Prüfsumme;
|
||||
- einen portablen, versionierten JSON-Projekttransfer.
|
||||
|
||||
Ein angewendeter Revit-Import muss später dieselbe Integritätsgrenze verwenden:
|
||||
Die bestätigten Änderungen sollen als ein nachvollziehbarer Projekt-Command
|
||||
beziehungsweise als ein fachlich definierter Änderungssatz atomar,
|
||||
revisioniert und rückgängig machbar sein.
|
||||
|
||||
## Klare Abgrenzung zum vorhandenen JSON-Projekttransfer
|
||||
|
||||
Der vorhandene JSON-Export/-Import überträgt den vollständigen internen
|
||||
Projektzustand. Er dient Sicherung, Wiederherstellung und Duplizierung eines
|
||||
Projekts innerhalb von Leistungsbilanz.
|
||||
|
||||
Dieser Projekttransfer ist **kein** Revit-Austauschformat. Er enthält interne
|
||||
Strukturen und UUIDs und soll nicht ohne bewusste Entscheidung als Grundlage
|
||||
für CSV-Dateien oder Revit-Parameter verwendet werden.
|
||||
|
||||
Der zukünftige Revit-Workflow soll dagegen externe Modellobjekte anhand stabiler
|
||||
externer Identitäten wiedererkennen, ausgewählte Planungswerte zuordnen und
|
||||
Ergebnisse so exportieren, dass Revit sie wieder den richtigen Objekten
|
||||
zuweisen kann.
|
||||
|
||||
## Bereits vereinbarte Leitplanken für den externen Modellaustausch
|
||||
|
||||
Diese Punkte gelten als Architekturvorgaben, noch nicht als fertiges
|
||||
Implementierungsdesign:
|
||||
|
||||
- Ein Import schreibt niemals ungeprüft direkt in Stromkreise oder
|
||||
Gerätezeilen.
|
||||
- Eingelesene Daten landen zuerst in einem Staging-/Vorschaubereich.
|
||||
- Vor der Übernahme werden neue, geänderte, fehlende, doppelte und
|
||||
widersprüchliche Objekte angezeigt.
|
||||
- Wiederholte Importe müssen Objekte deterministisch anhand der externen Quelle
|
||||
und einer stabilen Identität, voraussichtlich IFCGUID, erkennen.
|
||||
- Die Eindeutigkeit einer IFCGUID ist mindestens auf Projekt und externe
|
||||
Modellquelle begrenzt; die genaue Quellendefinition ist noch zu klären.
|
||||
- Importierte Quellwerte und lokal in Leistungsbilanz geplante Werte müssen
|
||||
unterscheidbar bleiben.
|
||||
- Lokale Planungswerte dürfen niemals still überschrieben werden.
|
||||
- Verknüpfungen zwischen externen Objekten und internen Fachobjekten müssen
|
||||
ausdrücklich modelliert werden.
|
||||
- Fehlende oder widersprüchliche Identitäten werden gemeldet und niemals
|
||||
automatisch einem anderen Objekt zugeordnet.
|
||||
- Ein Export bewahrt IFCGUID und erforderliche Quellidentitäten, damit Revit
|
||||
die Werte deterministisch zurückschreiben kann.
|
||||
- Ein Export bezieht sich auf eine bekannte Projektrevision.
|
||||
- Große Datenmengen allein erzwingen noch keinen Wechsel von SQLite zu
|
||||
PostgreSQL. PostgreSQL wird relevant bei Mehrbenutzerbetrieb, zentralem
|
||||
Serverbetrieb oder unabhängigen Hintergrundjobs.
|
||||
|
||||
Als mögliche, aber noch nicht beschlossene Konzepte wurden bisher genannt:
|
||||
|
||||
- `ExternalModelSource`
|
||||
- `ExternalImportBatch`
|
||||
- `ExternalModelObject`
|
||||
- `ExternalObjectLink`
|
||||
- `ExternalParameterMapping`
|
||||
- `ExternalExportBatch`
|
||||
|
||||
Diese Namen beschreiben Verantwortlichkeiten. Das andere LLM darf sie
|
||||
hinterfragen und verbessern, soll sie aber nicht als bereits vorhandene
|
||||
Tabellen behandeln.
|
||||
|
||||
## Auftrag an das andere LLM
|
||||
|
||||
Erarbeite die Anforderungen dialogorientiert mit dem Anwender. Beginne mit dem
|
||||
realen Ablauf in Revit und kläre danach schrittweise mindestens:
|
||||
|
||||
1. Welche Revit-Objekte beziehungsweise Kategorien werden exportiert?
|
||||
2. Wie entsteht die Ausgangsdatei in Revit und wie sieht sie technisch aus
|
||||
(CSV-Dialekt, Trennzeichen, Encoding, Dezimalformat, Kopfzeilen,
|
||||
Revit-Versionen)?
|
||||
3. Welche Identitäten stehen zur Verfügung: IFCGUID, Revit `ElementId`,
|
||||
Typ-ID, Modell-/Dateikennung oder weitere Schlüssel?
|
||||
4. Wie werden Hauptmodell, Teilmodelle, verknüpfte Modelle, Modellkopien und
|
||||
aktualisierte Revit-Dateien unterschieden?
|
||||
5. Welche Parameter kommen aus Revit und welche Werte werden ausschließlich in
|
||||
Leistungsbilanz geplant?
|
||||
6. Welche internen Ziele können externe Objekte erhalten: Projektgerät,
|
||||
Gerätezeile, Stromkreis, Raum, Verteilung oder weitere Entitäten?
|
||||
7. Werden externe Objekte einzeln oder gesammelt einem Stromkreis,
|
||||
Projektgerät, Raum oder einer Verteilung zugeordnet?
|
||||
8. Welche Werte sollen nach Revit zurückgeschrieben werden, in welchen
|
||||
Einheiten und unter welchen Revit-Parameternamen?
|
||||
9. Muss die ursprüngliche Datei einschließlich unbekannter Spalten,
|
||||
Zeilenreihenfolge und Formatierung erhalten werden, oder darf eine neue
|
||||
Rückgabedatei erzeugt werden?
|
||||
10. Wie sollen Spalten-/Parametermappings erstellt, gespeichert,
|
||||
wiederverwendet und zwischen Projekten geteilt werden?
|
||||
11. Was gilt bei erneutem Import für neue, geänderte, gelöschte, verschobene,
|
||||
doppelte oder nicht mehr sichtbare Revit-Objekte?
|
||||
12. Wer gewinnt bei Konflikten zwischen neuem Revit-Wert und lokaler Änderung?
|
||||
Welche Konflikte dürfen automatisch gelöst werden und welche benötigen
|
||||
eine Auswahl?
|
||||
13. Welche Vorschau, Filter, Sammelaktionen und Bestätigungen benötigt der
|
||||
Anwender vor der Übernahme?
|
||||
14. Welche fachlichen Validierungen und Pflichtfelder müssen eine Übernahme
|
||||
verhindern oder nur eine Warnung erzeugen?
|
||||
15. Welche Dateigrößen, Objektzahlen und Laufzeiten sind realistisch?
|
||||
16. Müssen Import und Export synchron im Browser laufen oder werden später
|
||||
Hintergrundjobs benötigt?
|
||||
17. Welche Informationen müssen für Nachvollziehbarkeit, Undo/Redo,
|
||||
Versionshistorie und Support gespeichert werden?
|
||||
18. Welche konkreten End-to-End-Beispiele und Fehlerfälle dienen als
|
||||
Abnahmekriterien?
|
||||
|
||||
Stelle Rückfragen in kleinen, zusammenhängenden Blöcken. Unterscheide in deinen
|
||||
Notizen ausdrücklich:
|
||||
|
||||
- vom Anwender bestätigte Anforderungen;
|
||||
- sinnvolle Vorschläge;
|
||||
- offene Entscheidungen;
|
||||
- technische Folgerungen;
|
||||
- bewusst nicht betrachtete spätere Ausbaustufen.
|
||||
|
||||
## Erwartetes Ergebnis der Anforderungsrunde
|
||||
|
||||
Erstelle am Ende eine neue Arbeitsanweisung für das implementierende LLM. Sie
|
||||
soll mindestens enthalten:
|
||||
|
||||
- Ziel, Umfang und ausdrücklich ausgeschlossene Funktionen;
|
||||
- vollständigen Anwenderworkflow für Erstimport, Zuordnung, Folgeimport und
|
||||
Rückexport;
|
||||
- vereinbartes Dateiformat mit einem anonymisierten Beispiel;
|
||||
- Feld- und Einheitenmapping einschließlich Richtung und Datenhoheit;
|
||||
- Identitäts-, Matching- und Quellmodell;
|
||||
- Regeln für neue, geänderte, fehlende und doppelte Objekte;
|
||||
- Konflikt-, Validierungs- und Freigaberegeln;
|
||||
- UI-Ablauf für Staging, Vorschau, Zuordnung und Bestätigung;
|
||||
- Anforderungen an Revision, Atomarität, Undo/Redo und Audit;
|
||||
- vorgeschlagene Modul- und Persistenzgrenzen, ohne bestehende
|
||||
Architekturregeln zu umgehen;
|
||||
- schrittweise Implementierungsphasen mit überprüfbaren Abnahmekriterien;
|
||||
- offene Punkte, die vor dem jeweiligen Implementierungsschritt geklärt werden
|
||||
müssen.
|
||||
|
||||
Die Arbeitsanweisung soll noch keinen Code enthalten. Sie muss klar markieren,
|
||||
welche Entscheidungen der Anwender tatsächlich getroffen hat und welche Teile
|
||||
nur Architekturvorschläge des LLM sind.
|
||||
|
||||
## Weiterführende Projektdokumente
|
||||
|
||||
- `AGENTS.md` – verbindliche Domänen- und Implementierungsregeln
|
||||
- `docs/current-architecture.md` – vollständiger aktueller Laufzeit- und
|
||||
Command-Pfad
|
||||
- `docs/project-history-and-external-model-architecture.md` – bisherige
|
||||
Zukunftsrichtung für Versionierung, externes Modell und PostgreSQL
|
||||
- `docs/spec/01-domain-context.md` – fachlicher Kontext
|
||||
- `docs/spec/03-data-model-concept.md` – Datenmodellkonzept
|
||||
- `docs/spec/05-linked-devices-and-sync.md` – Regeln für Projektgeräte und
|
||||
kontrollierte Synchronisierung
|
||||
- `docs/spec/07-implementation-phases-todo.md` – Roadmap, insbesondere Phase 13
|
||||
- `docs/circuit-list-editor-known-limitations.md` – noch nicht implementierte
|
||||
Funktionen
|
||||
@@ -303,6 +303,8 @@ Acceptance criteria:
|
||||
|
||||
## Phase 11: Persistence and Transaction Foundation
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Goal:
|
||||
|
||||
Prepare the current SQLite implementation for persistent history and a later PostgreSQL adapter without changing user-facing behavior.
|
||||
@@ -326,8 +328,9 @@ Implemented foundation:
|
||||
|
||||
- database contexts can be created independently from the production singleton
|
||||
- SQLite foreign-key enforcement is enabled explicitly for every context
|
||||
- the distribution-board repository receives its database dependency explicitly
|
||||
- distribution-board setup has real in-memory SQLite commit and rollback coverage using production migrations
|
||||
- the read-only distribution-board repository receives its database dependency explicitly
|
||||
- distribution-board setup uses its persistent command repository with real
|
||||
in-memory SQLite commit and rollback coverage using production migrations
|
||||
- database backups use SQLite's online backup API and include committed WAL data
|
||||
- every backup is opened independently and checked for integrity and foreign-key violations
|
||||
- an integration test restores the backup into a separate database and verifies its snapshot contents
|
||||
@@ -335,13 +338,30 @@ Implemented foundation:
|
||||
- a new circuit and all of its initial device rows use the same transaction store
|
||||
- device-row moves, reserve-state updates and optional target creation share one transaction
|
||||
- project-device synchronization, disconnect and reconnect use a separate injected transaction store
|
||||
- section renumbering and circuit reordering use a separate injected transaction store
|
||||
- section renumbering and circuit reordering use persistent command stores
|
||||
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
|
||||
- project-device row synchronization has real multi-row SQLite commit and rollback coverage
|
||||
- BMK swaps and section reorder have real SQLite commit and rollback coverage
|
||||
- the legacy consumer migration receives its database dependency at the script entry point
|
||||
- the legacy consumer migration service depends only on narrow domain ports;
|
||||
all concrete repositories are composed at the script entry point
|
||||
- legacy circuit, row, mapping and report writes have real late-failure rollback coverage
|
||||
- circuit and device-row persistence value mapping is separated from the general repositories
|
||||
- obsolete direct Circuit, CircuitDeviceRow, CircuitList, DistributionBoard and
|
||||
Project repository writes are removed; integration fixtures live under
|
||||
`tests/support` instead of production repositories
|
||||
- all supported runtime project-command stores, including complete snapshot
|
||||
restore, share one tested transaction wrapper for domain mutation, revision
|
||||
append and history transition; its applied-forward variant retains derived
|
||||
CircuitDeviceRow override metadata
|
||||
- low-level revision append persistence is tested directly; the unused
|
||||
standalone runtime revision repository and store interface are removed
|
||||
- runtime project-device synchronization and circuit-numbering services depend
|
||||
on narrow domain readers; server composition injects the SQLite repositories
|
||||
- all general application repositories require an explicit database context;
|
||||
server composition owns their runtime instances and controllers contain no
|
||||
direct global SQLite access
|
||||
- TypeScript maintenance and upgrade scripts have a dedicated no-emit
|
||||
typecheck, including all referenced application modules
|
||||
- the legacy consumer UI and application read/write endpoints are removed after verified data cutover
|
||||
- retained legacy rows are accessible only through explicit database upgrade tooling
|
||||
- project devices no longer persist duplicate legacy power, phase, cosPhi or remark fields
|
||||
@@ -358,14 +378,23 @@ Tasks:
|
||||
- [x] add immutable append-only server-side change-set storage with versioned
|
||||
forward/inverse payloads
|
||||
- [x] add optimistic checks when appending a revision
|
||||
- route domain writes and history records through one shared transaction
|
||||
- define concrete serializable domain command descriptions and executors
|
||||
- [x] route supported domain writes and history records through one shared transaction
|
||||
- [x] define concrete serializable domain command descriptions and executors
|
||||
outside React
|
||||
- [x] persist undo/redo eligibility on the server
|
||||
- [x] expose public command, undo and redo endpoints for the first supported
|
||||
update command types
|
||||
- add named and periodic logical project snapshots
|
||||
- restore a historical snapshot as a new revision
|
||||
- [x] expose a cursor-paginated, read-only revision metadata timeline without
|
||||
command payloads
|
||||
- [x] add explicitly named logical project snapshots with schema-versioned
|
||||
payloads and integrity checksums
|
||||
- [x] add periodic logical project snapshots with a bounded retention policy
|
||||
- [x] restore a historical snapshot as a new auditable revision with
|
||||
persistent undo/redo
|
||||
- [x] expose named snapshots, confirmed restore and the paginated revision
|
||||
timeline on the project page
|
||||
- [x] expose persistent project-wide undo/redo controls on the project page
|
||||
and remove its session-local ProjectDevice synchronization undo state
|
||||
- keep database backups separate from logical history
|
||||
|
||||
Implemented foundation:
|
||||
@@ -387,6 +416,18 @@ Implemented foundation:
|
||||
late-failure rollback coverage
|
||||
- a read-only project history endpoint exposes revision, stack depths and top
|
||||
change-set ids
|
||||
- a separate read-only timeline endpoint exposes immutable revision metadata
|
||||
in descending, cursor-paginated pages without forward/inverse payloads
|
||||
- named snapshot Create/List endpoints persist complete project-scoped runtime
|
||||
state with the source revision and SHA-256 without changing revision or
|
||||
undo/redo stacks
|
||||
- the central revision boundary captures an `automatic` snapshot after every
|
||||
25 new revisions and retains the newest 12 automatic snapshots per project
|
||||
without deleting named snapshots or immutable revisions
|
||||
- the project page lists, creates and explicitly restores named snapshots and
|
||||
displays paginated revision metadata without exposing command payloads
|
||||
- the version-card header reads persisted undo/redo eligibility and executes
|
||||
optimistic project-wide undo/redo even while its timeline body is collapsed
|
||||
- a central application dispatcher reconstructs persisted commands and exposes
|
||||
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
|
||||
field updates
|
||||
@@ -424,6 +465,15 @@ Implemented foundation:
|
||||
- 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
|
||||
- project voltage settings use `project.update-settings`; both values, inverse,
|
||||
revision and history transition commit atomically and the former direct
|
||||
settings write is removed
|
||||
- distribution-board setup uses `distribution-board.insert` with a complete
|
||||
stable board/list/default-section snapshot; Undo removes only an unchanged
|
||||
empty setup and the former direct controller write is removed
|
||||
- floor and room setup use `project-floor.insert` and `project-room.insert`
|
||||
with stable ids; their inverses refuse assigned or referenced records and the
|
||||
former direct repository create methods 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
|
||||
@@ -431,10 +481,13 @@ Implemented foundation:
|
||||
and rollback after a forced late persistence failure
|
||||
|
||||
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.
|
||||
Circuit/CircuitDeviceRow field/insert/delete actions, device-row moves and
|
||||
circuit reorders in the circuit-list editor are connected to this history
|
||||
boundary. Applying sorted order across multiple sections is one atomic command.
|
||||
Explicit renumbering is connected to the same persistent boundary. The
|
||||
editor reads the persistent undo/redo stack depths on initial load and after
|
||||
every tree reload. Tree and history revisions are reconciled before the state
|
||||
is exposed, so the toolbar remains usable after a page refresh.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Current Product Backlog
|
||||
|
||||
This document records the next agreed product improvements. It describes
|
||||
requirements and intended sequencing, not proof of implementation.
|
||||
|
||||
## Circuit List Editor
|
||||
|
||||
- Collect the remaining editor interaction issues before changing additional
|
||||
behavior.
|
||||
|
||||
## First Release Baseline
|
||||
|
||||
Complete this immediately before the first supported release, once the schema
|
||||
and initial feature set are frozen:
|
||||
|
||||
- Decide explicitly whether pre-release databases, portable JSON exports,
|
||||
logical snapshots and persisted command histories remain supported.
|
||||
- Create one clean baseline migration that builds the complete release schema
|
||||
on an empty database.
|
||||
- Replace the active development migration chain and Drizzle metadata only if
|
||||
no released installation depends on it.
|
||||
- Remove upgrade-only database, snapshot and command compatibility code only
|
||||
for formats that are explicitly declared unsupported.
|
||||
- Preserve the pre-release migration history in Git history or a release tag.
|
||||
- Verify installation, migrations, application startup and core workflows
|
||||
against a completely empty database.
|
||||
- Mark the resulting compatibility boundary with the first release tag.
|
||||
- Treat every migration published after that release as immutable.
|
||||
|
||||
## Project History
|
||||
|
||||
- [x] Every listed logical snapshot, including automatic snapshots, must be
|
||||
explicitly restorable from the project history UI.
|
||||
- [x] Show which project change produced a snapshot. Prefer immutable revision
|
||||
metadata over storing a second free-form description of the same change.
|
||||
- [x] Keep restoration auditable as a new project revision and preserve persistent
|
||||
undo/redo.
|
||||
|
||||
## Distribution Boards
|
||||
|
||||
- [x] Assign an optional project floor to a distribution board.
|
||||
- [x] Store a supply classification for each distribution board:
|
||||
- `AV` (Allgemeine Stromversorgung)
|
||||
- `SV` (Sicherheitsstromversorgung)
|
||||
- `EV` (Ersatzstromversorgung)
|
||||
- `USV` (Unterbrechungsfreie Stromversorgung)
|
||||
- `MSR` (Mess-, Steuerungs- und Regelungstechnik)
|
||||
- `SiBe` (Sicherheitsbeleuchtung)
|
||||
- [x] Let project settings select which catalog supply types are used in a
|
||||
project. Distribution-board dialogs offer only that selection; a type in
|
||||
use cannot be disabled.
|
||||
- [x] Include both fields in project snapshots and portable project
|
||||
export/import.
|
||||
- [x] Persist changes through project commands so they remain undoable after a
|
||||
restart.
|
||||
|
||||
## Device Voltage Derivation
|
||||
|
||||
- [x] Derive voltage from the selected phase type and the project settings:
|
||||
- single-phase uses the project's single-phase voltage;
|
||||
- three-phase uses the project's three-phase voltage.
|
||||
- [x] Voltage is not editable on global devices, project devices or circuits.
|
||||
Global devices store only their phase; copying one into a project derives
|
||||
the target project's voltage.
|
||||
- [x] Project-device voltage follows its phase. Circuit voltage follows the
|
||||
section phase; an unassigned circuit is three-phase only when all assigned
|
||||
device rows with a valid phase are three-phase.
|
||||
- [x] Changing project voltage settings updates all project devices and circuits
|
||||
atomically in the same persistent Undo/Redo step.
|
||||
- [x] Database migration `0019` and snapshot schema version `5` normalize older
|
||||
stored values. Version-four and older imports remain supported and are
|
||||
normalized while being upgraded.
|
||||
|
||||
## Circuit List Power Summary
|
||||
|
||||
- [x] Show the total power of every circuit section.
|
||||
- [x] Show the unadjusted total power of the complete distribution board.
|
||||
- [x] Store a distribution-board simultaneity factor between zero and one in
|
||||
the distribution-board settings.
|
||||
- [x] Show the distribution-board total after applying that factor.
|
||||
- [x] Preserve the factor in project commands, snapshots and portable project
|
||||
transfers.
|
||||
- [x] Keep column visibility and order as a project-specific UI preference so
|
||||
all distribution boards in the same project open with the same columns.
|
||||
|
||||
## Recommended Sequence
|
||||
|
||||
1. [x] Distribution-board floor and supply fields.
|
||||
2. [x] Device voltage derivation without overrides.
|
||||
3. [x] Snapshot-to-revision descriptions and history presentation.
|
||||
4. [x] Distribution-board power summary and project column layout.
|
||||
|
||||
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
|
||||
must not be inferred from these tasks.
|
||||
+4
-5
@@ -13,17 +13,16 @@
|
||||
"build": "npm run build:api",
|
||||
"build:api": "tsc -p tsconfig.json",
|
||||
"build:web": "next build",
|
||||
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
|
||||
"start": "node dist/server/index.js",
|
||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-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",
|
||||
"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.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.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-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.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.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.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-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.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",
|
||||
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
||||
"db:backfill:sections": "tsx scripts/db-backfill-sections.ts",
|
||||
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts",
|
||||
"dev:add-manual-circuit-row": "tsx scripts/dev-add-manual-circuit-row.ts",
|
||||
"dev:delete-circuit-row": "tsx scripts/dev-delete-circuit-row.ts"
|
||||
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { db } from "../src/db/client.js";
|
||||
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
|
||||
import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js";
|
||||
import { ProjectRepository } from "../src/db/repositories/project.repository.js";
|
||||
|
||||
const projectRepository = new ProjectRepository();
|
||||
const circuitListRepository = new CircuitListRepository();
|
||||
const circuitSectionRepository = new CircuitSectionRepository();
|
||||
const projectRepository = new ProjectRepository(db);
|
||||
const circuitListRepository = new CircuitListRepository(db);
|
||||
const circuitSectionRepository = new CircuitSectionRepository(db);
|
||||
|
||||
async function run() {
|
||||
const projects = await projectRepository.list();
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { db } from "../src/db/client.js";
|
||||
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
|
||||
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
|
||||
import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js";
|
||||
import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
|
||||
import { ProjectRepository } from "../src/db/repositories/project.repository.js";
|
||||
import { RoomRepository } from "../src/db/repositories/room.repository.js";
|
||||
import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-consumer-migration.service.js";
|
||||
|
||||
const projectRepository = new ProjectRepository();
|
||||
const circuitListRepository = new CircuitListRepository();
|
||||
const projectRepository = new ProjectRepository(db);
|
||||
const circuitListRepository = new CircuitListRepository(db);
|
||||
const migrationRepository = new LegacyConsumerMigrationRepository(db);
|
||||
const migrationService = new LegacyConsumerMigrationService(
|
||||
migrationRepository
|
||||
);
|
||||
const migrationService = new LegacyConsumerMigrationService({
|
||||
circuitListReader: circuitListRepository,
|
||||
sectionStore: new CircuitSectionRepository(db),
|
||||
circuitReader: new CircuitRepository(db),
|
||||
roomReader: new RoomRepository(db),
|
||||
migrationStore: migrationRepository,
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const projects = await projectRepository.list();
|
||||
|
||||
@@ -48,12 +48,45 @@ const circuitColumns = new Set(
|
||||
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
|
||||
);
|
||||
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
|
||||
const requiredProjectColumns = [
|
||||
"internal_project_number",
|
||||
"external_project_number",
|
||||
"building_owner",
|
||||
"description",
|
||||
"enabled_distribution_board_supply_types",
|
||||
];
|
||||
const projectColumns = new Set(
|
||||
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
|
||||
);
|
||||
const missingProjectColumns = requiredProjectColumns.filter(
|
||||
(name) => !projectColumns.has(name)
|
||||
);
|
||||
const requiredDistributionBoardColumns = ["floor_id", "supply_type"];
|
||||
const distributionBoardColumns = new Set(
|
||||
db
|
||||
.prepare("PRAGMA table_info(distribution_boards)")
|
||||
.all()
|
||||
.map((column) => column.name)
|
||||
);
|
||||
const missingDistributionBoardColumns =
|
||||
requiredDistributionBoardColumns.filter(
|
||||
(name) => !distributionBoardColumns.has(name)
|
||||
);
|
||||
const integrityCheck = db.pragma("integrity_check", { simple: true });
|
||||
const foreignKeyViolations = db.pragma("foreign_key_check");
|
||||
|
||||
console.log("Database:", dbPath);
|
||||
console.log("Required tables:", requiredTables.join(", "));
|
||||
console.log("Existing tables:", [...existing].join(", ") || "(none)");
|
||||
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
|
||||
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
|
||||
console.log("Required project columns:", requiredProjectColumns.join(", "));
|
||||
console.log(
|
||||
"Required distribution-board columns:",
|
||||
requiredDistributionBoardColumns.join(", ")
|
||||
);
|
||||
console.log("Integrity:", integrityCheck);
|
||||
console.log("Foreign-key violations:", foreignKeyViolations.length);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error("Missing tables:", missing.join(", "));
|
||||
@@ -78,4 +111,22 @@ if (missingCircuitColumns.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (missingProjectColumns.length > 0) {
|
||||
console.error("Missing project columns:", missingProjectColumns.join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (missingDistributionBoardColumns.length > 0) {
|
||||
console.error(
|
||||
"Missing distribution-board columns:",
|
||||
missingDistributionBoardColumns.join(", ")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (integrityCheck !== "ok" || foreignKeyViolations.length > 0) {
|
||||
console.error("Database integrity or foreign-key verification failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Circuit-first schema verification passed.");
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { db } from "../src/db/client.js";
|
||||
import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
|
||||
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
|
||||
|
||||
async function run() {
|
||||
const circuitId = process.argv[2];
|
||||
if (!circuitId) {
|
||||
console.error("Usage: npm run dev:add-manual-circuit-row -- <circuitId>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const circuitRepository = new CircuitRepository();
|
||||
const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db);
|
||||
|
||||
const circuit = await circuitRepository.findById(circuitId);
|
||||
if (!circuit) {
|
||||
console.error(`Circuit not found: ${circuitId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const createdRowId = rowTransactionRepository.createInCircuit({
|
||||
circuitId,
|
||||
name: "Test sub device",
|
||||
displayName: "Beleuchtung WC",
|
||||
phaseType: "single_phase",
|
||||
quantity: 1,
|
||||
powerPerUnit: 0.05,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 1,
|
||||
});
|
||||
|
||||
console.log(`Created test row id: ${createdRowId}`);
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error("Failed to create test row:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { db } from "../src/db/client.js";
|
||||
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
|
||||
import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
|
||||
|
||||
async function run() {
|
||||
const rowId = process.argv[2];
|
||||
if (!rowId) {
|
||||
console.error("Usage: npm run dev:delete-circuit-row -- <rowId>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rowRepository = new CircuitDeviceRowRepository();
|
||||
const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db);
|
||||
const row = await rowRepository.findById(rowId);
|
||||
if (!row) {
|
||||
console.error(`Row not found: ${rowId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
rowTransactionRepository.deleteFromCircuit(rowId, row.circuitId);
|
||||
console.log(`Deleted row id: ${rowId}`);
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error("Failed to delete row:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
+115
-14
@@ -71,6 +71,13 @@ body {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.editor-toolbar .project-device-drawer-toggle {
|
||||
border-color: #2563eb;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.active-view-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -109,6 +116,39 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.distribution-power-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(12rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.distribution-power-summary > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.distribution-power-summary span {
|
||||
color: #475569;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.distribution-power-summary strong {
|
||||
color: #172033;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.distribution-power-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.column-settings-menu {
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
@@ -271,12 +311,10 @@ body {
|
||||
}
|
||||
|
||||
.tree-editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
display: block;
|
||||
min-height: 560px;
|
||||
min-width: 0;
|
||||
align-items: start;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.project-device-sidebar {
|
||||
@@ -354,7 +392,7 @@ body {
|
||||
|
||||
.tree-grid {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
min-width: 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -362,37 +400,50 @@ body {
|
||||
.tree-grid th,
|
||||
.tree-grid td {
|
||||
border: 1px solid #e4e9f2;
|
||||
padding: 0.35rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
padding: 0.35rem 0.4rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tree-grid th {
|
||||
width: 1px;
|
||||
background: #f4f7fb;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.tree-grid td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-grid .header-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tree-grid .header-filter-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px solid #c4cddc;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-grid .header-sort-btn {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
@@ -400,10 +451,46 @@ body {
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-device-drawer {
|
||||
position: fixed;
|
||||
z-index: 1040;
|
||||
top: 5.5rem;
|
||||
right: 1rem;
|
||||
left: auto;
|
||||
width: min(360px, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 7rem);
|
||||
overflow: auto;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.75rem 2rem rgba(31, 41, 55, 0.22);
|
||||
}
|
||||
|
||||
.project-device-drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.project-device-drawer-header span {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.project-device-drawer-header button {
|
||||
border: 1px solid #c4cddc;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.tree-grid .header-sort-btn:hover span:first-child,
|
||||
.tree-grid .header-sort-btn:focus-visible span:first-child {
|
||||
text-decoration: underline;
|
||||
@@ -641,6 +728,18 @@ body {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.tree-grid .section-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tree-grid .section-title span {
|
||||
color: #475569;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-grid .cell-invalid {
|
||||
outline: 2px solid #c2410c;
|
||||
outline-offset: -2px;
|
||||
@@ -651,7 +750,8 @@ body {
|
||||
border-color: #c2410c;
|
||||
}
|
||||
|
||||
.tree-grid input {
|
||||
.tree-grid input,
|
||||
.tree-grid select {
|
||||
width: 100%;
|
||||
min-width: 5rem;
|
||||
border: 1px solid #9fb6e0;
|
||||
@@ -783,8 +883,9 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.tree-editor-layout {
|
||||
grid-template-columns: 1fr;
|
||||
@media (max-width: 720px) {
|
||||
.project-device-drawer {
|
||||
top: 4.5rem;
|
||||
max-height: calc(100vh - 5.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ export default function CircuitTreeEditPage() {
|
||||
const params = useParams<{ projectId: string; circuitListId: string }>();
|
||||
|
||||
return (
|
||||
<main className="container py-4">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<main className="container-fluid px-3 px-xxl-4 py-4">
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
||||
<div>
|
||||
<h1 className="h4 mb-1">Stromkreisliste</h1>
|
||||
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+137
-11
@@ -6,6 +6,7 @@ import {
|
||||
createGlobalDevice,
|
||||
createProject,
|
||||
deleteGlobalDevice,
|
||||
importProjectTransferAsNew,
|
||||
listGlobalDevices,
|
||||
listProjects,
|
||||
updateGlobalDevice,
|
||||
@@ -23,7 +24,6 @@ const emptyGlobalDevice: CreateGlobalDeviceInput = {
|
||||
quantity: 1,
|
||||
installedPowerPerUnitKw: 0.1,
|
||||
demandFactor: 1,
|
||||
voltageV: 230,
|
||||
phaseCount: 1,
|
||||
powerFactor: 1,
|
||||
note: "",
|
||||
@@ -48,13 +48,22 @@ export default function ProjectsPage() {
|
||||
quantity: "1",
|
||||
installedPowerPerUnitKw: "0.1",
|
||||
demandFactor: "1",
|
||||
voltageV: "230",
|
||||
phaseCount: "1",
|
||||
powerFactor: "1",
|
||||
note: "",
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [projectImport, setProjectImport] = useState<unknown>(null);
|
||||
const [projectImportFilename, setProjectImportFilename] = useState("");
|
||||
const [projectImportInputKey, setProjectImportInputKey] = useState(0);
|
||||
const [projectImportFileError, setProjectImportFileError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [importedProject, setImportedProject] = useState<{
|
||||
projectId: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
async function loadData() {
|
||||
setError(null);
|
||||
@@ -87,6 +96,49 @@ export default function ProjectsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProjectImportFile(file: File | undefined) {
|
||||
setProjectImport(null);
|
||||
setProjectImportFilename("");
|
||||
setProjectImportFileError(null);
|
||||
setImportedProject(null);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setProjectImport(JSON.parse(await file.text()) as unknown);
|
||||
setProjectImportFilename(file.name);
|
||||
} catch {
|
||||
setProjectImportFileError(
|
||||
"Die ausgewählte Datei enthält kein gültiges JSON."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportProject() {
|
||||
if (projectImport === null) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setImportedProject(null);
|
||||
try {
|
||||
const imported = await importProjectTransferAsNew(projectImport);
|
||||
setProjects(await listProjects());
|
||||
setImportedProject(imported);
|
||||
setProjectImport(null);
|
||||
setProjectImportFilename("");
|
||||
setProjectImportInputKey((current) => current + 1);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Projektdatei konnte nicht importiert werden."
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateGlobalDevice(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!globalDeviceForm.name.trim()) {
|
||||
@@ -99,7 +151,6 @@ export default function ProjectsPage() {
|
||||
quantity: Number(globalDeviceForm.quantity),
|
||||
installedPowerPerUnitKw: Number(globalDeviceForm.installedPowerPerUnitKw),
|
||||
demandFactor: Number(globalDeviceForm.demandFactor),
|
||||
voltageV: toOptionalNumber(globalDeviceForm.voltageV),
|
||||
phaseCount: globalDeviceForm.phaseCount === "3" ? 3 : 1,
|
||||
powerFactor: toOptionalNumber(globalDeviceForm.powerFactor),
|
||||
note: globalDeviceForm.note.trim() || undefined,
|
||||
@@ -116,7 +167,6 @@ export default function ProjectsPage() {
|
||||
quantity: String(emptyGlobalDevice.quantity),
|
||||
installedPowerPerUnitKw: String(emptyGlobalDevice.installedPowerPerUnitKw),
|
||||
demandFactor: String(emptyGlobalDevice.demandFactor),
|
||||
voltageV: String(emptyGlobalDevice.voltageV ?? ""),
|
||||
phaseCount: String(emptyGlobalDevice.phaseCount ?? 1),
|
||||
powerFactor: String(emptyGlobalDevice.powerFactor ?? ""),
|
||||
note: emptyGlobalDevice.note ?? "",
|
||||
@@ -143,7 +193,7 @@ export default function ProjectsPage() {
|
||||
|
||||
async function handleQuickUpdateGlobalDevice(
|
||||
device: GlobalDeviceDto,
|
||||
key: "name" | "displayName" | "category",
|
||||
key: "name" | "displayName" | "category" | "phaseCount",
|
||||
value: string
|
||||
) {
|
||||
const payload: CreateGlobalDeviceInput = {
|
||||
@@ -153,8 +203,12 @@ export default function ProjectsPage() {
|
||||
quantity: device.quantity,
|
||||
installedPowerPerUnitKw: device.installedPowerPerUnitKw,
|
||||
demandFactor: device.demandFactor,
|
||||
voltageV: device.voltageV ?? undefined,
|
||||
phaseCount: device.phaseCount ?? undefined,
|
||||
phaseCount:
|
||||
key === "phaseCount"
|
||||
? value === "3"
|
||||
? 3
|
||||
: 1
|
||||
: device.phaseCount ?? 1,
|
||||
powerFactor: device.powerFactor ?? undefined,
|
||||
note: device.note ?? undefined,
|
||||
};
|
||||
@@ -179,7 +233,7 @@ export default function ProjectsPage() {
|
||||
|
||||
<div className="row g-4">
|
||||
<section className="col-12 col-lg-4">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card shadow-sm mb-4">
|
||||
<div className="card-header">Neues Projekt</div>
|
||||
<div className="card-body">
|
||||
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
||||
@@ -198,6 +252,60 @@ export default function ProjectsPage() {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header">Projekt importieren</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary small">
|
||||
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
|
||||
mit neuen internen IDs angelegt.
|
||||
</p>
|
||||
<label className="form-label" htmlFor="project-overview-import">
|
||||
Projektdatei auswählen
|
||||
</label>
|
||||
<input
|
||||
accept=".json,application/json"
|
||||
className="form-control"
|
||||
disabled={isSaving}
|
||||
id="project-overview-import"
|
||||
key={projectImportInputKey}
|
||||
onChange={(event) =>
|
||||
void handleProjectImportFile(event.target.files?.[0])
|
||||
}
|
||||
type="file"
|
||||
/>
|
||||
{projectImportFilename ? (
|
||||
<div className="form-text">
|
||||
{projectImportFilename} ist bereit.
|
||||
</div>
|
||||
) : null}
|
||||
{projectImportFileError ? (
|
||||
<div className="text-danger small mt-1">
|
||||
{projectImportFileError}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-outline-primary mt-3 w-100"
|
||||
disabled={isSaving || projectImport === null}
|
||||
onClick={() => void handleImportProject()}
|
||||
type="button"
|
||||
>
|
||||
Als neues Projekt importieren
|
||||
</button>
|
||||
{importedProject ? (
|
||||
<div className="alert alert-success mt-3 mb-0">
|
||||
<div className="small mb-2">
|
||||
„{importedProject.name}“ wurde angelegt.
|
||||
</div>
|
||||
<Link
|
||||
className="btn btn-sm btn-success"
|
||||
href={`/projects/${importedProject.projectId}`}
|
||||
>
|
||||
Importiertes Projekt öffnen
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="col-12 col-lg-8">
|
||||
@@ -313,8 +421,8 @@ export default function ProjectsPage() {
|
||||
setGlobalDeviceForm((current) => ({ ...current, phaseCount: event.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="1">1-ph</option>
|
||||
<option value="3">3-ph</option>
|
||||
<option value="1">1-phasig</option>
|
||||
<option value="3">3-phasig</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-6 col-md-2">
|
||||
@@ -334,6 +442,7 @@ export default function ProjectsPage() {
|
||||
<th>Anzahl</th>
|
||||
<th>Leistung je Stück [kW]</th>
|
||||
<th>GZF</th>
|
||||
<th>Phasenart</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -376,6 +485,23 @@ export default function ProjectsPage() {
|
||||
<td>{device.quantity}</td>
|
||||
<td>{device.installedPowerPerUnitKw}</td>
|
||||
<td>{device.demandFactor}</td>
|
||||
<td>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
aria-label={`Phasenart für ${device.displayName}`}
|
||||
value={String(device.phaseCount ?? 1)}
|
||||
onChange={(event) =>
|
||||
void handleQuickUpdateGlobalDevice(
|
||||
device,
|
||||
"phaseCount",
|
||||
event.target.value
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="1">1-phasig</option>
|
||||
<option value="3">3-phasig</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
@@ -389,7 +515,7 @@ export default function ProjectsPage() {
|
||||
))}
|
||||
{!globalDevices.length ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center text-secondary py-4">
|
||||
<td colSpan={8} className="text-center text-secondary py-4">
|
||||
Noch keine globalen Geräte vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE `project_snapshots` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`source_revision` integer NOT NULL,
|
||||
`schema_version` integer NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
`payload_json` text NOT NULL,
|
||||
`payload_sha256` text NOT NULL,
|
||||
`created_at_iso` text NOT NULL,
|
||||
`created_by_actor_id` text,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `project_snapshots_project_created_idx` ON `project_snapshots` (`project_id`,`created_at_iso`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `project_snapshots_project_name_unique` ON `project_snapshots` (`project_id`,`name`);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `project_snapshots` ADD `kind` text DEFAULT 'named' NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX `project_snapshots_project_kind_revision_idx` ON `project_snapshots` (`project_id`,`kind`,`source_revision`);
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `projects` ADD `internal_project_number` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `external_project_number` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `building_owner` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `description` text;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `distribution_boards` ADD `floor_id` text REFERENCES floors(id);--> statement-breakpoint
|
||||
ALTER TABLE `distribution_boards` ADD `supply_type` text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `projects` ADD `enabled_distribution_board_supply_types` text DEFAULT '["AV","SV","EV","USV","MSR","SiBe"]' NOT NULL;
|
||||
@@ -0,0 +1,48 @@
|
||||
UPDATE `global_devices`
|
||||
SET `voltage_v` = NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE `project_devices` AS `pd`
|
||||
SET `voltage_v` = CASE
|
||||
WHEN `pd`.`phase_type` = 'three_phase' THEN `p`.`three_phase_voltage_v`
|
||||
ELSE `p`.`single_phase_voltage_v`
|
||||
END
|
||||
FROM `projects` AS `p`
|
||||
WHERE `p`.`id` = `pd`.`project_id`;
|
||||
--> statement-breakpoint
|
||||
WITH `derived_circuit_voltages` AS (
|
||||
SELECT
|
||||
`c`.`id` AS `circuit_id`,
|
||||
CASE
|
||||
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
|
||||
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuit_device_rows` AS `three_phase_row`
|
||||
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
|
||||
AND `three_phase_row`.`phase_type` = 'three_phase'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuit_device_rows` AS `other_row`
|
||||
WHERE `other_row`.`circuit_id` = `c`.`id`
|
||||
AND `other_row`.`phase_type` = 'single_phase'
|
||||
) THEN `p`.`three_phase_voltage_v`
|
||||
ELSE `p`.`single_phase_voltage_v`
|
||||
END AS `derived_voltage`
|
||||
FROM `circuits` AS `c`
|
||||
INNER JOIN `circuit_lists` AS `cl`
|
||||
ON `cl`.`id` = `c`.`circuit_list_id`
|
||||
INNER JOIN `projects` AS `p`
|
||||
ON `p`.`id` = `cl`.`project_id`
|
||||
INNER JOIN `circuit_sections` AS `cs`
|
||||
ON `cs`.`id` = `c`.`section_id`
|
||||
)
|
||||
UPDATE `circuits`
|
||||
SET `voltage` = (
|
||||
SELECT `derived_voltage`
|
||||
FROM `derived_circuit_voltages`
|
||||
WHERE `circuit_id` = `circuits`.`id`
|
||||
)
|
||||
WHERE `id` IN (
|
||||
SELECT `circuit_id`
|
||||
FROM `derived_circuit_voltages`
|
||||
);
|
||||
@@ -0,0 +1,85 @@
|
||||
UPDATE `project_devices`
|
||||
SET `phase_type` = CASE
|
||||
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||
IN ('1ph', '1phase', '1phasig', 'einphasig', 'singlephase')
|
||||
THEN 'single_phase'
|
||||
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||
IN ('3ph', '3phase', '3phasig', 'dreiphasig', 'threephase')
|
||||
THEN 'three_phase'
|
||||
ELSE `phase_type`
|
||||
END
|
||||
WHERE LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||
IN (
|
||||
'1ph',
|
||||
'1phase',
|
||||
'1phasig',
|
||||
'einphasig',
|
||||
'singlephase',
|
||||
'3ph',
|
||||
'3phase',
|
||||
'3phasig',
|
||||
'dreiphasig',
|
||||
'threephase'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
UPDATE `circuit_device_rows`
|
||||
SET `phase_type` = CASE
|
||||
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||
IN ('1ph', '1phase', '1phasig', 'einphasig', 'singlephase')
|
||||
THEN 'single_phase'
|
||||
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||
IN ('3ph', '3phase', '3phasig', 'dreiphasig', 'threephase')
|
||||
THEN 'three_phase'
|
||||
ELSE `phase_type`
|
||||
END
|
||||
WHERE LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||
IN (
|
||||
'1ph',
|
||||
'1phase',
|
||||
'1phasig',
|
||||
'einphasig',
|
||||
'singlephase',
|
||||
'3ph',
|
||||
'3phase',
|
||||
'3phasig',
|
||||
'dreiphasig',
|
||||
'threephase'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
WITH `derived_circuit_voltages` AS (
|
||||
SELECT
|
||||
`c`.`id` AS `circuit_id`,
|
||||
CASE
|
||||
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
|
||||
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuit_device_rows` AS `three_phase_row`
|
||||
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
|
||||
AND `three_phase_row`.`phase_type` = 'three_phase'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuit_device_rows` AS `single_phase_row`
|
||||
WHERE `single_phase_row`.`circuit_id` = `c`.`id`
|
||||
AND `single_phase_row`.`phase_type` = 'single_phase'
|
||||
) THEN `p`.`three_phase_voltage_v`
|
||||
ELSE `p`.`single_phase_voltage_v`
|
||||
END AS `derived_voltage`
|
||||
FROM `circuits` AS `c`
|
||||
INNER JOIN `circuit_lists` AS `cl`
|
||||
ON `cl`.`id` = `c`.`circuit_list_id`
|
||||
INNER JOIN `projects` AS `p`
|
||||
ON `p`.`id` = `cl`.`project_id`
|
||||
INNER JOIN `circuit_sections` AS `cs`
|
||||
ON `cs`.`id` = `c`.`section_id`
|
||||
)
|
||||
UPDATE `circuits`
|
||||
SET `voltage` = (
|
||||
SELECT `derived_voltage`
|
||||
FROM `derived_circuit_voltages`
|
||||
WHERE `circuit_id` = `circuits`.`id`
|
||||
)
|
||||
WHERE `id` IN (
|
||||
SELECT `circuit_id`
|
||||
FROM `derived_circuit_voltages`
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
UPDATE `circuit_device_rows`
|
||||
SET `phase_type` = COALESCE(
|
||||
(
|
||||
SELECT `pd`.`phase_type`
|
||||
FROM `project_devices` AS `pd`
|
||||
WHERE `pd`.`id` = `circuit_device_rows`.`linked_project_device_id`
|
||||
AND `pd`.`phase_type` IN ('single_phase', 'three_phase')
|
||||
),
|
||||
(
|
||||
SELECT CASE
|
||||
WHEN `cs`.`key` = 'three_phase' THEN 'three_phase'
|
||||
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN 'single_phase'
|
||||
ELSE NULL
|
||||
END
|
||||
FROM `circuits` AS `c`
|
||||
INNER JOIN `circuit_sections` AS `cs`
|
||||
ON `cs`.`id` = `c`.`section_id`
|
||||
WHERE `c`.`id` = `circuit_device_rows`.`circuit_id`
|
||||
)
|
||||
)
|
||||
WHERE `phase_type` IS NULL
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM `project_devices` AS `pd`
|
||||
WHERE `pd`.`id` = `circuit_device_rows`.`linked_project_device_id`
|
||||
AND `pd`.`phase_type` IN ('single_phase', 'three_phase')
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuits` AS `c`
|
||||
INNER JOIN `circuit_sections` AS `cs`
|
||||
ON `cs`.`id` = `c`.`section_id`
|
||||
WHERE `c`.`id` = `circuit_device_rows`.`circuit_id`
|
||||
AND `cs`.`key` IN ('lighting', 'single_phase', 'three_phase')
|
||||
)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
WITH `derived_circuit_voltages` AS (
|
||||
SELECT
|
||||
`c`.`id` AS `circuit_id`,
|
||||
CASE
|
||||
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
|
||||
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuit_device_rows` AS `three_phase_row`
|
||||
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
|
||||
AND `three_phase_row`.`phase_type` = 'three_phase'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuit_device_rows` AS `single_phase_row`
|
||||
WHERE `single_phase_row`.`circuit_id` = `c`.`id`
|
||||
AND `single_phase_row`.`phase_type` = 'single_phase'
|
||||
) THEN `p`.`three_phase_voltage_v`
|
||||
ELSE `p`.`single_phase_voltage_v`
|
||||
END AS `derived_voltage`
|
||||
FROM `circuits` AS `c`
|
||||
INNER JOIN `circuit_lists` AS `cl`
|
||||
ON `cl`.`id` = `c`.`circuit_list_id`
|
||||
INNER JOIN `projects` AS `p`
|
||||
ON `p`.`id` = `cl`.`project_id`
|
||||
INNER JOIN `circuit_sections` AS `cs`
|
||||
ON `cs`.`id` = `c`.`section_id`
|
||||
)
|
||||
UPDATE `circuits`
|
||||
SET `voltage` = (
|
||||
SELECT `derived_voltage`
|
||||
FROM `derived_circuit_voltages`
|
||||
WHERE `circuit_id` = `circuits`.`id`
|
||||
)
|
||||
WHERE `id` IN (
|
||||
SELECT `circuit_id`
|
||||
FROM `derived_circuit_voltages`
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
UPDATE `circuit_device_rows`
|
||||
SET `phase_type` = 'single_phase'
|
||||
WHERE `phase_type` IS NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE `global_devices`
|
||||
SET `phase_count` = 1
|
||||
WHERE `phase_count` IS NULL OR `phase_count` NOT IN (1, 3);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `distribution_boards` ADD `simultaneity_factor` real DEFAULT 1 NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,76 @@
|
||||
"when": 1784835703230,
|
||||
"tag": "0013_project_history_stacks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1785009799979,
|
||||
"tag": "0014_project_snapshots",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1785014383032,
|
||||
"tag": "0015_modern_madame_masque",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1785254079435,
|
||||
"tag": "0016_dashing_darkstar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1785307189539,
|
||||
"tag": "0017_vengeful_romulus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "6",
|
||||
"when": 1785308458789,
|
||||
"tag": "0018_fancy_argent",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1785310907349,
|
||||
"tag": "0019_normalize_project_voltages",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "6",
|
||||
"when": 1785314248007,
|
||||
"tag": "0020_normalize_phase_types",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "6",
|
||||
"when": 1785314558555,
|
||||
"tag": "0021_fill_missing_phase_types",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "6",
|
||||
"when": 1785314626476,
|
||||
"tag": "0022_default_remaining_phase_types",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "6",
|
||||
"when": 1785343645615,
|
||||
"tag": "0023_cheerful_night_thrasher",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
automaticProjectSnapshotIntervalRevisions,
|
||||
automaticProjectSnapshotRetentionCount,
|
||||
shouldCaptureAutomaticProjectSnapshot,
|
||||
} from "../../domain/models/project-snapshot-policy.model.js";
|
||||
import { projectStateSnapshotSchemaVersion } from "../../domain/models/project-state-snapshot.model.js";
|
||||
import type { ProjectSnapshotMetadata } from "../../domain/ports/project-snapshot.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projectSnapshots } from "../schema/project-snapshots.js";
|
||||
import { readProjectStateSnapshot } from "./project-state-snapshot.persistence.js";
|
||||
|
||||
interface CaptureAutomaticProjectSnapshotInput {
|
||||
projectId: string;
|
||||
currentRevision: number;
|
||||
createdAtIso: string;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export function captureAutomaticProjectSnapshot(
|
||||
database: AppDatabase,
|
||||
input: CaptureAutomaticProjectSnapshotInput
|
||||
): ProjectSnapshotMetadata | null {
|
||||
const latest = database
|
||||
.select({ sourceRevision: projectSnapshots.sourceRevision })
|
||||
.from(projectSnapshots)
|
||||
.where(
|
||||
and(
|
||||
eq(projectSnapshots.projectId, input.projectId),
|
||||
eq(projectSnapshots.kind, "automatic")
|
||||
)
|
||||
)
|
||||
.orderBy(desc(projectSnapshots.sourceRevision))
|
||||
.get();
|
||||
if (
|
||||
!shouldCaptureAutomaticProjectSnapshot(
|
||||
input.currentRevision,
|
||||
latest?.sourceRevision ?? null
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = readProjectStateSnapshot(database, input.projectId);
|
||||
if (!current || current.currentRevision !== input.currentRevision) {
|
||||
throw new Error(
|
||||
"Automatic snapshot state does not match the appended revision."
|
||||
);
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const preferredName = `Automatisch · Revision ${input.currentRevision}`;
|
||||
const conflictingName = database
|
||||
.select({ id: projectSnapshots.id })
|
||||
.from(projectSnapshots)
|
||||
.where(
|
||||
and(
|
||||
eq(projectSnapshots.projectId, input.projectId),
|
||||
eq(projectSnapshots.name, preferredName)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
const metadata: ProjectSnapshotMetadata = {
|
||||
id,
|
||||
projectId: input.projectId,
|
||||
sourceRevision: input.currentRevision,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
kind: "automatic",
|
||||
name: conflictingName
|
||||
? `${preferredName} · ${id}`
|
||||
: preferredName,
|
||||
description: `Automatischer Sicherungspunkt nach jeweils ${automaticProjectSnapshotIntervalRevisions} Projektänderungen.`,
|
||||
payloadSha256: current.payloadSha256,
|
||||
createdAtIso: input.createdAtIso,
|
||||
createdByActorId: input.actorId ?? null,
|
||||
};
|
||||
database
|
||||
.insert(projectSnapshots)
|
||||
.values({
|
||||
...metadata,
|
||||
payloadJson: current.payloadJson,
|
||||
})
|
||||
.run();
|
||||
|
||||
const automaticSnapshots = database
|
||||
.select({ id: projectSnapshots.id })
|
||||
.from(projectSnapshots)
|
||||
.where(
|
||||
and(
|
||||
eq(projectSnapshots.projectId, input.projectId),
|
||||
eq(projectSnapshots.kind, "automatic")
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
desc(projectSnapshots.sourceRevision),
|
||||
desc(projectSnapshots.createdAtIso),
|
||||
desc(projectSnapshots.id)
|
||||
)
|
||||
.all();
|
||||
const expiredIds = automaticSnapshots
|
||||
.slice(automaticProjectSnapshotRetentionCount)
|
||||
.map((snapshot) => snapshot.id);
|
||||
if (expiredIds.length) {
|
||||
database
|
||||
.delete(projectSnapshots)
|
||||
.where(inArray(projectSnapshots.id, expiredIds))
|
||||
.run();
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
@@ -19,13 +19,15 @@ import { circuitDeviceRows } from "../schema/circuit-device-rows.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";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
interface PersistedMoveRow {
|
||||
id: string;
|
||||
circuitId: string;
|
||||
sortOrder: number;
|
||||
phaseType: string | null;
|
||||
}
|
||||
|
||||
export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
@@ -36,29 +38,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
execute(input: ExecuteCircuitDeviceRowMoveCommandInput) {
|
||||
this.assertSupportedCommand(input.command);
|
||||
|
||||
return this.database.transaction((tx) => {
|
||||
const inverse = this.applyCommand(
|
||||
tx,
|
||||
input.projectId,
|
||||
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 };
|
||||
});
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
private assertSupportedCommand(
|
||||
@@ -103,6 +88,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
);
|
||||
this.applyMoves(database, command.payload.moves);
|
||||
this.updateReserveStates(database, circuitIds);
|
||||
this.updateVoltages(database, projectId, circuitIds);
|
||||
return inverse;
|
||||
}
|
||||
|
||||
@@ -112,7 +98,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
const { targetCircuit, targetCircuitAction, moves } =
|
||||
command.payload;
|
||||
const rowsById = this.loadExpectedRows(database, moves);
|
||||
this.assertCircuitLocation(database, projectId, targetCircuit);
|
||||
const appliedTargetCircuit = {
|
||||
...targetCircuit,
|
||||
voltage: resolveCircuitVoltage(
|
||||
database,
|
||||
projectId,
|
||||
targetCircuit.sectionId,
|
||||
moves.map((move) => rowsById.get(move.rowId)?.phaseType)
|
||||
),
|
||||
};
|
||||
this.assertCircuitLocation(database, projectId, appliedTargetCircuit);
|
||||
|
||||
if (targetCircuitAction === "create") {
|
||||
const sourceCircuitIds = [
|
||||
@@ -124,13 +119,13 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
sourceCircuitIds,
|
||||
targetCircuit.circuitListId
|
||||
);
|
||||
this.assertTargetCircuitAvailable(database, targetCircuit);
|
||||
this.insertTargetCircuit(database, targetCircuit);
|
||||
this.assertTargetCircuitAvailable(database, appliedTargetCircuit);
|
||||
this.insertTargetCircuit(database, appliedTargetCircuit);
|
||||
|
||||
const inverse =
|
||||
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||
"delete",
|
||||
targetCircuit,
|
||||
appliedTargetCircuit,
|
||||
this.reverseMoves(moves, rowsById)
|
||||
);
|
||||
this.applyMoves(database, moves);
|
||||
@@ -138,12 +133,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
...sourceCircuitIds,
|
||||
targetCircuit.id,
|
||||
]);
|
||||
this.updateVoltages(database, projectId, [
|
||||
...sourceCircuitIds,
|
||||
targetCircuit.id,
|
||||
]);
|
||||
return inverse;
|
||||
}
|
||||
|
||||
this.assertTargetCircuitUnchanged(
|
||||
database,
|
||||
targetCircuit,
|
||||
appliedTargetCircuit,
|
||||
moves.map((move) => move.rowId)
|
||||
);
|
||||
const destinationCircuitIds = [
|
||||
@@ -158,7 +157,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
const inverse =
|
||||
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||
"create",
|
||||
targetCircuit,
|
||||
appliedTargetCircuit,
|
||||
this.reverseMoves(moves, rowsById)
|
||||
);
|
||||
this.applyMoves(database, moves);
|
||||
@@ -166,6 +165,11 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
targetCircuit.id,
|
||||
...destinationCircuitIds,
|
||||
]);
|
||||
this.updateVoltages(
|
||||
database,
|
||||
projectId,
|
||||
destinationCircuitIds
|
||||
);
|
||||
const deleted = database
|
||||
.delete(circuits)
|
||||
.where(
|
||||
@@ -187,6 +191,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
return inverse;
|
||||
}
|
||||
|
||||
private updateVoltages(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitIds: string[]
|
||||
) {
|
||||
for (const circuitId of new Set(circuitIds)) {
|
||||
updateDerivedCircuitVoltage(database, projectId, circuitId);
|
||||
}
|
||||
}
|
||||
|
||||
private loadExpectedRows(
|
||||
database: AppDatabase,
|
||||
moves: CircuitDeviceRowMoveAssignment[]
|
||||
@@ -197,6 +211,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
id: circuitDeviceRows.id,
|
||||
circuitId: circuitDeviceRows.circuitId,
|
||||
sortOrder: circuitDeviceRows.sortOrder,
|
||||
phaseType: circuitDeviceRows.phaseType,
|
||||
})
|
||||
.from(circuitDeviceRows)
|
||||
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ExecuteCircuitDeviceRowUpdateCommandInput,
|
||||
} from "../../domain/ports/circuit-device-row-project-command.store.js";
|
||||
import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.js";
|
||||
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
@@ -21,8 +22,8 @@ import {
|
||||
toCircuitDeviceRowPatchValues,
|
||||
type CircuitDeviceRowPatchInput,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
|
||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
||||
|
||||
@@ -34,99 +35,109 @@ export class CircuitDeviceRowProjectCommandRepository
|
||||
executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) {
|
||||
assertCircuitDeviceRowUpdateProjectCommand(input.command);
|
||||
|
||||
return this.database.transaction((tx) => {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, input.command.payload.rowId))
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error("Invalid device row id.");
|
||||
}
|
||||
return executeProjectCommandTransactionWithAppliedForward(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
const circuit = tx
|
||||
.select({ circuitListId: circuits.circuitListId })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, current.circuitId))
|
||||
.get();
|
||||
const owningList = circuit
|
||||
? tx
|
||||
.select({ id: circuitLists.id })
|
||||
.from(circuitLists)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitLists.id, circuit.circuitListId),
|
||||
eq(circuitLists.projectId, input.projectId)
|
||||
)
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteCircuitDeviceRowUpdateCommandInput
|
||||
) {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, input.command.payload.rowId))
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error("Invalid device row id.");
|
||||
}
|
||||
|
||||
const circuit = tx
|
||||
.select({ circuitListId: circuits.circuitListId })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, current.circuitId))
|
||||
.get();
|
||||
const owningList = circuit
|
||||
? tx
|
||||
.select({ id: circuitLists.id })
|
||||
.from(circuitLists)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitLists.id, circuit.circuitListId),
|
||||
eq(circuitLists.projectId, input.projectId)
|
||||
)
|
||||
.get()
|
||||
: null;
|
||||
if (!owningList) {
|
||||
throw new Error("Circuit device row does not belong to project.");
|
||||
}
|
||||
)
|
||||
.get()
|
||||
: null;
|
||||
if (!owningList) {
|
||||
throw new Error("Circuit device row does not belong to project.");
|
||||
}
|
||||
|
||||
const patch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
change.value,
|
||||
])
|
||||
) as CircuitDeviceRowPatchInput;
|
||||
this.assertLinkedProjectDevice(tx, input.projectId, patch);
|
||||
this.assertRoom(tx, input.projectId, patch);
|
||||
const patch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
change.value,
|
||||
])
|
||||
) as CircuitDeviceRowPatchInput;
|
||||
if (
|
||||
input.source === "user" &&
|
||||
patch.phaseType !== undefined &&
|
||||
!isElectricalPhaseType(patch.phaseType)
|
||||
) {
|
||||
throw new Error("Device-row phase type is invalid.");
|
||||
}
|
||||
this.assertLinkedProjectDevice(tx, input.projectId, patch);
|
||||
this.assertRoom(tx, input.projectId, patch);
|
||||
|
||||
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
|
||||
current,
|
||||
patch
|
||||
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
|
||||
current,
|
||||
patch
|
||||
);
|
||||
if (
|
||||
overriddenFields !== undefined &&
|
||||
patch.overriddenFields === undefined
|
||||
) {
|
||||
patch.overriddenFields = overriddenFields;
|
||||
}
|
||||
|
||||
const appliedForward = createCircuitDeviceRowUpdateProjectCommand(
|
||||
current.id,
|
||||
patch as CircuitDeviceRowUpdatePatch
|
||||
);
|
||||
const inversePatch = Object.fromEntries(
|
||||
appliedForward.payload.changes.map((change) => [
|
||||
change.field,
|
||||
getCircuitDeviceRowFieldValue(current, change.field),
|
||||
])
|
||||
) as CircuitDeviceRowUpdatePatch;
|
||||
const inverse = createCircuitDeviceRowUpdateProjectCommand(
|
||||
current.id,
|
||||
inversePatch
|
||||
);
|
||||
|
||||
const update = tx
|
||||
.update(circuitDeviceRows)
|
||||
.set(toCircuitDeviceRowPatchValues(patch))
|
||||
.where(eq(circuitDeviceRows.id, current.id))
|
||||
.run();
|
||||
if (update.changes !== 1) {
|
||||
throw new Error("Circuit device row changed before command execution.");
|
||||
}
|
||||
if (patch.phaseType !== undefined) {
|
||||
updateDerivedCircuitVoltage(
|
||||
tx,
|
||||
input.projectId,
|
||||
current.circuitId
|
||||
);
|
||||
if (
|
||||
overriddenFields !== undefined &&
|
||||
patch.overriddenFields === undefined
|
||||
) {
|
||||
patch.overriddenFields = overriddenFields;
|
||||
}
|
||||
}
|
||||
|
||||
const appliedForward = createCircuitDeviceRowUpdateProjectCommand(
|
||||
current.id,
|
||||
patch as CircuitDeviceRowUpdatePatch
|
||||
);
|
||||
const inversePatch = Object.fromEntries(
|
||||
appliedForward.payload.changes.map((change) => [
|
||||
change.field,
|
||||
getCircuitDeviceRowFieldValue(current, change.field),
|
||||
])
|
||||
) as CircuitDeviceRowUpdatePatch;
|
||||
const inverse = createCircuitDeviceRowUpdateProjectCommand(
|
||||
current.id,
|
||||
inversePatch
|
||||
);
|
||||
|
||||
const update = tx
|
||||
.update(circuitDeviceRows)
|
||||
.set(toCircuitDeviceRowPatchValues(patch))
|
||||
.where(eq(circuitDeviceRows.id, current.id))
|
||||
.run();
|
||||
if (update.changes !== 1) {
|
||||
throw new Error("Circuit device row changed before command execution.");
|
||||
}
|
||||
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
source: input.source,
|
||||
description: input.description,
|
||||
actorId: input.actorId,
|
||||
forward: appliedForward,
|
||||
inverse,
|
||||
});
|
||||
applyProjectHistoryTransition(tx, {
|
||||
projectId: input.projectId,
|
||||
source: input.source,
|
||||
recordedChangeSetId: revision.changeSetId,
|
||||
targetChangeSetId: input.historyTargetChangeSetId,
|
||||
});
|
||||
|
||||
return { revision, inverse };
|
||||
});
|
||||
return {
|
||||
forward: appliedForward,
|
||||
inverse,
|
||||
};
|
||||
}
|
||||
|
||||
private assertLinkedProjectDevice(
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ExecuteCircuitDeviceRowStructureCommandInput,
|
||||
} from "../../domain/ports/circuit-device-row-structure-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
@@ -21,8 +22,8 @@ import {
|
||||
assertCircuitDeviceRowReferencesInProject,
|
||||
toCircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure.persistence.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
implements CircuitDeviceRowStructureProjectCommandStore
|
||||
@@ -30,30 +31,17 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
|
||||
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 };
|
||||
});
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(
|
||||
tx,
|
||||
input.projectId,
|
||||
input.source,
|
||||
input.command
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
@@ -94,6 +82,9 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
"User commands cannot assign legacy consumer ids."
|
||||
);
|
||||
}
|
||||
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
|
||||
throw new Error("Device-row phase type is invalid.");
|
||||
}
|
||||
this.assertCircuitInProject(database, projectId, row.circuitId);
|
||||
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
|
||||
|
||||
@@ -115,6 +106,7 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
if (circuitUpdate.changes !== 1) {
|
||||
throw new Error("Circuit changed before device-row insertion.");
|
||||
}
|
||||
updateDerivedCircuitVoltage(database, projectId, row.circuitId);
|
||||
|
||||
return createCircuitDeviceRowDeleteProjectCommand(
|
||||
row.id,
|
||||
@@ -167,6 +159,11 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
if (circuitUpdate.changes !== 1) {
|
||||
throw new Error("Circuit changed before device-row deletion.");
|
||||
}
|
||||
updateDerivedCircuitVoltage(
|
||||
database,
|
||||
projectId,
|
||||
expectedCircuitId
|
||||
);
|
||||
|
||||
return createCircuitDeviceRowInsertProjectCommand(
|
||||
toCircuitDeviceRowSnapshot(row)
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type {
|
||||
CircuitDeviceRowTransactionStore,
|
||||
CreateCircuitDeviceRowTransactionInput,
|
||||
CreateCircuitWithDeviceRowsTransactionInput,
|
||||
MoveCircuitDeviceRowsTransactionInput,
|
||||
} from "../../domain/ports/circuit-device-row-transaction.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { toCircuitCreateValues } from "./circuit.persistence.js";
|
||||
import {
|
||||
toCircuitDeviceRowCreateValues,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
|
||||
export class CircuitDeviceRowTransactionRepository
|
||||
implements CircuitDeviceRowTransactionStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
createInCircuit(input: CreateCircuitDeviceRowTransactionInput) {
|
||||
const id = crypto.randomUUID();
|
||||
this.database.transaction((tx) => {
|
||||
const [circuit] = tx
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, input.circuitId))
|
||||
.limit(1)
|
||||
.all();
|
||||
if (!circuit) {
|
||||
throw new Error("Der Stromkreis ist ungültig.");
|
||||
}
|
||||
|
||||
const existingRows = tx
|
||||
.select({ sortOrder: circuitDeviceRows.sortOrder })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, input.circuitId))
|
||||
.all();
|
||||
const lastSortOrder = existingRows.reduce(
|
||||
(highest, row) => Math.max(highest, row.sortOrder),
|
||||
0
|
||||
);
|
||||
const sortOrder = input.sortOrder ?? lastSortOrder + 10;
|
||||
|
||||
tx
|
||||
.insert(circuitDeviceRows)
|
||||
.values(toCircuitDeviceRowCreateValues(id, { ...input, sortOrder }))
|
||||
.run();
|
||||
tx
|
||||
.update(circuits)
|
||||
.set({ isReserve: 0 })
|
||||
.where(eq(circuits.id, input.circuitId))
|
||||
.run();
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
createCircuitWithDeviceRows(input: CreateCircuitWithDeviceRowsTransactionInput) {
|
||||
if (input.deviceRows.length === 0) {
|
||||
throw new Error("Mindestens eine Gerätezeile ist erforderlich.");
|
||||
}
|
||||
|
||||
const circuitId = crypto.randomUUID();
|
||||
const rowIds = input.deviceRows.map(() => crypto.randomUUID());
|
||||
this.database.transaction((tx) => {
|
||||
tx
|
||||
.insert(circuits)
|
||||
.values(
|
||||
toCircuitCreateValues(circuitId, {
|
||||
...input.circuit,
|
||||
isReserve: false,
|
||||
})
|
||||
)
|
||||
.run();
|
||||
|
||||
for (let index = 0; index < input.deviceRows.length; index += 1) {
|
||||
const row = input.deviceRows[index];
|
||||
tx
|
||||
.insert(circuitDeviceRows)
|
||||
.values(
|
||||
toCircuitDeviceRowCreateValues(rowIds[index], {
|
||||
...row,
|
||||
circuitId,
|
||||
sortOrder: row.sortOrder ?? (index + 1) * 10,
|
||||
})
|
||||
)
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
return { circuitId, rowIds };
|
||||
}
|
||||
|
||||
deleteFromCircuit(rowId: string, expectedCircuitId: string) {
|
||||
this.database.transaction((tx) => {
|
||||
const [row] = tx
|
||||
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, rowId))
|
||||
.limit(1)
|
||||
.all();
|
||||
if (!row || row.circuitId !== expectedCircuitId) {
|
||||
throw new Error("Die Gerätezeile wurde vor dem Löschen verändert.");
|
||||
}
|
||||
|
||||
const result = tx
|
||||
.delete(circuitDeviceRows)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitDeviceRows.id, rowId),
|
||||
eq(circuitDeviceRows.circuitId, expectedCircuitId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (result.changes !== 1) {
|
||||
throw new Error("Die Gerätezeile konnte nicht gelöscht werden.");
|
||||
}
|
||||
|
||||
const remainingRows = tx
|
||||
.select({ id: circuitDeviceRows.id })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, expectedCircuitId))
|
||||
.all();
|
||||
tx
|
||||
.update(circuits)
|
||||
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
|
||||
.where(eq(circuits.id, expectedCircuitId))
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
moveRows(input: MoveCircuitDeviceRowsTransactionInput) {
|
||||
if (input.rows.length === 0) {
|
||||
throw new Error("Keine Gerätezeilen zum Verschieben angegeben.");
|
||||
}
|
||||
if (Boolean(input.targetCircuitId) === Boolean(input.createTargetCircuit)) {
|
||||
throw new Error("Für die Mehrfachverschiebung muss genau ein Ziel angegeben werden.");
|
||||
}
|
||||
|
||||
const rowIds = input.rows.map((row) => row.id);
|
||||
if (new Set(rowIds).size !== rowIds.length) {
|
||||
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
|
||||
}
|
||||
|
||||
const createdCircuitId = input.createTargetCircuit ? crypto.randomUUID() : undefined;
|
||||
const targetCircuitId = input.targetCircuitId ?? createdCircuitId!;
|
||||
|
||||
this.database.transaction((tx) => {
|
||||
const persistedRows = tx
|
||||
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
|
||||
.from(circuitDeviceRows)
|
||||
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||
.all();
|
||||
if (persistedRows.length !== input.rows.length) {
|
||||
throw new Error("Eine oder mehrere Gerätezeilen sind ungültig.");
|
||||
}
|
||||
|
||||
const persistedRowsById = new Map(persistedRows.map((row) => [row.id, row]));
|
||||
for (const expectedRow of input.rows) {
|
||||
if (
|
||||
persistedRowsById.get(expectedRow.id)?.circuitId !==
|
||||
expectedRow.expectedCircuitId
|
||||
) {
|
||||
throw new Error(
|
||||
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let targetCircuit: { id: string; circuitListId: string };
|
||||
if (input.createTargetCircuit) {
|
||||
tx
|
||||
.insert(circuits)
|
||||
.values({
|
||||
id: targetCircuitId,
|
||||
circuitListId: input.createTargetCircuit.circuitListId,
|
||||
sectionId: input.createTargetCircuit.sectionId,
|
||||
equipmentIdentifier: input.createTargetCircuit.equipmentIdentifier,
|
||||
displayName: input.createTargetCircuit.displayName,
|
||||
sortOrder: input.createTargetCircuit.sortOrder,
|
||||
isReserve: 0,
|
||||
})
|
||||
.run();
|
||||
targetCircuit = {
|
||||
id: targetCircuitId,
|
||||
circuitListId: input.createTargetCircuit.circuitListId,
|
||||
};
|
||||
} else {
|
||||
const [persistedTargetCircuit] = tx
|
||||
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, targetCircuitId))
|
||||
.limit(1)
|
||||
.all();
|
||||
if (!persistedTargetCircuit) {
|
||||
throw new Error("Der Zielstromkreis ist ungültig.");
|
||||
}
|
||||
targetCircuit = persistedTargetCircuit;
|
||||
}
|
||||
|
||||
const sourceCircuitIds = [
|
||||
...new Set(input.rows.map((row) => row.expectedCircuitId)),
|
||||
];
|
||||
const sourceCircuits = tx
|
||||
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
|
||||
.from(circuits)
|
||||
.where(inArray(circuits.id, sourceCircuitIds))
|
||||
.all();
|
||||
if (sourceCircuits.length !== sourceCircuitIds.length) {
|
||||
throw new Error("Einer oder mehrere Quellstromkreise sind ungültig.");
|
||||
}
|
||||
if (
|
||||
sourceCircuits.some(
|
||||
(circuit) => circuit.circuitListId !== targetCircuit.circuitListId
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Alle verschobenen Zeilen müssen zur Stromkreisliste des Ziels gehören."
|
||||
);
|
||||
}
|
||||
|
||||
const movedRowIdSet = new Set(rowIds);
|
||||
const retainedTargetRows = tx
|
||||
.select({ id: circuitDeviceRows.id, sortOrder: circuitDeviceRows.sortOrder })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, targetCircuitId))
|
||||
.all()
|
||||
.filter((row) => !movedRowIdSet.has(row.id));
|
||||
const lastTargetSortOrder = retainedTargetRows.reduce(
|
||||
(highest, row) => Math.max(highest, row.sortOrder),
|
||||
0
|
||||
);
|
||||
|
||||
for (let index = 0; index < input.rows.length; index += 1) {
|
||||
const row = input.rows[index];
|
||||
const result = tx
|
||||
.update(circuitDeviceRows)
|
||||
.set({
|
||||
circuitId: targetCircuitId,
|
||||
sortOrder: lastTargetSortOrder + (index + 1) * 10,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(circuitDeviceRows.id, row.id),
|
||||
eq(circuitDeviceRows.circuitId, row.expectedCircuitId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (result.changes !== 1) {
|
||||
throw new Error(
|
||||
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sourceCircuitId of sourceCircuitIds) {
|
||||
const remainingRows = tx
|
||||
.select({ id: circuitDeviceRows.id })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, sourceCircuitId))
|
||||
.all();
|
||||
tx
|
||||
.update(circuits)
|
||||
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
|
||||
.where(eq(circuits.id, sourceCircuitId))
|
||||
.run();
|
||||
}
|
||||
tx
|
||||
.update(circuits)
|
||||
.set({ isReserve: 0 })
|
||||
.where(eq(circuits.id, targetCircuitId))
|
||||
.run();
|
||||
});
|
||||
|
||||
return {
|
||||
movedRowIds: rowIds,
|
||||
targetCircuitId,
|
||||
createdCircuitId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,18 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { db } from "../client.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 { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import {
|
||||
toCircuitDeviceRowCreateValues,
|
||||
toCircuitDeviceRowUpdateValues,
|
||||
type CircuitDeviceRowCreateInput,
|
||||
type CircuitDeviceRowUpdateInput,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
export type {
|
||||
CircuitDeviceRowCreateInput,
|
||||
CircuitDeviceRowUpdateInput,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
|
||||
|
||||
export class CircuitDeviceRowRepository {
|
||||
async findById(rowId: string) {
|
||||
const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async countByCircuit(circuitId: string) {
|
||||
const rows = await db.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows).where(eq(circuitDeviceRows.circuitId, circuitId));
|
||||
return rows.length;
|
||||
}
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByCircuitList(circuitIds: string[]) {
|
||||
if (!circuitIds.length) {
|
||||
return [];
|
||||
}
|
||||
return db
|
||||
return this.database
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(inArray(circuitDeviceRows.circuitId, circuitIds))
|
||||
@@ -40,7 +20,7 @@ export class CircuitDeviceRowRepository {
|
||||
}
|
||||
|
||||
async listLinkedByProjectDevice(projectId: string, projectDeviceId: string) {
|
||||
return db
|
||||
return this.database
|
||||
.select({
|
||||
id: circuitDeviceRows.id,
|
||||
circuitId: circuitDeviceRows.circuitId,
|
||||
@@ -83,34 +63,4 @@ export class CircuitDeviceRowRepository {
|
||||
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
|
||||
}
|
||||
|
||||
async create(input: CircuitDeviceRowCreateInput) {
|
||||
const id = crypto.randomUUID();
|
||||
await db.insert(circuitDeviceRows).values(toCircuitDeviceRowCreateValues(id, input));
|
||||
return id;
|
||||
}
|
||||
|
||||
async update(
|
||||
rowId: string,
|
||||
input: CircuitDeviceRowUpdateInput
|
||||
) {
|
||||
await db
|
||||
.update(circuitDeviceRows)
|
||||
.set(toCircuitDeviceRowUpdateValues(input))
|
||||
.where(eq(circuitDeviceRows.id, rowId));
|
||||
}
|
||||
|
||||
async delete(rowId: string) {
|
||||
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
|
||||
}
|
||||
|
||||
async moveToCircuit(rowId: string, targetCircuitId: string, sortOrder: number) {
|
||||
await db
|
||||
.update(circuitDeviceRows)
|
||||
.set({
|
||||
circuitId: targetCircuitId,
|
||||
sortOrder,
|
||||
})
|
||||
.where(eq(circuitDeviceRows.id, rowId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,51 +1,17 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
|
||||
export class CircuitListRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByProject(projectId: string) {
|
||||
const db = this.database;
|
||||
return db.select().from(circuitLists).where(eq(circuitLists.projectId, projectId));
|
||||
}
|
||||
|
||||
async createForDistributionBoard(input: {
|
||||
projectId: string;
|
||||
distributionBoardId: string;
|
||||
name: string;
|
||||
}) {
|
||||
const entry = {
|
||||
id: input.distributionBoardId,
|
||||
projectId: input.projectId,
|
||||
distributionBoardId: input.distributionBoardId,
|
||||
name: input.name,
|
||||
};
|
||||
await db.insert(circuitLists).values(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async findByDistributionBoardId(projectId: string, distributionBoardId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitLists.projectId, projectId),
|
||||
eq(circuitLists.distributionBoardId, distributionBoardId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async existsInProject(projectId: string, circuitListId: string) {
|
||||
const [row] = await db
|
||||
.select({ id: circuitLists.id })
|
||||
.from(circuitLists)
|
||||
.where(and(eq(circuitLists.projectId, projectId), eq(circuitLists.id, circuitListId)))
|
||||
.limit(1);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
async findById(projectId: string, circuitListId: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
@@ -53,13 +19,4 @@ export class CircuitListRepository {
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findByIdByListIdOnly(circuitListId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, circuitListId))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
toCircuitPatchValues,
|
||||
type CircuitPatchPersistenceInput,
|
||||
} from "./circuit.persistence.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
|
||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
|
||||
type CircuitRow = typeof circuits.$inferSelect;
|
||||
|
||||
@@ -31,81 +32,104 @@ export class CircuitProjectCommandRepository
|
||||
executeUpdate(input: ExecuteCircuitUpdateCommandInput) {
|
||||
assertCircuitUpdateProjectCommand(input.command);
|
||||
|
||||
return this.database.transaction((tx) => {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, input.command.payload.circuitId))
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error("Invalid circuit id.");
|
||||
}
|
||||
return executeProjectCommandTransactionWithAppliedForward(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
const owningList = tx
|
||||
.select({ id: circuitLists.id })
|
||||
.from(circuitLists)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitLists.id, current.circuitListId),
|
||||
eq(circuitLists.projectId, input.projectId)
|
||||
)
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteCircuitUpdateCommandInput
|
||||
) {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, input.command.payload.circuitId))
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error("Invalid circuit id.");
|
||||
}
|
||||
|
||||
const owningList = tx
|
||||
.select({ id: circuitLists.id })
|
||||
.from(circuitLists)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitLists.id, current.circuitListId),
|
||||
eq(circuitLists.projectId, input.projectId)
|
||||
)
|
||||
.get();
|
||||
if (!owningList) {
|
||||
throw new Error("Circuit does not belong to project.");
|
||||
}
|
||||
)
|
||||
.get();
|
||||
if (!owningList) {
|
||||
throw new Error("Circuit does not belong to project.");
|
||||
}
|
||||
|
||||
const patch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
change.value,
|
||||
])
|
||||
) as CircuitPatchPersistenceInput;
|
||||
this.assertSectionInCircuitList(tx, current, patch.sectionId);
|
||||
this.assertUniqueEquipmentIdentifier(
|
||||
const patch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
change.value,
|
||||
])
|
||||
) as CircuitPatchPersistenceInput;
|
||||
this.assertSectionInCircuitList(tx, current, patch.sectionId);
|
||||
if (input.source === "user") {
|
||||
if (
|
||||
input.command.payload.changes.every(
|
||||
(change) => change.field === "voltage"
|
||||
)
|
||||
) {
|
||||
throw new Error("Circuit voltage is derived and cannot be edited.");
|
||||
}
|
||||
const devicePhaseTypes = tx
|
||||
.select({ phaseType: circuitDeviceRows.phaseType })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, current.id))
|
||||
.all()
|
||||
.map((row) => row.phaseType);
|
||||
const derivedVoltage = resolveCircuitVoltage(
|
||||
tx,
|
||||
current,
|
||||
patch.equipmentIdentifier
|
||||
input.projectId,
|
||||
patch.sectionId ?? current.sectionId,
|
||||
devicePhaseTypes
|
||||
);
|
||||
|
||||
const inversePatch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
getCircuitFieldValue(current, change.field),
|
||||
])
|
||||
) as CircuitUpdatePatch;
|
||||
const inverse = createCircuitUpdateProjectCommand(
|
||||
current.id,
|
||||
inversePatch
|
||||
);
|
||||
|
||||
const update = tx
|
||||
.update(circuits)
|
||||
.set(toCircuitPatchValues(patch))
|
||||
.where(eq(circuits.id, current.id))
|
||||
.run();
|
||||
if (update.changes !== 1) {
|
||||
throw new Error("Circuit changed before command execution.");
|
||||
if (derivedVoltage === current.voltage) {
|
||||
delete patch.voltage;
|
||||
} else {
|
||||
patch.voltage = derivedVoltage;
|
||||
}
|
||||
}
|
||||
this.assertUniqueEquipmentIdentifier(
|
||||
tx,
|
||||
current,
|
||||
patch.equipmentIdentifier
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
const appliedForward = createCircuitUpdateProjectCommand(
|
||||
current.id,
|
||||
patch as CircuitUpdatePatch
|
||||
);
|
||||
const inversePatch = Object.fromEntries(
|
||||
appliedForward.payload.changes.map((change) => [
|
||||
change.field,
|
||||
getCircuitFieldValue(current, change.field),
|
||||
])
|
||||
) as CircuitUpdatePatch;
|
||||
const inverse = createCircuitUpdateProjectCommand(
|
||||
current.id,
|
||||
inversePatch
|
||||
);
|
||||
|
||||
return { revision, inverse };
|
||||
});
|
||||
const update = tx
|
||||
.update(circuits)
|
||||
.set(toCircuitPatchValues(patch))
|
||||
.where(eq(circuits.id, current.id))
|
||||
.run();
|
||||
if (update.changes !== 1) {
|
||||
throw new Error("Circuit changed before command execution.");
|
||||
}
|
||||
|
||||
return { forward: appliedForward, inverse };
|
||||
}
|
||||
|
||||
private assertSectionInCircuitList(
|
||||
|
||||
@@ -12,8 +12,7 @@ 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";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitSectionRenumberProjectCommandRepository
|
||||
implements CircuitSectionRenumberProjectCommandStore
|
||||
@@ -23,190 +22,184 @@ export class CircuitSectionRenumberProjectCommandRepository
|
||||
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."
|
||||
);
|
||||
}
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteCircuitSectionRenumberCommandInput
|
||||
) {
|
||||
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 (
|
||||
sectionCircuits.length !== assignments.length ||
|
||||
sectionCircuits.some(
|
||||
(circuit) =>
|
||||
circuit.circuitListId !== section.circuitListId
|
||||
)
|
||||
!circuit ||
|
||||
circuit.equipmentIdentifier !==
|
||||
assignment.expectedEquipmentIdentifier
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit renumber must include every circuit in the section."
|
||||
"Circuit changed before section renumber execution."
|
||||
);
|
||||
}
|
||||
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 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 targetIdentifiers = new Set(
|
||||
assignments.map(
|
||||
(assignment) => assignment.targetEquipmentIdentifier
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
const conflictingCircuit = listCircuits.find(
|
||||
(circuit) =>
|
||||
!sectionCircuitIds.has(circuit.id) &&
|
||||
targetIdentifiers.has(circuit.equipmentIdentifier)
|
||||
);
|
||||
if (conflictingCircuit) {
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Target equipment identifier already exists in circuit list."
|
||||
"Circuit changed during section renumber execution."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const inverse = createCircuitSectionRenumberProjectCommand(
|
||||
section.id,
|
||||
assignments.map((assignment) => ({
|
||||
circuitId: assignment.circuitId,
|
||||
expectedEquipmentIdentifier:
|
||||
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,
|
||||
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
|
||||
)
|
||||
})
|
||||
.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 section renumber execution."
|
||||
);
|
||||
}
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Circuit changed during final 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 };
|
||||
});
|
||||
return inverse;
|
||||
}
|
||||
|
||||
private createTemporaryIdentifiers(
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitSectionReorderProjectCommand,
|
||||
circuitSectionReorderCommandType,
|
||||
createCircuitSectionReorderProjectCommand,
|
||||
type CircuitSectionReorderAssignment,
|
||||
type CircuitSectionReorderProjectCommand,
|
||||
} from "../../domain/models/circuit-section-reorder-project-command.model.js";
|
||||
import {
|
||||
assertCircuitSectionsReorderProjectCommand,
|
||||
circuitSectionsReorderCommandType,
|
||||
createCircuitSectionsReorderProjectCommand,
|
||||
type CircuitSectionsReorderProjectCommand,
|
||||
type CircuitSectionsReorderSection,
|
||||
} from "../../domain/models/circuit-sections-reorder-project-command.model.js";
|
||||
import type {
|
||||
CircuitSectionReorderProjectCommandStore,
|
||||
ExecuteCircuitSectionReorderCommandInput,
|
||||
@@ -11,8 +21,11 @@ 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";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
type ReorderHistoryCommand =
|
||||
| CircuitSectionReorderProjectCommand
|
||||
| CircuitSectionsReorderProjectCommand;
|
||||
|
||||
export class CircuitSectionReorderProjectCommandRepository
|
||||
implements CircuitSectionReorderProjectCommandStore
|
||||
@@ -20,116 +33,170 @@ export class CircuitSectionReorderProjectCommandRepository
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitSectionReorderCommandInput) {
|
||||
assertCircuitSectionReorderProjectCommand(input.command);
|
||||
this.assertSupportedCommand(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."
|
||||
);
|
||||
}
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
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])
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteCircuitSectionReorderCommandInput
|
||||
) {
|
||||
const commandSections = this.getCommandSections(input.command);
|
||||
const inverseSections = commandSections.map((section) => {
|
||||
this.assertCompleteCurrentSection(
|
||||
tx,
|
||||
input.projectId,
|
||||
section
|
||||
);
|
||||
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) => ({
|
||||
return {
|
||||
sectionId: section.sectionId,
|
||||
assignments: section.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 };
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
for (const section of commandSections) {
|
||||
this.applyAssignments(tx, section);
|
||||
}
|
||||
|
||||
const inverse =
|
||||
input.command.type === circuitSectionReorderCommandType
|
||||
? createCircuitSectionReorderProjectCommand(
|
||||
inverseSections[0].sectionId,
|
||||
inverseSections[0].assignments
|
||||
)
|
||||
: createCircuitSectionsReorderProjectCommand(
|
||||
inverseSections
|
||||
);
|
||||
return inverse;
|
||||
}
|
||||
|
||||
private assertSupportedCommand(
|
||||
command: ReorderHistoryCommand
|
||||
) {
|
||||
if (command.type === circuitSectionReorderCommandType) {
|
||||
assertCircuitSectionReorderProjectCommand(command);
|
||||
return;
|
||||
}
|
||||
if (command.type === circuitSectionsReorderCommandType) {
|
||||
assertCircuitSectionsReorderProjectCommand(command);
|
||||
return;
|
||||
}
|
||||
throw new Error("Unsupported circuit section reorder command.");
|
||||
}
|
||||
|
||||
private getCommandSections(
|
||||
command: ReorderHistoryCommand
|
||||
): CircuitSectionsReorderSection[] {
|
||||
if (command.type === circuitSectionReorderCommandType) {
|
||||
return [
|
||||
{
|
||||
sectionId: command.payload.sectionId,
|
||||
assignments: command.payload.assignments,
|
||||
},
|
||||
];
|
||||
}
|
||||
return command.payload.sections;
|
||||
}
|
||||
|
||||
private assertCompleteCurrentSection(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
sectionCommand: CircuitSectionsReorderSection
|
||||
) {
|
||||
const section = database
|
||||
.select({
|
||||
id: circuitSections.id,
|
||||
circuitListId: circuitSections.circuitListId,
|
||||
projectId: circuitLists.projectId,
|
||||
})
|
||||
.from(circuitSections)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, circuitSections.circuitListId)
|
||||
)
|
||||
.where(eq(circuitSections.id, sectionCommand.sectionId))
|
||||
.get();
|
||||
if (!section || section.projectId !== projectId) {
|
||||
throw new Error(
|
||||
"Circuit section does not belong to project."
|
||||
);
|
||||
}
|
||||
|
||||
const persistedCircuits = database
|
||||
.select({
|
||||
id: circuits.id,
|
||||
circuitListId: circuits.circuitListId,
|
||||
sortOrder: circuits.sortOrder,
|
||||
})
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, section.id))
|
||||
.all();
|
||||
const assignments = sectionCommand.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."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyAssignments(
|
||||
database: AppDatabase,
|
||||
sectionCommand: {
|
||||
sectionId: string;
|
||||
assignments: CircuitSectionReorderAssignment[];
|
||||
}
|
||||
) {
|
||||
for (const assignment of sectionCommand.assignments) {
|
||||
if (
|
||||
assignment.expectedSortOrder === assignment.targetSortOrder
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const updated = database
|
||||
.update(circuits)
|
||||
.set({ sortOrder: assignment.targetSortOrder })
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.id, assignment.circuitId),
|
||||
eq(circuits.sectionId, sectionCommand.sectionId),
|
||||
eq(circuits.sortOrder, assignment.expectedSortOrder)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Circuit changed during section reorder execution."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { CircuitSectionTransactionStore } from "../../domain/ports/circuit-section-transaction.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
|
||||
export class CircuitSectionTransactionRepository
|
||||
implements CircuitSectionTransactionStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
updateSortOrders(sectionId: string, orderedCircuitIds: string[]) {
|
||||
if (orderedCircuitIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (new Set(orderedCircuitIds).size !== orderedCircuitIds.length) {
|
||||
throw new Error(
|
||||
"Stromkreise dürfen in der Reihenfolge nicht mehrfach vorkommen."
|
||||
);
|
||||
}
|
||||
|
||||
this.database.transaction((tx) => {
|
||||
const sectionCircuits = tx
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, sectionId))
|
||||
.all();
|
||||
const sectionCircuitIds = new Set(
|
||||
sectionCircuits.map((circuit) => circuit.id)
|
||||
);
|
||||
if (
|
||||
sectionCircuits.length !== orderedCircuitIds.length ||
|
||||
orderedCircuitIds.some((circuitId) => !sectionCircuitIds.has(circuitId))
|
||||
) {
|
||||
throw new Error(
|
||||
"Die Reihenfolge muss alle Stromkreise des Bereichs enthalten."
|
||||
);
|
||||
}
|
||||
|
||||
for (let index = 0; index < orderedCircuitIds.length; index += 1) {
|
||||
const result = tx
|
||||
.update(circuits)
|
||||
.set({ sortOrder: (index + 1) * 10 })
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.sectionId, sectionId),
|
||||
eq(circuits.id, orderedCircuitIds[index])
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (result.changes !== 1) {
|
||||
throw new Error(
|
||||
"Ein Stromkreis wurde vor Abschluss der Sortierung verändert."
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateEquipmentIdentifiers(
|
||||
circuitListId: string,
|
||||
updates: Array<{ id: string; equipmentIdentifier: string }>,
|
||||
tempNamespace: string
|
||||
) {
|
||||
if (updates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.database.transaction((tx) => {
|
||||
const ids = updates.map((entry) => entry.id);
|
||||
const existing = tx
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.circuitListId, circuitListId),
|
||||
inArray(circuits.id, ids)
|
||||
)
|
||||
)
|
||||
.all();
|
||||
if (existing.length !== ids.length) {
|
||||
throw new Error(
|
||||
"One or more circuit ids are invalid for circuit list."
|
||||
);
|
||||
}
|
||||
|
||||
const stamp = Date.now();
|
||||
for (let index = 0; index < updates.length; index += 1) {
|
||||
const entry = updates[index];
|
||||
const tempIdentifier = `__tmp_renumber_${tempNamespace}_${stamp}_${index}`;
|
||||
tx
|
||||
.update(circuits)
|
||||
.set({ equipmentIdentifier: tempIdentifier })
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.circuitListId, circuitListId),
|
||||
eq(circuits.id, entry.id)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
for (const entry of updates) {
|
||||
tx
|
||||
.update(circuits)
|
||||
.set({ equipmentIdentifier: entry.equipmentIdentifier })
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.circuitListId, circuitListId),
|
||||
eq(circuits.id, entry.id)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
|
||||
export function createDefaultCircuitSectionValues(circuitListId: string) {
|
||||
return [
|
||||
{ key: "lighting", displayName: "Lighting", prefix: "-1F", sortOrder: 10 },
|
||||
{ key: "single_phase", displayName: "Single-phase circuits", prefix: "-2F", sortOrder: 20 },
|
||||
{ key: "three_phase", displayName: "Three-phase circuits", prefix: "-3F", sortOrder: 30 },
|
||||
{ key: "unassigned", displayName: "Unassigned", prefix: "-UF", sortOrder: 90 },
|
||||
].map((entry) => ({
|
||||
return defaultCircuitSectionDefinitions.map((entry) => ({
|
||||
id: crypto.randomUUID(),
|
||||
circuitListId,
|
||||
...entry,
|
||||
@@ -17,12 +13,16 @@ export function createDefaultCircuitSectionValues(circuitListId: string) {
|
||||
}
|
||||
|
||||
export class CircuitSectionRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async findById(sectionId: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db.select().from(circuitSections).where(eq(circuitSections.id, sectionId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async listByCircuitList(circuitListId: string) {
|
||||
const db = this.database;
|
||||
return db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
@@ -31,6 +31,7 @@ export class CircuitSectionRepository {
|
||||
}
|
||||
|
||||
async createDefaults(circuitListId: string) {
|
||||
const db = this.database;
|
||||
for (const entry of createDefaultCircuitSectionValues(circuitListId)) {
|
||||
const existing = await db
|
||||
.select({ id: circuitSections.id })
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
CircuitStructureProjectCommandStore,
|
||||
ExecuteCircuitStructureCommandInput,
|
||||
} from "../../domain/ports/circuit-structure-project-command.store.js";
|
||||
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
@@ -22,8 +23,8 @@ import {
|
||||
assertCircuitDeviceRowReferencesInProject,
|
||||
toCircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure.persistence.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
export class CircuitStructureProjectCommandRepository
|
||||
implements CircuitStructureProjectCommandStore
|
||||
@@ -31,30 +32,17 @@ export class CircuitStructureProjectCommandRepository
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitStructureCommandInput) {
|
||||
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 };
|
||||
});
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(
|
||||
tx,
|
||||
input.projectId,
|
||||
input.source,
|
||||
input.command
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
@@ -91,6 +79,19 @@ export class CircuitStructureProjectCommandRepository
|
||||
snapshot: CircuitSnapshot
|
||||
) {
|
||||
this.assertCircuitLocation(database, projectId, snapshot);
|
||||
if (source === "user") {
|
||||
const derivedVoltage = resolveCircuitVoltage(
|
||||
database,
|
||||
projectId,
|
||||
snapshot.sectionId,
|
||||
snapshot.deviceRows.map((row) => row.phaseType)
|
||||
);
|
||||
if (snapshot.voltage !== derivedVoltage) {
|
||||
throw new Error(
|
||||
"Circuit voltage must match the project phase voltage."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingCircuit = database
|
||||
.select({ id: circuits.id })
|
||||
@@ -136,6 +137,9 @@ export class CircuitStructureProjectCommandRepository
|
||||
"User commands cannot assign legacy consumer ids."
|
||||
);
|
||||
}
|
||||
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
|
||||
throw new Error("Device-row phase type is invalid.");
|
||||
}
|
||||
assertCircuitDeviceRowReferencesInProject(
|
||||
database,
|
||||
projectId,
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, eq, ne } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import {
|
||||
toCircuitCreateValues,
|
||||
type CircuitCreatePersistenceInput,
|
||||
} from "./circuit.persistence.js";
|
||||
|
||||
export type {
|
||||
CircuitCreatePersistenceInput,
|
||||
} from "./circuit.persistence.js";
|
||||
export {
|
||||
toCircuitCreateValues,
|
||||
} from "./circuit.persistence.js";
|
||||
|
||||
export class CircuitRepository {
|
||||
async findById(circuitId: string) {
|
||||
const [row] = await db.select().from(circuits).where(eq(circuits.id, circuitId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByCircuitList(circuitListId: string) {
|
||||
const db = this.database;
|
||||
return db
|
||||
.select()
|
||||
.from(circuits)
|
||||
@@ -28,85 +14,12 @@ export class CircuitRepository {
|
||||
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
||||
}
|
||||
|
||||
async create(input: CircuitCreatePersistenceInput) {
|
||||
const id = crypto.randomUUID();
|
||||
await db.insert(circuits).values(toCircuitCreateValues(id, input));
|
||||
return id;
|
||||
}
|
||||
|
||||
async update(
|
||||
circuitId: string,
|
||||
input: {
|
||||
sectionId: string;
|
||||
equipmentIdentifier: string;
|
||||
displayName?: string;
|
||||
sortOrder: number;
|
||||
protectionType?: string;
|
||||
protectionRatedCurrent?: number;
|
||||
protectionCharacteristic?: string;
|
||||
cableType?: string;
|
||||
cableCrossSection?: string;
|
||||
cableLength?: number;
|
||||
rcdAssignment?: string;
|
||||
terminalDesignation?: string;
|
||||
voltage?: number;
|
||||
controlRequirement?: string;
|
||||
status?: string;
|
||||
isReserve: boolean;
|
||||
remark?: string;
|
||||
}
|
||||
) {
|
||||
await db
|
||||
.update(circuits)
|
||||
.set({
|
||||
sectionId: input.sectionId,
|
||||
equipmentIdentifier: input.equipmentIdentifier,
|
||||
displayName: input.displayName ?? null,
|
||||
sortOrder: input.sortOrder,
|
||||
protectionType: input.protectionType ?? null,
|
||||
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
|
||||
protectionCharacteristic: input.protectionCharacteristic ?? null,
|
||||
cableType: input.cableType ?? null,
|
||||
cableCrossSection: input.cableCrossSection ?? null,
|
||||
cableLength: input.cableLength ?? null,
|
||||
rcdAssignment: input.rcdAssignment ?? null,
|
||||
terminalDesignation: input.terminalDesignation ?? null,
|
||||
voltage: input.voltage ?? null,
|
||||
controlRequirement: input.controlRequirement ?? null,
|
||||
status: input.status ?? null,
|
||||
isReserve: input.isReserve ? 1 : 0,
|
||||
remark: input.remark ?? null,
|
||||
})
|
||||
.where(eq(circuits.id, circuitId));
|
||||
}
|
||||
|
||||
async delete(circuitId: string) {
|
||||
await db.delete(circuits).where(eq(circuits.id, circuitId));
|
||||
}
|
||||
|
||||
async existsByEquipmentIdentifier(circuitListId: string, equipmentIdentifier: string, excludeCircuitId?: string) {
|
||||
const rows = await db
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.where(
|
||||
excludeCircuitId
|
||||
? and(
|
||||
eq(circuits.circuitListId, circuitListId),
|
||||
eq(circuits.equipmentIdentifier, equipmentIdentifier),
|
||||
ne(circuits.id, excludeCircuitId)
|
||||
)
|
||||
: and(eq(circuits.circuitListId, circuitListId), eq(circuits.equipmentIdentifier, equipmentIdentifier))
|
||||
)
|
||||
.limit(1);
|
||||
return Boolean(rows.length);
|
||||
}
|
||||
|
||||
async listBySection(sectionId: string) {
|
||||
const db = this.database;
|
||||
return db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, sectionId))
|
||||
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
assertDistributionBoardDeleteProjectCommand,
|
||||
assertDistributionBoardInsertProjectCommand,
|
||||
createDistributionBoardDeleteProjectCommand,
|
||||
createDistributionBoardInsertProjectCommand,
|
||||
distributionBoardDeleteCommandType,
|
||||
distributionBoardInsertCommandType,
|
||||
normalizeDistributionBoardStructureProjectCommand,
|
||||
type DistributionBoardStructureProjectCommand,
|
||||
type DistributionBoardStructureSnapshot,
|
||||
legacyDistributionBoardStructureCommandSchemaVersion,
|
||||
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardUpdateProjectCommand,
|
||||
createDistributionBoardUpdateProjectCommand,
|
||||
legacyDistributionBoardUpdateCommandSchemaVersion,
|
||||
type DistributionBoardUpdateField,
|
||||
type DistributionBoardUpdatePatch,
|
||||
type DistributionBoardUpdateProjectCommand,
|
||||
type DistributionBoardUpdateValues,
|
||||
} from "../../domain/models/distribution-board-project-command.model.js";
|
||||
import type {
|
||||
DistributionBoardStructureProjectCommandStore,
|
||||
ExecuteDistributionBoardStructureCommandInput,
|
||||
} from "../../domain/ports/distribution-board-structure-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 { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class DistributionBoardStructureProjectCommandRepository
|
||||
implements DistributionBoardStructureProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteDistributionBoardStructureCommandInput) {
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
executeUpdate(
|
||||
input: Omit<
|
||||
ExecuteDistributionBoardStructureCommandInput,
|
||||
"command"
|
||||
> & {
|
||||
command: DistributionBoardUpdateProjectCommand;
|
||||
}
|
||||
) {
|
||||
assertDistributionBoardUpdateProjectCommand(input.command);
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.update(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: DistributionBoardStructureProjectCommand
|
||||
): DistributionBoardStructureProjectCommand {
|
||||
const normalized =
|
||||
normalizeDistributionBoardStructureProjectCommand(command);
|
||||
if (normalized.type === distributionBoardInsertCommandType) {
|
||||
assertDistributionBoardInsertProjectCommand(normalized);
|
||||
const inverse = this.insert(
|
||||
database,
|
||||
projectId,
|
||||
normalized.payload.structure
|
||||
);
|
||||
return command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
? toLegacyStructureCommand(inverse)
|
||||
: inverse;
|
||||
}
|
||||
if (normalized.type === distributionBoardDeleteCommandType) {
|
||||
assertDistributionBoardDeleteProjectCommand(normalized);
|
||||
const inverse = this.delete(
|
||||
database,
|
||||
projectId,
|
||||
normalized.payload.structure
|
||||
);
|
||||
return command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
? toLegacyStructureCommand(inverse)
|
||||
: inverse;
|
||||
}
|
||||
throw new Error("Unsupported distribution-board structure command.");
|
||||
}
|
||||
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
structure: DistributionBoardStructureSnapshot
|
||||
) {
|
||||
if (
|
||||
structure.distributionBoard.projectId !== projectId ||
|
||||
structure.circuitList.projectId !== projectId
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board structure does not belong to project."
|
||||
);
|
||||
}
|
||||
const project = database
|
||||
.select({
|
||||
id: projects.id,
|
||||
enabledSupplyTypes:
|
||||
projects.enabledDistributionBoardSupplyTypes,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
assertSupplyTypeEnabled(
|
||||
project.enabledSupplyTypes,
|
||||
structure.distributionBoard.supplyType
|
||||
);
|
||||
if (structure.distributionBoard.floorId !== null) {
|
||||
const floor = database
|
||||
.select({ projectId: floors.projectId })
|
||||
.from(floors)
|
||||
.where(eq(floors.id, structure.distributionBoard.floorId))
|
||||
.get();
|
||||
if (!floor || floor.projectId !== projectId) {
|
||||
throw new Error(
|
||||
"Distribution-board floor does not belong to project."
|
||||
);
|
||||
}
|
||||
}
|
||||
const existingBoard = database
|
||||
.select({ id: distributionBoards.id })
|
||||
.from(distributionBoards)
|
||||
.where(eq(distributionBoards.id, structure.distributionBoard.id))
|
||||
.get();
|
||||
const existingList = database
|
||||
.select({ id: circuitLists.id })
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, structure.circuitList.id))
|
||||
.get();
|
||||
const existingSection = database
|
||||
.select({ id: circuitSections.id })
|
||||
.from(circuitSections)
|
||||
.where(
|
||||
inArray(
|
||||
circuitSections.id,
|
||||
structure.sections.map((section) => section.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.get();
|
||||
if (existingBoard || existingList || existingSection) {
|
||||
throw new Error("Distribution-board structure id already exists.");
|
||||
}
|
||||
|
||||
database
|
||||
.insert(distributionBoards)
|
||||
.values(structure.distributionBoard)
|
||||
.run();
|
||||
database.insert(circuitLists).values(structure.circuitList).run();
|
||||
database.insert(circuitSections).values(structure.sections).run();
|
||||
return createDistributionBoardDeleteProjectCommand(structure);
|
||||
}
|
||||
|
||||
private delete(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
structure: DistributionBoardStructureSnapshot
|
||||
) {
|
||||
const board = database
|
||||
.select()
|
||||
.from(distributionBoards)
|
||||
.where(eq(distributionBoards.id, structure.distributionBoard.id))
|
||||
.get();
|
||||
const list = database
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, structure.circuitList.id))
|
||||
.get();
|
||||
const sections = database
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(
|
||||
eq(circuitSections.circuitListId, structure.circuitList.id)
|
||||
)
|
||||
.orderBy(
|
||||
asc(circuitSections.sortOrder),
|
||||
asc(circuitSections.id)
|
||||
)
|
||||
.all();
|
||||
if (
|
||||
!board ||
|
||||
!list ||
|
||||
board.projectId !== projectId ||
|
||||
!structureMatches(structure, { board, list, sections })
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board structure changed before deletion."
|
||||
);
|
||||
}
|
||||
const populatedCircuit = database
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.circuitListId, structure.circuitList.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (populatedCircuit) {
|
||||
throw new Error(
|
||||
"A populated distribution board cannot be removed by history."
|
||||
);
|
||||
}
|
||||
|
||||
const deleted = database
|
||||
.delete(distributionBoards)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
distributionBoards.id,
|
||||
structure.distributionBoard.id
|
||||
),
|
||||
eq(distributionBoards.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Distribution board could not be deleted.");
|
||||
}
|
||||
return createDistributionBoardInsertProjectCommand(structure);
|
||||
}
|
||||
|
||||
private update(
|
||||
database: AppDatabase,
|
||||
input: Omit<
|
||||
ExecuteDistributionBoardStructureCommandInput,
|
||||
"command"
|
||||
> & {
|
||||
command: DistributionBoardUpdateProjectCommand;
|
||||
}
|
||||
) {
|
||||
const current = database
|
||||
.select()
|
||||
.from(distributionBoards)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
distributionBoards.id,
|
||||
input.command.payload.distributionBoardId
|
||||
),
|
||||
eq(distributionBoards.projectId, input.projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error("Distribution board does not belong to project.");
|
||||
}
|
||||
const project = database
|
||||
.select({
|
||||
enabledSupplyTypes:
|
||||
projects.enabledDistributionBoardSupplyTypes,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
const patch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
change.value,
|
||||
])
|
||||
) as DistributionBoardUpdatePatch;
|
||||
if (patch.floorId !== undefined && patch.floorId !== null) {
|
||||
const floor = database
|
||||
.select({ projectId: floors.projectId })
|
||||
.from(floors)
|
||||
.where(eq(floors.id, patch.floorId))
|
||||
.get();
|
||||
if (!floor || floor.projectId !== input.projectId) {
|
||||
throw new Error(
|
||||
"Distribution-board floor does not belong to project."
|
||||
);
|
||||
}
|
||||
}
|
||||
if (patch.supplyType !== undefined) {
|
||||
assertSupplyTypeEnabled(
|
||||
project.enabledSupplyTypes,
|
||||
patch.supplyType
|
||||
);
|
||||
}
|
||||
const inversePatch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
getDistributionBoardFieldValue(current, change.field),
|
||||
])
|
||||
) as DistributionBoardUpdatePatch;
|
||||
const currentInverse = createDistributionBoardUpdateProjectCommand(
|
||||
current.id,
|
||||
inversePatch
|
||||
);
|
||||
const inverse =
|
||||
input.command.schemaVersion ===
|
||||
legacyDistributionBoardUpdateCommandSchemaVersion
|
||||
? ({
|
||||
...currentInverse,
|
||||
schemaVersion:
|
||||
legacyDistributionBoardUpdateCommandSchemaVersion,
|
||||
} as DistributionBoardUpdateProjectCommand)
|
||||
: currentInverse;
|
||||
const updated = database
|
||||
.update(distributionBoards)
|
||||
.set(patch)
|
||||
.where(
|
||||
and(
|
||||
eq(distributionBoards.id, current.id),
|
||||
eq(distributionBoards.projectId, input.projectId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Distribution board changed before command execution."
|
||||
);
|
||||
}
|
||||
return inverse;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSupplyTypeEnabled(
|
||||
enabledSupplyTypes: (typeof projects.$inferSelect)["enabledDistributionBoardSupplyTypes"],
|
||||
supplyType: (typeof distributionBoards.$inferSelect)["supplyType"]
|
||||
) {
|
||||
if (
|
||||
supplyType !== null &&
|
||||
!enabledSupplyTypes.includes(supplyType)
|
||||
) {
|
||||
throw new Error(
|
||||
`Die Netzart ${supplyType} ist in den Projekteinstellungen nicht aktiviert.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toLegacyStructureCommand(
|
||||
command:
|
||||
| ReturnType<typeof createDistributionBoardInsertProjectCommand>
|
||||
| ReturnType<typeof createDistributionBoardDeleteProjectCommand>
|
||||
): DistributionBoardStructureProjectCommand {
|
||||
const {
|
||||
floorId: _floorId,
|
||||
supplyType: _supplyType,
|
||||
...distributionBoard
|
||||
} = command.payload.structure.distributionBoard;
|
||||
return {
|
||||
schemaVersion: legacyDistributionBoardStructureCommandSchemaVersion,
|
||||
type: command.type,
|
||||
payload: {
|
||||
structure: {
|
||||
...command.payload.structure,
|
||||
distributionBoard,
|
||||
},
|
||||
},
|
||||
} as DistributionBoardStructureProjectCommand;
|
||||
}
|
||||
|
||||
function getDistributionBoardFieldValue<
|
||||
TField extends DistributionBoardUpdateField,
|
||||
>(
|
||||
distributionBoard: typeof distributionBoards.$inferSelect,
|
||||
field: TField
|
||||
): DistributionBoardUpdateValues[TField] {
|
||||
return distributionBoard[field] as DistributionBoardUpdateValues[TField];
|
||||
}
|
||||
|
||||
function structureMatches(
|
||||
expected: DistributionBoardStructureSnapshot,
|
||||
actual: {
|
||||
board: typeof distributionBoards.$inferSelect;
|
||||
list: typeof circuitLists.$inferSelect;
|
||||
sections: Array<typeof circuitSections.$inferSelect>;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
simultaneityFactor,
|
||||
...actualDistributionBoard
|
||||
} = actual.board;
|
||||
if (
|
||||
simultaneityFactor !== 1 ||
|
||||
!sameRecord(
|
||||
expected.distributionBoard,
|
||||
actualDistributionBoard
|
||||
) ||
|
||||
!sameRecord(expected.circuitList, actual.list) ||
|
||||
expected.sections.length !== actual.sections.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const expectedSections = [...expected.sections].sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
return expectedSections.every((section, index) =>
|
||||
sameRecord(section, actual.sections[index])
|
||||
);
|
||||
}
|
||||
|
||||
function sameRecord(
|
||||
expected: Record<string, unknown>,
|
||||
actual: Record<string, unknown>
|
||||
) {
|
||||
const expectedEntries = Object.entries(expected);
|
||||
return (
|
||||
expectedEntries.length === Object.keys(actual).length &&
|
||||
expectedEntries.every(
|
||||
([key, value]) => actual[key] === value
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { createDefaultCircuitSectionValues } from "./circuit-section.repository.js";
|
||||
|
||||
export class DistributionBoardRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
@@ -16,44 +12,19 @@ export class DistributionBoardRepository {
|
||||
.where(eq(distributionBoards.projectId, projectId));
|
||||
}
|
||||
|
||||
async create(projectId: string, name: string) {
|
||||
const id = crypto.randomUUID();
|
||||
const board = { id, projectId, name };
|
||||
await this.database.insert(distributionBoards).values(board);
|
||||
return board;
|
||||
}
|
||||
|
||||
createWithCircuitListAndDefaultSections(projectId: string, name: string) {
|
||||
const id = crypto.randomUUID();
|
||||
const board = { id, projectId, name };
|
||||
const circuitList = {
|
||||
id,
|
||||
projectId,
|
||||
distributionBoardId: id,
|
||||
name: `${name} Stromkreisliste`,
|
||||
};
|
||||
const sections = createDefaultCircuitSectionValues(id);
|
||||
|
||||
this.database.transaction((tx) => {
|
||||
tx.insert(distributionBoards).values(board).run();
|
||||
tx.insert(circuitLists).values(circuitList).run();
|
||||
tx.insert(circuitSections).values(sections).run();
|
||||
});
|
||||
|
||||
return board;
|
||||
}
|
||||
|
||||
async existsInProject(projectId: string, distributionBoardId: string) {
|
||||
const [row] = await this.database
|
||||
.select({ id: distributionBoards.id })
|
||||
.from(distributionBoards)
|
||||
.where(
|
||||
and(
|
||||
eq(distributionBoards.projectId, projectId),
|
||||
eq(distributionBoards.id, distributionBoardId)
|
||||
async findById(projectId: string, distributionBoardId: string) {
|
||||
return (
|
||||
this.database
|
||||
.select()
|
||||
.from(distributionBoards)
|
||||
.where(
|
||||
and(
|
||||
eq(distributionBoards.projectId, projectId),
|
||||
eq(distributionBoards.id, distributionBoardId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return Boolean(row);
|
||||
.get() ?? null
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,36 +1,16 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
|
||||
export class FloorRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByProject(projectId: string) {
|
||||
const db = this.database;
|
||||
return db
|
||||
.select()
|
||||
.from(floors)
|
||||
.where(eq(floors.projectId, projectId))
|
||||
.orderBy(asc(floors.sortOrder), asc(floors.name));
|
||||
}
|
||||
|
||||
async create(projectId: string, name: string) {
|
||||
const id = crypto.randomUUID();
|
||||
const existing = await this.listByProject(projectId);
|
||||
const floor = {
|
||||
id,
|
||||
projectId,
|
||||
name,
|
||||
sortOrder: existing.length,
|
||||
};
|
||||
await db.insert(floors).values(floor);
|
||||
return floor;
|
||||
}
|
||||
|
||||
async existsInProject(projectId: string, floorId: string) {
|
||||
const [row] = await db
|
||||
.select({ id: floors.id })
|
||||
.from(floors)
|
||||
.where(and(eq(floors.projectId, projectId), eq(floors.id, floorId)))
|
||||
.limit(1);
|
||||
return Boolean(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { globalDevices } from "../schema/global-devices.js";
|
||||
import type {
|
||||
CreateGlobalDeviceInput,
|
||||
@@ -8,11 +8,15 @@ import type {
|
||||
} from "../../shared/validation/global-device.schemas.js";
|
||||
|
||||
export class GlobalDeviceRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async list() {
|
||||
const db = this.database;
|
||||
return db.select().from(globalDevices);
|
||||
}
|
||||
|
||||
async create(input: CreateGlobalDeviceInput) {
|
||||
const db = this.database;
|
||||
const id = crypto.randomUUID();
|
||||
await db.insert(globalDevices).values({
|
||||
id,
|
||||
@@ -22,8 +26,8 @@ export class GlobalDeviceRepository {
|
||||
quantity: input.quantity,
|
||||
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
|
||||
demandFactor: input.demandFactor,
|
||||
voltageV: input.voltageV ?? null,
|
||||
phaseCount: input.phaseCount ?? null,
|
||||
voltageV: null,
|
||||
phaseCount: input.phaseCount,
|
||||
powerFactor: input.powerFactor ?? null,
|
||||
note: input.note ?? null,
|
||||
});
|
||||
@@ -31,6 +35,7 @@ export class GlobalDeviceRepository {
|
||||
}
|
||||
|
||||
async update(globalDeviceId: string, input: UpdateGlobalDeviceInput) {
|
||||
const db = this.database;
|
||||
await db
|
||||
.update(globalDevices)
|
||||
.set({
|
||||
@@ -40,8 +45,8 @@ export class GlobalDeviceRepository {
|
||||
quantity: input.quantity,
|
||||
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
|
||||
demandFactor: input.demandFactor,
|
||||
voltageV: input.voltageV ?? null,
|
||||
phaseCount: input.phaseCount ?? null,
|
||||
voltageV: null,
|
||||
phaseCount: input.phaseCount,
|
||||
powerFactor: input.powerFactor ?? null,
|
||||
note: input.note ?? null,
|
||||
})
|
||||
@@ -49,11 +54,13 @@ export class GlobalDeviceRepository {
|
||||
}
|
||||
|
||||
async findById(globalDeviceId: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db.select().from(globalDevices).where(eq(globalDevices.id, globalDeviceId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async delete(globalDeviceId: string) {
|
||||
const db = this.database;
|
||||
await db.delete(globalDevices).where(eq(globalDevices.id, globalDeviceId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,17 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq, inArray, isNull } from "drizzle-orm";
|
||||
import type {
|
||||
LegacyMigrationCircuitInput,
|
||||
LegacyMigrationReportInput,
|
||||
} from "../../domain/ports/legacy-consumer-migration.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { consumers } from "../schema/consumers.js";
|
||||
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
||||
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
|
||||
import {
|
||||
toCircuitDeviceRowCreateValues,
|
||||
type CircuitDeviceRowCreateInput,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
import {
|
||||
toCircuitCreateValues,
|
||||
type CircuitCreatePersistenceInput,
|
||||
} from "./circuit.persistence.js";
|
||||
|
||||
export interface LegacyMigrationCircuitPersistenceInput {
|
||||
circuit: CircuitCreatePersistenceInput;
|
||||
deviceRows: Array<
|
||||
Omit<CircuitDeviceRowCreateInput, "circuitId"> & { legacyConsumerId: string }
|
||||
>;
|
||||
}
|
||||
|
||||
export interface LegacyMigrationReportPersistenceInput {
|
||||
legacyConsumerCount: number;
|
||||
createdCircuitCount: number;
|
||||
createdDeviceRowCount: number;
|
||||
duplicateGroupedCount: number;
|
||||
generatedIdentifierCount: number;
|
||||
unassignedRowCount: number;
|
||||
warningsJson: string;
|
||||
generatedIdentifiersJson: string;
|
||||
duplicateGroupsJson: string;
|
||||
}
|
||||
import { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
|
||||
import { toCircuitCreateValues } from "./circuit.persistence.js";
|
||||
|
||||
export class LegacyConsumerMigrationRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
@@ -69,8 +48,8 @@ export class LegacyConsumerMigrationRepository {
|
||||
|
||||
persistCircuitListMigration(input: {
|
||||
circuitListId: string;
|
||||
circuits: LegacyMigrationCircuitPersistenceInput[];
|
||||
report: LegacyMigrationReportPersistenceInput;
|
||||
circuits: LegacyMigrationCircuitInput[];
|
||||
report: LegacyMigrationReportInput;
|
||||
}) {
|
||||
if (input.circuits.some((entry) => entry.circuit.circuitListId !== input.circuitListId)) {
|
||||
throw new Error("All migrated circuits must belong to the target circuit list.");
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "../../domain/ports/project-revision.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
|
||||
export interface ProjectCommandTransactionInput<
|
||||
Command extends SerializedProjectCommand<unknown>,
|
||||
> {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: Command;
|
||||
}
|
||||
|
||||
export interface AppliedProjectCommand<
|
||||
Forward extends SerializedProjectCommand<unknown>,
|
||||
Inverse extends SerializedProjectCommand<unknown>,
|
||||
> {
|
||||
forward: Forward;
|
||||
inverse: Inverse;
|
||||
}
|
||||
|
||||
export function executeProjectCommandTransaction<
|
||||
Command extends SerializedProjectCommand<unknown>,
|
||||
Inverse extends SerializedProjectCommand<unknown>,
|
||||
>(
|
||||
database: AppDatabase,
|
||||
input: ProjectCommandTransactionInput<Command>,
|
||||
applyCommand: (transaction: AppDatabase) => Inverse
|
||||
): { revision: AppendedProjectRevision; inverse: Inverse } {
|
||||
return executeAppliedProjectCommandTransaction(
|
||||
database,
|
||||
input,
|
||||
(transaction) => ({
|
||||
forward: input.command,
|
||||
inverse: applyCommand(transaction),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function executeProjectCommandTransactionWithAppliedForward<
|
||||
Command extends SerializedProjectCommand<unknown>,
|
||||
Forward extends SerializedProjectCommand<unknown>,
|
||||
Inverse extends SerializedProjectCommand<unknown>,
|
||||
>(
|
||||
database: AppDatabase,
|
||||
input: ProjectCommandTransactionInput<Command>,
|
||||
applyCommand: (
|
||||
transaction: AppDatabase
|
||||
) => AppliedProjectCommand<Forward, Inverse>
|
||||
): { revision: AppendedProjectRevision; inverse: Inverse } {
|
||||
return executeAppliedProjectCommandTransaction(
|
||||
database,
|
||||
input,
|
||||
applyCommand
|
||||
);
|
||||
}
|
||||
|
||||
function executeAppliedProjectCommandTransaction<
|
||||
Command extends SerializedProjectCommand<unknown>,
|
||||
Forward extends SerializedProjectCommand<unknown>,
|
||||
Inverse extends SerializedProjectCommand<unknown>,
|
||||
>(
|
||||
database: AppDatabase,
|
||||
input: ProjectCommandTransactionInput<Command>,
|
||||
applyCommand: (
|
||||
transaction: AppDatabase
|
||||
) => AppliedProjectCommand<Forward, Inverse>
|
||||
): { revision: AppendedProjectRevision; inverse: Inverse } {
|
||||
return database.transaction((tx) => {
|
||||
const { forward, inverse } = applyCommand(tx);
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
source: input.source,
|
||||
description: input.description,
|
||||
actorId: input.actorId,
|
||||
forward,
|
||||
inverse,
|
||||
});
|
||||
applyProjectHistoryTransition(tx, {
|
||||
projectId: input.projectId,
|
||||
source: input.source,
|
||||
recordedChangeSetId: revision.changeSetId,
|
||||
targetChangeSetId: input.historyTargetChangeSetId,
|
||||
});
|
||||
return { revision, inverse };
|
||||
});
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import type {
|
||||
} 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";
|
||||
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
|
||||
import { resolveProjectDeviceVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
type ProjectDeviceRow = typeof projectDevices.$inferSelect;
|
||||
|
||||
@@ -25,76 +25,102 @@ export class ProjectDeviceProjectCommandRepository
|
||||
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."
|
||||
);
|
||||
}
|
||||
return executeProjectCommandTransactionWithAppliedForward(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteProjectDeviceUpdateCommandInput
|
||||
) {
|
||||
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 updated = tx
|
||||
.update(projectDevices)
|
||||
.set(patch)
|
||||
.where(
|
||||
and(
|
||||
eq(projectDevices.id, current.id),
|
||||
eq(projectDevices.projectId, input.projectId)
|
||||
)
|
||||
const patch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
change.value,
|
||||
])
|
||||
) as ProjectDeviceUpdatePatch;
|
||||
if (input.source === "user") {
|
||||
if (
|
||||
input.command.payload.changes.every(
|
||||
(change) => change.field === "voltageV"
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
) {
|
||||
throw new Error(
|
||||
"Project device changed before command execution."
|
||||
"Project-device voltage is derived and cannot be edited."
|
||||
);
|
||||
}
|
||||
const targetPhaseType = patch.phaseType ?? current.phaseType;
|
||||
if (
|
||||
targetPhaseType !== "single_phase" &&
|
||||
targetPhaseType !== "three_phase"
|
||||
) {
|
||||
throw new Error("Project-device phase type is invalid.");
|
||||
}
|
||||
const derivedVoltage = resolveProjectDeviceVoltage(
|
||||
tx,
|
||||
input.projectId,
|
||||
targetPhaseType
|
||||
);
|
||||
if (derivedVoltage === current.voltageV) {
|
||||
delete patch.voltageV;
|
||||
} else {
|
||||
patch.voltageV = derivedVoltage;
|
||||
}
|
||||
}
|
||||
const appliedForward = createProjectDeviceUpdateProjectCommand(
|
||||
current.id,
|
||||
patch
|
||||
);
|
||||
const inversePatch = Object.fromEntries(
|
||||
appliedForward.payload.changes.map((change) => [
|
||||
change.field,
|
||||
getProjectDeviceFieldValue(current, change.field),
|
||||
])
|
||||
) as ProjectDeviceUpdatePatch;
|
||||
const inverse = createProjectDeviceUpdateProjectCommand(
|
||||
current.id,
|
||||
inversePatch
|
||||
);
|
||||
|
||||
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 };
|
||||
});
|
||||
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."
|
||||
);
|
||||
}
|
||||
|
||||
return { forward: appliedForward, inverse };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ 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";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
||||
|
||||
@@ -28,116 +28,129 @@ export class ProjectDeviceRowSyncProjectCommandRepository
|
||||
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."
|
||||
);
|
||||
}
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
const rowIds = input.command.payload.rows.map(
|
||||
(row) => row.rowId
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteProjectDeviceRowSyncCommandInput
|
||||
) {
|
||||
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 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();
|
||||
}
|
||||
|
||||
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 (
|
||||
persistedRows.length !== rowIds.length ||
|
||||
persistedRows.some(
|
||||
(persisted) => persisted.projectId !== input.projectId
|
||||
)
|
||||
!persisted ||
|
||||
!snapshotMatchesRow(assignment.expected, persisted)
|
||||
) {
|
||||
throw new Error(
|
||||
"One or more sync rows do not belong to project."
|
||||
"Project-device row changed before sync execution."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 inverse = createProjectDeviceRowSyncProjectCommand(
|
||||
projectDevice.id,
|
||||
invertProjectDeviceRowSyncOperation(
|
||||
input.command.payload.operation
|
||||
),
|
||||
input.command.payload.rows.map((row) => ({
|
||||
rowId: row.rowId,
|
||||
expected: row.target,
|
||||
target: row.expected,
|
||||
}))
|
||||
}
|
||||
const affectedCircuitIds = new Set(
|
||||
input.command.payload.rows
|
||||
.filter(
|
||||
(assignment) =>
|
||||
assignment.expected.phaseType !==
|
||||
assignment.target.phaseType
|
||||
)
|
||||
.map(
|
||||
(assignment) =>
|
||||
persistedById.get(assignment.rowId)!.circuitId
|
||||
)
|
||||
);
|
||||
for (const circuitId of affectedCircuitIds) {
|
||||
updateDerivedCircuitVoltage(
|
||||
tx,
|
||||
input.projectId,
|
||||
circuitId
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
return inverse;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ 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";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { resolveProjectDeviceVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
const circuitDeviceRowSnapshotFields = [
|
||||
"id",
|
||||
@@ -60,30 +60,17 @@ export class ProjectDeviceStructureProjectCommandRepository
|
||||
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 };
|
||||
});
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(
|
||||
tx,
|
||||
input.projectId,
|
||||
input.source,
|
||||
input.command
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
@@ -139,6 +126,19 @@ export class ProjectDeviceStructureProjectCommandRepository
|
||||
if (!project) {
|
||||
throw new Error("Project does not exist.");
|
||||
}
|
||||
if (
|
||||
source === "user" &&
|
||||
snapshot.voltageV !==
|
||||
resolveProjectDeviceVoltage(
|
||||
database,
|
||||
projectId,
|
||||
snapshot.phaseType
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Project-device voltage must match the project phase voltage."
|
||||
);
|
||||
}
|
||||
const existing = database
|
||||
.select({ id: projectDevices.id })
|
||||
.from(projectDevices)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
|
||||
|
||||
@@ -11,12 +11,16 @@ function withTotalPower(row: typeof projectDevices.$inferSelect) {
|
||||
}
|
||||
|
||||
export class ProjectDeviceRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByProject(projectId: string) {
|
||||
const db = this.database;
|
||||
const rows = await db.select().from(projectDevices).where(eq(projectDevices.projectId, projectId));
|
||||
return rows.map(withTotalPower);
|
||||
}
|
||||
|
||||
async findById(projectId: string, projectDeviceId: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(projectDevices)
|
||||
|
||||
@@ -14,7 +14,7 @@ export function applyProjectHistoryTransition(
|
||||
database: AppDatabase,
|
||||
input: ApplyProjectHistoryTransitionInput
|
||||
) {
|
||||
if (input.source === "user") {
|
||||
if (input.source === "user" || input.source === "restore") {
|
||||
database
|
||||
.delete(projectHistoryStackEntries)
|
||||
.where(
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { and, asc, desc, eq } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, lt } from "drizzle-orm";
|
||||
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
|
||||
import type {
|
||||
ProjectHistoryCommand,
|
||||
ProjectHistoryDirection,
|
||||
ListProjectRevisionsInput,
|
||||
ProjectRevisionPage,
|
||||
ProjectRevisionSummary,
|
||||
ProjectHistoryState,
|
||||
ProjectHistoryStore,
|
||||
} from "../../domain/ports/project-history.store.js";
|
||||
import type { ProjectRevisionSource } from "../../domain/ports/project-revision.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
|
||||
@@ -47,6 +51,132 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
|
||||
};
|
||||
}
|
||||
|
||||
listRevisions(
|
||||
input: ListProjectRevisionsInput
|
||||
): ProjectRevisionPage | null {
|
||||
validateRevisionPageInput(input);
|
||||
const project = this.database
|
||||
.select({ currentRevision: projects.currentRevision })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectCondition = eq(
|
||||
projectRevisions.projectId,
|
||||
input.projectId
|
||||
);
|
||||
const rows = this.database
|
||||
.select({
|
||||
revisionId: projectRevisions.id,
|
||||
changeSetId: projectChangeSets.id,
|
||||
revisionNumber: projectRevisions.revisionNumber,
|
||||
createdAtIso: projectRevisions.createdAtIso,
|
||||
actorId: projectRevisions.actorId,
|
||||
source: projectRevisions.source,
|
||||
description: projectRevisions.description,
|
||||
commandType: projectChangeSets.commandType,
|
||||
payloadSchemaVersion: projectChangeSets.payloadSchemaVersion,
|
||||
})
|
||||
.from(projectRevisions)
|
||||
.innerJoin(
|
||||
projectChangeSets,
|
||||
eq(
|
||||
projectChangeSets.projectRevisionId,
|
||||
projectRevisions.id
|
||||
)
|
||||
)
|
||||
.where(
|
||||
input.beforeRevision === undefined
|
||||
? projectCondition
|
||||
: and(
|
||||
projectCondition,
|
||||
lt(
|
||||
projectRevisions.revisionNumber,
|
||||
input.beforeRevision
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(projectRevisions.revisionNumber))
|
||||
.limit(input.limit + 1)
|
||||
.all();
|
||||
const hasMore = rows.length > input.limit;
|
||||
const revisions = rows.slice(0, input.limit).map((row) => ({
|
||||
...row,
|
||||
source: row.source as ProjectRevisionSource,
|
||||
}));
|
||||
|
||||
return {
|
||||
projectId: input.projectId,
|
||||
currentRevision: project.currentRevision,
|
||||
revisions,
|
||||
nextBeforeRevision:
|
||||
hasMore && revisions.length > 0
|
||||
? revisions.at(-1)!.revisionNumber
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
listRevisionsByNumbers(
|
||||
projectId: string,
|
||||
revisionNumbers: number[]
|
||||
): ProjectRevisionSummary[] {
|
||||
for (const revisionNumber of revisionNumbers) {
|
||||
if (
|
||||
!Number.isSafeInteger(revisionNumber) ||
|
||||
revisionNumber < 0
|
||||
) {
|
||||
throw new Error(
|
||||
"Project revision numbers must be non-negative integers."
|
||||
);
|
||||
}
|
||||
}
|
||||
const normalizedRevisionNumbers = [
|
||||
...new Set(revisionNumbers),
|
||||
].filter((revisionNumber) => revisionNumber > 0);
|
||||
if (!normalizedRevisionNumbers.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.database
|
||||
.select({
|
||||
revisionId: projectRevisions.id,
|
||||
changeSetId: projectChangeSets.id,
|
||||
revisionNumber: projectRevisions.revisionNumber,
|
||||
createdAtIso: projectRevisions.createdAtIso,
|
||||
actorId: projectRevisions.actorId,
|
||||
source: projectRevisions.source,
|
||||
description: projectRevisions.description,
|
||||
commandType: projectChangeSets.commandType,
|
||||
payloadSchemaVersion: projectChangeSets.payloadSchemaVersion,
|
||||
})
|
||||
.from(projectRevisions)
|
||||
.innerJoin(
|
||||
projectChangeSets,
|
||||
eq(
|
||||
projectChangeSets.projectRevisionId,
|
||||
projectRevisions.id
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(projectRevisions.projectId, projectId),
|
||||
inArray(
|
||||
projectRevisions.revisionNumber,
|
||||
normalizedRevisionNumbers
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(projectRevisions.revisionNumber))
|
||||
.all()
|
||||
.map((row) => ({
|
||||
...row,
|
||||
source: row.source as ProjectRevisionSource,
|
||||
}));
|
||||
}
|
||||
|
||||
getNextCommand(
|
||||
projectId: string,
|
||||
direction: ProjectHistoryDirection
|
||||
@@ -93,3 +223,22 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateRevisionPageInput(input: ListProjectRevisionsInput) {
|
||||
if (
|
||||
!Number.isSafeInteger(input.limit) ||
|
||||
input.limit < 1 ||
|
||||
input.limit > 100
|
||||
) {
|
||||
throw new Error("Project revision page limit must be between 1 and 100.");
|
||||
}
|
||||
if (
|
||||
input.beforeRevision !== undefined &&
|
||||
(!Number.isSafeInteger(input.beforeRevision) ||
|
||||
input.beforeRevision < 1)
|
||||
) {
|
||||
throw new Error(
|
||||
"Project revision cursor must be a positive integer."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertProjectFloorDeleteProjectCommand,
|
||||
assertProjectFloorInsertProjectCommand,
|
||||
assertProjectRoomDeleteProjectCommand,
|
||||
assertProjectRoomInsertProjectCommand,
|
||||
createProjectFloorDeleteProjectCommand,
|
||||
createProjectFloorInsertProjectCommand,
|
||||
createProjectRoomDeleteProjectCommand,
|
||||
createProjectRoomInsertProjectCommand,
|
||||
projectFloorDeleteCommandType,
|
||||
projectFloorInsertCommandType,
|
||||
projectRoomDeleteCommandType,
|
||||
projectRoomInsertCommandType,
|
||||
type ProjectFloorSnapshot,
|
||||
type ProjectLocationStructureProjectCommand,
|
||||
type ProjectRoomSnapshot,
|
||||
} from "../../domain/models/project-location-structure-project-command.model.js";
|
||||
import type {
|
||||
ExecuteProjectLocationStructureCommandInput,
|
||||
ProjectLocationStructureProjectCommandStore,
|
||||
} from "../../domain/ports/project-location-structure-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { consumers } from "../schema/consumers.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class ProjectLocationStructureProjectCommandRepository
|
||||
implements ProjectLocationStructureProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteProjectLocationStructureCommandInput) {
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: ProjectLocationStructureProjectCommand
|
||||
): ProjectLocationStructureProjectCommand {
|
||||
switch (command.type) {
|
||||
case projectFloorInsertCommandType:
|
||||
assertProjectFloorInsertProjectCommand(command);
|
||||
return this.insertFloor(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.floor
|
||||
);
|
||||
case projectFloorDeleteCommandType:
|
||||
assertProjectFloorDeleteProjectCommand(command);
|
||||
return this.deleteFloor(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.floor
|
||||
);
|
||||
case projectRoomInsertCommandType:
|
||||
assertProjectRoomInsertProjectCommand(command);
|
||||
return this.insertRoom(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.room
|
||||
);
|
||||
case projectRoomDeleteCommandType:
|
||||
assertProjectRoomDeleteProjectCommand(command);
|
||||
return this.deleteRoom(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.room
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private insertFloor(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
floor: ProjectFloorSnapshot
|
||||
) {
|
||||
this.assertProjectExists(database, projectId);
|
||||
if (floor.projectId !== projectId) {
|
||||
throw new Error("Project-floor snapshot does not belong to project.");
|
||||
}
|
||||
const existing = database
|
||||
.select({ id: floors.id })
|
||||
.from(floors)
|
||||
.where(eq(floors.id, floor.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Project-floor id already exists.");
|
||||
}
|
||||
database.insert(floors).values(floor).run();
|
||||
return createProjectFloorDeleteProjectCommand(floor);
|
||||
}
|
||||
|
||||
private deleteFloor(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: ProjectFloorSnapshot
|
||||
) {
|
||||
const persisted = database
|
||||
.select()
|
||||
.from(floors)
|
||||
.where(
|
||||
and(
|
||||
eq(floors.id, expected.id),
|
||||
eq(floors.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!persisted || !sameRecord(expected, persisted)) {
|
||||
throw new Error("Project floor changed before deletion.");
|
||||
}
|
||||
const referencedRoom = database
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(eq(rooms.floorId, expected.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (referencedRoom) {
|
||||
throw new Error(
|
||||
"A project floor with assigned rooms cannot be removed by history."
|
||||
);
|
||||
}
|
||||
const referencedDistributionBoard = database
|
||||
.select({ id: distributionBoards.id })
|
||||
.from(distributionBoards)
|
||||
.where(eq(distributionBoards.floorId, expected.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (referencedDistributionBoard) {
|
||||
throw new Error(
|
||||
"A project floor assigned to a distribution board cannot be removed by history."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(floors)
|
||||
.where(
|
||||
and(
|
||||
eq(floors.id, expected.id),
|
||||
eq(floors.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Project floor could not be deleted.");
|
||||
}
|
||||
return createProjectFloorInsertProjectCommand(expected);
|
||||
}
|
||||
|
||||
private insertRoom(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
room: ProjectRoomSnapshot
|
||||
) {
|
||||
this.assertProjectExists(database, projectId);
|
||||
if (room.projectId !== projectId) {
|
||||
throw new Error("Project-room snapshot does not belong to project.");
|
||||
}
|
||||
if (room.floorId) {
|
||||
const floor = database
|
||||
.select({ id: floors.id })
|
||||
.from(floors)
|
||||
.where(
|
||||
and(
|
||||
eq(floors.id, room.floorId),
|
||||
eq(floors.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!floor) {
|
||||
throw new Error("Project room references a foreign floor.");
|
||||
}
|
||||
}
|
||||
const existing = database
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(eq(rooms.id, room.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Project-room id already exists.");
|
||||
}
|
||||
database.insert(rooms).values(room).run();
|
||||
return createProjectRoomDeleteProjectCommand(room);
|
||||
}
|
||||
|
||||
private deleteRoom(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: ProjectRoomSnapshot
|
||||
) {
|
||||
const persisted = database
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.id, expected.id),
|
||||
eq(rooms.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!persisted || !sameRecord(expected, persisted)) {
|
||||
throw new Error("Project room changed before deletion.");
|
||||
}
|
||||
const referencedDeviceRow = database
|
||||
.select({ id: circuitDeviceRows.id })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.roomId, expected.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
const referencedLegacyConsumer = database
|
||||
.select({ id: consumers.id })
|
||||
.from(consumers)
|
||||
.where(eq(consumers.roomId, expected.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (referencedDeviceRow || referencedLegacyConsumer) {
|
||||
throw new Error(
|
||||
"A referenced project room cannot be removed by history."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(rooms)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.id, expected.id),
|
||||
eq(rooms.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Project room could not be deleted.");
|
||||
}
|
||||
return createProjectRoomInsertProjectCommand(expected);
|
||||
}
|
||||
|
||||
private assertProjectExists(
|
||||
database: AppDatabase,
|
||||
projectId: string
|
||||
) {
|
||||
const project = database
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
throw new Error("Project does not exist.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameRecord(
|
||||
expected: object,
|
||||
actual: object
|
||||
) {
|
||||
const expectedEntries = Object.entries(expected);
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
return (
|
||||
expectedEntries.length === Object.keys(actual).length &&
|
||||
expectedEntries.every(([key, value]) => actualRecord[key] === value)
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { AppDatabase } from "../database-context.js";
|
||||
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||
import { projectRevisions } from "../schema/project-revisions.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { captureAutomaticProjectSnapshot } from "./automatic-project-snapshot.persistence.js";
|
||||
|
||||
export function appendProjectRevision(
|
||||
database: AppDatabase,
|
||||
@@ -73,6 +74,13 @@ export function appendProjectRevision(
|
||||
})
|
||||
.run();
|
||||
|
||||
captureAutomaticProjectSnapshot(database, {
|
||||
projectId: input.projectId,
|
||||
currentRevision: revisionNumber,
|
||||
createdAtIso,
|
||||
actorId: input.actorId,
|
||||
});
|
||||
|
||||
return {
|
||||
revisionId,
|
||||
changeSetId,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||
import type {
|
||||
AppendProjectRevisionInput,
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionStore,
|
||||
} from "../../domain/ports/project-revision.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
|
||||
export { ProjectRevisionConflictError };
|
||||
|
||||
export class ProjectRevisionRepository implements ProjectRevisionStore {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
getCurrentRevision(projectId: string) {
|
||||
const project = this.database
|
||||
.select({ currentRevision: projects.currentRevision })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
return project?.currentRevision ?? null;
|
||||
}
|
||||
|
||||
append(input: AppendProjectRevisionInput): AppendedProjectRevision {
|
||||
return this.database.transaction((tx) =>
|
||||
appendProjectRevision(tx, input)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import {
|
||||
assertProjectSettingsUpdateProjectCommand,
|
||||
createProjectSettingsUpdateProjectCommand,
|
||||
legacyProjectSettingsUpdateCommandSchemaVersion,
|
||||
normalizeProjectSettingsValues,
|
||||
previousProjectSettingsUpdateCommandSchemaVersion,
|
||||
type ProjectSettingsValues,
|
||||
} from "../../domain/models/project-settings-project-command.model.js";
|
||||
import type {
|
||||
ExecuteProjectSettingsUpdateCommandInput,
|
||||
ProjectSettingsProjectCommandStore,
|
||||
} from "../../domain/ports/project-settings-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { updateAllDerivedProjectVoltages } from "./project-voltage.persistence.js";
|
||||
|
||||
export class ProjectSettingsProjectCommandRepository
|
||||
implements ProjectSettingsProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
executeUpdate(input: ExecuteProjectSettingsUpdateCommandInput) {
|
||||
assertProjectSettingsUpdateProjectCommand(input.command);
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteProjectSettingsUpdateCommandInput
|
||||
) {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
const target = normalizeProjectSettingsValues(input.command, current);
|
||||
if (projectSettingsEqual(current, target)) {
|
||||
throw new Error("Project settings did not change.");
|
||||
}
|
||||
|
||||
const inverse =
|
||||
input.command.schemaVersion ===
|
||||
legacyProjectSettingsUpdateCommandSchemaVersion
|
||||
? {
|
||||
schemaVersion: legacyProjectSettingsUpdateCommandSchemaVersion,
|
||||
type: input.command.type,
|
||||
payload: {
|
||||
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||
},
|
||||
}
|
||||
: input.command.schemaVersion ===
|
||||
previousProjectSettingsUpdateCommandSchemaVersion
|
||||
? {
|
||||
schemaVersion:
|
||||
previousProjectSettingsUpdateCommandSchemaVersion,
|
||||
type: input.command.type,
|
||||
payload: {
|
||||
name: current.name,
|
||||
internalProjectNumber: current.internalProjectNumber,
|
||||
externalProjectNumber: current.externalProjectNumber,
|
||||
buildingOwner: current.buildingOwner,
|
||||
description: current.description,
|
||||
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||
},
|
||||
}
|
||||
: createProjectSettingsUpdateProjectCommand({
|
||||
name: current.name,
|
||||
internalProjectNumber: current.internalProjectNumber,
|
||||
externalProjectNumber: current.externalProjectNumber,
|
||||
buildingOwner: current.buildingOwner,
|
||||
description: current.description,
|
||||
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
current.enabledDistributionBoardSupplyTypes,
|
||||
});
|
||||
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
|
||||
const updated = tx
|
||||
.update(projects)
|
||||
.set(target)
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Project changed before settings update.");
|
||||
}
|
||||
updateAllDerivedProjectVoltages(tx, input.projectId);
|
||||
|
||||
return inverse;
|
||||
}
|
||||
}
|
||||
|
||||
function projectSettingsEqual(
|
||||
current: ProjectSettingsValues,
|
||||
target: ProjectSettingsValues
|
||||
) {
|
||||
return (
|
||||
current.name === target.name &&
|
||||
current.internalProjectNumber === target.internalProjectNumber &&
|
||||
current.externalProjectNumber === target.externalProjectNumber &&
|
||||
current.buildingOwner === target.buildingOwner &&
|
||||
current.description === target.description &&
|
||||
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
|
||||
current.threePhaseVoltageV === target.threePhaseVoltageV
|
||||
&& sameSupplyTypes(
|
||||
current.enabledDistributionBoardSupplyTypes,
|
||||
target.enabledDistributionBoardSupplyTypes
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function sameSupplyTypes(
|
||||
left: ProjectSettingsValues["enabledDistributionBoardSupplyTypes"],
|
||||
right: ProjectSettingsValues["enabledDistributionBoardSupplyTypes"]
|
||||
) {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((value, index) => value === right[index])
|
||||
);
|
||||
}
|
||||
|
||||
function assertDisabledSupplyTypesAreUnused(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
target: ProjectSettingsValues
|
||||
) {
|
||||
const used = database
|
||||
.select({ supplyType: distributionBoards.supplyType })
|
||||
.from(distributionBoards)
|
||||
.where(
|
||||
and(
|
||||
eq(distributionBoards.projectId, projectId),
|
||||
isNotNull(distributionBoards.supplyType)
|
||||
)
|
||||
)
|
||||
.all();
|
||||
const disabledUsedTypes = [
|
||||
...new Set(
|
||||
used
|
||||
.map((entry) => entry.supplyType)
|
||||
.filter(
|
||||
(supplyType) =>
|
||||
supplyType !== null &&
|
||||
!target.enabledDistributionBoardSupplyTypes.some(
|
||||
(enabled) => enabled === supplyType
|
||||
)
|
||||
)
|
||||
),
|
||||
];
|
||||
if (disabledUsedTypes.length > 0) {
|
||||
throw new Error(
|
||||
`Verwendete Netzarten können nicht deaktiviert werden: ${disabledUsedTypes.join(", ")}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
|
||||
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||
import {
|
||||
distributionBoardProjectStateSnapshotSchemaVersion,
|
||||
deserializeProjectStateSnapshot,
|
||||
legacyProjectStateSnapshotSchemaVersion,
|
||||
previousProjectStateSnapshotSchemaVersion,
|
||||
projectStateSnapshotSchemaVersion,
|
||||
supplyTypesProjectStateSnapshotSchemaVersion,
|
||||
voltageProjectStateSnapshotSchemaVersion,
|
||||
} from "../../domain/models/project-state-snapshot.model.js";
|
||||
import type {
|
||||
CreateNamedProjectSnapshotInput,
|
||||
PreparedProjectSnapshotRestore,
|
||||
ProjectSnapshotMetadata,
|
||||
ProjectSnapshotStore,
|
||||
} from "../../domain/ports/project-snapshot.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projectSnapshots } from "../schema/project-snapshots.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import {
|
||||
hashProjectStatePayload,
|
||||
readProjectStateSnapshot,
|
||||
} from "./project-state-snapshot.persistence.js";
|
||||
|
||||
export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
createNamed(
|
||||
input: CreateNamedProjectSnapshotInput
|
||||
): ProjectSnapshotMetadata | null {
|
||||
validateCreateInput(input);
|
||||
const snapshotName = input.name.trim();
|
||||
const snapshotDescription =
|
||||
input.description?.trim() || null;
|
||||
return this.database.transaction((tx) => {
|
||||
const current = readProjectStateSnapshot(tx, input.projectId);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
if (current.currentRevision !== input.expectedRevision) {
|
||||
throw new ProjectRevisionConflictError(
|
||||
input.projectId,
|
||||
input.expectedRevision,
|
||||
current.currentRevision
|
||||
);
|
||||
}
|
||||
const existing = tx
|
||||
.select({ id: projectSnapshots.id })
|
||||
.from(projectSnapshots)
|
||||
.where(
|
||||
and(
|
||||
eq(projectSnapshots.projectId, input.projectId),
|
||||
eq(projectSnapshots.name, snapshotName)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new ProjectSnapshotNameConflictError(
|
||||
input.projectId,
|
||||
snapshotName
|
||||
);
|
||||
}
|
||||
|
||||
const metadata: ProjectSnapshotMetadata = {
|
||||
id: crypto.randomUUID(),
|
||||
projectId: input.projectId,
|
||||
sourceRevision: current.currentRevision,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
kind: "named",
|
||||
name: snapshotName,
|
||||
description: snapshotDescription,
|
||||
payloadSha256: current.payloadSha256,
|
||||
createdAtIso: new Date().toISOString(),
|
||||
createdByActorId: input.actorId ?? null,
|
||||
};
|
||||
tx.insert(projectSnapshots)
|
||||
.values({
|
||||
...metadata,
|
||||
payloadJson: current.payloadJson,
|
||||
})
|
||||
.run();
|
||||
return metadata;
|
||||
});
|
||||
}
|
||||
|
||||
listByProject(projectId: string): ProjectSnapshotMetadata[] | null {
|
||||
const project = this.database
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
return this.database
|
||||
.select({
|
||||
id: projectSnapshots.id,
|
||||
projectId: projectSnapshots.projectId,
|
||||
sourceRevision: projectSnapshots.sourceRevision,
|
||||
schemaVersion: projectSnapshots.schemaVersion,
|
||||
kind: projectSnapshots.kind,
|
||||
name: projectSnapshots.name,
|
||||
description: projectSnapshots.description,
|
||||
payloadSha256: projectSnapshots.payloadSha256,
|
||||
createdAtIso: projectSnapshots.createdAtIso,
|
||||
createdByActorId: projectSnapshots.createdByActorId,
|
||||
})
|
||||
.from(projectSnapshots)
|
||||
.where(eq(projectSnapshots.projectId, projectId))
|
||||
.orderBy(
|
||||
desc(projectSnapshots.createdAtIso),
|
||||
desc(projectSnapshots.id)
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
prepareRestore(
|
||||
projectId: string,
|
||||
snapshotId: string
|
||||
): PreparedProjectSnapshotRestore | null {
|
||||
const current = readProjectStateSnapshot(this.database, projectId);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
const stored = this.database
|
||||
.select()
|
||||
.from(projectSnapshots)
|
||||
.where(
|
||||
and(
|
||||
eq(projectSnapshots.id, snapshotId),
|
||||
eq(projectSnapshots.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
const actualChecksum = hashProjectStatePayload(stored.payloadJson);
|
||||
if (actualChecksum !== stored.payloadSha256) {
|
||||
throw new Error("Project snapshot checksum verification failed.");
|
||||
}
|
||||
if (
|
||||
stored.schemaVersion !== legacyProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !== previousProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
distributionBoardProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
supplyTypesProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
voltageProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !== projectStateSnapshotSchemaVersion
|
||||
) {
|
||||
throw new Error("Project snapshot schema version is not supported.");
|
||||
}
|
||||
const targetState = deserializeProjectStateSnapshot(stored.payloadJson);
|
||||
return {
|
||||
snapshot: {
|
||||
id: stored.id,
|
||||
projectId: stored.projectId,
|
||||
sourceRevision: stored.sourceRevision,
|
||||
schemaVersion: stored.schemaVersion,
|
||||
kind: stored.kind,
|
||||
name: stored.name,
|
||||
description: stored.description,
|
||||
payloadSha256: stored.payloadSha256,
|
||||
createdAtIso: stored.createdAtIso,
|
||||
createdByActorId: stored.createdByActorId,
|
||||
},
|
||||
command: createProjectStateRestoreCommand(
|
||||
current.payloadSha256,
|
||||
targetState
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateCreateInput(input: CreateNamedProjectSnapshotInput) {
|
||||
if (
|
||||
!Number.isSafeInteger(input.expectedRevision) ||
|
||||
input.expectedRevision < 0
|
||||
) {
|
||||
throw new Error(
|
||||
"Expected project revision must be a non-negative integer."
|
||||
);
|
||||
}
|
||||
if (!input.name.trim()) {
|
||||
throw new Error("Project snapshot name is required.");
|
||||
}
|
||||
if (input.name.trim().length > 100) {
|
||||
throw new Error(
|
||||
"Project snapshot name must not exceed 100 characters."
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.description !== undefined &&
|
||||
input.description.trim().length > 500
|
||||
) {
|
||||
throw new Error(
|
||||
"Project snapshot description must not exceed 500 characters."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js";
|
||||
import {
|
||||
assertProjectStateRestoreCommand,
|
||||
createProjectStateRestoreCommand,
|
||||
} from "../../domain/models/project-state-restore-command.model.js";
|
||||
import {
|
||||
parseProjectStateSnapshot,
|
||||
type ProjectStateSnapshot,
|
||||
} from "../../domain/models/project-state-snapshot.model.js";
|
||||
import type {
|
||||
ExecuteProjectStateRestoreCommandInput,
|
||||
ProjectStateRestoreCommandStore,
|
||||
} from "../../domain/ports/project-state-restore-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 { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { consumers } from "../schema/consumers.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
||||
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import {
|
||||
readProjectStateSnapshot,
|
||||
} from "./project-state-snapshot.persistence.js";
|
||||
|
||||
export class ProjectStateRestoreCommandRepository
|
||||
implements ProjectStateRestoreCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteProjectStateRestoreCommandInput) {
|
||||
assertProjectStateRestoreCommand(input.command);
|
||||
const targetState = parseProjectStateSnapshot(
|
||||
input.command.payload.targetState
|
||||
);
|
||||
if (targetState.project.id !== input.projectId) {
|
||||
throw new Error("Restore state belongs to a different project.");
|
||||
}
|
||||
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteProjectStateRestoreCommandInput
|
||||
) {
|
||||
const current = readProjectStateSnapshot(tx, input.projectId);
|
||||
if (!current) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
if (
|
||||
current.payloadSha256 !==
|
||||
input.command.payload.expectedStateSha256 &&
|
||||
!matchesHistoryRestoreTarget(tx, input, current.state)
|
||||
) {
|
||||
throw new ProjectStateConflictError(
|
||||
input.projectId,
|
||||
input.command.payload.expectedStateSha256,
|
||||
current.payloadSha256
|
||||
);
|
||||
}
|
||||
|
||||
const targetState = parseProjectStateSnapshot(
|
||||
input.command.payload.targetState
|
||||
);
|
||||
replaceProjectState(tx, targetState);
|
||||
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
|
||||
if (!persistedTarget) {
|
||||
throw new Error("Restored project could not be loaded.");
|
||||
}
|
||||
const inverse = createProjectStateRestoreCommand(
|
||||
persistedTarget.payloadSha256,
|
||||
current.state
|
||||
);
|
||||
|
||||
return inverse;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesHistoryRestoreTarget(
|
||||
database: AppDatabase,
|
||||
input: ExecuteProjectStateRestoreCommandInput,
|
||||
currentState: ProjectStateSnapshot
|
||||
) {
|
||||
if (
|
||||
(input.source !== "undo" && input.source !== "redo") ||
|
||||
!input.historyTargetChangeSetId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const changeSet = database
|
||||
.select({
|
||||
forwardPayloadJson: projectChangeSets.forwardPayloadJson,
|
||||
inversePayloadJson: projectChangeSets.inversePayloadJson,
|
||||
})
|
||||
.from(projectChangeSets)
|
||||
.where(eq(projectChangeSets.id, input.historyTargetChangeSetId))
|
||||
.get();
|
||||
if (!changeSet) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const serialized =
|
||||
input.source === "undo"
|
||||
? changeSet.forwardPayloadJson
|
||||
: changeSet.inversePayloadJson;
|
||||
const command = JSON.parse(serialized) as {
|
||||
type?: unknown;
|
||||
payload?: { targetState?: unknown };
|
||||
};
|
||||
if (command.type !== "project.restore-state") {
|
||||
return false;
|
||||
}
|
||||
const expectedState = parseProjectStateSnapshot(
|
||||
command.payload?.targetState
|
||||
);
|
||||
return (
|
||||
canonicalProjectState(expectedState) ===
|
||||
canonicalProjectState(currentState)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||
return JSON.stringify({
|
||||
...state,
|
||||
distributionBoards: byId(state.distributionBoards),
|
||||
circuitLists: byId(state.circuitLists),
|
||||
circuitSections: byId(state.circuitSections),
|
||||
circuits: byId(
|
||||
state.circuits.map((circuit) => ({
|
||||
...circuit,
|
||||
deviceRows: byId(circuit.deviceRows),
|
||||
}))
|
||||
),
|
||||
projectDevices: byId(state.projectDevices),
|
||||
floors: byId(state.floors),
|
||||
rooms: byId(state.rooms),
|
||||
});
|
||||
}
|
||||
|
||||
function byId<T extends { id: string }>(entries: readonly T[]) {
|
||||
return [...entries].sort((left, right) =>
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function replaceProjectState(
|
||||
database: AppDatabase,
|
||||
state: ProjectStateSnapshot
|
||||
) {
|
||||
const upgradeOnlyData = captureUpgradeOnlyData(
|
||||
database,
|
||||
state.project.id
|
||||
);
|
||||
database
|
||||
.delete(distributionBoards)
|
||||
.where(eq(distributionBoards.projectId, state.project.id))
|
||||
.run();
|
||||
database
|
||||
.delete(projectDevices)
|
||||
.where(eq(projectDevices.projectId, state.project.id))
|
||||
.run();
|
||||
database
|
||||
.delete(rooms)
|
||||
.where(eq(rooms.projectId, state.project.id))
|
||||
.run();
|
||||
database
|
||||
.delete(floors)
|
||||
.where(eq(floors.projectId, state.project.id))
|
||||
.run();
|
||||
|
||||
database
|
||||
.update(projects)
|
||||
.set({
|
||||
name: state.project.name,
|
||||
internalProjectNumber: state.project.internalProjectNumber,
|
||||
externalProjectNumber: state.project.externalProjectNumber,
|
||||
buildingOwner: state.project.buildingOwner,
|
||||
description: state.project.description,
|
||||
singlePhaseVoltageV: state.project.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: state.project.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
state.project.enabledDistributionBoardSupplyTypes,
|
||||
})
|
||||
.where(eq(projects.id, state.project.id))
|
||||
.run();
|
||||
|
||||
insertMany(database, floors, state.floors);
|
||||
insertMany(database, rooms, state.rooms);
|
||||
insertMany(database, projectDevices, state.projectDevices);
|
||||
insertMany(database, distributionBoards, state.distributionBoards);
|
||||
insertMany(database, circuitLists, state.circuitLists);
|
||||
insertMany(database, circuitSections, state.circuitSections);
|
||||
insertMany(
|
||||
database,
|
||||
circuits,
|
||||
state.circuits.map(({ deviceRows: _deviceRows, ...circuit }) => ({
|
||||
...circuit,
|
||||
isReserve: circuit.isReserve ? 1 : 0,
|
||||
}))
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
circuitDeviceRows,
|
||||
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
||||
);
|
||||
restoreCompatibleUpgradeOnlyData(database, state, upgradeOnlyData);
|
||||
}
|
||||
|
||||
function captureUpgradeOnlyData(
|
||||
database: AppDatabase,
|
||||
projectId: string
|
||||
) {
|
||||
const consumerLinks = database
|
||||
.select({
|
||||
id: consumers.id,
|
||||
distributionBoardId: consumers.distributionBoardId,
|
||||
circuitListId: consumers.circuitListId,
|
||||
projectDeviceId: consumers.projectDeviceId,
|
||||
roomId: consumers.roomId,
|
||||
})
|
||||
.from(consumers)
|
||||
.where(eq(consumers.projectId, projectId))
|
||||
.all();
|
||||
const migrationMappings = database
|
||||
.select({
|
||||
consumerId: legacyConsumerCircuitMigrations.consumerId,
|
||||
circuitId: legacyConsumerCircuitMigrations.circuitId,
|
||||
circuitDeviceRowId:
|
||||
legacyConsumerCircuitMigrations.circuitDeviceRowId,
|
||||
circuitListId: legacyConsumerCircuitMigrations.circuitListId,
|
||||
createdAtIso: legacyConsumerCircuitMigrations.createdAtIso,
|
||||
})
|
||||
.from(legacyConsumerCircuitMigrations)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(
|
||||
circuitLists.id,
|
||||
legacyConsumerCircuitMigrations.circuitListId
|
||||
)
|
||||
)
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.all();
|
||||
const migrationReports = database
|
||||
.select({
|
||||
id: legacyConsumerMigrationReports.id,
|
||||
circuitListId: legacyConsumerMigrationReports.circuitListId,
|
||||
legacyConsumerCount:
|
||||
legacyConsumerMigrationReports.legacyConsumerCount,
|
||||
createdCircuitCount:
|
||||
legacyConsumerMigrationReports.createdCircuitCount,
|
||||
createdDeviceRowCount:
|
||||
legacyConsumerMigrationReports.createdDeviceRowCount,
|
||||
duplicateGroupedCount:
|
||||
legacyConsumerMigrationReports.duplicateGroupedCount,
|
||||
generatedIdentifierCount:
|
||||
legacyConsumerMigrationReports.generatedIdentifierCount,
|
||||
unassignedRowCount:
|
||||
legacyConsumerMigrationReports.unassignedRowCount,
|
||||
warningsJson: legacyConsumerMigrationReports.warningsJson,
|
||||
generatedIdentifiersJson:
|
||||
legacyConsumerMigrationReports.generatedIdentifiersJson,
|
||||
duplicateGroupsJson:
|
||||
legacyConsumerMigrationReports.duplicateGroupsJson,
|
||||
createdAtIso: legacyConsumerMigrationReports.createdAtIso,
|
||||
})
|
||||
.from(legacyConsumerMigrationReports)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, legacyConsumerMigrationReports.circuitListId)
|
||||
)
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.all();
|
||||
return { consumerLinks, migrationMappings, migrationReports };
|
||||
}
|
||||
|
||||
function restoreCompatibleUpgradeOnlyData(
|
||||
database: AppDatabase,
|
||||
state: ProjectStateSnapshot,
|
||||
data: ReturnType<typeof captureUpgradeOnlyData>
|
||||
) {
|
||||
const boardIds = new Set(
|
||||
state.distributionBoards.map((entry) => entry.id)
|
||||
);
|
||||
const listIds = new Set(state.circuitLists.map((entry) => entry.id));
|
||||
const deviceIds = new Set(
|
||||
state.projectDevices.map((entry) => entry.id)
|
||||
);
|
||||
const roomIds = new Set(state.rooms.map((entry) => entry.id));
|
||||
const circuitIds = new Set(state.circuits.map((entry) => entry.id));
|
||||
const rowIds = new Set(
|
||||
state.circuits.flatMap((circuit) =>
|
||||
circuit.deviceRows.map((entry) => entry.id)
|
||||
)
|
||||
);
|
||||
|
||||
for (const link of data.consumerLinks) {
|
||||
database
|
||||
.update(consumers)
|
||||
.set({
|
||||
distributionBoardId:
|
||||
link.distributionBoardId &&
|
||||
boardIds.has(link.distributionBoardId)
|
||||
? link.distributionBoardId
|
||||
: null,
|
||||
circuitListId:
|
||||
link.circuitListId && listIds.has(link.circuitListId)
|
||||
? link.circuitListId
|
||||
: null,
|
||||
projectDeviceId:
|
||||
link.projectDeviceId && deviceIds.has(link.projectDeviceId)
|
||||
? link.projectDeviceId
|
||||
: null,
|
||||
roomId:
|
||||
link.roomId && roomIds.has(link.roomId) ? link.roomId : null,
|
||||
})
|
||||
.where(eq(consumers.id, link.id))
|
||||
.run();
|
||||
}
|
||||
insertMany(
|
||||
database,
|
||||
legacyConsumerCircuitMigrations,
|
||||
data.migrationMappings.filter(
|
||||
(entry) =>
|
||||
listIds.has(entry.circuitListId) &&
|
||||
circuitIds.has(entry.circuitId) &&
|
||||
rowIds.has(entry.circuitDeviceRowId)
|
||||
)
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
legacyConsumerMigrationReports,
|
||||
data.migrationReports.filter((entry) =>
|
||||
listIds.has(entry.circuitListId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||
database: AppDatabase,
|
||||
table: TTable,
|
||||
values: unknown[]
|
||||
) {
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
database
|
||||
.insert(table)
|
||||
.values(values as never)
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import crypto from "node:crypto";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import {
|
||||
parseProjectStateSnapshot,
|
||||
projectStateSnapshotSchemaVersion,
|
||||
serializeProjectStateSnapshot,
|
||||
type ProjectStateSnapshot,
|
||||
} from "../../domain/models/project-state-snapshot.model.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
|
||||
export interface PersistedProjectStateSnapshot {
|
||||
currentRevision: number;
|
||||
state: ProjectStateSnapshot;
|
||||
payloadJson: string;
|
||||
payloadSha256: string;
|
||||
}
|
||||
|
||||
export function readProjectStateSnapshot(
|
||||
database: AppDatabase,
|
||||
projectId: string
|
||||
): PersistedProjectStateSnapshot | null {
|
||||
const project = database
|
||||
.select({
|
||||
id: projects.id,
|
||||
name: projects.name,
|
||||
internalProjectNumber: projects.internalProjectNumber,
|
||||
externalProjectNumber: projects.externalProjectNumber,
|
||||
buildingOwner: projects.buildingOwner,
|
||||
description: projects.description,
|
||||
singlePhaseVoltageV: projects.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
projects.enabledDistributionBoardSupplyTypes,
|
||||
currentRevision: projects.currentRevision,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const boardRows = database
|
||||
.select()
|
||||
.from(distributionBoards)
|
||||
.where(eq(distributionBoards.projectId, projectId))
|
||||
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
|
||||
.all();
|
||||
const listRows = database
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.orderBy(asc(circuitLists.name), asc(circuitLists.id))
|
||||
.all();
|
||||
const sectionRows = database
|
||||
.select({
|
||||
id: circuitSections.id,
|
||||
circuitListId: circuitSections.circuitListId,
|
||||
key: circuitSections.key,
|
||||
displayName: circuitSections.displayName,
|
||||
prefix: circuitSections.prefix,
|
||||
sortOrder: circuitSections.sortOrder,
|
||||
})
|
||||
.from(circuitSections)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, circuitSections.circuitListId)
|
||||
)
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.orderBy(
|
||||
asc(circuitSections.circuitListId),
|
||||
asc(circuitSections.sortOrder),
|
||||
asc(circuitSections.id)
|
||||
)
|
||||
.all();
|
||||
const circuitRows = database
|
||||
.select({
|
||||
id: circuits.id,
|
||||
circuitListId: circuits.circuitListId,
|
||||
sectionId: circuits.sectionId,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
displayName: circuits.displayName,
|
||||
sortOrder: circuits.sortOrder,
|
||||
protectionType: circuits.protectionType,
|
||||
protectionRatedCurrent: circuits.protectionRatedCurrent,
|
||||
protectionCharacteristic: circuits.protectionCharacteristic,
|
||||
cableType: circuits.cableType,
|
||||
cableCrossSection: circuits.cableCrossSection,
|
||||
cableLength: circuits.cableLength,
|
||||
rcdAssignment: circuits.rcdAssignment,
|
||||
terminalDesignation: circuits.terminalDesignation,
|
||||
voltage: circuits.voltage,
|
||||
controlRequirement: circuits.controlRequirement,
|
||||
status: circuits.status,
|
||||
isReserve: circuits.isReserve,
|
||||
remark: circuits.remark,
|
||||
})
|
||||
.from(circuits)
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.orderBy(
|
||||
asc(circuits.circuitListId),
|
||||
asc(circuits.sortOrder),
|
||||
asc(circuits.id)
|
||||
)
|
||||
.all();
|
||||
const deviceRowRows = database
|
||||
.select({
|
||||
id: circuitDeviceRows.id,
|
||||
circuitId: circuitDeviceRows.circuitId,
|
||||
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
|
||||
legacyConsumerId: circuitDeviceRows.legacyConsumerId,
|
||||
sortOrder: circuitDeviceRows.sortOrder,
|
||||
name: circuitDeviceRows.name,
|
||||
displayName: circuitDeviceRows.displayName,
|
||||
phaseType: circuitDeviceRows.phaseType,
|
||||
connectionKind: circuitDeviceRows.connectionKind,
|
||||
costGroup: circuitDeviceRows.costGroup,
|
||||
category: circuitDeviceRows.category,
|
||||
level: circuitDeviceRows.level,
|
||||
roomId: circuitDeviceRows.roomId,
|
||||
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
|
||||
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
|
||||
quantity: circuitDeviceRows.quantity,
|
||||
powerPerUnit: circuitDeviceRows.powerPerUnit,
|
||||
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
|
||||
cosPhi: circuitDeviceRows.cosPhi,
|
||||
remark: circuitDeviceRows.remark,
|
||||
overriddenFields: circuitDeviceRows.overriddenFields,
|
||||
})
|
||||
.from(circuitDeviceRows)
|
||||
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.orderBy(
|
||||
asc(circuitDeviceRows.circuitId),
|
||||
asc(circuitDeviceRows.sortOrder),
|
||||
asc(circuitDeviceRows.id)
|
||||
)
|
||||
.all();
|
||||
const projectDeviceRows = database
|
||||
.select()
|
||||
.from(projectDevices)
|
||||
.where(eq(projectDevices.projectId, projectId))
|
||||
.orderBy(asc(projectDevices.displayName), asc(projectDevices.id))
|
||||
.all();
|
||||
const floorRows = database
|
||||
.select()
|
||||
.from(floors)
|
||||
.where(eq(floors.projectId, projectId))
|
||||
.orderBy(asc(floors.sortOrder), asc(floors.id))
|
||||
.all();
|
||||
const roomRows = database
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(eq(rooms.projectId, projectId))
|
||||
.orderBy(asc(rooms.roomNumber), asc(rooms.id))
|
||||
.all();
|
||||
|
||||
const deviceRowsByCircuit = new Map<string, typeof deviceRowRows>();
|
||||
for (const row of deviceRowRows) {
|
||||
const entries = deviceRowsByCircuit.get(row.circuitId) ?? [];
|
||||
entries.push(row);
|
||||
deviceRowsByCircuit.set(row.circuitId, entries);
|
||||
}
|
||||
|
||||
const state = parseProjectStateSnapshot({
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
internalProjectNumber: project.internalProjectNumber,
|
||||
externalProjectNumber: project.externalProjectNumber,
|
||||
buildingOwner: project.buildingOwner,
|
||||
description: project.description,
|
||||
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
project.enabledDistributionBoardSupplyTypes,
|
||||
},
|
||||
distributionBoards: boardRows,
|
||||
circuitLists: listRows,
|
||||
circuitSections: sectionRows,
|
||||
circuits: circuitRows.map((circuit) => ({
|
||||
...circuit,
|
||||
isReserve: Boolean(circuit.isReserve),
|
||||
deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [],
|
||||
})),
|
||||
projectDevices: projectDeviceRows,
|
||||
floors: floorRows,
|
||||
rooms: roomRows,
|
||||
});
|
||||
const payloadJson = serializeProjectStateSnapshot(state);
|
||||
return {
|
||||
currentRevision: project.currentRevision,
|
||||
state,
|
||||
payloadJson,
|
||||
payloadSha256: hashProjectStatePayload(payloadJson),
|
||||
};
|
||||
}
|
||||
|
||||
export function hashProjectStatePayload(payloadJson: string) {
|
||||
return crypto.createHash("sha256").update(payloadJson).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
createProjectTransferEnvelope,
|
||||
parseProjectTransferEnvelope,
|
||||
remapProjectState,
|
||||
} from "../../domain/models/project-transfer.model.js";
|
||||
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.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 { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import {
|
||||
hashProjectStatePayload,
|
||||
readProjectStateSnapshot,
|
||||
} from "./project-state-snapshot.persistence.js";
|
||||
import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js";
|
||||
|
||||
export class ProjectTransferRepository implements ProjectTransferStore {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
exportProject(projectId: string) {
|
||||
const snapshot = readProjectStateSnapshot(this.database, projectId);
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
return createProjectTransferEnvelope(
|
||||
new Date().toISOString(),
|
||||
snapshot.payloadSha256,
|
||||
snapshot.state
|
||||
);
|
||||
}
|
||||
|
||||
prepareReplace(projectId: string, value: unknown) {
|
||||
const transfer = this.parseVerified(value);
|
||||
const current = readProjectStateSnapshot(this.database, projectId);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
const targetState =
|
||||
transfer.projectState.project.id === projectId
|
||||
? transfer.projectState
|
||||
: remapProjectState(
|
||||
transfer.projectState,
|
||||
projectId,
|
||||
() => crypto.randomUUID()
|
||||
);
|
||||
return {
|
||||
command: createProjectStateRestoreCommand(
|
||||
current.payloadSha256,
|
||||
targetState
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
importDuplicate(value: unknown) {
|
||||
const transfer = this.parseVerified(value);
|
||||
const projectId = crypto.randomUUID();
|
||||
const state = remapProjectState(
|
||||
transfer.projectState,
|
||||
projectId,
|
||||
() => crypto.randomUUID(),
|
||||
`${transfer.projectState.project.name} (Kopie)`
|
||||
);
|
||||
this.database.transaction((tx) => insertProjectState(tx, state));
|
||||
return { projectId, name: state.project.name };
|
||||
}
|
||||
|
||||
private parseVerified(value: unknown) {
|
||||
const rawPayloadSha256 =
|
||||
isPlainObject(value) &&
|
||||
isPlainObject(value.projectState)
|
||||
? hashProjectStatePayload(JSON.stringify(value.projectState))
|
||||
: null;
|
||||
const transfer = parseProjectTransferEnvelope(value);
|
||||
const payloadJson = serializeProjectStateSnapshot(
|
||||
transfer.projectState
|
||||
);
|
||||
if (
|
||||
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
|
||||
rawPayloadSha256 !== transfer.payloadSha256
|
||||
) {
|
||||
throw new Error("Project transfer checksum verification failed.");
|
||||
}
|
||||
return transfer;
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function insertProjectState(
|
||||
database: AppDatabase,
|
||||
state: ReturnType<typeof remapProjectState>
|
||||
) {
|
||||
const existing = database
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, state.project.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Project transfer target already exists.");
|
||||
}
|
||||
database
|
||||
.insert(projects)
|
||||
.values({ ...state.project, currentRevision: 0 })
|
||||
.run();
|
||||
insertMany(database, floors, state.floors);
|
||||
insertMany(database, rooms, state.rooms);
|
||||
insertMany(database, projectDevices, state.projectDevices);
|
||||
insertMany(database, distributionBoards, state.distributionBoards);
|
||||
insertMany(database, circuitLists, state.circuitLists);
|
||||
insertMany(database, circuitSections, state.circuitSections);
|
||||
insertMany(
|
||||
database,
|
||||
circuits,
|
||||
state.circuits.map(({ deviceRows: _deviceRows, ...circuit }) => ({
|
||||
...circuit,
|
||||
isReserve: circuit.isReserve ? 1 : 0,
|
||||
}))
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
circuitDeviceRows,
|
||||
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
||||
);
|
||||
}
|
||||
|
||||
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||
database: AppDatabase,
|
||||
table: TTable,
|
||||
values: unknown[]
|
||||
) {
|
||||
if (values.length > 0) {
|
||||
database.insert(table).values(values as never).run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
resolveCircuitPhaseType,
|
||||
resolveProjectVoltage,
|
||||
type ElectricalPhaseType,
|
||||
type ProjectVoltageSettings,
|
||||
} from "../../domain/services/project-voltage.service.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
|
||||
export function readProjectVoltageSettings(
|
||||
database: AppDatabase,
|
||||
projectId: string
|
||||
): ProjectVoltageSettings {
|
||||
const project = database
|
||||
.select({
|
||||
singlePhaseVoltageV: projects.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
export function resolveProjectDeviceVoltage(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
phaseType: ElectricalPhaseType
|
||||
) {
|
||||
return resolveProjectVoltage(
|
||||
phaseType,
|
||||
readProjectVoltageSettings(database, projectId)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCircuitVoltage(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
sectionId: string,
|
||||
devicePhaseTypes: ReadonlyArray<string | null | undefined> = []
|
||||
) {
|
||||
const section = database
|
||||
.select({ key: circuitSections.key })
|
||||
.from(circuitSections)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, circuitSections.circuitListId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, sectionId),
|
||||
eq(circuitLists.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!section) {
|
||||
throw new Error("Circuit section does not belong to project.");
|
||||
}
|
||||
return resolveProjectVoltage(
|
||||
resolveCircuitPhaseType(section.key, devicePhaseTypes),
|
||||
readProjectVoltageSettings(database, projectId)
|
||||
);
|
||||
}
|
||||
|
||||
export function updateDerivedCircuitVoltage(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitId: string
|
||||
) {
|
||||
const circuit = database
|
||||
.select({
|
||||
id: circuits.id,
|
||||
sectionId: circuits.sectionId,
|
||||
})
|
||||
.from(circuits)
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.id, circuitId),
|
||||
eq(circuitLists.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!circuit) {
|
||||
throw new Error("Circuit does not belong to project.");
|
||||
}
|
||||
const devicePhaseTypes = database
|
||||
.select({ phaseType: circuitDeviceRows.phaseType })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, circuitId))
|
||||
.all()
|
||||
.map((row) => row.phaseType);
|
||||
const voltage = resolveCircuitVoltage(
|
||||
database,
|
||||
projectId,
|
||||
circuit.sectionId,
|
||||
devicePhaseTypes
|
||||
);
|
||||
database
|
||||
.update(circuits)
|
||||
.set({ voltage })
|
||||
.where(eq(circuits.id, circuitId))
|
||||
.run();
|
||||
return voltage;
|
||||
}
|
||||
|
||||
export function updateAllDerivedProjectVoltages(
|
||||
database: AppDatabase,
|
||||
projectId: string
|
||||
) {
|
||||
const settings = readProjectVoltageSettings(database, projectId);
|
||||
const devices = database
|
||||
.select({
|
||||
id: projectDevices.id,
|
||||
phaseType: projectDevices.phaseType,
|
||||
})
|
||||
.from(projectDevices)
|
||||
.where(eq(projectDevices.projectId, projectId))
|
||||
.all();
|
||||
for (const device of devices) {
|
||||
if (
|
||||
device.phaseType !== "single_phase" &&
|
||||
device.phaseType !== "three_phase"
|
||||
) {
|
||||
throw new Error("Persisted project-device phase type is invalid.");
|
||||
}
|
||||
database
|
||||
.update(projectDevices)
|
||||
.set({
|
||||
voltageV: resolveProjectVoltage(device.phaseType, settings),
|
||||
})
|
||||
.where(eq(projectDevices.id, device.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
const projectCircuits = database
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.all();
|
||||
for (const circuit of projectCircuits) {
|
||||
updateDerivedCircuitVoltage(database, projectId, circuit.id);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,35 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import type {
|
||||
CreateProjectInput,
|
||||
UpdateProjectSettingsInput,
|
||||
} from "../../shared/validation/project-structure.schemas.js";
|
||||
import { defaultDistributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js";
|
||||
|
||||
export class ProjectRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async list() {
|
||||
const db = this.database;
|
||||
return db.select().from(projects);
|
||||
}
|
||||
|
||||
async create(input: CreateProjectInput) {
|
||||
const db = this.database;
|
||||
const id = crypto.randomUUID();
|
||||
const project = {
|
||||
id,
|
||||
name: input.name,
|
||||
internalProjectNumber: input.internalProjectNumber ?? null,
|
||||
externalProjectNumber: input.externalProjectNumber ?? null,
|
||||
buildingOwner: input.buildingOwner ?? null,
|
||||
description: input.description ?? null,
|
||||
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
|
||||
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
|
||||
enabledDistributionBoardSupplyTypes: [
|
||||
...defaultDistributionBoardSupplyTypes,
|
||||
],
|
||||
currentRevision: 0,
|
||||
};
|
||||
await db.insert(projects).values(project);
|
||||
@@ -26,21 +37,8 @@ export class ProjectRepository {
|
||||
}
|
||||
|
||||
async findById(projectId: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async updateSettings(projectId: string, input: UpdateProjectSettingsInput) {
|
||||
await db
|
||||
.update(projects)
|
||||
.set({
|
||||
singlePhaseVoltageV: input.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: input.threePhaseVoltageV,
|
||||
})
|
||||
.where(eq(projects.id, projectId));
|
||||
}
|
||||
|
||||
async delete(projectId: string) {
|
||||
await db.delete(projects).where(eq(projects.id, projectId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,16 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import type { CreateRoomInput } from "../../shared/validation/project-structure.schemas.js";
|
||||
|
||||
export class RoomRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByProject(projectId: string) {
|
||||
const db = this.database;
|
||||
return db
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(eq(rooms.projectId, projectId))
|
||||
.orderBy(asc(rooms.roomNumber), asc(rooms.roomName));
|
||||
}
|
||||
|
||||
async create(projectId: string, input: CreateRoomInput) {
|
||||
const id = crypto.randomUUID();
|
||||
const room = {
|
||||
id,
|
||||
projectId,
|
||||
floorId: input.floorId ?? null,
|
||||
roomNumber: input.roomNumber,
|
||||
roomName: input.roomName,
|
||||
};
|
||||
await db.insert(rooms).values(room);
|
||||
return room;
|
||||
}
|
||||
|
||||
async existsInProject(projectId: string, roomId: string) {
|
||||
const [row] = await db
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(and(eq(rooms.projectId, projectId), eq(rooms.id, roomId)))
|
||||
.limit(1);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
async findById(roomId: string) {
|
||||
const [row] = await db.select().from(rooms).where(eq(rooms.id, roomId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { floors } from "./floors.js";
|
||||
import { projects } from "./projects.js";
|
||||
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
|
||||
|
||||
export const distributionBoards = sqliteTable("distribution_boards", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -7,5 +9,12 @@ export const distributionBoards = sqliteTable("distribution_boards", {
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
floorId: text("floor_id").references(() => floors.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
supplyType: text("supply_type").$type<DistributionBoardSupplyType>(),
|
||||
simultaneityFactor: real("simultaneity_factor")
|
||||
.notNull()
|
||||
.default(1),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
sqliteTable,
|
||||
text,
|
||||
unique,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { projectSnapshotKinds } from "../../domain/models/project-snapshot-policy.model.js";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
export const projectSnapshots = sqliteTable(
|
||||
"project_snapshots",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
sourceRevision: integer("source_revision").notNull(),
|
||||
schemaVersion: integer("schema_version").notNull(),
|
||||
kind: text("kind", { enum: projectSnapshotKinds })
|
||||
.notNull()
|
||||
.default("named"),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
payloadJson: text("payload_json").notNull(),
|
||||
payloadSha256: text("payload_sha256").notNull(),
|
||||
createdAtIso: text("created_at_iso").notNull(),
|
||||
createdByActorId: text("created_by_actor_id"),
|
||||
},
|
||||
(table) => [
|
||||
unique("project_snapshots_project_name_unique").on(
|
||||
table.projectId,
|
||||
table.name
|
||||
),
|
||||
index("project_snapshots_project_created_idx").on(
|
||||
table.projectId,
|
||||
table.createdAtIso
|
||||
),
|
||||
index("project_snapshots_project_kind_revision_idx").on(
|
||||
table.projectId,
|
||||
table.kind,
|
||||
table.sourceRevision
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -1,9 +1,22 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
|
||||
|
||||
export const projects = sqliteTable("projects", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
internalProjectNumber: text("internal_project_number"),
|
||||
externalProjectNumber: text("external_project_number"),
|
||||
buildingOwner: text("building_owner"),
|
||||
description: text("description"),
|
||||
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
|
||||
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
|
||||
enabledDistributionBoardSupplyTypes: text(
|
||||
"enabled_distribution_board_supply_types",
|
||||
{ mode: "json" }
|
||||
)
|
||||
.$type<DistributionBoardSupplyType[]>()
|
||||
.notNull()
|
||||
.default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`),
|
||||
currentRevision: integer("current_revision").notNull().default(0),
|
||||
});
|
||||
|
||||
@@ -15,3 +15,37 @@ export function calculateCircuitTotalPower(
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateSectionTotalPower(
|
||||
circuits: Array<{ circuitTotalPower: number }>
|
||||
): number {
|
||||
return circuits.reduce(
|
||||
(sum, circuit) => sum + circuit.circuitTotalPower,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateDistributionBoardTotalPower(
|
||||
sections: Array<{ sectionTotalPower: number }>
|
||||
): number {
|
||||
return sections.reduce(
|
||||
(sum, section) => sum + section.sectionTotalPower,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
export function applyDistributionBoardSimultaneityFactor(
|
||||
totalPower: number,
|
||||
simultaneityFactor: number
|
||||
): number {
|
||||
if (
|
||||
!Number.isFinite(simultaneityFactor) ||
|
||||
simultaneityFactor < 0 ||
|
||||
simultaneityFactor > 1
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board simultaneity factor must be between zero and one."
|
||||
);
|
||||
}
|
||||
return totalPower * simultaneityFactor;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export class ProjectSnapshotNameConflictError extends Error {
|
||||
constructor(
|
||||
readonly projectId: string,
|
||||
readonly snapshotName: string
|
||||
) {
|
||||
super(
|
||||
`Project ${projectId} already has a snapshot named "${snapshotName}".`
|
||||
);
|
||||
this.name = "ProjectSnapshotNameConflictError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export class ProjectStateConflictError extends Error {
|
||||
constructor(
|
||||
public readonly projectId: string,
|
||||
public readonly expectedSha256: string,
|
||||
public readonly actualSha256: string
|
||||
) {
|
||||
super(`Project ${projectId} changed outside the prepared restore state.`);
|
||||
this.name = "ProjectStateConflictError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
assertCircuitSectionReorderProjectCommand,
|
||||
circuitSectionReorderCommandSchemaVersion,
|
||||
type CircuitSectionReorderAssignment,
|
||||
} from "./circuit-section-reorder-project-command.model.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitSectionsReorderCommandType =
|
||||
"circuit.reorder-sections" as const;
|
||||
export const circuitSectionsReorderCommandSchemaVersion =
|
||||
circuitSectionReorderCommandSchemaVersion;
|
||||
|
||||
export interface CircuitSectionsReorderSection {
|
||||
sectionId: string;
|
||||
assignments: CircuitSectionReorderAssignment[];
|
||||
}
|
||||
|
||||
export interface CircuitSectionsReorderCommandPayload {
|
||||
sections: CircuitSectionsReorderSection[];
|
||||
}
|
||||
|
||||
export interface CircuitSectionsReorderProjectCommand
|
||||
extends SerializedProjectCommand<CircuitSectionsReorderCommandPayload> {
|
||||
schemaVersion: typeof circuitSectionsReorderCommandSchemaVersion;
|
||||
type: typeof circuitSectionsReorderCommandType;
|
||||
}
|
||||
|
||||
export function createCircuitSectionsReorderProjectCommand(
|
||||
sections: CircuitSectionsReorderSection[]
|
||||
): CircuitSectionsReorderProjectCommand {
|
||||
const command: CircuitSectionsReorderProjectCommand = {
|
||||
schemaVersion: circuitSectionsReorderCommandSchemaVersion,
|
||||
type: circuitSectionsReorderCommandType,
|
||||
payload: { sections },
|
||||
};
|
||||
assertCircuitSectionsReorderProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitSectionsReorderProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitSectionsReorderProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !==
|
||||
circuitSectionsReorderCommandSchemaVersion ||
|
||||
command.type !== circuitSectionsReorderCommandType
|
||||
) {
|
||||
throw new Error("Unsupported circuit sections reorder command.");
|
||||
}
|
||||
if (!isPlainObject(command.payload)) {
|
||||
throw new Error(
|
||||
"Circuit sections reorder payload must be an object."
|
||||
);
|
||||
}
|
||||
const sections = command.payload.sections;
|
||||
if (!Array.isArray(sections) || sections.length === 0) {
|
||||
throw new Error(
|
||||
"Circuit sections reorder command requires sections."
|
||||
);
|
||||
}
|
||||
|
||||
const sectionIds = new Set<string>();
|
||||
for (const section of sections) {
|
||||
if (!isPlainObject(section)) {
|
||||
throw new Error(
|
||||
"Circuit sections reorder command contains an invalid section."
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof section.sectionId !== "string" ||
|
||||
!section.sectionId.trim()
|
||||
) {
|
||||
throw new Error("sectionId must be a non-empty string.");
|
||||
}
|
||||
if (sectionIds.has(section.sectionId)) {
|
||||
throw new Error(
|
||||
"Circuit sections reorder command contains duplicate section ids."
|
||||
);
|
||||
}
|
||||
assertCircuitSectionReorderProjectCommand({
|
||||
schemaVersion: circuitSectionReorderCommandSchemaVersion,
|
||||
type: "circuit.reorder-section",
|
||||
payload: {
|
||||
sectionId: section.sectionId,
|
||||
assignments: section.assignments,
|
||||
},
|
||||
});
|
||||
sectionIds.add(section.sectionId);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -52,12 +52,18 @@ export interface CircuitTreeSectionBlock {
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
sectionTotalPower: number;
|
||||
circuits: CircuitTreeCircuit[];
|
||||
}
|
||||
|
||||
export interface CircuitTreeResponse {
|
||||
circuitListId: string;
|
||||
currentRevision: number;
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
distributionBoardSimultaneityFactor: number;
|
||||
distributionBoardTotalPower: number;
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||
sections: CircuitTreeSectionBlock[];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const distributionBoardUpdateCommandType =
|
||||
"distribution-board.update" as const;
|
||||
export const legacyDistributionBoardUpdateCommandSchemaVersion = 1 as const;
|
||||
export const distributionBoardUpdateCommandSchemaVersion = 2 as const;
|
||||
|
||||
export interface DistributionBoardUpdateValues {
|
||||
floorId: string | null;
|
||||
supplyType: DistributionBoardSupplyType | null;
|
||||
simultaneityFactor: number;
|
||||
}
|
||||
|
||||
export type DistributionBoardUpdateField =
|
||||
keyof DistributionBoardUpdateValues;
|
||||
export type DistributionBoardUpdatePatch =
|
||||
Partial<DistributionBoardUpdateValues>;
|
||||
|
||||
export interface DistributionBoardUpdateFieldChange<
|
||||
TField extends DistributionBoardUpdateField =
|
||||
DistributionBoardUpdateField,
|
||||
> {
|
||||
field: TField;
|
||||
value: DistributionBoardUpdateValues[TField];
|
||||
}
|
||||
|
||||
export interface DistributionBoardUpdateCommandPayload {
|
||||
distributionBoardId: string;
|
||||
changes: DistributionBoardUpdateFieldChange[];
|
||||
}
|
||||
|
||||
interface CurrentDistributionBoardUpdateProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
|
||||
schemaVersion: typeof distributionBoardUpdateCommandSchemaVersion;
|
||||
type: typeof distributionBoardUpdateCommandType;
|
||||
}
|
||||
|
||||
interface LegacyDistributionBoardUpdateProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
|
||||
schemaVersion: typeof legacyDistributionBoardUpdateCommandSchemaVersion;
|
||||
type: typeof distributionBoardUpdateCommandType;
|
||||
}
|
||||
|
||||
export type DistributionBoardUpdateProjectCommand =
|
||||
| CurrentDistributionBoardUpdateProjectCommand
|
||||
| LegacyDistributionBoardUpdateProjectCommand;
|
||||
|
||||
export function createDistributionBoardUpdateProjectCommand(
|
||||
distributionBoardId: string,
|
||||
patch: DistributionBoardUpdatePatch
|
||||
): CurrentDistributionBoardUpdateProjectCommand {
|
||||
const command: CurrentDistributionBoardUpdateProjectCommand = {
|
||||
schemaVersion: distributionBoardUpdateCommandSchemaVersion,
|
||||
type: distributionBoardUpdateCommandType,
|
||||
payload: {
|
||||
distributionBoardId,
|
||||
changes: Object.entries(patch).map(([field, value]) => ({
|
||||
field: field as DistributionBoardUpdateField,
|
||||
value,
|
||||
})) as DistributionBoardUpdateFieldChange[],
|
||||
},
|
||||
};
|
||||
assertDistributionBoardUpdateProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertDistributionBoardUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardUpdateProjectCommand {
|
||||
if (
|
||||
(command.schemaVersion !==
|
||||
legacyDistributionBoardUpdateCommandSchemaVersion &&
|
||||
command.schemaVersion !== distributionBoardUpdateCommandSchemaVersion) ||
|
||||
command.type !== distributionBoardUpdateCommandType ||
|
||||
!isPlainObject(command.payload)
|
||||
) {
|
||||
throw new Error("Unsupported distribution-board update command.");
|
||||
}
|
||||
const { distributionBoardId, changes } = command.payload;
|
||||
if (
|
||||
typeof distributionBoardId !== "string" ||
|
||||
!distributionBoardId.trim() ||
|
||||
!Array.isArray(changes) ||
|
||||
changes.length === 0
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board update command requires an id and changes."
|
||||
);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const change of changes) {
|
||||
if (
|
||||
!isPlainObject(change) ||
|
||||
(change.field !== "floorId" &&
|
||||
change.field !== "supplyType" &&
|
||||
(command.schemaVersion ===
|
||||
legacyDistributionBoardUpdateCommandSchemaVersion ||
|
||||
change.field !== "simultaneityFactor")) ||
|
||||
seen.has(change.field)
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board update contains an invalid or duplicate field."
|
||||
);
|
||||
}
|
||||
if (change.field === "floorId") {
|
||||
if (
|
||||
change.value !== null &&
|
||||
(typeof change.value !== "string" || !change.value.trim())
|
||||
) {
|
||||
throw new Error("floorId must be a non-empty string or null.");
|
||||
}
|
||||
} else if (change.field === "supplyType") {
|
||||
if (
|
||||
change.value !== null &&
|
||||
!distributionBoardSupplyTypes.includes(
|
||||
change.value as DistributionBoardSupplyType
|
||||
)
|
||||
) {
|
||||
throw new Error("supplyType is invalid.");
|
||||
}
|
||||
} else if (
|
||||
typeof change.value !== "number" ||
|
||||
!Number.isFinite(change.value) ||
|
||||
change.value < 0 ||
|
||||
change.value > 1
|
||||
) {
|
||||
throw new Error(
|
||||
"simultaneityFactor must be between zero and one."
|
||||
);
|
||||
}
|
||||
seen.add(change.field);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const distributionBoardInsertCommandType =
|
||||
"distribution-board.insert" as const;
|
||||
export const distributionBoardDeleteCommandType =
|
||||
"distribution-board.delete" as const;
|
||||
export const legacyDistributionBoardStructureCommandSchemaVersion = 1 as const;
|
||||
export const distributionBoardStructureCommandSchemaVersion = 2 as const;
|
||||
|
||||
export const defaultCircuitSectionDefinitions = [
|
||||
{
|
||||
key: "lighting",
|
||||
displayName: "Lighting",
|
||||
prefix: "-1F",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
key: "single_phase",
|
||||
displayName: "Single-phase circuits",
|
||||
prefix: "-2F",
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
key: "three_phase",
|
||||
displayName: "Three-phase circuits",
|
||||
prefix: "-3F",
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
key: "unassigned",
|
||||
displayName: "Unassigned",
|
||||
prefix: "-UF",
|
||||
sortOrder: 90,
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface LegacyDistributionBoardStructureSnapshot {
|
||||
distributionBoard: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
};
|
||||
circuitList: DistributionBoardStructureSnapshot["circuitList"];
|
||||
sections: DistributionBoardStructureSnapshot["sections"];
|
||||
}
|
||||
|
||||
export interface DistributionBoardStructureSnapshot {
|
||||
distributionBoard: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
floorId: string | null;
|
||||
supplyType: DistributionBoardSupplyType | null;
|
||||
};
|
||||
circuitList: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
distributionBoardId: string;
|
||||
name: string;
|
||||
};
|
||||
sections: Array<{
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
key: string;
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DistributionBoardStructureCommandPayload<
|
||||
TStructure = DistributionBoardStructureSnapshot,
|
||||
> {
|
||||
structure: TStructure;
|
||||
}
|
||||
|
||||
interface CurrentDistributionBoardStructureProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
|
||||
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
|
||||
type:
|
||||
| typeof distributionBoardInsertCommandType
|
||||
| typeof distributionBoardDeleteCommandType;
|
||||
}
|
||||
|
||||
interface LegacyDistributionBoardStructureProjectCommand
|
||||
extends SerializedProjectCommand<
|
||||
DistributionBoardStructureCommandPayload<LegacyDistributionBoardStructureSnapshot>
|
||||
> {
|
||||
schemaVersion: typeof legacyDistributionBoardStructureCommandSchemaVersion;
|
||||
type:
|
||||
| typeof distributionBoardInsertCommandType
|
||||
| typeof distributionBoardDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface DistributionBoardInsertProjectCommand
|
||||
extends CurrentDistributionBoardStructureProjectCommand {
|
||||
type: typeof distributionBoardInsertCommandType;
|
||||
}
|
||||
|
||||
export interface DistributionBoardDeleteProjectCommand
|
||||
extends CurrentDistributionBoardStructureProjectCommand {
|
||||
type: typeof distributionBoardDeleteCommandType;
|
||||
}
|
||||
|
||||
export type DistributionBoardStructureProjectCommand =
|
||||
| DistributionBoardInsertProjectCommand
|
||||
| DistributionBoardDeleteProjectCommand
|
||||
| LegacyDistributionBoardStructureProjectCommand;
|
||||
|
||||
export function createDistributionBoardStructureSnapshot(
|
||||
projectId: string,
|
||||
name: string,
|
||||
input: {
|
||||
floorId?: string | null;
|
||||
supplyType?: DistributionBoardSupplyType | null;
|
||||
} = {}
|
||||
): DistributionBoardStructureSnapshot {
|
||||
const normalizedName = name.trim();
|
||||
assertNonEmptyString(projectId, "projectId");
|
||||
assertNonEmptyString(normalizedName, "name");
|
||||
const structureId = crypto.randomUUID();
|
||||
const structure: DistributionBoardStructureSnapshot = {
|
||||
distributionBoard: {
|
||||
id: structureId,
|
||||
projectId,
|
||||
name: normalizedName,
|
||||
floorId: input.floorId ?? null,
|
||||
supplyType: input.supplyType ?? null,
|
||||
},
|
||||
circuitList: {
|
||||
id: structureId,
|
||||
projectId,
|
||||
distributionBoardId: structureId,
|
||||
name: `${normalizedName} Stromkreisliste`,
|
||||
},
|
||||
sections: defaultCircuitSectionDefinitions.map((section) => ({
|
||||
id: crypto.randomUUID(),
|
||||
circuitListId: structureId,
|
||||
...section,
|
||||
})),
|
||||
};
|
||||
assertDistributionBoardStructureSnapshot(structure);
|
||||
return structure;
|
||||
}
|
||||
|
||||
export function createDistributionBoardInsertProjectCommand(
|
||||
structure: DistributionBoardStructureSnapshot
|
||||
): DistributionBoardInsertProjectCommand {
|
||||
const command: DistributionBoardInsertProjectCommand = {
|
||||
schemaVersion: distributionBoardStructureCommandSchemaVersion,
|
||||
type: distributionBoardInsertCommandType,
|
||||
payload: { structure },
|
||||
};
|
||||
assertDistributionBoardInsertProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createDistributionBoardDeleteProjectCommand(
|
||||
structure: DistributionBoardStructureSnapshot
|
||||
): DistributionBoardDeleteProjectCommand {
|
||||
const command: DistributionBoardDeleteProjectCommand = {
|
||||
schemaVersion: distributionBoardStructureCommandSchemaVersion,
|
||||
type: distributionBoardDeleteCommandType,
|
||||
payload: { structure },
|
||||
};
|
||||
assertDistributionBoardDeleteProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertDistributionBoardInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardStructureProjectCommand {
|
||||
assertDistributionBoardStructureProjectCommand(
|
||||
command,
|
||||
distributionBoardInsertCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertDistributionBoardDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardStructureProjectCommand {
|
||||
assertDistributionBoardStructureProjectCommand(
|
||||
command,
|
||||
distributionBoardDeleteCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeDistributionBoardStructureProjectCommand(
|
||||
command: DistributionBoardStructureProjectCommand
|
||||
): DistributionBoardInsertProjectCommand | DistributionBoardDeleteProjectCommand {
|
||||
if (
|
||||
command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
const structure: DistributionBoardStructureSnapshot = {
|
||||
...command.payload.structure,
|
||||
distributionBoard: {
|
||||
...command.payload.structure.distributionBoard,
|
||||
floorId: null,
|
||||
supplyType: null,
|
||||
},
|
||||
};
|
||||
return command.type === distributionBoardInsertCommandType
|
||||
? createDistributionBoardInsertProjectCommand(structure)
|
||||
: createDistributionBoardDeleteProjectCommand(structure);
|
||||
}
|
||||
return command as
|
||||
| DistributionBoardInsertProjectCommand
|
||||
| DistributionBoardDeleteProjectCommand;
|
||||
}
|
||||
|
||||
export function assertDistributionBoardStructureSnapshot(
|
||||
structure: unknown
|
||||
): asserts structure is DistributionBoardStructureSnapshot {
|
||||
assertDistributionBoardStructureSnapshotVersion(structure, false);
|
||||
}
|
||||
|
||||
function assertDistributionBoardStructureProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
expectedType:
|
||||
| typeof distributionBoardInsertCommandType
|
||||
| typeof distributionBoardDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.type !== expectedType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported distribution-board structure command.");
|
||||
}
|
||||
if (
|
||||
command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
assertDistributionBoardStructureSnapshotVersion(
|
||||
command.payload.structure,
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
command.schemaVersion !== distributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
throw new Error("Unsupported distribution-board structure command.");
|
||||
}
|
||||
assertDistributionBoardStructureSnapshotVersion(
|
||||
command.payload.structure,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
function assertDistributionBoardStructureSnapshotVersion(
|
||||
structure: unknown,
|
||||
legacy: boolean
|
||||
) {
|
||||
if (!isPlainObject(structure) || Object.keys(structure).length !== 3) {
|
||||
throw new Error("Distribution-board structure is invalid.");
|
||||
}
|
||||
const { distributionBoard, circuitList, sections } = structure;
|
||||
if (
|
||||
!isPlainObject(distributionBoard) ||
|
||||
Object.keys(distributionBoard).length !== (legacy ? 3 : 5) ||
|
||||
!isPlainObject(circuitList) ||
|
||||
Object.keys(circuitList).length !== 4 ||
|
||||
!Array.isArray(sections) ||
|
||||
sections.length !== defaultCircuitSectionDefinitions.length
|
||||
) {
|
||||
throw new Error("Distribution-board structure is incomplete.");
|
||||
}
|
||||
for (const field of ["id", "projectId", "name"] as const) {
|
||||
assertNonEmptyString(
|
||||
distributionBoard[field],
|
||||
`distributionBoard.${field}`
|
||||
);
|
||||
}
|
||||
if (!legacy) {
|
||||
assertNullableId(distributionBoard.floorId, "distributionBoard.floorId");
|
||||
assertNullableSupplyType(distributionBoard.supplyType);
|
||||
}
|
||||
for (const [field, value] of Object.entries(circuitList)) {
|
||||
assertNonEmptyString(value, `circuitList.${field}`);
|
||||
}
|
||||
if (
|
||||
circuitList.projectId !== distributionBoard.projectId ||
|
||||
circuitList.distributionBoardId !== distributionBoard.id ||
|
||||
circuitList.name !== `${distributionBoard.name} Stromkreisliste`
|
||||
) {
|
||||
throw new Error("Distribution board and circuit list are inconsistent.");
|
||||
}
|
||||
|
||||
const sectionIds = new Set<string>();
|
||||
for (let index = 0; index < sections.length; index += 1) {
|
||||
const section = sections[index];
|
||||
const expected = defaultCircuitSectionDefinitions[index];
|
||||
if (!isPlainObject(section) || Object.keys(section).length !== 6) {
|
||||
throw new Error("Default circuit section is invalid.");
|
||||
}
|
||||
assertNonEmptyString(section.id, "section.id");
|
||||
if (sectionIds.has(section.id)) {
|
||||
throw new Error("Default circuit-section ids must be unique.");
|
||||
}
|
||||
sectionIds.add(section.id);
|
||||
if (
|
||||
section.circuitListId !== circuitList.id ||
|
||||
section.key !== expected.key ||
|
||||
section.displayName !== expected.displayName ||
|
||||
section.prefix !== expected.prefix ||
|
||||
section.sortOrder !== expected.sortOrder
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board structure has invalid default sections."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertNullableId(value: unknown, field: string) {
|
||||
if (value !== null) {
|
||||
assertNonEmptyString(value, field);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNullableSupplyType(
|
||||
value: unknown
|
||||
): asserts value is DistributionBoardSupplyType | null {
|
||||
if (
|
||||
value !== null &&
|
||||
!distributionBoardSupplyTypes.includes(
|
||||
value as DistributionBoardSupplyType
|
||||
)
|
||||
) {
|
||||
throw new Error("distributionBoard.supplyType is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
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,259 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const projectFloorInsertCommandType = "project-floor.insert" as const;
|
||||
export const projectFloorDeleteCommandType = "project-floor.delete" as const;
|
||||
export const projectRoomInsertCommandType = "project-room.insert" as const;
|
||||
export const projectRoomDeleteCommandType = "project-room.delete" as const;
|
||||
export const projectLocationStructureCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface ProjectFloorSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ProjectRoomSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
floorId: string | null;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
interface ProjectFloorStructureCommandPayload {
|
||||
floor: ProjectFloorSnapshot;
|
||||
}
|
||||
|
||||
interface ProjectRoomStructureCommandPayload {
|
||||
room: ProjectRoomSnapshot;
|
||||
}
|
||||
|
||||
export interface ProjectFloorInsertProjectCommand
|
||||
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectFloorInsertCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectFloorDeleteProjectCommand
|
||||
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectFloorDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectRoomInsertProjectCommand
|
||||
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectRoomInsertCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectRoomDeleteProjectCommand
|
||||
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectRoomDeleteCommandType;
|
||||
}
|
||||
|
||||
export type ProjectLocationStructureProjectCommand =
|
||||
| ProjectFloorInsertProjectCommand
|
||||
| ProjectFloorDeleteProjectCommand
|
||||
| ProjectRoomInsertProjectCommand
|
||||
| ProjectRoomDeleteProjectCommand;
|
||||
|
||||
export function createProjectFloorSnapshot(
|
||||
projectId: string,
|
||||
name: string,
|
||||
sortOrder: number
|
||||
): ProjectFloorSnapshot {
|
||||
const floor: ProjectFloorSnapshot = {
|
||||
id: crypto.randomUUID(),
|
||||
projectId,
|
||||
name: name.trim(),
|
||||
sortOrder,
|
||||
};
|
||||
assertProjectFloorSnapshot(floor);
|
||||
return floor;
|
||||
}
|
||||
|
||||
export function createProjectRoomSnapshot(
|
||||
projectId: string,
|
||||
input: {
|
||||
floorId?: string;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
}
|
||||
): ProjectRoomSnapshot {
|
||||
const room: ProjectRoomSnapshot = {
|
||||
id: crypto.randomUUID(),
|
||||
projectId,
|
||||
floorId: input.floorId?.trim() || null,
|
||||
roomNumber: input.roomNumber.trim(),
|
||||
roomName: input.roomName.trim(),
|
||||
};
|
||||
assertProjectRoomSnapshot(room);
|
||||
return room;
|
||||
}
|
||||
|
||||
export function createProjectFloorInsertProjectCommand(
|
||||
floor: ProjectFloorSnapshot
|
||||
): ProjectFloorInsertProjectCommand {
|
||||
assertProjectFloorSnapshot(floor);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
type: projectFloorInsertCommandType,
|
||||
payload: { floor },
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectFloorDeleteProjectCommand(
|
||||
floor: ProjectFloorSnapshot
|
||||
): ProjectFloorDeleteProjectCommand {
|
||||
assertProjectFloorSnapshot(floor);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
type: projectFloorDeleteCommandType,
|
||||
payload: { floor },
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectRoomInsertProjectCommand(
|
||||
room: ProjectRoomSnapshot
|
||||
): ProjectRoomInsertProjectCommand {
|
||||
assertProjectRoomSnapshot(room);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
type: projectRoomInsertCommandType,
|
||||
payload: { room },
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectRoomDeleteProjectCommand(
|
||||
room: ProjectRoomSnapshot
|
||||
): ProjectRoomDeleteProjectCommand {
|
||||
assertProjectRoomSnapshot(room);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
type: projectRoomDeleteCommandType,
|
||||
payload: { room },
|
||||
};
|
||||
}
|
||||
|
||||
export function assertProjectFloorInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectFloorInsertProjectCommand {
|
||||
assertProjectFloorStructureProjectCommand(
|
||||
command,
|
||||
projectFloorInsertCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertProjectFloorDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectFloorDeleteProjectCommand {
|
||||
assertProjectFloorStructureProjectCommand(
|
||||
command,
|
||||
projectFloorDeleteCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertProjectRoomInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectRoomInsertProjectCommand {
|
||||
assertProjectRoomStructureProjectCommand(
|
||||
command,
|
||||
projectRoomInsertCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertProjectRoomDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectRoomDeleteProjectCommand {
|
||||
assertProjectRoomStructureProjectCommand(
|
||||
command,
|
||||
projectRoomDeleteCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertProjectFloorSnapshot(
|
||||
floor: unknown
|
||||
): asserts floor is ProjectFloorSnapshot {
|
||||
if (!isPlainObject(floor) || Object.keys(floor).length !== 4) {
|
||||
throw new Error("Project-floor snapshot is invalid.");
|
||||
}
|
||||
assertNormalizedNonEmptyString(floor.id, "floor.id");
|
||||
assertNormalizedNonEmptyString(floor.projectId, "floor.projectId");
|
||||
assertNormalizedNonEmptyString(floor.name, "floor.name");
|
||||
if (
|
||||
typeof floor.sortOrder !== "number" ||
|
||||
!Number.isSafeInteger(floor.sortOrder) ||
|
||||
floor.sortOrder < 0
|
||||
) {
|
||||
throw new Error("floor.sortOrder must be a non-negative integer.");
|
||||
}
|
||||
}
|
||||
|
||||
export function assertProjectRoomSnapshot(
|
||||
room: unknown
|
||||
): asserts room is ProjectRoomSnapshot {
|
||||
if (!isPlainObject(room) || Object.keys(room).length !== 5) {
|
||||
throw new Error("Project-room snapshot is invalid.");
|
||||
}
|
||||
assertNormalizedNonEmptyString(room.id, "room.id");
|
||||
assertNormalizedNonEmptyString(room.projectId, "room.projectId");
|
||||
if (room.floorId !== null) {
|
||||
assertNormalizedNonEmptyString(room.floorId, "room.floorId");
|
||||
}
|
||||
assertNormalizedNonEmptyString(room.roomNumber, "room.roomNumber");
|
||||
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
|
||||
}
|
||||
|
||||
function assertProjectFloorStructureProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
expectedType:
|
||||
| typeof projectFloorInsertCommandType
|
||||
| typeof projectFloorDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||
command.type !== expectedType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported project-floor structure command.");
|
||||
}
|
||||
assertProjectFloorSnapshot(command.payload.floor);
|
||||
}
|
||||
|
||||
function assertProjectRoomStructureProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
expectedType:
|
||||
| typeof projectRoomInsertCommandType
|
||||
| typeof projectRoomDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||
command.type !== expectedType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported project-room structure command.");
|
||||
}
|
||||
assertProjectRoomSnapshot(command.payload.room);
|
||||
}
|
||||
|
||||
function assertNormalizedNonEmptyString(
|
||||
value: unknown,
|
||||
field: string
|
||||
): asserts value is string {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!value ||
|
||||
value !== value.trim()
|
||||
) {
|
||||
throw new Error(`${field} must be a normalized non-empty string.`);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
import {
|
||||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
|
||||
export const projectSettingsUpdateCommandType =
|
||||
"project.update-settings" as const;
|
||||
export const legacyProjectSettingsUpdateCommandSchemaVersion = 1 as const;
|
||||
export const previousProjectSettingsUpdateCommandSchemaVersion = 2 as const;
|
||||
export const projectSettingsUpdateCommandSchemaVersion = 3 as const;
|
||||
|
||||
export interface LegacyProjectSettingsValues {
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
}
|
||||
|
||||
export interface PreviousProjectSettingsValues extends LegacyProjectSettingsValues {
|
||||
name: string;
|
||||
internalProjectNumber: string | null;
|
||||
externalProjectNumber: string | null;
|
||||
buildingOwner: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectSettingsValues extends PreviousProjectSettingsValues {
|
||||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||
}
|
||||
|
||||
export interface LegacyProjectSettingsUpdateProjectCommand
|
||||
extends SerializedProjectCommand<LegacyProjectSettingsValues> {
|
||||
schemaVersion: typeof legacyProjectSettingsUpdateCommandSchemaVersion;
|
||||
type: typeof projectSettingsUpdateCommandType;
|
||||
}
|
||||
|
||||
export interface CurrentProjectSettingsUpdateProjectCommand
|
||||
extends SerializedProjectCommand<ProjectSettingsValues> {
|
||||
schemaVersion: typeof projectSettingsUpdateCommandSchemaVersion;
|
||||
type: typeof projectSettingsUpdateCommandType;
|
||||
}
|
||||
|
||||
export interface PreviousProjectSettingsUpdateProjectCommand
|
||||
extends SerializedProjectCommand<PreviousProjectSettingsValues> {
|
||||
schemaVersion: typeof previousProjectSettingsUpdateCommandSchemaVersion;
|
||||
type: typeof projectSettingsUpdateCommandType;
|
||||
}
|
||||
|
||||
export type ProjectSettingsUpdateProjectCommand =
|
||||
| LegacyProjectSettingsUpdateProjectCommand
|
||||
| PreviousProjectSettingsUpdateProjectCommand
|
||||
| CurrentProjectSettingsUpdateProjectCommand;
|
||||
|
||||
export function createProjectSettingsUpdateProjectCommand(
|
||||
values: ProjectSettingsValues
|
||||
): CurrentProjectSettingsUpdateProjectCommand {
|
||||
const command: CurrentProjectSettingsUpdateProjectCommand = {
|
||||
schemaVersion: projectSettingsUpdateCommandSchemaVersion,
|
||||
type: projectSettingsUpdateCommandType,
|
||||
payload: { ...values },
|
||||
};
|
||||
assertProjectSettingsUpdateProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertProjectSettingsUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectSettingsUpdateProjectCommand {
|
||||
if (
|
||||
command.type !== projectSettingsUpdateCommandType ||
|
||||
(command.schemaVersion !==
|
||||
legacyProjectSettingsUpdateCommandSchemaVersion &&
|
||||
command.schemaVersion !==
|
||||
previousProjectSettingsUpdateCommandSchemaVersion &&
|
||||
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion)
|
||||
) {
|
||||
throw new Error("Unsupported project settings update command.");
|
||||
}
|
||||
if (!isPlainObject(command.payload)) {
|
||||
throw new Error(
|
||||
"Project settings update command contains invalid values."
|
||||
);
|
||||
}
|
||||
if (command.schemaVersion === legacyProjectSettingsUpdateCommandSchemaVersion) {
|
||||
assertLegacyValues(command.payload);
|
||||
return;
|
||||
}
|
||||
const expectedKeyCount =
|
||||
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion
|
||||
? 7
|
||||
: 8;
|
||||
if (
|
||||
!isTrimmedString(command.payload.name, 1, 200) ||
|
||||
!isNullableTrimmedString(command.payload.internalProjectNumber, 100) ||
|
||||
!isNullableTrimmedString(command.payload.externalProjectNumber, 100) ||
|
||||
!isNullableTrimmedString(command.payload.buildingOwner, 200) ||
|
||||
!isNullableTrimmedString(command.payload.description, 2000) ||
|
||||
!isPositiveFiniteNumber(command.payload.singlePhaseVoltageV) ||
|
||||
!isPositiveFiniteNumber(command.payload.threePhaseVoltageV) ||
|
||||
Object.keys(command.payload).length !== expectedKeyCount
|
||||
) {
|
||||
throw new Error(
|
||||
"Project settings update command contains invalid values."
|
||||
);
|
||||
}
|
||||
if (
|
||||
command.schemaVersion === projectSettingsUpdateCommandSchemaVersion &&
|
||||
!isValidSupplyTypes(
|
||||
command.payload.enabledDistributionBoardSupplyTypes
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Project settings update command contains invalid supply types."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProjectSettingsValues(
|
||||
command: ProjectSettingsUpdateProjectCommand,
|
||||
current: ProjectSettingsValues
|
||||
): ProjectSettingsValues {
|
||||
if (command.schemaVersion === legacyProjectSettingsUpdateCommandSchemaVersion) {
|
||||
return {
|
||||
...current,
|
||||
...command.payload,
|
||||
};
|
||||
}
|
||||
if (
|
||||
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion
|
||||
) {
|
||||
return {
|
||||
...command.payload,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
current.enabledDistributionBoardSupplyTypes,
|
||||
};
|
||||
}
|
||||
return command.payload;
|
||||
}
|
||||
|
||||
function isValidSupplyTypes(
|
||||
value: unknown
|
||||
): value is DistributionBoardSupplyType[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
new Set(value).size === value.length &&
|
||||
value.every(
|
||||
(entry) =>
|
||||
typeof entry === "string" &&
|
||||
distributionBoardSupplyTypes.includes(
|
||||
entry as DistributionBoardSupplyType
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function assertLegacyValues(
|
||||
payload: Record<string, unknown>
|
||||
): asserts payload is Record<string, unknown> & LegacyProjectSettingsValues {
|
||||
if (
|
||||
!isPositiveFiniteNumber(payload.singlePhaseVoltageV) ||
|
||||
!isPositiveFiniteNumber(payload.threePhaseVoltageV) ||
|
||||
Object.keys(payload).length !== 2
|
||||
) {
|
||||
throw new Error(
|
||||
"Project settings update command contains invalid voltages."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isPositiveFiniteNumber(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === "number" &&
|
||||
Number.isFinite(value) &&
|
||||
value > 0
|
||||
);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isTrimmedString(
|
||||
value: unknown,
|
||||
minimumLength: number,
|
||||
maximumLength: number
|
||||
): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value === value.trim() &&
|
||||
value.length >= minimumLength &&
|
||||
value.length <= maximumLength
|
||||
);
|
||||
}
|
||||
|
||||
function isNullableTrimmedString(
|
||||
value: unknown,
|
||||
maximumLength: number
|
||||
): value is string | null {
|
||||
return (
|
||||
value === null ||
|
||||
(isTrimmedString(value, 1, maximumLength))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export const projectSnapshotKinds = ["named", "automatic"] as const;
|
||||
export type ProjectSnapshotKind =
|
||||
(typeof projectSnapshotKinds)[number];
|
||||
|
||||
export const automaticProjectSnapshotIntervalRevisions = 25;
|
||||
export const automaticProjectSnapshotRetentionCount = 12;
|
||||
|
||||
export function shouldCaptureAutomaticProjectSnapshot(
|
||||
currentRevision: number,
|
||||
latestAutomaticRevision: number | null
|
||||
) {
|
||||
assertRevision(currentRevision, "Current");
|
||||
if (latestAutomaticRevision !== null) {
|
||||
assertRevision(latestAutomaticRevision, "Latest automatic");
|
||||
if (latestAutomaticRevision > currentRevision) {
|
||||
throw new Error(
|
||||
"Latest automatic snapshot revision exceeds current revision."
|
||||
);
|
||||
}
|
||||
}
|
||||
if (currentRevision < automaticProjectSnapshotIntervalRevisions) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
latestAutomaticRevision === null ||
|
||||
currentRevision - latestAutomaticRevision >=
|
||||
automaticProjectSnapshotIntervalRevisions
|
||||
);
|
||||
}
|
||||
|
||||
function assertRevision(value: number, label: string) {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error(
|
||||
`${label} snapshot revision must be a non-negative integer.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
parseProjectStateSnapshot,
|
||||
type ProjectStateSnapshot,
|
||||
} from "./project-state-snapshot.model.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const projectStateRestoreCommandType = "project.restore-state";
|
||||
export const projectStateRestoreCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface ProjectStateRestoreCommandPayload {
|
||||
expectedStateSha256: string;
|
||||
targetState: ProjectStateSnapshot;
|
||||
}
|
||||
|
||||
export type ProjectStateRestoreCommand = SerializedProjectCommand<
|
||||
ProjectStateRestoreCommandPayload
|
||||
> & {
|
||||
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
|
||||
type: typeof projectStateRestoreCommandType;
|
||||
};
|
||||
|
||||
export function createProjectStateRestoreCommand(
|
||||
expectedStateSha256: string,
|
||||
targetState: ProjectStateSnapshot
|
||||
): ProjectStateRestoreCommand {
|
||||
const command: ProjectStateRestoreCommand = {
|
||||
schemaVersion: projectStateRestoreCommandSchemaVersion,
|
||||
type: projectStateRestoreCommandType,
|
||||
payload: {
|
||||
expectedStateSha256,
|
||||
targetState: parseProjectStateSnapshot(targetState),
|
||||
},
|
||||
};
|
||||
assertProjectStateRestoreCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertProjectStateRestoreCommand(
|
||||
value: unknown
|
||||
): asserts value is ProjectStateRestoreCommand {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("Project state restore command must be an object.");
|
||||
}
|
||||
const command = value as Partial<ProjectStateRestoreCommand>;
|
||||
if (
|
||||
command.type !== projectStateRestoreCommandType ||
|
||||
command.schemaVersion !== projectStateRestoreCommandSchemaVersion
|
||||
) {
|
||||
throw new Error("Unsupported project state restore command.");
|
||||
}
|
||||
if (!command.payload || typeof command.payload !== "object") {
|
||||
throw new Error("Project state restore payload is required.");
|
||||
}
|
||||
const payload = command.payload as Partial<ProjectStateRestoreCommandPayload>;
|
||||
if (
|
||||
typeof payload.expectedStateSha256 !== "string" ||
|
||||
!/^[a-f0-9]{64}$/.test(payload.expectedStateSha256)
|
||||
) {
|
||||
throw new Error("Expected project state checksum is invalid.");
|
||||
}
|
||||
parseProjectStateSnapshot(payload.targetState);
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defaultDistributionBoardSupplyTypes,
|
||||
distributionBoardSupplyTypes,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import {
|
||||
resolveCircuitPhaseType,
|
||||
resolveProjectVoltage,
|
||||
normalizeKnownElectricalPhaseType,
|
||||
} from "../services/project-voltage.service.js";
|
||||
|
||||
export const legacyProjectStateSnapshotSchemaVersion = 1 as const;
|
||||
export const previousProjectStateSnapshotSchemaVersion = 2 as const;
|
||||
export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const;
|
||||
export const supplyTypesProjectStateSnapshotSchemaVersion = 4 as const;
|
||||
export const voltageProjectStateSnapshotSchemaVersion = 5 as const;
|
||||
export const projectStateSnapshotSchemaVersion = 6 as const;
|
||||
|
||||
const idSchema = z.string().trim().min(1);
|
||||
const nullableStringSchema = z.string().nullable();
|
||||
const finiteNumberSchema = z.number().finite();
|
||||
|
||||
const legacyProjectSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
name: z.string().trim().min(1),
|
||||
singlePhaseVoltageV: finiteNumberSchema.positive(),
|
||||
threePhaseVoltageV: finiteNumberSchema.positive(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const projectSchema = legacyProjectSchema.extend({
|
||||
internalProjectNumber: z.string().trim().min(1).max(100).nullable(),
|
||||
externalProjectNumber: z.string().trim().min(1).max(100).nullable(),
|
||||
buildingOwner: z.string().trim().min(1).max(200).nullable(),
|
||||
description: z.string().trim().min(1).max(2000).nullable(),
|
||||
});
|
||||
|
||||
const currentProjectSchema = projectSchema.extend({
|
||||
enabledDistributionBoardSupplyTypes: z
|
||||
.array(z.enum(distributionBoardSupplyTypes))
|
||||
.min(1)
|
||||
.refine((values) => new Set(values).size === values.length),
|
||||
});
|
||||
|
||||
const legacyDistributionBoardSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
name: z.string().trim().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const distributionBoardSchema = legacyDistributionBoardSchema
|
||||
.extend({
|
||||
floorId: idSchema.nullable(),
|
||||
supplyType: z.enum(distributionBoardSupplyTypes).nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const currentDistributionBoardSchema = distributionBoardSchema
|
||||
.extend({
|
||||
simultaneityFactor: finiteNumberSchema.min(0).max(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const circuitListSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
distributionBoardId: idSchema,
|
||||
name: z.string().trim().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const circuitSectionSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
circuitListId: idSchema,
|
||||
key: z.string().trim().min(1),
|
||||
displayName: z.string().trim().min(1),
|
||||
prefix: z.string().trim().min(1),
|
||||
sortOrder: finiteNumberSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const circuitDeviceRowSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
circuitId: idSchema,
|
||||
linkedProjectDeviceId: idSchema.nullable(),
|
||||
legacyConsumerId: z.string().nullable(),
|
||||
sortOrder: finiteNumberSchema,
|
||||
name: z.string().trim().min(1),
|
||||
displayName: z.string().trim().min(1),
|
||||
phaseType: nullableStringSchema,
|
||||
connectionKind: nullableStringSchema,
|
||||
costGroup: nullableStringSchema,
|
||||
category: nullableStringSchema,
|
||||
level: nullableStringSchema,
|
||||
roomId: idSchema.nullable(),
|
||||
roomNumberSnapshot: nullableStringSchema,
|
||||
roomNameSnapshot: nullableStringSchema,
|
||||
quantity: finiteNumberSchema.nonnegative(),
|
||||
powerPerUnit: finiteNumberSchema.nonnegative(),
|
||||
simultaneityFactor: finiteNumberSchema.nonnegative(),
|
||||
cosPhi: finiteNumberSchema.positive().nullable(),
|
||||
remark: nullableStringSchema,
|
||||
overriddenFields: nullableStringSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const circuitSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
circuitListId: idSchema,
|
||||
sectionId: idSchema,
|
||||
equipmentIdentifier: z.string().trim().min(1),
|
||||
displayName: nullableStringSchema,
|
||||
sortOrder: finiteNumberSchema,
|
||||
protectionType: nullableStringSchema,
|
||||
protectionRatedCurrent: finiteNumberSchema.nonnegative().nullable(),
|
||||
protectionCharacteristic: nullableStringSchema,
|
||||
cableType: nullableStringSchema,
|
||||
cableCrossSection: nullableStringSchema,
|
||||
cableLength: finiteNumberSchema.nonnegative().nullable(),
|
||||
rcdAssignment: nullableStringSchema,
|
||||
terminalDesignation: nullableStringSchema,
|
||||
voltage: finiteNumberSchema.positive().nullable(),
|
||||
controlRequirement: nullableStringSchema,
|
||||
status: nullableStringSchema,
|
||||
isReserve: z.boolean(),
|
||||
remark: nullableStringSchema,
|
||||
deviceRows: z.array(circuitDeviceRowSchema),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const projectDeviceSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
name: z.string().trim().min(1),
|
||||
displayName: z.string().trim().min(1),
|
||||
phaseType: z.enum(["single_phase", "three_phase"]),
|
||||
connectionKind: nullableStringSchema,
|
||||
costGroup: nullableStringSchema,
|
||||
category: nullableStringSchema,
|
||||
quantity: finiteNumberSchema.nonnegative(),
|
||||
powerPerUnit: finiteNumberSchema.nonnegative(),
|
||||
simultaneityFactor: finiteNumberSchema.min(0).max(1),
|
||||
cosPhi: finiteNumberSchema.min(0).max(1).nullable(),
|
||||
remark: nullableStringSchema,
|
||||
voltageV: finiteNumberSchema.positive().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const floorSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
name: z.string().trim().min(1),
|
||||
sortOrder: finiteNumberSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const roomSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
floorId: idSchema.nullable(),
|
||||
roomNumber: z.string().trim().min(1),
|
||||
roomName: z.string().trim().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const commonProjectStateSnapshotContents = {
|
||||
circuitLists: z.array(circuitListSchema),
|
||||
circuitSections: z.array(circuitSectionSchema),
|
||||
circuits: z.array(circuitSchema),
|
||||
projectDevices: z.array(projectDeviceSchema),
|
||||
floors: z.array(floorSchema),
|
||||
rooms: z.array(roomSchema),
|
||||
};
|
||||
|
||||
const legacyProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||
project: legacyProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const previousProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const distributionBoardProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(
|
||||
distributionBoardProjectStateSnapshotSchemaVersion
|
||||
),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const supplyTypesProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(supplyTypesProjectStateSnapshotSchemaVersion),
|
||||
project: currentProjectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const voltageProjectStateSnapshotSchema =
|
||||
supplyTypesProjectStateSnapshotSchema.extend({
|
||||
schemaVersion: z.literal(
|
||||
voltageProjectStateSnapshotSchemaVersion
|
||||
),
|
||||
});
|
||||
|
||||
export const projectStateSnapshotSchema =
|
||||
voltageProjectStateSnapshotSchema.extend({
|
||||
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||
distributionBoards: z.array(currentDistributionBoardSchema),
|
||||
});
|
||||
|
||||
export type ProjectStateSnapshot = z.infer<
|
||||
typeof projectStateSnapshotSchema
|
||||
>;
|
||||
|
||||
export function parseProjectStateSnapshot(
|
||||
value: unknown
|
||||
): ProjectStateSnapshot {
|
||||
const version = isPlainObject(value) ? value.schemaVersion : undefined;
|
||||
const parsedSnapshot =
|
||||
version === legacyProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
upgradePreviousProjectStateSnapshot(
|
||||
upgradeLegacyProjectStateSnapshot(
|
||||
legacyProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === previousProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
upgradePreviousProjectStateSnapshot(
|
||||
previousProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === distributionBoardProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
distributionBoardProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === supplyTypesProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
supplyTypesProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
: version === voltageProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
voltageProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
: projectStateSnapshotSchema.parse(value);
|
||||
const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot);
|
||||
assertProjectStateSnapshotRelations(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function normalizeSnapshotPhaseTypes(
|
||||
snapshot: ProjectStateSnapshot
|
||||
): ProjectStateSnapshot {
|
||||
const sectionById = new Map(
|
||||
snapshot.circuitSections.map((section) => [section.id, section])
|
||||
);
|
||||
const projectDeviceById = new Map(
|
||||
snapshot.projectDevices.map((device) => [device.id, device])
|
||||
);
|
||||
return {
|
||||
...snapshot,
|
||||
circuits: snapshot.circuits.map((circuit) => {
|
||||
const section = sectionById.get(circuit.sectionId);
|
||||
const deviceRows = circuit.deviceRows.map((row) => {
|
||||
const normalizedPhaseType =
|
||||
normalizeKnownElectricalPhaseType(row.phaseType);
|
||||
const linkedPhaseType = row.linkedProjectDeviceId
|
||||
? projectDeviceById.get(row.linkedProjectDeviceId)?.phaseType
|
||||
: undefined;
|
||||
return {
|
||||
...row,
|
||||
phaseType:
|
||||
normalizedPhaseType ??
|
||||
linkedPhaseType ??
|
||||
(section?.key === "three_phase"
|
||||
? "three_phase"
|
||||
: "single_phase"),
|
||||
};
|
||||
});
|
||||
return {
|
||||
...circuit,
|
||||
voltage: section
|
||||
? resolveProjectVoltage(
|
||||
resolveCircuitPhaseType(
|
||||
section.key,
|
||||
deviceRows.map((row) => row.phaseType)
|
||||
),
|
||||
snapshot.project
|
||||
)
|
||||
: circuit.voltage,
|
||||
deviceRows,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeLegacyProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof legacyProjectStateSnapshotSchema>
|
||||
): z.infer<typeof previousProjectStateSnapshotSchema> {
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: previousProjectStateSnapshotSchemaVersion,
|
||||
project: {
|
||||
...snapshot.project,
|
||||
internalProjectNumber: null,
|
||||
externalProjectNumber: null,
|
||||
buildingOwner: null,
|
||||
description: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function upgradePreviousProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof previousProjectStateSnapshotSchema>
|
||||
): z.infer<typeof distributionBoardProjectStateSnapshotSchema> {
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: distributionBoardProjectStateSnapshotSchemaVersion,
|
||||
distributionBoards: snapshot.distributionBoards.map((board) => ({
|
||||
...board,
|
||||
floorId: null,
|
||||
supplyType: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeDistributionBoardProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof distributionBoardProjectStateSnapshotSchema>
|
||||
): z.infer<typeof supplyTypesProjectStateSnapshotSchema> {
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: supplyTypesProjectStateSnapshotSchemaVersion,
|
||||
project: {
|
||||
...snapshot.project,
|
||||
enabledDistributionBoardSupplyTypes: [
|
||||
...defaultDistributionBoardSupplyTypes,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeSupplyTypesProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof supplyTypesProjectStateSnapshotSchema>
|
||||
): z.infer<typeof voltageProjectStateSnapshotSchema> {
|
||||
const settings = snapshot.project;
|
||||
const sectionById = new Map(
|
||||
snapshot.circuitSections.map((section) => [section.id, section])
|
||||
);
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: voltageProjectStateSnapshotSchemaVersion,
|
||||
projectDevices: snapshot.projectDevices.map((device) => ({
|
||||
...device,
|
||||
voltageV: resolveProjectVoltage(device.phaseType, settings),
|
||||
})),
|
||||
circuits: snapshot.circuits.map((circuit) => {
|
||||
const section = sectionById.get(circuit.sectionId);
|
||||
if (!section) {
|
||||
return circuit;
|
||||
}
|
||||
const phaseType = resolveCircuitPhaseType(
|
||||
section.key,
|
||||
circuit.deviceRows.map((row) => row.phaseType)
|
||||
);
|
||||
return {
|
||||
...circuit,
|
||||
voltage: resolveProjectVoltage(phaseType, settings),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeVoltageProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof voltageProjectStateSnapshotSchema>
|
||||
): ProjectStateSnapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
distributionBoards: snapshot.distributionBoards.map((board) => ({
|
||||
...board,
|
||||
simultaneityFactor: 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function serializeProjectStateSnapshot(
|
||||
snapshot: ProjectStateSnapshot
|
||||
): string {
|
||||
return JSON.stringify(parseProjectStateSnapshot(snapshot));
|
||||
}
|
||||
|
||||
export function deserializeProjectStateSnapshot(
|
||||
serialized: string
|
||||
): ProjectStateSnapshot {
|
||||
return parseProjectStateSnapshot(JSON.parse(serialized) as unknown);
|
||||
}
|
||||
|
||||
function assertProjectStateSnapshotRelations(
|
||||
snapshot: ProjectStateSnapshot
|
||||
) {
|
||||
const projectId = snapshot.project.id;
|
||||
const boardIds = uniqueIds(
|
||||
snapshot.distributionBoards,
|
||||
"distribution board"
|
||||
);
|
||||
const circuitListIds = uniqueIds(
|
||||
snapshot.circuitLists,
|
||||
"circuit list"
|
||||
);
|
||||
const sectionIds = uniqueIds(
|
||||
snapshot.circuitSections,
|
||||
"circuit section"
|
||||
);
|
||||
const projectDeviceIds = uniqueIds(
|
||||
snapshot.projectDevices,
|
||||
"project device"
|
||||
);
|
||||
const floorIds = uniqueIds(snapshot.floors, "floor");
|
||||
const roomIds = uniqueIds(snapshot.rooms, "room");
|
||||
uniqueIds(snapshot.circuits, "circuit");
|
||||
const sectionById = new Map(
|
||||
snapshot.circuitSections.map((entry) => [entry.id, entry])
|
||||
);
|
||||
|
||||
for (const board of snapshot.distributionBoards) {
|
||||
assertProjectOwnership(board.projectId, projectId, "distribution board");
|
||||
if (board.floorId !== null) {
|
||||
assertReference(
|
||||
floorIds,
|
||||
board.floorId,
|
||||
"distribution board floor"
|
||||
);
|
||||
}
|
||||
if (
|
||||
board.supplyType !== null &&
|
||||
!snapshot.project.enabledDistributionBoardSupplyTypes.includes(
|
||||
board.supplyType
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Snapshot distribution board uses a disabled supply type."
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const list of snapshot.circuitLists) {
|
||||
assertProjectOwnership(list.projectId, projectId, "circuit list");
|
||||
assertReference(
|
||||
boardIds,
|
||||
list.distributionBoardId,
|
||||
"circuit list distribution board"
|
||||
);
|
||||
}
|
||||
for (const section of snapshot.circuitSections) {
|
||||
assertReference(
|
||||
circuitListIds,
|
||||
section.circuitListId,
|
||||
"circuit section list"
|
||||
);
|
||||
}
|
||||
for (const device of snapshot.projectDevices) {
|
||||
assertProjectOwnership(device.projectId, projectId, "project device");
|
||||
if (
|
||||
device.voltageV !==
|
||||
resolveProjectVoltage(device.phaseType, snapshot.project)
|
||||
) {
|
||||
throw new Error(
|
||||
"Snapshot project device voltage must match project settings."
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const floor of snapshot.floors) {
|
||||
assertProjectOwnership(floor.projectId, projectId, "floor");
|
||||
}
|
||||
for (const room of snapshot.rooms) {
|
||||
assertProjectOwnership(room.projectId, projectId, "room");
|
||||
if (room.floorId !== null) {
|
||||
assertReference(floorIds, room.floorId, "room floor");
|
||||
}
|
||||
}
|
||||
|
||||
const deviceRowIds = new Set<string>();
|
||||
for (const circuit of snapshot.circuits) {
|
||||
assertReference(
|
||||
circuitListIds,
|
||||
circuit.circuitListId,
|
||||
"circuit list"
|
||||
);
|
||||
assertReference(sectionIds, circuit.sectionId, "circuit section");
|
||||
const section = sectionById.get(circuit.sectionId)!;
|
||||
if (section.circuitListId !== circuit.circuitListId) {
|
||||
throw new Error(
|
||||
"Snapshot circuit section belongs to a different circuit list."
|
||||
);
|
||||
}
|
||||
if (circuit.isReserve !== (circuit.deviceRows.length === 0)) {
|
||||
throw new Error(
|
||||
"Snapshot circuit reserve state must match its device rows."
|
||||
);
|
||||
}
|
||||
const expectedVoltage = resolveProjectVoltage(
|
||||
resolveCircuitPhaseType(
|
||||
section.key,
|
||||
circuit.deviceRows.map((row) => row.phaseType)
|
||||
),
|
||||
snapshot.project
|
||||
);
|
||||
if (circuit.voltage !== expectedVoltage) {
|
||||
throw new Error(
|
||||
"Snapshot circuit voltage must match project settings."
|
||||
);
|
||||
}
|
||||
for (const row of circuit.deviceRows) {
|
||||
if (row.circuitId !== circuit.id) {
|
||||
throw new Error(
|
||||
"Snapshot device row belongs to a different circuit."
|
||||
);
|
||||
}
|
||||
if (deviceRowIds.has(row.id)) {
|
||||
throw new Error("Snapshot contains duplicate device row ids.");
|
||||
}
|
||||
deviceRowIds.add(row.id);
|
||||
if (row.linkedProjectDeviceId !== null) {
|
||||
assertReference(
|
||||
projectDeviceIds,
|
||||
row.linkedProjectDeviceId,
|
||||
"device row project device"
|
||||
);
|
||||
}
|
||||
if (row.roomId !== null) {
|
||||
assertReference(roomIds, row.roomId, "device row room");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueIds(
|
||||
entries: ReadonlyArray<{ id: string }>,
|
||||
label: string
|
||||
) {
|
||||
const ids = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (ids.has(entry.id)) {
|
||||
throw new Error(`Snapshot contains duplicate ${label} ids.`);
|
||||
}
|
||||
ids.add(entry.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function assertProjectOwnership(
|
||||
actualProjectId: string,
|
||||
expectedProjectId: string,
|
||||
label: string
|
||||
) {
|
||||
if (actualProjectId !== expectedProjectId) {
|
||||
throw new Error(`Snapshot ${label} belongs to a different project.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertReference(
|
||||
ids: ReadonlySet<string>,
|
||||
id: string,
|
||||
label: string
|
||||
) {
|
||||
if (!ids.has(id)) {
|
||||
throw new Error(`Snapshot ${label} reference is invalid.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
parseProjectStateSnapshot,
|
||||
projectStateSnapshotSchemaVersion,
|
||||
type ProjectStateSnapshot,
|
||||
} from "./project-state-snapshot.model.js";
|
||||
|
||||
export const projectTransferFormat = "leistungsbilanz.project" as const;
|
||||
export const projectTransferFormatVersion = 1 as const;
|
||||
|
||||
export interface ProjectTransferEnvelope {
|
||||
format: typeof projectTransferFormat;
|
||||
formatVersion: typeof projectTransferFormatVersion;
|
||||
exportedAtIso: string;
|
||||
payloadSha256: string;
|
||||
projectState: ProjectStateSnapshot;
|
||||
}
|
||||
|
||||
export function createProjectTransferEnvelope(
|
||||
exportedAtIso: string,
|
||||
payloadSha256: string,
|
||||
projectState: ProjectStateSnapshot
|
||||
): ProjectTransferEnvelope {
|
||||
return parseProjectTransferEnvelope({
|
||||
format: projectTransferFormat,
|
||||
formatVersion: projectTransferFormatVersion,
|
||||
exportedAtIso,
|
||||
payloadSha256,
|
||||
projectState,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseProjectTransferEnvelope(
|
||||
value: unknown
|
||||
): ProjectTransferEnvelope {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error("Project transfer file must be an object.");
|
||||
}
|
||||
if (
|
||||
value.format !== projectTransferFormat ||
|
||||
value.formatVersion !== projectTransferFormatVersion
|
||||
) {
|
||||
throw new Error("Project transfer format is not supported.");
|
||||
}
|
||||
if (
|
||||
typeof value.exportedAtIso !== "string" ||
|
||||
!Number.isFinite(Date.parse(value.exportedAtIso))
|
||||
) {
|
||||
throw new Error("Project transfer timestamp is invalid.");
|
||||
}
|
||||
if (
|
||||
typeof value.payloadSha256 !== "string" ||
|
||||
!/^[a-f0-9]{64}$/.test(value.payloadSha256)
|
||||
) {
|
||||
throw new Error("Project transfer checksum is invalid.");
|
||||
}
|
||||
const projectState = parseProjectStateSnapshot(value.projectState);
|
||||
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
||||
throw new Error("Project transfer snapshot version is not supported.");
|
||||
}
|
||||
if (Object.keys(value).length !== 5) {
|
||||
throw new Error("Project transfer file contains unknown fields.");
|
||||
}
|
||||
return {
|
||||
format: projectTransferFormat,
|
||||
formatVersion: projectTransferFormatVersion,
|
||||
exportedAtIso: value.exportedAtIso,
|
||||
payloadSha256: value.payloadSha256,
|
||||
projectState,
|
||||
};
|
||||
}
|
||||
|
||||
export function remapProjectState(
|
||||
source: ProjectStateSnapshot,
|
||||
targetProjectId: string,
|
||||
createId: () => string,
|
||||
name: string = source.project.name
|
||||
): ProjectStateSnapshot {
|
||||
const boardIds = idMap(source.distributionBoards, createId);
|
||||
const listIds = idMap(source.circuitLists, createId);
|
||||
const sectionIds = idMap(source.circuitSections, createId);
|
||||
const circuitIds = idMap(source.circuits, createId);
|
||||
const deviceIds = idMap(source.projectDevices, createId);
|
||||
const floorIds = idMap(source.floors, createId);
|
||||
const roomIds = idMap(source.rooms, createId);
|
||||
const rowIds = new Map(
|
||||
source.circuits.flatMap((circuit) =>
|
||||
circuit.deviceRows.map((row) => [row.id, createId()] as const)
|
||||
)
|
||||
);
|
||||
return parseProjectStateSnapshot({
|
||||
...source,
|
||||
project: { ...source.project, id: targetProjectId, name },
|
||||
distributionBoards: source.distributionBoards.map((board) => ({
|
||||
...board,
|
||||
id: requiredId(boardIds, board.id),
|
||||
projectId: targetProjectId,
|
||||
floorId:
|
||||
board.floorId === null
|
||||
? null
|
||||
: requiredId(floorIds, board.floorId),
|
||||
})),
|
||||
circuitLists: source.circuitLists.map((list) => ({
|
||||
...list,
|
||||
id: requiredId(listIds, list.id),
|
||||
projectId: targetProjectId,
|
||||
distributionBoardId: requiredId(boardIds, list.distributionBoardId),
|
||||
})),
|
||||
circuitSections: source.circuitSections.map((section) => ({
|
||||
...section,
|
||||
id: requiredId(sectionIds, section.id),
|
||||
circuitListId: requiredId(listIds, section.circuitListId),
|
||||
})),
|
||||
circuits: source.circuits.map((circuit) => ({
|
||||
...circuit,
|
||||
id: requiredId(circuitIds, circuit.id),
|
||||
circuitListId: requiredId(listIds, circuit.circuitListId),
|
||||
sectionId: requiredId(sectionIds, circuit.sectionId),
|
||||
deviceRows: circuit.deviceRows.map((row) => ({
|
||||
...row,
|
||||
id: requiredId(rowIds, row.id),
|
||||
circuitId: requiredId(circuitIds, circuit.id),
|
||||
linkedProjectDeviceId:
|
||||
row.linkedProjectDeviceId === null
|
||||
? null
|
||||
: requiredId(deviceIds, row.linkedProjectDeviceId),
|
||||
legacyConsumerId: null,
|
||||
roomId:
|
||||
row.roomId === null ? null : requiredId(roomIds, row.roomId),
|
||||
})),
|
||||
})),
|
||||
projectDevices: source.projectDevices.map((device) => ({
|
||||
...device,
|
||||
id: requiredId(deviceIds, device.id),
|
||||
projectId: targetProjectId,
|
||||
})),
|
||||
floors: source.floors.map((floor) => ({
|
||||
...floor,
|
||||
id: requiredId(floorIds, floor.id),
|
||||
projectId: targetProjectId,
|
||||
})),
|
||||
rooms: source.rooms.map((room) => ({
|
||||
...room,
|
||||
id: requiredId(roomIds, room.id),
|
||||
projectId: targetProjectId,
|
||||
floorId:
|
||||
room.floorId === null ? null : requiredId(floorIds, room.floorId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function idMap(
|
||||
entries: ReadonlyArray<{ id: string }>,
|
||||
createId: () => string
|
||||
) {
|
||||
return new Map(entries.map((entry) => [entry.id, createId()]));
|
||||
}
|
||||
|
||||
function requiredId(ids: ReadonlyMap<string, string>, sourceId: string) {
|
||||
const targetId = ids.get(sourceId);
|
||||
if (!targetId) {
|
||||
throw new Error(`Project transfer reference "${sourceId}" is invalid.`);
|
||||
}
|
||||
return targetId;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
export interface CreateCircuitDeviceRowTransactionInput {
|
||||
circuitId: string;
|
||||
linkedProjectDeviceId?: string;
|
||||
sortOrder?: number;
|
||||
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 CreateCircuitTransactionInput {
|
||||
circuitListId: string;
|
||||
sectionId: string;
|
||||
equipmentIdentifier: string;
|
||||
displayName?: string;
|
||||
sortOrder: number;
|
||||
protectionType?: string;
|
||||
protectionRatedCurrent?: number;
|
||||
protectionCharacteristic?: string;
|
||||
cableType?: string;
|
||||
cableCrossSection?: string;
|
||||
cableLength?: number;
|
||||
voltage?: number;
|
||||
controlRequirement?: string;
|
||||
remark?: string;
|
||||
rcdAssignment?: string;
|
||||
terminalDesignation?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CreateCircuitWithDeviceRowsTransactionInput {
|
||||
circuit: CreateCircuitTransactionInput;
|
||||
deviceRows: Array<Omit<CreateCircuitDeviceRowTransactionInput, "circuitId">>;
|
||||
}
|
||||
|
||||
export interface MoveCircuitDeviceRowsTransactionInput {
|
||||
rows: Array<{ id: string; expectedCircuitId: string }>;
|
||||
targetCircuitId?: string;
|
||||
createTargetCircuit?: {
|
||||
circuitListId: string;
|
||||
sectionId: string;
|
||||
equipmentIdentifier: string;
|
||||
displayName: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MoveCircuitDeviceRowsTransactionResult {
|
||||
movedRowIds: string[];
|
||||
targetCircuitId: string;
|
||||
createdCircuitId?: string;
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowTransactionStore {
|
||||
createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string;
|
||||
createCircuitWithDeviceRows(
|
||||
input: CreateCircuitWithDeviceRowsTransactionInput
|
||||
): { circuitId: string; rowIds: string[] };
|
||||
deleteFromCircuit(rowId: string, expectedCircuitId: string): void;
|
||||
moveRows(
|
||||
input: MoveCircuitDeviceRowsTransactionInput
|
||||
): MoveCircuitDeviceRowsTransactionResult;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CircuitSectionReorderProjectCommand } from "../models/circuit-section-reorder-project-command.model.js";
|
||||
import type { CircuitSectionsReorderProjectCommand } from "../models/circuit-sections-reorder-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
@@ -11,12 +12,16 @@ export interface ExecuteCircuitSectionReorderCommandInput {
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitSectionReorderProjectCommand;
|
||||
command:
|
||||
| CircuitSectionReorderProjectCommand
|
||||
| CircuitSectionsReorderProjectCommand;
|
||||
}
|
||||
|
||||
export interface ExecutedCircuitSectionReorderCommand {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitSectionReorderProjectCommand;
|
||||
inverse:
|
||||
| CircuitSectionReorderProjectCommand
|
||||
| CircuitSectionsReorderProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitSectionReorderProjectCommandStore {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user