Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac21d6eb5d | |||
| 7a9530a914 | |||
| 99bdf6491b | |||
| db4c757018 | |||
| 3399fcd88b | |||
| 9ad91887b2 | |||
| 07925b2fd9 | |||
| 63650a623c | |||
| e702712bd8 | |||
| 1250f9a6af | |||
| 69b020339e | |||
| 604604f056 | |||
| 9d4d4082fe | |||
| 012308984d | |||
| e7607c90c4 | |||
| a88c4b2086 | |||
| a72d2c76e0 | |||
| 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 |
@@ -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
|
||||
@@ -237,6 +255,11 @@ 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.
|
||||
Snapshot schema version 7 also contains circuit-group identity,
|
||||
distribution-board components and the separate circuit/component protection
|
||||
records. Versions 1 through 6 remain readable. Portable duplicate imports
|
||||
remap component ids and protection-owner references together with the existing
|
||||
project graph.
|
||||
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.
|
||||
@@ -265,13 +288,62 @@ 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 voltage settings use the persistent `project.update-settings` command.
|
||||
Both voltage defaults change in one revision and Undo/Redo restores them
|
||||
together; the project PUT route requires `expectedRevision`.
|
||||
Distribution-board setup uses `distribution-board.insert` with a complete
|
||||
stable snapshot of the board, circuit list and four default sections. Its
|
||||
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` schema version 3
|
||||
with a complete stable snapshot of the board, circuit list, three default
|
||||
groups, main switch `-Q0` and surge protective device `-FA`. Its
|
||||
inverse removes only the same unchanged and still-empty structure; the POST
|
||||
route requires `expectedRevision` and returns the updated history state.
|
||||
Stored schema-version 1 and 2 setup commands remain executable with their four
|
||||
legacy sections and without invented components.
|
||||
Mutable group-protection and auxiliary distribution-board components use
|
||||
`distribution-board-component.insert` and
|
||||
`distribution-board-component.delete`; edits and footer reordering use
|
||||
`distribution-board-component.update`. Their complete expected/target
|
||||
snapshots include the optional one-to-one protection-device state and reject
|
||||
stale data. Updates preserve id, ownership, role and placement. Fixed
|
||||
main-switch and surge-protection header roles are excluded from these general
|
||||
component commands.
|
||||
Empty circuit-group creation, display-name updates and deletion use
|
||||
`circuit-group.insert`, `circuit-group.update` and `circuit-group.delete`.
|
||||
Their snapshots keep category, positive group number and derived prefix
|
||||
consistent. General deletion rejects groups containing circuits or group
|
||||
components; populated deletion remains a separate confirmed command.
|
||||
`circuit-group.reorder` requires a complete expected/target sort assignment for
|
||||
every group in one circuit list. It changes only sort positions and never
|
||||
renumbers groups, prefixes or circuits.
|
||||
Explicit group-number changes use `circuit-group.renumber`. The command carries
|
||||
the complete expected/target group, circuit-BMK and optional group-component
|
||||
BMK plan, applies swaps through collision-safe temporary values and preserves
|
||||
circuit suffixes. Undo/Redo uses the exact inverse plan.
|
||||
Cross-group circuit moves use `circuit.move-group` and are limited to distinct
|
||||
groups of the same category. The stored target BMK is highest target suffix
|
||||
plus one at planning time and is never recalculated for Redo. Only circuit
|
||||
group, BMK and sort order change; device rows and circuit protection retain the
|
||||
stable circuit id.
|
||||
Confirmed populated-group deletion uses
|
||||
`circuit-group.delete-subtree`/`circuit-group.restore-subtree`. The exact
|
||||
snapshot contains the group, optional component protections, complete circuits,
|
||||
circuit protections and all device-row link/override metadata. Delete
|
||||
re-captures and compares the subtree before cascading; restore preserves every
|
||||
UUID in foreign-key-safe order.
|
||||
The circuit-tree read model exposes fixed `headerComponents`, group-owned
|
||||
`components`, `footerComponents`, optional group category/number and optional
|
||||
one-to-one protection DTOs for circuits and group components. These are
|
||||
additive to the retained flat circuit protection fields during the transition.
|
||||
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:
|
||||
|
||||
@@ -341,6 +413,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +43,7 @@ Limitierungen und Roadmap.
|
||||
- [Datenmodellkonzept](spec/03-data-model-concept.md)
|
||||
- [UI- und Interaktionsanforderungen](spec/04-ui-interaction-requirements.md)
|
||||
- [Verknüpfte Projektgeräte und Synchronisierung](spec/05-linked-devices-and-sync.md)
|
||||
- [Verteilerkomponenten und Schutzgruppen](spec/09-distribution-board-components-and-protection-groups.md)
|
||||
|
||||
## Archiv
|
||||
|
||||
|
||||
@@ -145,11 +145,18 @@ synchronizes linked circuit rows implicitly.
|
||||
|
||||
- `PUT /projects/:projectId`
|
||||
- request:
|
||||
`{ "expectedRevision": 12, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400 }`
|
||||
`{ "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 both voltage values together
|
||||
- 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`
|
||||
|
||||
@@ -181,19 +188,53 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||
### Distribution Board Setup
|
||||
|
||||
- `POST /projects/:projectId/distribution-boards`
|
||||
- body: `{ "name": "UV-02", "expectedRevision": 12 }`
|
||||
- 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
|
||||
board, its circuit list, the three default circuit groups, main switch
|
||||
`-Q0` and surge protective device `-FA`
|
||||
- response:
|
||||
`{ "distributionBoard": { ... }, "revision": { ... }, "history": { ... } }`
|
||||
- persistent Undo removes only the unchanged and still-empty generated
|
||||
structure; Redo restores the same ids
|
||||
- stored command schema versions 1 and 2 remain executable with their four
|
||||
legacy sections and without generated components
|
||||
- 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
|
||||
|
||||
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
||||
- Purpose: returns section/circuit/device-row tree with calculated row and circuit totals.
|
||||
- Purpose: returns fixed header components, grouped sections with optional
|
||||
protection components, complete circuit/device-row blocks and auxiliary
|
||||
footer components. Circuit and component protection devices use their
|
||||
separate one-to-one DTOs. Calculated row, circuit, section and board totals
|
||||
remain included.
|
||||
|
||||
Response sketch:
|
||||
|
||||
@@ -201,16 +242,47 @@ Response sketch:
|
||||
{
|
||||
"circuitListId": "cl_1",
|
||||
"currentRevision": 12,
|
||||
"headerComponents": [
|
||||
{
|
||||
"id": "cmp_main",
|
||||
"equipmentIdentifier": "-Q0",
|
||||
"name": "Hauptschalter",
|
||||
"role": "main_switch",
|
||||
"placement": "header"
|
||||
}
|
||||
],
|
||||
"sections": [
|
||||
{
|
||||
"id": "sec_1",
|
||||
"key": "lighting",
|
||||
"prefix": "-1F",
|
||||
"prefix": "-1F1.",
|
||||
"category": "lighting",
|
||||
"groupNumber": 1,
|
||||
"components": [
|
||||
{
|
||||
"id": "cmp_rcd",
|
||||
"equipmentIdentifier": "-1Q1.0",
|
||||
"name": "Gruppen-FI",
|
||||
"role": "group_residual_current_protection",
|
||||
"placement": "group",
|
||||
"protectionDevice": {
|
||||
"type": "FI",
|
||||
"ratedCurrentA": 40,
|
||||
"rcdType": "A",
|
||||
"ratedResidualCurrentMa": 30
|
||||
}
|
||||
}
|
||||
],
|
||||
"circuits": [
|
||||
{
|
||||
"id": "cir_1",
|
||||
"equipmentIdentifier": "-1F1",
|
||||
"equipmentIdentifier": "-1F1.1",
|
||||
"circuitTotalPower": 4.2,
|
||||
"protectionDevice": {
|
||||
"type": "LS",
|
||||
"ratedCurrentA": 10,
|
||||
"tripCharacteristic": "B"
|
||||
},
|
||||
"deviceRows": [
|
||||
{
|
||||
"id": "row_1",
|
||||
@@ -221,6 +293,15 @@ Response sketch:
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"footerComponents": [
|
||||
{
|
||||
"id": "cmp_actor",
|
||||
"equipmentIdentifier": "-K1",
|
||||
"name": "KNX Schaltaktor",
|
||||
"role": "auxiliary",
|
||||
"placement": "footer"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
+221
-14
@@ -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
|
||||
|
||||
@@ -66,11 +68,13 @@ 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. Ein getestetes Repository kann diese Historienmetadaten optimistisch
|
||||
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
|
||||
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. Alle aktuell unterstützten
|
||||
Circuit-, Gerätezeilen-, Projektgeräte- und Projekteinstellungsänderungen
|
||||
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.
|
||||
@@ -83,6 +87,26 @@ 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.
|
||||
@@ -125,8 +149,14 @@ 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, nach expliziter Bestätigung wiederherstellen und die paginierte
|
||||
Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen.
|
||||
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
|
||||
@@ -143,6 +173,26 @@ 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
|
||||
@@ -185,19 +235,152 @@ 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 die beiden Projekt-Standardspannungen als
|
||||
eine atomare Änderung. 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.
|
||||
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
||||
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
||||
`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-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
|
||||
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen 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 mit
|
||||
Command-Schema 3 als vollständigen Block aus Verteilung, Stromkreisliste, den
|
||||
drei Gruppen `Beleuchtung 1`, `1-phasig 1`, `3-phasig 1`, Hauptschalter `-Q0`
|
||||
und Überspannungsableiter `-FA`.
|
||||
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.
|
||||
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.
|
||||
Gespeicherte Anlage-Commands der Schemas 1 und 2 bleiben mit ihren vier
|
||||
Legacy-Abschnitten und ohne nachträglich erfundene Komponenten ausführbar.
|
||||
`distribution-board-component.insert` und
|
||||
`distribution-board-component.delete` sowie
|
||||
`distribution-board-component.update` versionieren Anlage, Entfernung,
|
||||
Bearbeitung und Sortierung veränderlicher Gruppen-Schutzgeräte und zusätzlicher
|
||||
Verteilergeräte. Die vollständigen Snapshots enthalten stabile
|
||||
Komponenten-UUIDs und, für Gruppen-Schutzgeräte, die getrennte
|
||||
1:1-Schutzgerätekonfiguration. Updates tauschen einen exakt erwarteten gegen
|
||||
einen vollständigen Zielzustand aus; ID, Eigentum, Rolle und Platzierungszone
|
||||
bleiben dabei unverändert. Projekt-, Listen- und Gruppenzugehörigkeit sowie
|
||||
veraltete Zustände werden innerhalb derselben Transaktion geprüft. Die festen
|
||||
Kopfkomponenten Hauptschalter und Überspannungsableiter sind von diesen
|
||||
allgemeinen Commands ausgeschlossen. API- und Editorintegration folgen in den
|
||||
nächsten Arbeitspaketen.
|
||||
Leere Stromkreisgruppen werden über `circuit-group.insert`,
|
||||
`circuit-group.update` und `circuit-group.delete` verwaltet. Ihr vollständiger
|
||||
Snapshot koppelt Kategorie, positive Gruppennummer und daraus abgeleiteten
|
||||
BMK-Präfix; allgemeine Updates ändern ausschließlich den Anzeigenamen.
|
||||
Löschen verlangt einen exakt unveränderten Zustand ohne Stromkreise und ohne
|
||||
Gruppenkomponenten. Das bestätigte Löschen befüllter Unterbäume bleibt ein
|
||||
eigener späterer Command.
|
||||
`circuit-group.reorder` sortiert Gruppen als vollständige Stromkreislisten-
|
||||
Zuordnung. Jede vorhandene Gruppe muss mit erwarteter und neuer Position
|
||||
enthalten sein. Der Command verändert ausschließlich `sortOrder`; Nummern,
|
||||
Präfixe und Stromkreis-BMKs bleiben stabil. Die gesamte Sortierung bildet eine
|
||||
Revision und einen Undo/Redo-Schritt.
|
||||
`src/domain/services/circuit-group-renumbering.ts` plant explizite
|
||||
Gruppennummerierungen deterministisch. Der reine Plan erhält
|
||||
Stromkreis-Endnummern, leitet Gruppenpräfixe und optionale `.0`-Komponenten-BMKs
|
||||
neu ab und prüft Nummerntausch sowie Kollisionen mit unveränderten Gruppen.
|
||||
`circuit-group.renumber` führt diesen Plan über die gemeinsame
|
||||
Projekt-Command-Transaktion aus. Vor dem Schreiben müssen alle betroffenen
|
||||
Gruppen, Stromkreise und Gruppenkomponenten exakt dem erwarteten Zustand
|
||||
entsprechen. Nummerntausch verwendet kollisionsfreie temporäre Präfixe und
|
||||
BMKs über beide BMK-Tabellen; anschließend werden alle Zielwerte finalisiert.
|
||||
Der vollständige inverse Plan ermöglicht dauerhaftes Undo/Redo.
|
||||
`src/domain/services/circuit-group-move-planning.ts` plant den Wechsel eines
|
||||
vollständigen Stromkreises zwischen zwei verschiedenen Gruppen derselben
|
||||
Kategorie. Die Ziel-BMK verwendet die höchste vorhandene Stromkreis-Endnummer
|
||||
der Zielgruppe plus eins; Lücken werden nicht gefüllt. Quell-/Zielgruppe,
|
||||
Quell-/Zielposition und beide BMKs werden festgeschrieben, damit eine spätere
|
||||
Wiederholung nichts neu berechnet.
|
||||
`circuit.move-group` führt den geplanten Wechsel als eine Projekt-Revision aus.
|
||||
Nur `sectionId`, `equipmentIdentifier` und `sortOrder` des Stromkreises ändern
|
||||
sich. Gerätezeilen und das getrennte 1:1-Schutzgerät bleiben über die stabile
|
||||
Stromkreis-UUID verbunden. Der inverse Wechsel speichert Quellgruppe, BMK und
|
||||
Position vollständig für dauerhaftes Undo/Redo.
|
||||
`CircuitGroupSubtreeSnapshot` ist der kanonische Vertrag für destruktive
|
||||
Gruppenoperationen. Er umfasst Gruppe, optionale Gruppenkomponenten samt
|
||||
1:1-Schutzdaten, vollständige Stromkreise samt 1:1-Schutzdaten und sämtliche
|
||||
Gerätezeilen einschließlich Link- und Override-Metadaten. Seine
|
||||
Warnzusammenfassung liefert Vorsicherung/FI sowie Stromkreis- und
|
||||
Gerätezeilenanzahl.
|
||||
`circuit-group.delete-subtree` nimmt den aktuellen Unterbaum unmittelbar vor
|
||||
dem Löschen erneut auf und verlangt exakte Übereinstimmung mit dem bestätigten
|
||||
Snapshot. `circuit-group.restore-subtree` schreibt ihn in FK-sicherer Reihenfolge
|
||||
mit denselben UUIDs zurück. Beide Richtungen laufen als vollständige inverse
|
||||
Projekt-Commands; späte Fehler hinterlassen weder Teillöschungen noch
|
||||
Teilwiederherstellungen.
|
||||
Der Circuit-Tree liefert zusätzlich `headerComponents`,
|
||||
gruppenbezogene `components` und `footerComponents`. Abschnitte enthalten
|
||||
optionale Gruppenkategorie und -nummer; Stromkreise und Gruppenkomponenten
|
||||
enthalten ihre optionale getrennte Schutzgerätekonfiguration. Die bisherigen
|
||||
flachen Stromkreis-Schutzfelder bleiben während der Übergangsphase additiv
|
||||
erhalten. Separate aktive Read-Repositories lesen Komponenten- und beide
|
||||
Schutzgerätetabellen; Schreibzugriffe bleiben ausschließlich in Commands.
|
||||
`src/frontend/utils/circuit-structure-projection.ts` projiziert diesen Tree
|
||||
rein und deterministisch in die drei Zonen Kopf, vollständige Gruppen und Fuß.
|
||||
Innerhalb einer Gruppe folgen auf die Überschrift optionale Schutzkomponenten,
|
||||
vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleiben
|
||||
sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert.
|
||||
`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.
|
||||
Snapshot-Schema 7 ergänzt Gruppenkategorie und -nummer,
|
||||
Verteilerkomponenten sowie die getrennten 1:1-Schutzgerätedaten. Capture,
|
||||
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
|
||||
JSON-Importmodi verwenden denselben vollständigen Zustand. Schema 6 und älter
|
||||
bleiben lesbar: Die drei bekannten Abschnittsschlüssel werden deterministisch
|
||||
Gruppe 1 zugeordnet, neue Komponenten- und Schutzgerätesammlungen bleiben leer
|
||||
und bestehende flache Schutzangaben unverändert erhalten.
|
||||
`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
|
||||
|
||||
@@ -220,8 +403,32 @@ 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.
|
||||
- Migration `0024` bildet die additive relationale Grundlage für geschützte
|
||||
Stromkreisgruppen und Verteilerkomponenten. Die drei bestehenden
|
||||
Stromkreisabschnitte erhalten Kategorie und Gruppennummer 1. Separate
|
||||
1:1-Tabellen halten künftig Stromkreis- und Komponenten-Schutzgeräte; die
|
||||
bisherigen flachen Schutzfelder bleiben in dieser Übergangsphase unverändert.
|
||||
Ein triggergeführtes Register erzwingt bereits eine normalisierte,
|
||||
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
|
||||
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden
|
||||
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
|
||||
veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen
|
||||
sowie vollständige Gruppensortierung sind integriert; befüllte Unterbäume
|
||||
und UI folgen in abgegrenzten Arbeitspaketen.
|
||||
|
||||
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
||||
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -265,16 +265,25 @@ Completed foundation:
|
||||
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
|
||||
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 project version history:
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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 14
|
||||
- `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
|
||||
@@ -339,9 +342,26 @@ Implemented foundation:
|
||||
- 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
|
||||
@@ -451,6 +471,9 @@ Implemented foundation:
|
||||
- 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
|
||||
@@ -473,7 +496,41 @@ Acceptance criteria:
|
||||
- restoring an older state does not delete intervening history
|
||||
- concurrent stale commands cannot silently overwrite newer project state
|
||||
|
||||
## Phase 13: External Model Round-Trip
|
||||
## Phase 13: Distribution Board Components and Protection Groups
|
||||
|
||||
Goal:
|
||||
|
||||
Extend the circuit list into the structural basis of a tabular single-line
|
||||
diagram before defining Revit parameter exchange.
|
||||
|
||||
Detailed specification:
|
||||
|
||||
- `docs/spec/09-distribution-board-components-and-protection-groups.md`
|
||||
|
||||
Delivery:
|
||||
|
||||
- add typed protection-device catalogs and defaults
|
||||
- evolve sections into repeatable category-specific circuit groups
|
||||
- add fixed, group and auxiliary distribution-board components
|
||||
- give every new circuit one explicit protection device
|
||||
- implement nested BMK generation, same-category group moves and explicit
|
||||
collision-safe group renumbering
|
||||
- preserve all new state through commands, snapshots, transfer and persistent
|
||||
Undo/Redo
|
||||
- project the header, groups and auxiliary footer in the spreadsheet editor
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- new boards contain the main switch, surge protective device and three default
|
||||
groups
|
||||
- all circuits and components have list-wide unique BMKs
|
||||
- circuit reordering keeps identifiers while cross-group moves assign the next
|
||||
target-group identifier
|
||||
- destructive group operations are confirmed, atomic and fully undoable
|
||||
- existing supported project data migrates without silently changing BMKs or
|
||||
protection selections
|
||||
|
||||
## Phase 14: External Model Round-Trip
|
||||
|
||||
Goal:
|
||||
|
||||
@@ -498,7 +555,7 @@ Acceptance criteria:
|
||||
|
||||
PostgreSQL should be introduced when shared multi-user operation, background jobs or operational scale justify it. External-object count alone does not require an immediate migration.
|
||||
|
||||
## Phase 14: Documentation and Collaboration Handoff
|
||||
## Phase 15: Documentation and Collaboration Handoff
|
||||
|
||||
Goal:
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# 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.
|
||||
|
||||
## Distribution Board Components and Protection Groups
|
||||
|
||||
- [ ] Implement the agreed protected circuit-group and distribution-board
|
||||
component model in phased work packages.
|
||||
- [x] Phase A: central protection catalog, validation, defaults and pure grouped
|
||||
BMK rules.
|
||||
- [x] Phase B1: additive relational schema, group backfill, component/protection
|
||||
tables and shared BMK uniqueness.
|
||||
- [x] Phase B2: snapshot/transfer schema upgrade and deterministic compatibility
|
||||
mapping.
|
||||
- [x] Phase C1: versioned new-board command with three groups and fixed header
|
||||
components.
|
||||
- [x] Phase C2a1: persistent insert/delete commands for mutable group
|
||||
protection and auxiliary components.
|
||||
- [x] Phase C2a2: component update and reorder commands.
|
||||
- [x] Phase C2b1: empty-group insert/update/delete commands.
|
||||
- [x] Phase C2b2: complete group reorder command.
|
||||
- [x] Phase D1a: deterministic collision-aware group-renumber plan.
|
||||
- [x] Phase D1b: persistent collision-safe group-renumber command.
|
||||
- [x] Phase D2a: deterministic same-category circuit-move plan.
|
||||
- [x] Phase D2b: persistent same-category circuit-move command.
|
||||
- [x] Phase D3a: complete validated group-subtree snapshot and warning summary.
|
||||
- [x] Phase D3b: persistent populated-group delete/restore command.
|
||||
- [x] Phase E1: tree read model for components, groups and protection devices.
|
||||
- [x] Phase E2a: pure header/group/footer structure projection.
|
||||
- [ ] Phase E2b: render the structure projection in the editor grid.
|
||||
- [ ] Phase E: editor projection and editing.
|
||||
- [ ] Phase F: documentation and full GUI verification.
|
||||
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
|
||||
the planner supplies the calculation requirements.
|
||||
- Detailed specification:
|
||||
`docs/spec/09-distribution-board-components-and-protection-groups.md`
|
||||
|
||||
## 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 follows the distribution-board component
|
||||
foundation as a separate jointly planned phase and must not be inferred from
|
||||
these tasks.
|
||||
@@ -0,0 +1,811 @@
|
||||
# Distribution Board Components and Protection Groups
|
||||
|
||||
## Status
|
||||
|
||||
This document defines the agreed target behavior for distribution-board
|
||||
components, protected circuit groups and circuit protection devices. Phase A
|
||||
and the relational Phase-B1 foundation are implemented; later sections remain
|
||||
requirements and are not proof of runtime or editor support.
|
||||
|
||||
Full rule-based protection and cable sizing is deliberately deferred. This
|
||||
phase establishes the structure and manually selected technical values that a
|
||||
later calculation and warning engine will evaluate.
|
||||
|
||||
## Goals
|
||||
|
||||
- represent the circuit list as the basis of a tabular single-line diagram
|
||||
- list incoming, group-level and auxiliary distribution-board components with
|
||||
stable equipment identifiers
|
||||
- support multiple independently protected groups for lighting, single-phase
|
||||
and three-phase outgoing circuits
|
||||
- give every newly created outgoing circuit one explicit protection device
|
||||
- preserve the existing distinction between a circuit and its device rows
|
||||
- keep every structural change atomic, revisioned and persistently undoable
|
||||
- prepare stable target fields for the later Revit round-trip
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- norm-compliant protection or cable sizing
|
||||
- automatic creation of additional groups based on the incoming rating
|
||||
- automatic phase balancing
|
||||
- manufacturer-specific product catalogs
|
||||
- automatic equipment-identifier generation for auxiliary components
|
||||
- channel-level relationships between actors and controlled circuits
|
||||
- PostgreSQL or multi-user operation
|
||||
|
||||
## Existing Model Audit
|
||||
|
||||
### Keep
|
||||
|
||||
- `Circuit` remains the outgoing-circuit aggregate.
|
||||
- `CircuitDeviceRow` remains a load assigned to a circuit and never receives
|
||||
its own protection device or equipment identifier.
|
||||
- Circuit-level load, cable and control fields remain circuit-owned.
|
||||
- Existing stable UUIDs, project revisions, snapshots and persistent Undo/Redo
|
||||
remain authoritative.
|
||||
- `CircuitSection` remains the migration starting point for a protected circuit
|
||||
group.
|
||||
- Grid projection remains separate from persisted domain structure.
|
||||
|
||||
### Change
|
||||
|
||||
- A `CircuitSection` gains an explicit category and group number and represents
|
||||
one protected circuit group.
|
||||
- Circuit protection changes from loosely related flat strings to an explicit
|
||||
one-to-one protection-device model.
|
||||
- Distribution-board components become independent BMK-bearing list entries.
|
||||
- Equipment-identifier uniqueness covers circuits and all distribution-board
|
||||
components in the complete circuit list.
|
||||
- Moving a circuit to another group of the same category atomically assigns the
|
||||
next identifier of the target group.
|
||||
- New distribution boards create three default groups rather than treating the
|
||||
optional unassigned compatibility section as a normal fourth group.
|
||||
|
||||
### Remove Eventually
|
||||
|
||||
- hard-coded assumptions that each category has exactly one section
|
||||
- numbering that only understands `prefix + integer`
|
||||
- free-text protection-device combinations that cannot be validated
|
||||
- `rcdAssignment` as a substitute for an explicit group relationship
|
||||
|
||||
Removal happens only after migrated state, snapshots, commands and the editor
|
||||
use the replacement model.
|
||||
|
||||
## Target Domain Structure
|
||||
|
||||
```text
|
||||
Project
|
||||
└── DistributionBoard
|
||||
└── CircuitList
|
||||
├── fixed header components
|
||||
│ ├── Main switch
|
||||
│ └── Surge protective device
|
||||
├── CircuitGroup 1..n
|
||||
│ ├── optional upstream protection component
|
||||
│ ├── optional group RCD component
|
||||
│ └── Circuit 0..n
|
||||
│ ├── ProtectionDevice exactly 1
|
||||
│ └── CircuitDeviceRow 0..n
|
||||
└── auxiliary components 0..n
|
||||
```
|
||||
|
||||
The hierarchy defines ownership and electrical grouping. It does not require
|
||||
every level to be visually indented in the table.
|
||||
|
||||
## Circuit Groups
|
||||
|
||||
`CircuitSection` evolves into the circuit-group entity. A group has:
|
||||
|
||||
- stable UUID
|
||||
- circuit-list ownership
|
||||
- category
|
||||
- positive group number
|
||||
- display name
|
||||
- sort order
|
||||
- optional upstream protection component
|
||||
- optional group RCD component
|
||||
- zero or more outgoing circuits
|
||||
|
||||
Supported categories:
|
||||
|
||||
- `lighting`
|
||||
- `single_phase`
|
||||
- `three_phase`
|
||||
|
||||
Each new distribution board creates exactly:
|
||||
|
||||
1. `Beleuchtung 1`
|
||||
2. `1-phasig 1`
|
||||
3. `3-phasig 1`
|
||||
|
||||
Additional groups are created manually. A new group receives the highest
|
||||
existing group number in its category plus one. Gaps are not filled
|
||||
automatically.
|
||||
|
||||
A group may have at most one upstream protection component and at most one
|
||||
group RCD. Both are optional. Every circuit in the group remains downstream of
|
||||
those group components until it is moved to another group.
|
||||
|
||||
## Distribution-Board Components
|
||||
|
||||
Every distribution-board component has:
|
||||
|
||||
- stable UUID
|
||||
- circuit-list ownership
|
||||
- unique equipment identifier
|
||||
- name
|
||||
- role
|
||||
- placement
|
||||
- sort order within its placement
|
||||
|
||||
Initial roles:
|
||||
|
||||
- `main_switch`
|
||||
- `surge_protective_device`
|
||||
- `group_upstream_protection`
|
||||
- `group_residual_current_protection`
|
||||
- `auxiliary`
|
||||
|
||||
Initial placement zones:
|
||||
|
||||
- `header`
|
||||
- `group`
|
||||
- `footer`
|
||||
|
||||
New distribution boards create exactly:
|
||||
|
||||
- `-Q0`, name `Hauptschalter`, role `main_switch`
|
||||
- `-FA`, name `Überspannungsableiter`, role
|
||||
`surge_protective_device`
|
||||
|
||||
These fixed components appear at the top and may initially have no further
|
||||
technical product data.
|
||||
|
||||
Auxiliary components initially require only:
|
||||
|
||||
- manually entered equipment identifier
|
||||
- name
|
||||
- sort order
|
||||
|
||||
Examples include KNX switching actuators, blind actuators, phase-monitoring
|
||||
modules, DALI gateways, relays and other installed board equipment. They appear
|
||||
after all circuit groups and can be reordered by drag-and-drop. Automatic BMK
|
||||
suggestions and channel-to-circuit relationships are deferred.
|
||||
|
||||
## Circuit Protection
|
||||
|
||||
Every newly created outgoing circuit owns exactly one protection device.
|
||||
`CircuitDeviceRow` does not own a protection device.
|
||||
|
||||
The circuit and its protection device are projected as one circuit row. The
|
||||
circuit equipment identifier is also the displayed identifier of its
|
||||
protection device and must not be stored twice.
|
||||
|
||||
Supported protection-device types:
|
||||
|
||||
- `D02`
|
||||
- `NH000`
|
||||
- `NH00`
|
||||
- `NH0`
|
||||
- `NH1`
|
||||
- `NH2`
|
||||
- `NH3`
|
||||
- `LS`
|
||||
- `FI`
|
||||
- `FI_LS`
|
||||
- `AFDD`
|
||||
|
||||
`D01` and `NH4` are intentionally excluded.
|
||||
|
||||
## Protection-Device Fields
|
||||
|
||||
All protection devices have:
|
||||
|
||||
- type
|
||||
- rated current in ampere
|
||||
|
||||
Type-dependent fields:
|
||||
|
||||
- D02 and NH:
|
||||
- fuse utilization category `gG` or `gR`
|
||||
- LS:
|
||||
- trip characteristic `B`, `C`, `D` or `Z`
|
||||
- FI:
|
||||
- RCD type `A`, `AC`, `B` or `B+`
|
||||
- rated residual current in milliampere
|
||||
- FI/LS:
|
||||
- trip characteristic `B`, `C`, `D` or `Z`
|
||||
- RCD type `A`, `AC`, `B` or `B+`
|
||||
- rated residual current in milliampere
|
||||
- AFDD:
|
||||
- trip characteristic `B`, `C`, `D` or `Z`
|
||||
- no RCD type
|
||||
- no rated residual current
|
||||
|
||||
Poles are intentionally not stored. The circuit-group category supplies the
|
||||
required phase context for the current scope.
|
||||
|
||||
## Allowed Ratings
|
||||
|
||||
### D02
|
||||
|
||||
`20, 25, 32, 35, 40, 50, 63 A`
|
||||
|
||||
### NH000 and NH00
|
||||
|
||||
`6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160 A`
|
||||
|
||||
### NH0
|
||||
|
||||
`6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250 A`
|
||||
|
||||
### NH1
|
||||
|
||||
`16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250 A`
|
||||
|
||||
### NH2
|
||||
|
||||
`25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250, 300, 315, 355, 400 A`
|
||||
|
||||
### NH3
|
||||
|
||||
`50, 63, 80, 100, 125, 160, 200, 224, 250, 315, 355, 400, 500, 630, 800 A`
|
||||
|
||||
### LS, FI/LS and AFDD
|
||||
|
||||
`6, 10, 13, 16, 20, 25, 32, 40, 50, 63, 80, 100, 125 A`
|
||||
|
||||
### FI/RCD
|
||||
|
||||
Rated currents:
|
||||
|
||||
`16, 25, 40, 63, 80, 100, 125 A`
|
||||
|
||||
Rated residual currents:
|
||||
|
||||
`10, 30, 100, 300, 500 mA`
|
||||
|
||||
The device type determines which values and characteristics are valid. UI,
|
||||
request validation and domain validation use one shared catalog rather than
|
||||
duplicated free-text rules.
|
||||
|
||||
All supported devices that provide overcurrent protection may be selected as a
|
||||
group upstream protection device. A standalone FI belongs in the separate
|
||||
optional group-RCD role.
|
||||
|
||||
## Default Protection Devices
|
||||
|
||||
New circuits receive:
|
||||
|
||||
- lighting:
|
||||
- LS
|
||||
- 10 A
|
||||
- characteristic B
|
||||
- single-phase:
|
||||
- FI/LS
|
||||
- 16 A
|
||||
- characteristic B
|
||||
- RCD type A
|
||||
- 30 mA
|
||||
- three-phase:
|
||||
- FI/LS
|
||||
- 16 A
|
||||
- characteristic B
|
||||
- RCD type A
|
||||
- 30 mA
|
||||
|
||||
A newly added group RCD defaults to:
|
||||
|
||||
- FI
|
||||
- 40 A
|
||||
- RCD type A
|
||||
- 30 mA
|
||||
|
||||
An upstream protection component has no automatic default and is created only
|
||||
when the planner requests it.
|
||||
|
||||
## Equipment-Identifier Rules
|
||||
|
||||
The leading hyphen is part of every generated identifier.
|
||||
|
||||
Group 1 examples:
|
||||
|
||||
```text
|
||||
-1F1.0 upstream protection, lighting
|
||||
-1Q1.0 group RCD, lighting
|
||||
-1F1.1 first lighting circuit
|
||||
|
||||
-2F1.0 upstream protection, single-phase
|
||||
-2Q1.0 group RCD, single-phase
|
||||
-2F1.1 first single-phase circuit
|
||||
|
||||
-3F1.0 upstream protection, three-phase
|
||||
-3Q1.0 group RCD, three-phase
|
||||
-3F1.1 first three-phase circuit
|
||||
```
|
||||
|
||||
Category numbers:
|
||||
|
||||
- lighting: `1`
|
||||
- single-phase: `2`
|
||||
- three-phase: `3`
|
||||
|
||||
Function letters:
|
||||
|
||||
- overcurrent protection and outgoing circuit: `F`
|
||||
- group RCD: `Q`
|
||||
|
||||
The group number follows the function letter. `.0` is reserved for the group
|
||||
component. Outgoing-circuit suffixes start at `.1`.
|
||||
|
||||
Equipment identifiers must be unique across all circuits and components of the
|
||||
complete circuit list, including header and footer components.
|
||||
|
||||
Manual BMK edits must validate that same shared uniqueness boundary.
|
||||
|
||||
## Reorder, Move and Renumber Behavior
|
||||
|
||||
### Reorder Within One Group
|
||||
|
||||
Changing circuit order changes only `sortOrder`. It never changes an equipment
|
||||
identifier.
|
||||
|
||||
### Move to Another Group
|
||||
|
||||
A circuit may move only to another group of the same category.
|
||||
|
||||
The move:
|
||||
|
||||
1. finds the highest existing outgoing-circuit suffix in the target group
|
||||
2. assigns that suffix plus one
|
||||
3. moves the complete circuit, its one-to-one protection device and all device
|
||||
rows
|
||||
4. commits old and new group, position and identifier atomically
|
||||
|
||||
Gaps are not filled. Undo restores the original group, sort position and
|
||||
equipment identifier. Redo reapplies the same previously determined target
|
||||
identifier rather than calculating another one.
|
||||
|
||||
This automatic identifier change is specific to a cross-group move initiated
|
||||
by the user. A reorder inside one group never renumbers.
|
||||
|
||||
### Reorder Groups
|
||||
|
||||
Changing group display order changes only group `sortOrder`. Group numbers and
|
||||
all equipment identifiers remain unchanged.
|
||||
|
||||
### Explicitly Renumber Groups
|
||||
|
||||
Group renumbering is a separate explicit action. It:
|
||||
|
||||
- changes group numbers according to the requested mapping
|
||||
- preserves every outgoing circuit suffix
|
||||
- updates optional `.0` group-component identifiers
|
||||
- updates every affected circuit identifier
|
||||
- resolves swaps through collision-safe temporary identifiers
|
||||
- commits the complete mapping as one project revision
|
||||
|
||||
Undo restores the exact previous group numbers and identifiers.
|
||||
|
||||
## Delete Behavior
|
||||
|
||||
A group may be deleted even when populated, but only after an explicit warning
|
||||
that summarizes:
|
||||
|
||||
- optional upstream protection
|
||||
- optional group RCD
|
||||
- number of circuits
|
||||
- number of circuit device rows
|
||||
|
||||
Confirmation deletes the complete group subtree atomically. The inverse
|
||||
command stores the complete stable snapshots needed to restore all UUIDs,
|
||||
fields, links, protection devices, group relationships, identifiers and sort
|
||||
positions.
|
||||
|
||||
A stale or partially changed subtree must not be silently deleted or restored.
|
||||
|
||||
## UI Projection
|
||||
|
||||
The circuit-list editor projects three fixed zones:
|
||||
|
||||
1. header:
|
||||
- `-Q0 Hauptschalter`
|
||||
- `-FA Überspannungsableiter`
|
||||
2. groups:
|
||||
- group heading
|
||||
- optional upstream protection row
|
||||
- optional group RCD row
|
||||
- circuit rows and their optional expanded device rows
|
||||
3. footer:
|
||||
- auxiliary distribution-board components
|
||||
|
||||
Group indentation is optional presentation. Group ownership must remain clear
|
||||
through headings, separators or a dedicated group indication.
|
||||
|
||||
The grid remains a custom spreadsheet component:
|
||||
|
||||
- static text by default
|
||||
- edit mode only when invoked
|
||||
- group and auxiliary-component drag handles
|
||||
- clear valid and invalid drop indicators
|
||||
- German user-facing labels
|
||||
|
||||
Protection editing must show only fields valid for the selected device type.
|
||||
Changing the type resets or explicitly confirms removal of fields that the new
|
||||
type cannot use.
|
||||
|
||||
## History and Command Boundary
|
||||
|
||||
New project-scoped writes must use typed persistent commands and the existing
|
||||
shared project-command transaction boundary.
|
||||
|
||||
Required logical operations:
|
||||
|
||||
- insert/update/delete auxiliary component
|
||||
- reorder auxiliary components
|
||||
- insert/update/delete group protection component
|
||||
- insert/delete group
|
||||
- reorder groups
|
||||
- explicitly renumber groups
|
||||
- move circuit between same-category groups with deterministic BMK assignment
|
||||
- update circuit protection device
|
||||
|
||||
Creating a distribution board includes its fixed components, three default
|
||||
groups, circuit list and all stable UUIDs in one `distribution-board.insert`
|
||||
command.
|
||||
|
||||
Every command must:
|
||||
|
||||
- require `expectedRevision`
|
||||
- validate complete project/list/group ownership
|
||||
- validate shared BMK uniqueness inside the write transaction
|
||||
- contain deterministic forward and inverse data
|
||||
- commit the domain mutation, revision and history transition atomically
|
||||
- remain undoable and redoable after application restart
|
||||
|
||||
## Snapshot and Transfer Compatibility
|
||||
|
||||
The supported logical project snapshot and portable JSON transfer must include:
|
||||
|
||||
- group category and group number
|
||||
- fixed, group and auxiliary components
|
||||
- circuit protection-device state
|
||||
- all new component relationships and sort positions
|
||||
|
||||
Introducing the model requires a snapshot-schema version increase and explicit
|
||||
upgraders for supported older payloads.
|
||||
|
||||
Database migration rules:
|
||||
|
||||
- preserve all existing stable UUIDs
|
||||
- preserve existing circuit equipment identifiers during schema migration
|
||||
- map the existing lighting, single-phase and three-phase sections to group 1
|
||||
of the respective category
|
||||
- preserve an existing unassigned section only when required by existing data;
|
||||
it is not created for a new distribution board
|
||||
- preserve existing flat circuit protection values when creating the new
|
||||
one-to-one protection state
|
||||
- do not silently replace an existing protection selection with a new default
|
||||
- allow legacy identifiers to remain until an explicit, collision-safe
|
||||
conversion or renumber action is approved
|
||||
|
||||
New defaults apply to newly created circuits and boards, not as an implicit
|
||||
rewrite of existing planning data.
|
||||
|
||||
Historical persisted commands and snapshots that remain in the supported
|
||||
compatibility window must still deserialize and execute after the migration.
|
||||
|
||||
## Later Sizing and Warning Engine
|
||||
|
||||
The selected protection device is the planner-owned value. Later sizing logic
|
||||
will calculate a recommendation separately.
|
||||
|
||||
Target behavior:
|
||||
|
||||
```text
|
||||
stored selection
|
||||
-> calculation and validation
|
||||
-> valid: no warning
|
||||
-> invalid: colored warning plus recommended protection
|
||||
-> user explicitly accepts or rejects the recommendation
|
||||
```
|
||||
|
||||
The same pattern applies later to cable cross-section and length. Suggestions
|
||||
never silently overwrite selected values.
|
||||
|
||||
Dimensioning rules and additional inputs are defined in a separate future
|
||||
specification supplied by the planner.
|
||||
|
||||
## Delivery Phases
|
||||
|
||||
### A. Catalog and Pure Domain Rules
|
||||
|
||||
Status: Complete.
|
||||
|
||||
- introduce typed component roles, categories and protection-device types
|
||||
- centralize rating and characteristic catalogs
|
||||
- validate device-specific field combinations
|
||||
- define default circuit and group-RCD protection values
|
||||
- test numbering parsing and formatting independently from persistence
|
||||
|
||||
Implemented foundation:
|
||||
|
||||
- shared circuit-group categories, German labels and category numbers
|
||||
- one protection-device catalog with all agreed ratings and characteristics
|
||||
- strict device-dependent validation for fuse, LS, FI, FI/LS and AFDD fields
|
||||
- pure factories for circuit and group-RCD defaults
|
||||
- pure nested BMK formatting, parsing and highest-number-plus-one rules
|
||||
- no runtime database or editor integration before delivery phase B
|
||||
|
||||
Acceptance:
|
||||
|
||||
- every agreed valid combination is accepted
|
||||
- invalid rating/type/field combinations are rejected
|
||||
- defaults produce the exact agreed values
|
||||
|
||||
### B. Persistence and Compatibility Model
|
||||
|
||||
Status: Complete.
|
||||
|
||||
- add group category and group number
|
||||
- add distribution-board component persistence
|
||||
- add one-to-one circuit protection persistence
|
||||
- implement shared BMK uniqueness
|
||||
- generate and inspect one additive migration
|
||||
- add snapshot/transfer schema upgrade
|
||||
- preserve existing identifiers and protection values
|
||||
|
||||
Implemented in B1:
|
||||
|
||||
- additive migration `0024` adds group identity to circuit sections and maps
|
||||
the three established sections to group 1 without changing UUIDs
|
||||
- separate component and circuit/component protection tables preserve the
|
||||
one-to-one ownership boundaries
|
||||
- a trigger-maintained circuit-list registry rejects normalized BMK collisions
|
||||
across circuits and distribution-board components
|
||||
- the existing flat circuit protection fields remain untouched until their
|
||||
deterministic snapshot/runtime transition is implemented
|
||||
- clean and populated pre-migration databases are covered by migration tests
|
||||
- newly created legacy-compatible board structures receive the same initial
|
||||
group identity while their persisted command format remains unchanged
|
||||
|
||||
Implemented in B2:
|
||||
|
||||
- snapshot schema version 7 includes group identity, distribution-board
|
||||
components and both one-to-one protection collections
|
||||
- capture, named and automatic snapshots, restore, Undo/Redo and portable JSON
|
||||
transfers preserve the complete new relational state
|
||||
- duplicate imports remap component UUIDs and both protection-owner references
|
||||
together with the existing project structure
|
||||
- versions 1 through 6 remain readable; version 6 maps the three established
|
||||
section keys to group 1 and leaves new collections empty
|
||||
- older flat circuit protection values remain present and are not guessed into
|
||||
strict new protection configurations
|
||||
- current snapshots reject invalid ownership, duplicate group numbers,
|
||||
cross-entity BMKs and invalid protection configurations before persistence
|
||||
|
||||
Acceptance:
|
||||
|
||||
- a current database migrates without data loss
|
||||
- an empty database creates the complete schema
|
||||
- old supported snapshots and transfers upgrade deterministically
|
||||
- BMK collisions across circuits and components are rejected
|
||||
|
||||
### C. Persistent Commands and Board Defaults
|
||||
|
||||
Status: Complete.
|
||||
|
||||
- extend `distribution-board.insert` with fixed components and three groups
|
||||
- implement component and group CRUD commands
|
||||
- implement group and footer reordering
|
||||
- add complete inverse snapshots and rollback tests
|
||||
|
||||
Implemented in C1:
|
||||
|
||||
- `distribution-board.insert` schema version 3 carries stable UUIDs for three
|
||||
initial groups, main switch `-Q0` and surge protective device `-FA`
|
||||
- its inverse verifies and removes the unchanged complete structure; Redo
|
||||
restores the same UUIDs
|
||||
- a late revision/history failure rolls back board, list, groups and components
|
||||
together
|
||||
- stored schema versions 1 and 2 retain four legacy sections and receive no
|
||||
invented components
|
||||
|
||||
Implemented in C2a1:
|
||||
|
||||
- `distribution-board-component.insert` and
|
||||
`distribution-board-component.delete` persist complete component snapshots
|
||||
with their optional one-to-one protection-device configuration
|
||||
- mutable group protection components validate their group ownership and
|
||||
protection-device combination before writing
|
||||
- auxiliary components remain footer entries without protection-device data
|
||||
- fixed header roles are excluded from these general CRUD commands
|
||||
- deletion requires an exact unchanged snapshot; Undo/Redo restores the same
|
||||
component UUID and a late revision/history failure rolls back the complete
|
||||
write
|
||||
|
||||
Implemented in C2a2:
|
||||
|
||||
- `distribution-board-component.update` exchanges one complete expected
|
||||
component/protection snapshot for one complete target snapshot
|
||||
- name, BMK, protection configuration and `sortOrder` can change atomically
|
||||
- component id, list/group ownership, role and placement zone remain immutable
|
||||
through this general update command
|
||||
- stale expected state and cross-entity BMK collisions are rejected; Undo/Redo
|
||||
restores the exact previous snapshot
|
||||
|
||||
Implemented in C2b1:
|
||||
|
||||
- `circuit-group.insert`, `circuit-group.update` and
|
||||
`circuit-group.delete` persist complete group snapshots with stable UUIDs
|
||||
- category, group number and prefix are validated as one consistent identity;
|
||||
general updates change only the display name
|
||||
- deletion requires an exact unchanged and empty group; populated destructive
|
||||
deletion remains the separately confirmed Phase-D operation
|
||||
- project/list ownership, stale state, Undo/Redo and late rollback are covered
|
||||
at the shared transaction boundary
|
||||
|
||||
Implemented in C2b2:
|
||||
|
||||
- `circuit-group.reorder` requires every group in the circuit list with its
|
||||
exact expected and target `sortOrder`
|
||||
- sorting changes no group number, prefix or circuit equipment identifier
|
||||
- incomplete or stale assignments are rejected before history is recorded
|
||||
- the complete reorder is one project revision and one persistent Undo/Redo
|
||||
step; late failures roll back every position
|
||||
|
||||
Acceptance:
|
||||
|
||||
- new boards contain the agreed fixed structure
|
||||
- every structural write survives reload and supports Undo/Redo
|
||||
- forced late failures leave no partial components or groups
|
||||
|
||||
### D. Group Numbering and Circuit Moves
|
||||
|
||||
Status: Complete.
|
||||
|
||||
- implement nested identifier generation
|
||||
- support same-category cross-group circuit moves
|
||||
- implement explicit collision-safe group renumbering
|
||||
- implement confirmed populated-group deletion
|
||||
|
||||
Implemented in D1a:
|
||||
|
||||
- a pure renumber planner derives group prefixes, circuit BMKs and optional
|
||||
upstream-protection/RCD BMKs from category and target group number
|
||||
- outgoing-circuit suffixes remain unchanged
|
||||
- number swaps are allowed only when every conflicting group participates
|
||||
- collisions with unchanged groups, no-op mappings and mismatched current BMKs
|
||||
are rejected before persistence
|
||||
|
||||
Implemented in D1b:
|
||||
|
||||
- `circuit-group.renumber` persists the complete expected/target plan as one
|
||||
project revision
|
||||
- group-number swaps use collision-safe temporary group identities and BMKs
|
||||
across both circuit and component tables
|
||||
- every current circuit and group component must be included unchanged before
|
||||
execution starts
|
||||
- Undo/Redo uses the exact inverse plan; late revision/history failures roll
|
||||
back every group, prefix and BMK
|
||||
|
||||
Implemented in D2a:
|
||||
|
||||
- a pure planner permits moves only between distinct groups of the same
|
||||
category
|
||||
- it validates the source BMK and stores source/target group and sort position
|
||||
- the target BMK uses the highest existing target-group circuit suffix plus one
|
||||
and never fills gaps
|
||||
- the computed target BMK is part of the plan and is not recalculated for Redo
|
||||
|
||||
Implemented in D2b:
|
||||
|
||||
- `circuit.move-group` atomically changes only the circuit's group, BMK and
|
||||
sort position
|
||||
- the circuit UUID remains stable, so its device rows and one-to-one protection
|
||||
device remain connected without copying
|
||||
- execution revalidates project/list ownership, both group identities and the
|
||||
complete expected circuit state
|
||||
- Undo/Redo uses the stored inverse move and late failures restore source
|
||||
group, BMK and position
|
||||
|
||||
Implemented in D3a:
|
||||
|
||||
- one validated subtree snapshot combines the group, optional group components
|
||||
and their protection devices, complete circuits and their protection devices,
|
||||
and all circuit device rows
|
||||
- linked-project-device and override metadata remain part of every row snapshot
|
||||
- ownership and BMK uniqueness are checked across the complete subtree
|
||||
- the snapshot derives the confirmation summary for upstream protection, RCD,
|
||||
circuit count and device-row count
|
||||
|
||||
Implemented in D3b:
|
||||
|
||||
- `circuit-group.delete-subtree` requires the exact previously confirmed
|
||||
snapshot and re-captures the full current subtree before deletion
|
||||
- `circuit-group.restore-subtree` restores stable UUIDs in foreign-key-safe
|
||||
order, including both protection layers and row link/override metadata
|
||||
- deletion and restoration are exact persistent inverses for Undo/Redo
|
||||
- stale confirmation state and late history failures leave no partial delete
|
||||
or restore
|
||||
|
||||
Acceptance:
|
||||
|
||||
- target identifiers use highest suffix plus one
|
||||
- reordering never renumbers
|
||||
- cross-category moves are rejected
|
||||
- group renumbering preserves circuit suffixes
|
||||
- delete, move and renumber Undo restore exact prior UUIDs and BMKs
|
||||
|
||||
### E. Editor Projection and Editing
|
||||
|
||||
Status: In progress. The complete tree read model E1 and pure structural
|
||||
projection E2a are complete. Grid rendering and editing remain pending.
|
||||
|
||||
- render fixed header components
|
||||
- render group components and circuit blocks
|
||||
- render sortable auxiliary footer components
|
||||
- add group management and destructive confirmations
|
||||
- add type-dependent protection editing
|
||||
- keep spreadsheet selection, keyboard and drag behavior coherent
|
||||
|
||||
Implemented in E1/E2a:
|
||||
|
||||
- the tree response exposes fixed header, group and auxiliary footer
|
||||
components plus both one-to-one protection layers
|
||||
- a pure projection produces the stable order header, complete groups and
|
||||
footer without mixing zones
|
||||
- each group keeps its heading, optional protection components, complete
|
||||
circuit blocks and free placeholder together
|
||||
- empty groups remain visible and circuit device rows remain owned by their
|
||||
circuit block
|
||||
|
||||
Acceptance:
|
||||
|
||||
- the visible order is header, groups, footer
|
||||
- users can create, configure, reorder and delete groups
|
||||
- users can configure circuit and group protection without invalid fields
|
||||
- device rows remain visually and technically subordinate to their circuit
|
||||
|
||||
### F. Documentation and Full Verification
|
||||
|
||||
- update current architecture, API and interaction documentation
|
||||
- update demo/sample data
|
||||
- run focused domain and transaction tests
|
||||
- run the complete required test, build and type-check suite
|
||||
- perform the agreed GUI test checklist
|
||||
|
||||
Acceptance:
|
||||
|
||||
- documentation distinguishes implemented behavior from later sizing
|
||||
- clean install and migrated database both work
|
||||
- snapshots, JSON transfer and Undo/Redo cover all new state
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- Mixed legacy and grouped BMKs:
|
||||
preserve old identifiers and provide an explicit conversion path.
|
||||
- Cross-entity BMK collisions:
|
||||
enforce one transaction-level uniqueness boundary for the complete list.
|
||||
- Partial destructive writes:
|
||||
persist complete subtree commands through the shared transaction wrapper.
|
||||
- Bloated grid logic:
|
||||
add pure projection and ownership modules before React rendering changes.
|
||||
- Invalid protection combinations:
|
||||
use one shared typed catalog for UI, API and domain validation.
|
||||
- Silent planning changes:
|
||||
apply defaults only on creation and keep later recommendations explicit.
|
||||
- Snapshot incompatibility:
|
||||
version payloads and test every supported upgrade path.
|
||||
|
||||
## Open Implementation Decisions
|
||||
|
||||
These decisions may be resolved during the relevant delivery phase without
|
||||
changing the agreed domain behavior:
|
||||
|
||||
- exact relational implementation of the shared BMK ownership boundary
|
||||
- presentation of group ownership through indentation, headings or a column
|
||||
- filter behavior for structural component rows
|
||||
- explicit UI for converting retained legacy identifiers to grouped numbering
|
||||
- whether fixed header components become editable beyond name and BMK in this
|
||||
phase
|
||||
+3
-2
@@ -13,9 +13,10 @@
|
||||
"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-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.repository.test.ts tests/distribution-board-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.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-history-timeline.test.ts tests/project-version-history.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.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.repository.test.ts tests/distribution-board-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.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-history-timeline.test.ts tests/project-version-history.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": "tsx --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/distribution-board-component-schema-migration.test.ts tests/circuit-group-numbering.test.ts 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/protection-device.test.ts tests/distribution-board-component.test.ts tests/distribution-board-component-schema-migration.test.ts tests/circuit-group-numbering.test.ts 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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -8,12 +8,21 @@ const requiredTables = [
|
||||
"circuit_sections",
|
||||
"circuits",
|
||||
"circuit_device_rows",
|
||||
"circuit_list_equipment_identifiers",
|
||||
"circuit_protection_devices",
|
||||
"distribution_board_components",
|
||||
"distribution_board_component_protection_devices",
|
||||
"legacy_consumer_circuit_migrations",
|
||||
"legacy_consumer_migration_reports",
|
||||
];
|
||||
|
||||
const tablePlaceholders = requiredTables.map(() => "?").join(", ");
|
||||
const rows = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?, ?, ?)")
|
||||
.prepare(
|
||||
`SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name IN (${tablePlaceholders})`
|
||||
)
|
||||
.all(...requiredTables);
|
||||
|
||||
const existing = new Set(rows.map((row) => row.name));
|
||||
@@ -48,12 +57,89 @@ const circuitColumns = new Set(
|
||||
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
|
||||
);
|
||||
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
|
||||
const requiredCircuitSectionColumns = ["category", "group_number"];
|
||||
const circuitSectionColumns = new Set(
|
||||
db
|
||||
.prepare("PRAGMA table_info(circuit_sections)")
|
||||
.all()
|
||||
.map((column) => column.name)
|
||||
);
|
||||
const missingCircuitSectionColumns = requiredCircuitSectionColumns.filter(
|
||||
(name) => !circuitSectionColumns.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",
|
||||
"simultaneity_factor",
|
||||
];
|
||||
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 requiredEquipmentIdentifierTriggers = [
|
||||
"circuits_equipment_identifier_insert",
|
||||
"circuits_equipment_identifier_update",
|
||||
"circuits_equipment_identifier_delete",
|
||||
"distribution_board_components_equipment_identifier_insert",
|
||||
"distribution_board_components_equipment_identifier_update",
|
||||
"distribution_board_components_equipment_identifier_delete",
|
||||
];
|
||||
const equipmentIdentifierTriggers = new Set(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'trigger'`
|
||||
)
|
||||
.all()
|
||||
.map((trigger) => trigger.name)
|
||||
);
|
||||
const missingEquipmentIdentifierTriggers =
|
||||
requiredEquipmentIdentifierTriggers.filter(
|
||||
(name) => !equipmentIdentifierTriggers.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 circuit-section columns:",
|
||||
requiredCircuitSectionColumns.join(", ")
|
||||
);
|
||||
console.log("Required project columns:", requiredProjectColumns.join(", "));
|
||||
console.log(
|
||||
"Required distribution-board columns:",
|
||||
requiredDistributionBoardColumns.join(", ")
|
||||
);
|
||||
console.log(
|
||||
"Required BMK synchronization triggers:",
|
||||
requiredEquipmentIdentifierTriggers.join(", ")
|
||||
);
|
||||
console.log("Integrity:", integrityCheck);
|
||||
console.log("Foreign-key violations:", foreignKeyViolations.length);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error("Missing tables:", missing.join(", "));
|
||||
@@ -78,4 +164,38 @@ if (missingCircuitColumns.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Circuit-first schema verification passed.");
|
||||
if (missingCircuitSectionColumns.length > 0) {
|
||||
console.error(
|
||||
"Missing circuit-section columns:",
|
||||
missingCircuitSectionColumns.join(", ")
|
||||
);
|
||||
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 (missingEquipmentIdentifierTriggers.length > 0) {
|
||||
console.error(
|
||||
"Missing BMK synchronization triggers:",
|
||||
missingEquipmentIdentifierTriggers.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 and distribution-component schema verification passed.");
|
||||
|
||||
+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,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;
|
||||
@@ -0,0 +1,150 @@
|
||||
CREATE TABLE `circuit_list_equipment_identifiers` (
|
||||
`owner_type` text NOT NULL,
|
||||
`owner_id` text NOT NULL,
|
||||
`circuit_list_id` text NOT NULL,
|
||||
`equipment_identifier` text NOT NULL,
|
||||
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_owner_unique` ON `circuit_list_equipment_identifiers` (`owner_type`,`owner_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_list_identifier_unique` ON `circuit_list_equipment_identifiers` (`circuit_list_id`,`equipment_identifier`);--> statement-breakpoint
|
||||
CREATE TABLE `circuit_protection_devices` (
|
||||
`circuit_id` text PRIMARY KEY NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`rated_current_a` real NOT NULL,
|
||||
`fuse_utilization_category` text,
|
||||
`trip_characteristic` text,
|
||||
`rcd_type` text,
|
||||
`rated_residual_current_ma` real,
|
||||
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `distribution_board_component_protection_devices` (
|
||||
`component_id` text PRIMARY KEY NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`rated_current_a` real NOT NULL,
|
||||
`fuse_utilization_category` text,
|
||||
`trip_characteristic` text,
|
||||
`rcd_type` text,
|
||||
`rated_residual_current_ma` real,
|
||||
FOREIGN KEY (`component_id`) REFERENCES `distribution_board_components`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `distribution_board_components` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`circuit_list_id` text NOT NULL,
|
||||
`section_id` text,
|
||||
`equipment_identifier` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`placement` text NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`section_id`) REFERENCES `circuit_sections`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `distribution_board_components_list_identifier_unique` ON `distribution_board_components` (`circuit_list_id`,`equipment_identifier`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `distribution_board_components_section_role_unique` ON `distribution_board_components` (`section_id`,`role`);--> statement-breakpoint
|
||||
ALTER TABLE `circuit_sections` ADD `category` text;--> statement-breakpoint
|
||||
ALTER TABLE `circuit_sections` ADD `group_number` integer;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `circuit_sections_list_category_group_unique` ON `circuit_sections` (`circuit_list_id`,`category`,`group_number`);--> statement-breakpoint
|
||||
UPDATE `circuit_sections`
|
||||
SET
|
||||
`category` = CASE `key`
|
||||
WHEN 'lighting' THEN 'lighting'
|
||||
WHEN 'single_phase' THEN 'single_phase'
|
||||
WHEN 'three_phase' THEN 'three_phase'
|
||||
END,
|
||||
`group_number` = 1
|
||||
WHERE `key` IN ('lighting', 'single_phase', 'three_phase');--> statement-breakpoint
|
||||
INSERT INTO `circuit_list_equipment_identifiers` (
|
||||
`owner_type`,
|
||||
`owner_id`,
|
||||
`circuit_list_id`,
|
||||
`equipment_identifier`
|
||||
)
|
||||
SELECT
|
||||
'circuit',
|
||||
`id`,
|
||||
`circuit_list_id`,
|
||||
`equipment_identifier`
|
||||
FROM `circuits`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_normalized_unique`
|
||||
ON `circuit_list_equipment_identifiers` (
|
||||
`circuit_list_id`,
|
||||
lower(trim(`equipment_identifier`))
|
||||
);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `distribution_board_components_fixed_role_unique`
|
||||
ON `distribution_board_components` (`circuit_list_id`, `role`)
|
||||
WHERE `role` IN ('main_switch', 'surge_protective_device');--> statement-breakpoint
|
||||
CREATE TRIGGER `circuits_equipment_identifier_insert`
|
||||
AFTER INSERT ON `circuits`
|
||||
BEGIN
|
||||
INSERT INTO `circuit_list_equipment_identifiers` (
|
||||
`owner_type`,
|
||||
`owner_id`,
|
||||
`circuit_list_id`,
|
||||
`equipment_identifier`
|
||||
)
|
||||
VALUES (
|
||||
'circuit',
|
||||
NEW.`id`,
|
||||
NEW.`circuit_list_id`,
|
||||
NEW.`equipment_identifier`
|
||||
);
|
||||
END;--> statement-breakpoint
|
||||
CREATE TRIGGER `circuits_equipment_identifier_update`
|
||||
AFTER UPDATE OF `circuit_list_id`, `equipment_identifier` ON `circuits`
|
||||
BEGIN
|
||||
UPDATE `circuit_list_equipment_identifiers`
|
||||
SET
|
||||
`circuit_list_id` = NEW.`circuit_list_id`,
|
||||
`equipment_identifier` = NEW.`equipment_identifier`
|
||||
WHERE
|
||||
`owner_type` = 'circuit'
|
||||
AND `owner_id` = OLD.`id`;
|
||||
END;--> statement-breakpoint
|
||||
CREATE TRIGGER `circuits_equipment_identifier_delete`
|
||||
AFTER DELETE ON `circuits`
|
||||
BEGIN
|
||||
DELETE FROM `circuit_list_equipment_identifiers`
|
||||
WHERE
|
||||
`owner_type` = 'circuit'
|
||||
AND `owner_id` = OLD.`id`;
|
||||
END;--> statement-breakpoint
|
||||
CREATE TRIGGER `distribution_board_components_equipment_identifier_insert`
|
||||
AFTER INSERT ON `distribution_board_components`
|
||||
BEGIN
|
||||
INSERT INTO `circuit_list_equipment_identifiers` (
|
||||
`owner_type`,
|
||||
`owner_id`,
|
||||
`circuit_list_id`,
|
||||
`equipment_identifier`
|
||||
)
|
||||
VALUES (
|
||||
'distribution_board_component',
|
||||
NEW.`id`,
|
||||
NEW.`circuit_list_id`,
|
||||
NEW.`equipment_identifier`
|
||||
);
|
||||
END;--> statement-breakpoint
|
||||
CREATE TRIGGER `distribution_board_components_equipment_identifier_update`
|
||||
AFTER UPDATE OF `circuit_list_id`, `equipment_identifier`
|
||||
ON `distribution_board_components`
|
||||
BEGIN
|
||||
UPDATE `circuit_list_equipment_identifiers`
|
||||
SET
|
||||
`circuit_list_id` = NEW.`circuit_list_id`,
|
||||
`equipment_identifier` = NEW.`equipment_identifier`
|
||||
WHERE
|
||||
`owner_type` = 'distribution_board_component'
|
||||
AND `owner_id` = OLD.`id`;
|
||||
END;--> statement-breakpoint
|
||||
CREATE TRIGGER `distribution_board_components_equipment_identifier_delete`
|
||||
AFTER DELETE ON `distribution_board_components`
|
||||
BEGIN
|
||||
DELETE FROM `circuit_list_equipment_identifiers`
|
||||
WHERE
|
||||
`owner_type` = 'distribution_board_component'
|
||||
AND `owner_id` = OLD.`id`;
|
||||
END;
|
||||
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
@@ -113,6 +113,69 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "6",
|
||||
"when": 1785431250141,
|
||||
"tag": "0024_damp_mister_sinister",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitGroupMoveProjectCommand,
|
||||
createCircuitGroupMoveProjectCommand,
|
||||
invertCircuitGroupMovePlan,
|
||||
} from "../../domain/models/circuit-group-move-project-command.model.js";
|
||||
import type {
|
||||
CircuitGroupMoveProjectCommandStore,
|
||||
ExecuteCircuitGroupMoveCommandInput,
|
||||
} from "../../domain/ports/circuit-group-move-project-command.store.js";
|
||||
import { parseGroupedEquipmentIdentifier } from "../../domain/services/circuit-group-numbering.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 { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitGroupMoveProjectCommandRepository
|
||||
implements CircuitGroupMoveProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitGroupMoveCommandInput) {
|
||||
assertCircuitGroupMoveProjectCommand(input.command);
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
input: ExecuteCircuitGroupMoveCommandInput
|
||||
) {
|
||||
const plan = input.command.payload;
|
||||
const list = database
|
||||
.select({ projectId: circuitLists.projectId })
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, plan.circuitListId))
|
||||
.get();
|
||||
if (!list || list.projectId !== input.projectId) {
|
||||
throw new Error("Circuit list does not belong to project.");
|
||||
}
|
||||
const source = database
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, plan.expectedSectionId))
|
||||
.get();
|
||||
const target = database
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, plan.targetSectionId))
|
||||
.get();
|
||||
const expectedBmk = parseGroupedEquipmentIdentifier(
|
||||
plan.expectedEquipmentIdentifier
|
||||
);
|
||||
const targetBmk = parseGroupedEquipmentIdentifier(
|
||||
plan.targetEquipmentIdentifier
|
||||
);
|
||||
if (
|
||||
!source ||
|
||||
!target ||
|
||||
source.circuitListId !== plan.circuitListId ||
|
||||
target.circuitListId !== plan.circuitListId ||
|
||||
source.category === null ||
|
||||
source.category !== target.category ||
|
||||
source.category !== expectedBmk?.category ||
|
||||
source.groupNumber !== expectedBmk.groupNumber ||
|
||||
target.category !== targetBmk?.category ||
|
||||
target.groupNumber !== targetBmk.groupNumber
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit move requires matching source and target groups."
|
||||
);
|
||||
}
|
||||
const updated = database
|
||||
.update(circuits)
|
||||
.set({
|
||||
sectionId: plan.targetSectionId,
|
||||
equipmentIdentifier: plan.targetEquipmentIdentifier,
|
||||
sortOrder: plan.targetSortOrder,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.id, plan.circuitId),
|
||||
eq(circuits.circuitListId, plan.circuitListId),
|
||||
eq(circuits.sectionId, plan.expectedSectionId),
|
||||
eq(
|
||||
circuits.equipmentIdentifier,
|
||||
plan.expectedEquipmentIdentifier
|
||||
),
|
||||
eq(circuits.sortOrder, plan.expectedSortOrder)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Circuit changed before group move.");
|
||||
}
|
||||
return createCircuitGroupMoveProjectCommand(
|
||||
invertCircuitGroupMovePlan(plan)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitGroupRenumberProjectCommand,
|
||||
createCircuitGroupRenumberProjectCommand,
|
||||
type CircuitGroupRenumberProjectCommand,
|
||||
} from "../../domain/models/circuit-group-renumber-project-command.model.js";
|
||||
import type { CircuitGroupRenumberProjectCommandStore } from "../../domain/ports/circuit-group-renumber-project-command.store.js";
|
||||
import type { ExecuteCircuitGroupRenumberCommandInput } from "../../domain/ports/circuit-group-renumber-project-command.store.js";
|
||||
import type { CircuitGroupRenumberPlan } from "../../domain/services/circuit-group-renumbering.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 { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitGroupRenumberProjectCommandRepository
|
||||
implements CircuitGroupRenumberProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitGroupRenumberCommandInput) {
|
||||
assertCircuitGroupRenumberProjectCommand(input.command);
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: CircuitGroupRenumberProjectCommand
|
||||
) {
|
||||
const { circuitListId, groups } = command.payload;
|
||||
this.assertCurrentState(database, projectId, circuitListId, groups);
|
||||
const inverse = createCircuitGroupRenumberProjectCommand(
|
||||
circuitListId,
|
||||
invertPlan(groups)
|
||||
);
|
||||
const occupiedIdentifiers = new Set(
|
||||
[
|
||||
...database
|
||||
.select({ value: circuits.equipmentIdentifier })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.circuitListId, circuitListId))
|
||||
.all(),
|
||||
...database
|
||||
.select({ value: distributionBoardComponents.equipmentIdentifier })
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
eq(distributionBoardComponents.circuitListId, circuitListId)
|
||||
)
|
||||
.all(),
|
||||
].map(({ value }) => normalizeIdentifier(value))
|
||||
);
|
||||
const temporaryIdentifiers = new Map<string, string>();
|
||||
for (const group of groups) {
|
||||
for (const assignment of [
|
||||
...group.circuits.map((entry) => ({
|
||||
id: entry.circuitId,
|
||||
expected: entry.expectedEquipmentIdentifier,
|
||||
table: "circuit" as const,
|
||||
})),
|
||||
...group.components.map((entry) => ({
|
||||
id: entry.componentId,
|
||||
expected: entry.expectedEquipmentIdentifier,
|
||||
table: "component" as const,
|
||||
})),
|
||||
]) {
|
||||
const key = `${assignment.table}:${assignment.id}`;
|
||||
const temporary = createTemporaryIdentifier(
|
||||
key,
|
||||
occupiedIdentifiers
|
||||
);
|
||||
temporaryIdentifiers.set(key, temporary);
|
||||
this.updateEntityIdentifier(
|
||||
database,
|
||||
assignment.table,
|
||||
assignment.id,
|
||||
circuitListId,
|
||||
assignment.expected,
|
||||
temporary
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const occupiedPrefixes = new Set(
|
||||
database
|
||||
.select({ prefix: circuitSections.prefix })
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||
.all()
|
||||
.map(({ prefix }) => prefix)
|
||||
);
|
||||
const temporaryPrefixes = new Map<string, string>();
|
||||
for (const group of groups) {
|
||||
const temporaryPrefix = createTemporaryValue(
|
||||
`__tmp_group_prefix_${group.groupId}`,
|
||||
occupiedPrefixes
|
||||
);
|
||||
temporaryPrefixes.set(group.groupId, temporaryPrefix);
|
||||
const updated = database
|
||||
.update(circuitSections)
|
||||
.set({ groupNumber: null, prefix: temporaryPrefix })
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, group.groupId),
|
||||
eq(circuitSections.circuitListId, circuitListId),
|
||||
eq(circuitSections.groupNumber, group.expectedGroupNumber),
|
||||
eq(circuitSections.prefix, group.expectedPrefix)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Circuit group changed during renumbering.");
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
const updated = database
|
||||
.update(circuitSections)
|
||||
.set({
|
||||
groupNumber: group.targetGroupNumber,
|
||||
prefix: group.targetPrefix,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, group.groupId),
|
||||
eq(circuitSections.circuitListId, circuitListId),
|
||||
eq(circuitSections.prefix, temporaryPrefixes.get(group.groupId)!)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Circuit group changed during final renumbering.");
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
for (const assignment of group.circuits) {
|
||||
this.updateEntityIdentifier(
|
||||
database,
|
||||
"circuit",
|
||||
assignment.circuitId,
|
||||
circuitListId,
|
||||
temporaryIdentifiers.get(`circuit:${assignment.circuitId}`)!,
|
||||
assignment.targetEquipmentIdentifier
|
||||
);
|
||||
}
|
||||
for (const assignment of group.components) {
|
||||
this.updateEntityIdentifier(
|
||||
database,
|
||||
"component",
|
||||
assignment.componentId,
|
||||
circuitListId,
|
||||
temporaryIdentifiers.get(`component:${assignment.componentId}`)!,
|
||||
assignment.targetEquipmentIdentifier
|
||||
);
|
||||
}
|
||||
}
|
||||
return inverse;
|
||||
}
|
||||
|
||||
private assertCurrentState(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitListId: string,
|
||||
groups: CircuitGroupRenumberPlan["groups"]
|
||||
) {
|
||||
const list = database
|
||||
.select({ projectId: circuitLists.projectId })
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, circuitListId))
|
||||
.get();
|
||||
if (!list || list.projectId !== projectId) {
|
||||
throw new Error("Circuit-group list does not belong to project.");
|
||||
}
|
||||
const selectedIds = new Set(groups.map((group) => group.groupId));
|
||||
const allGroups = database
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||
.all();
|
||||
for (const group of groups) {
|
||||
const current = allGroups.find((entry) => entry.id === group.groupId);
|
||||
if (
|
||||
!current ||
|
||||
current.category !== group.category ||
|
||||
current.groupNumber !== group.expectedGroupNumber ||
|
||||
current.prefix !== group.expectedPrefix
|
||||
) {
|
||||
throw new Error("Circuit group changed before renumbering.");
|
||||
}
|
||||
const currentCircuits = database
|
||||
.select({
|
||||
id: circuits.id,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
})
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, group.groupId))
|
||||
.all();
|
||||
const currentComponents = database
|
||||
.select({
|
||||
id: distributionBoardComponents.id,
|
||||
equipmentIdentifier:
|
||||
distributionBoardComponents.equipmentIdentifier,
|
||||
})
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.sectionId, group.groupId))
|
||||
.all();
|
||||
if (
|
||||
!matchesAssignments(currentCircuits, group.circuits, "circuitId") ||
|
||||
!matchesAssignments(
|
||||
currentComponents,
|
||||
group.components,
|
||||
"componentId"
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit-group renumber must include every unchanged child."
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const group of groups) {
|
||||
if (
|
||||
allGroups.some(
|
||||
(entry) =>
|
||||
!selectedIds.has(entry.id) &&
|
||||
entry.category === group.category &&
|
||||
entry.groupNumber === group.targetGroupNumber
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Target group number belongs to an unchanged group."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateEntityIdentifier(
|
||||
database: AppDatabase,
|
||||
table: "circuit" | "component",
|
||||
id: string,
|
||||
circuitListId: string,
|
||||
expected: string,
|
||||
target: string
|
||||
) {
|
||||
const schema =
|
||||
table === "circuit" ? circuits : distributionBoardComponents;
|
||||
const updated = database
|
||||
.update(schema)
|
||||
.set({ equipmentIdentifier: target })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.id, id),
|
||||
eq(schema.circuitListId, circuitListId),
|
||||
eq(schema.equipmentIdentifier, expected)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Circuit-group child changed during renumbering."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function matchesAssignments<
|
||||
TAssignment extends {
|
||||
expectedEquipmentIdentifier: string;
|
||||
},
|
||||
>(
|
||||
current: Array<{ id: string; equipmentIdentifier: string }>,
|
||||
assignments: TAssignment[],
|
||||
idField: keyof TAssignment
|
||||
) {
|
||||
if (current.length !== assignments.length) {
|
||||
return false;
|
||||
}
|
||||
const currentById = new Map(current.map((entry) => [entry.id, entry]));
|
||||
return assignments.every((assignment) => {
|
||||
const id = assignment[idField];
|
||||
return (
|
||||
typeof id === "string" &&
|
||||
currentById.get(id)?.equipmentIdentifier ===
|
||||
assignment.expectedEquipmentIdentifier
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function invertPlan(
|
||||
groups: CircuitGroupRenumberPlan["groups"]
|
||||
): CircuitGroupRenumberPlan {
|
||||
return {
|
||||
groups: groups.map((group) => ({
|
||||
...group,
|
||||
expectedGroupNumber: group.targetGroupNumber,
|
||||
targetGroupNumber: group.expectedGroupNumber,
|
||||
expectedPrefix: group.targetPrefix,
|
||||
targetPrefix: group.expectedPrefix,
|
||||
circuits: group.circuits.map((circuit) => ({
|
||||
...circuit,
|
||||
expectedEquipmentIdentifier:
|
||||
circuit.targetEquipmentIdentifier,
|
||||
targetEquipmentIdentifier:
|
||||
circuit.expectedEquipmentIdentifier,
|
||||
})),
|
||||
components: group.components.map((component) => ({
|
||||
...component,
|
||||
expectedEquipmentIdentifier:
|
||||
component.targetEquipmentIdentifier,
|
||||
targetEquipmentIdentifier:
|
||||
component.expectedEquipmentIdentifier,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createTemporaryIdentifier(
|
||||
key: string,
|
||||
occupied: Set<string>
|
||||
) {
|
||||
return createTemporaryValue(
|
||||
`__tmp_group_renumber_${key.replace(":", "_")}`,
|
||||
occupied,
|
||||
normalizeIdentifier
|
||||
);
|
||||
}
|
||||
|
||||
function createTemporaryValue(
|
||||
base: string,
|
||||
occupied: Set<string>,
|
||||
normalize: (value: string) => string = (value) => value
|
||||
) {
|
||||
let candidate = base;
|
||||
while (occupied.has(normalize(candidate))) {
|
||||
candidate += "_";
|
||||
}
|
||||
occupied.add(normalize(candidate));
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function normalizeIdentifier(value: string) {
|
||||
return value.trim().toLocaleLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitGroupDeleteProjectCommand,
|
||||
assertCircuitGroupInsertProjectCommand,
|
||||
assertCircuitGroupReorderProjectCommand,
|
||||
assertCircuitGroupUpdateProjectCommand,
|
||||
circuitGroupDeleteCommandType,
|
||||
circuitGroupInsertCommandType,
|
||||
circuitGroupReorderCommandType,
|
||||
circuitGroupUpdateCommandType,
|
||||
createCircuitGroupDeleteProjectCommand,
|
||||
createCircuitGroupInsertProjectCommand,
|
||||
createCircuitGroupReorderProjectCommand,
|
||||
createCircuitGroupUpdateProjectCommand,
|
||||
type CircuitGroupReorderAssignment,
|
||||
type CircuitGroupSnapshot,
|
||||
type CircuitGroupStructureProjectCommand,
|
||||
} from "../../domain/models/circuit-group-structure-project-command.model.js";
|
||||
import type {
|
||||
CircuitGroupStructureProjectCommandStore,
|
||||
ExecuteCircuitGroupStructureCommandInput,
|
||||
} from "../../domain/ports/circuit-group-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 { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitGroupStructureProjectCommandRepository
|
||||
implements CircuitGroupStructureProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitGroupStructureCommandInput) {
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: CircuitGroupStructureProjectCommand
|
||||
): CircuitGroupStructureProjectCommand {
|
||||
if (command.type === circuitGroupInsertCommandType) {
|
||||
assertCircuitGroupInsertProjectCommand(command);
|
||||
this.insert(database, projectId, command.payload.snapshot);
|
||||
return createCircuitGroupDeleteProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
if (command.type === circuitGroupDeleteCommandType) {
|
||||
assertCircuitGroupDeleteProjectCommand(command);
|
||||
this.delete(database, projectId, command.payload.snapshot);
|
||||
return createCircuitGroupInsertProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
if (command.type === circuitGroupUpdateCommandType) {
|
||||
assertCircuitGroupUpdateProjectCommand(command);
|
||||
this.update(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.expected,
|
||||
command.payload.target
|
||||
);
|
||||
return createCircuitGroupUpdateProjectCommand(
|
||||
command.payload.target,
|
||||
command.payload.expected
|
||||
);
|
||||
}
|
||||
if (command.type === circuitGroupReorderCommandType) {
|
||||
assertCircuitGroupReorderProjectCommand(command);
|
||||
const inverseAssignments = this.reorder(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.circuitListId,
|
||||
command.payload.assignments
|
||||
);
|
||||
return createCircuitGroupReorderProjectCommand(
|
||||
command.payload.circuitListId,
|
||||
inverseAssignments
|
||||
);
|
||||
}
|
||||
throw new Error("Unsupported circuit-group structure command.");
|
||||
}
|
||||
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: CircuitGroupSnapshot
|
||||
) {
|
||||
this.assertListOwnership(database, projectId, snapshot.circuitListId);
|
||||
const existing = database
|
||||
.select({ id: circuitSections.id })
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Circuit-group id already exists.");
|
||||
}
|
||||
database.insert(circuitSections).values(snapshot).run();
|
||||
}
|
||||
|
||||
private delete(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: CircuitGroupSnapshot
|
||||
) {
|
||||
this.assertListOwnership(database, projectId, snapshot.circuitListId);
|
||||
const current = this.getCurrent(database, snapshot.id);
|
||||
if (!current || !sameRecord(snapshot, current)) {
|
||||
throw new Error("Circuit group changed before deletion.");
|
||||
}
|
||||
const circuit = database
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, snapshot.id))
|
||||
.get();
|
||||
const component = database
|
||||
.select({ id: distributionBoardComponents.id })
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.sectionId, snapshot.id))
|
||||
.get();
|
||||
if (circuit || component) {
|
||||
throw new Error(
|
||||
"Only empty circuit groups can be deleted by this command."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(circuitSections)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, snapshot.id),
|
||||
eq(circuitSections.circuitListId, snapshot.circuitListId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Circuit group could not be deleted.");
|
||||
}
|
||||
}
|
||||
|
||||
private update(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: CircuitGroupSnapshot,
|
||||
target: CircuitGroupSnapshot
|
||||
) {
|
||||
this.assertListOwnership(database, projectId, expected.circuitListId);
|
||||
const current = this.getCurrent(database, expected.id);
|
||||
if (!current || !sameRecord(expected, current)) {
|
||||
throw new Error("Circuit group changed before update.");
|
||||
}
|
||||
const updated = database
|
||||
.update(circuitSections)
|
||||
.set({ displayName: target.displayName })
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, expected.id),
|
||||
eq(circuitSections.circuitListId, expected.circuitListId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Circuit group could not be updated.");
|
||||
}
|
||||
}
|
||||
|
||||
private assertListOwnership(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitListId: string
|
||||
) {
|
||||
const list = database
|
||||
.select({ projectId: circuitLists.projectId })
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, circuitListId))
|
||||
.get();
|
||||
if (!list || list.projectId !== projectId) {
|
||||
throw new Error("Circuit group does not belong to project.");
|
||||
}
|
||||
}
|
||||
|
||||
private reorder(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitListId: string,
|
||||
assignments: CircuitGroupReorderAssignment[]
|
||||
) {
|
||||
this.assertListOwnership(database, projectId, circuitListId);
|
||||
const persisted = database
|
||||
.select({
|
||||
id: circuitSections.id,
|
||||
sortOrder: circuitSections.sortOrder,
|
||||
})
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||
.all();
|
||||
const persistedById = new Map(
|
||||
persisted.map((group) => [group.id, group])
|
||||
);
|
||||
if (
|
||||
persisted.length !== assignments.length ||
|
||||
assignments.some((assignment) => {
|
||||
const group = persistedById.get(assignment.groupId);
|
||||
return !group || group.sortOrder !== assignment.expectedSortOrder;
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit-group reorder must include every unchanged group in the list."
|
||||
);
|
||||
}
|
||||
for (const assignment of assignments) {
|
||||
if (assignment.expectedSortOrder === assignment.targetSortOrder) {
|
||||
continue;
|
||||
}
|
||||
const updated = database
|
||||
.update(circuitSections)
|
||||
.set({ sortOrder: assignment.targetSortOrder })
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, assignment.groupId),
|
||||
eq(circuitSections.circuitListId, circuitListId),
|
||||
eq(circuitSections.sortOrder, assignment.expectedSortOrder)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Circuit group changed during reorder.");
|
||||
}
|
||||
}
|
||||
return assignments.map((assignment) => ({
|
||||
groupId: assignment.groupId,
|
||||
expectedSortOrder: assignment.targetSortOrder,
|
||||
targetSortOrder: assignment.expectedSortOrder,
|
||||
}));
|
||||
}
|
||||
|
||||
private getCurrent(database: AppDatabase, id: string) {
|
||||
return database
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, id))
|
||||
.get();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import {
|
||||
circuitGroupDeleteSubtreeCommandType,
|
||||
circuitGroupRestoreSubtreeCommandType,
|
||||
assertCircuitGroupDeleteSubtreeProjectCommand,
|
||||
assertCircuitGroupRestoreSubtreeProjectCommand,
|
||||
createCircuitGroupDeleteSubtreeProjectCommand,
|
||||
createCircuitGroupRestoreSubtreeProjectCommand,
|
||||
type CircuitGroupSubtreeProjectCommand,
|
||||
} from "../../domain/models/circuit-group-subtree-project-command.model.js";
|
||||
import type {
|
||||
CircuitGroupSubtreeSnapshot,
|
||||
CircuitProtectionDeviceSnapshot,
|
||||
} from "../../domain/models/circuit-group-subtree-snapshot.model.js";
|
||||
import type {
|
||||
CircuitGroupSubtreeProjectCommandStore,
|
||||
ExecuteCircuitGroupSubtreeCommandInput,
|
||||
} from "../../domain/ports/circuit-group-subtree-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitGroupSubtreeProjectCommandRepository
|
||||
implements CircuitGroupSubtreeProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
capture(projectId: string, groupId: string) {
|
||||
return this.captureFrom(this.database, projectId, groupId);
|
||||
}
|
||||
|
||||
execute(input: ExecuteCircuitGroupSubtreeCommandInput) {
|
||||
if (input.command.type === circuitGroupDeleteSubtreeCommandType) {
|
||||
assertCircuitGroupDeleteSubtreeProjectCommand(input.command);
|
||||
} else {
|
||||
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
|
||||
}
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: CircuitGroupSubtreeProjectCommand
|
||||
) {
|
||||
if (command.type === circuitGroupDeleteSubtreeCommandType) {
|
||||
const current = this.captureFrom(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.snapshot.group.id
|
||||
);
|
||||
if (!sameSnapshot(current, command.payload.snapshot)) {
|
||||
throw new Error(
|
||||
"Circuit-group subtree changed before deletion."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(circuitSections)
|
||||
.where(eq(circuitSections.id, current.group.id))
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Circuit-group subtree could not be deleted.");
|
||||
}
|
||||
return createCircuitGroupRestoreSubtreeProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
if (command.type === circuitGroupRestoreSubtreeCommandType) {
|
||||
this.restore(database, projectId, command.payload.snapshot);
|
||||
return createCircuitGroupDeleteSubtreeProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
throw new Error("Unsupported circuit-group subtree command.");
|
||||
}
|
||||
|
||||
private captureFrom(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
groupId: string
|
||||
): CircuitGroupSubtreeSnapshot {
|
||||
const group = database
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, groupId))
|
||||
.get();
|
||||
if (!group || group.category === null || group.groupNumber === null) {
|
||||
throw new Error("Circuit group not found.");
|
||||
}
|
||||
this.assertListOwnership(database, projectId, group.circuitListId);
|
||||
const components = database
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.sectionId, group.id))
|
||||
.orderBy(
|
||||
asc(distributionBoardComponents.sortOrder),
|
||||
asc(distributionBoardComponents.id)
|
||||
)
|
||||
.all()
|
||||
.map((component) => ({
|
||||
component: {
|
||||
...component,
|
||||
role: component.role as
|
||||
| "group_upstream_protection"
|
||||
| "group_residual_current_protection",
|
||||
},
|
||||
protectionDevice:
|
||||
database
|
||||
.select()
|
||||
.from(distributionBoardComponentProtectionDevices)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponentProtectionDevices.componentId,
|
||||
component.id
|
||||
)
|
||||
)
|
||||
.get() ?? null,
|
||||
}));
|
||||
const circuitSnapshots = database
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, group.id))
|
||||
.orderBy(asc(circuits.sortOrder), asc(circuits.id))
|
||||
.all()
|
||||
.map((circuit) => {
|
||||
const { isReserve, ...values } = circuit;
|
||||
return {
|
||||
circuit: {
|
||||
...values,
|
||||
isReserve: Boolean(isReserve),
|
||||
deviceRows: database
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, circuit.id))
|
||||
.orderBy(
|
||||
asc(circuitDeviceRows.sortOrder),
|
||||
asc(circuitDeviceRows.id)
|
||||
)
|
||||
.all(),
|
||||
},
|
||||
protectionDevice:
|
||||
(database
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
|
||||
.get() as CircuitProtectionDeviceSnapshot | undefined) ??
|
||||
null,
|
||||
};
|
||||
});
|
||||
return {
|
||||
group: {
|
||||
...group,
|
||||
category: group.category,
|
||||
groupNumber: group.groupNumber,
|
||||
},
|
||||
components,
|
||||
circuits: circuitSnapshots,
|
||||
};
|
||||
}
|
||||
|
||||
private restore(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: CircuitGroupSubtreeSnapshot
|
||||
) {
|
||||
this.assertListOwnership(
|
||||
database,
|
||||
projectId,
|
||||
snapshot.group.circuitListId
|
||||
);
|
||||
const existing = database
|
||||
.select({ id: circuitSections.id })
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.group.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Circuit group already exists before restoration.");
|
||||
}
|
||||
database.insert(circuitSections).values(snapshot.group).run();
|
||||
for (const component of snapshot.components) {
|
||||
database
|
||||
.insert(distributionBoardComponents)
|
||||
.values(component.component)
|
||||
.run();
|
||||
if (component.protectionDevice !== null) {
|
||||
database
|
||||
.insert(distributionBoardComponentProtectionDevices)
|
||||
.values(component.protectionDevice)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
for (const entry of snapshot.circuits) {
|
||||
const { deviceRows, isReserve, ...circuit } = entry.circuit;
|
||||
database
|
||||
.insert(circuits)
|
||||
.values({ ...circuit, isReserve: isReserve ? 1 : 0 })
|
||||
.run();
|
||||
if (entry.protectionDevice !== null) {
|
||||
database
|
||||
.insert(circuitProtectionDevices)
|
||||
.values(entry.protectionDevice)
|
||||
.run();
|
||||
}
|
||||
if (deviceRows.length > 0) {
|
||||
database.insert(circuitDeviceRows).values(deviceRows).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private assertListOwnership(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitListId: string
|
||||
) {
|
||||
const list = database
|
||||
.select({ projectId: circuitLists.projectId })
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, circuitListId))
|
||||
.get();
|
||||
if (!list || list.projectId !== projectId) {
|
||||
throw new Error("Circuit-group list does not belong to project.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameSnapshot(
|
||||
left: CircuitGroupSubtreeSnapshot,
|
||||
right: CircuitGroupSubtreeSnapshot
|
||||
) {
|
||||
return canonicalJson(left) === canonicalJson(right);
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(canonicalJson).join(",")}]`;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return `{${Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(
|
||||
([key, child]) =>
|
||||
`${JSON.stringify(key)}:${canonicalJson(child)}`
|
||||
)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { inArray } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
|
||||
export class CircuitProtectionDeviceRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByCircuitIds(circuitIds: string[]) {
|
||||
if (circuitIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return this.database
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(inArray(circuitProtectionDevices.circuitId, circuitIds));
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -21,8 +21,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";
|
||||
|
||||
type ReorderHistoryCommand =
|
||||
| CircuitSectionReorderProjectCommand
|
||||
@@ -36,54 +35,48 @@ export class CircuitSectionReorderProjectCommandRepository
|
||||
execute(input: ExecuteCircuitSectionReorderCommandInput) {
|
||||
this.assertSupportedCommand(input.command);
|
||||
|
||||
return this.database.transaction((tx) => {
|
||||
const commandSections = this.getCommandSections(input.command);
|
||||
const inverseSections = commandSections.map((section) => {
|
||||
this.assertCompleteCurrentSection(
|
||||
tx,
|
||||
input.projectId,
|
||||
section
|
||||
);
|
||||
return {
|
||||
sectionId: section.sectionId,
|
||||
assignments: section.assignments.map((assignment) => ({
|
||||
circuitId: assignment.circuitId,
|
||||
expectedSortOrder: assignment.targetSortOrder,
|
||||
targetSortOrder: assignment.expectedSortOrder,
|
||||
})),
|
||||
};
|
||||
});
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
for (const section of commandSections) {
|
||||
this.applyAssignments(tx, section);
|
||||
}
|
||||
|
||||
const inverse =
|
||||
input.command.type === circuitSectionReorderCommandType
|
||||
? createCircuitSectionReorderProjectCommand(
|
||||
inverseSections[0].sectionId,
|
||||
inverseSections[0].assignments
|
||||
)
|
||||
: createCircuitSectionsReorderProjectCommand(
|
||||
inverseSections
|
||||
);
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
source: input.source,
|
||||
description: input.description,
|
||||
actorId: input.actorId,
|
||||
forward: input.command,
|
||||
inverse,
|
||||
});
|
||||
applyProjectHistoryTransition(tx, {
|
||||
projectId: input.projectId,
|
||||
source: input.source,
|
||||
recordedChangeSetId: revision.changeSetId,
|
||||
targetChangeSetId: input.historyTargetChangeSetId,
|
||||
});
|
||||
return { revision, inverse };
|
||||
private applyCommand(
|
||||
tx: AppDatabase,
|
||||
input: ExecuteCircuitSectionReorderCommandInput
|
||||
) {
|
||||
const commandSections = this.getCommandSections(input.command);
|
||||
const inverseSections = commandSections.map((section) => {
|
||||
this.assertCompleteCurrentSection(
|
||||
tx,
|
||||
input.projectId,
|
||||
section
|
||||
);
|
||||
return {
|
||||
sectionId: section.sectionId,
|
||||
assignments: section.assignments.map((assignment) => ({
|
||||
circuitId: assignment.circuitId,
|
||||
expectedSortOrder: assignment.targetSortOrder,
|
||||
targetSortOrder: assignment.expectedSortOrder,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||
import { db } from "../client.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
|
||||
export function createDefaultCircuitSectionValues(circuitListId: string) {
|
||||
@@ -13,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)
|
||||
@@ -27,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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertDistributionBoardComponentDeleteProjectCommand,
|
||||
assertDistributionBoardComponentInsertProjectCommand,
|
||||
assertDistributionBoardComponentUpdateProjectCommand,
|
||||
createDistributionBoardComponentDeleteProjectCommand,
|
||||
createDistributionBoardComponentInsertProjectCommand,
|
||||
createDistributionBoardComponentUpdateProjectCommand,
|
||||
distributionBoardComponentDeleteCommandType,
|
||||
distributionBoardComponentInsertCommandType,
|
||||
distributionBoardComponentUpdateCommandType,
|
||||
type DistributionBoardComponentSnapshot,
|
||||
type DistributionBoardComponentStructureProjectCommand,
|
||||
} from "../../domain/models/distribution-board-component-structure-project-command.model.js";
|
||||
import type {
|
||||
DistributionBoardComponentStructureProjectCommandStore,
|
||||
ExecuteDistributionBoardComponentStructureCommandInput,
|
||||
} from "../../domain/ports/distribution-board-component-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 { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class DistributionBoardComponentStructureProjectCommandRepository
|
||||
implements DistributionBoardComponentStructureProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(
|
||||
input: ExecuteDistributionBoardComponentStructureCommandInput
|
||||
) {
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(
|
||||
tx,
|
||||
input.projectId,
|
||||
input.command
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: DistributionBoardComponentStructureProjectCommand
|
||||
): DistributionBoardComponentStructureProjectCommand {
|
||||
if (command.type === distributionBoardComponentInsertCommandType) {
|
||||
assertDistributionBoardComponentInsertProjectCommand(command);
|
||||
this.insert(database, projectId, command.payload.snapshot);
|
||||
return createDistributionBoardComponentDeleteProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
if (command.type === distributionBoardComponentDeleteCommandType) {
|
||||
assertDistributionBoardComponentDeleteProjectCommand(command);
|
||||
this.delete(database, projectId, command.payload.snapshot);
|
||||
return createDistributionBoardComponentInsertProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
if (command.type === distributionBoardComponentUpdateCommandType) {
|
||||
assertDistributionBoardComponentUpdateProjectCommand(command);
|
||||
this.update(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.expected,
|
||||
command.payload.target
|
||||
);
|
||||
return createDistributionBoardComponentUpdateProjectCommand(
|
||||
command.payload.target,
|
||||
command.payload.expected
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
"Unsupported distribution-board component structure command."
|
||||
);
|
||||
}
|
||||
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
this.assertOwnership(database, projectId, snapshot);
|
||||
const existing = database
|
||||
.select({ id: distributionBoardComponents.id })
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, snapshot.component.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Distribution-board component id already exists.");
|
||||
}
|
||||
database
|
||||
.insert(distributionBoardComponents)
|
||||
.values(snapshot.component)
|
||||
.run();
|
||||
if (snapshot.protectionDevice !== null) {
|
||||
database
|
||||
.insert(distributionBoardComponentProtectionDevices)
|
||||
.values(snapshot.protectionDevice)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
private delete(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
this.assertOwnership(database, projectId, snapshot);
|
||||
const component = database
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, snapshot.component.id))
|
||||
.get();
|
||||
const protection = database
|
||||
.select()
|
||||
.from(distributionBoardComponentProtectionDevices)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponentProtectionDevices.componentId,
|
||||
snapshot.component.id
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (
|
||||
!component ||
|
||||
!sameRecord(snapshot.component, component) ||
|
||||
!sameNullableRecord(snapshot.protectionDevice, protection)
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board component changed before deletion."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(distributionBoardComponents)
|
||||
.where(
|
||||
and(
|
||||
eq(distributionBoardComponents.id, component.id),
|
||||
eq(
|
||||
distributionBoardComponents.circuitListId,
|
||||
snapshot.component.circuitListId
|
||||
)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error(
|
||||
"Distribution-board component could not be deleted."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private update(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: DistributionBoardComponentSnapshot,
|
||||
target: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
this.assertOwnership(database, projectId, expected);
|
||||
this.assertOwnership(database, projectId, target);
|
||||
const component = database
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, expected.component.id))
|
||||
.get();
|
||||
const protection = database
|
||||
.select()
|
||||
.from(distributionBoardComponentProtectionDevices)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponentProtectionDevices.componentId,
|
||||
expected.component.id
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (
|
||||
!component ||
|
||||
!sameRecord(expected.component, component) ||
|
||||
!sameNullableRecord(expected.protectionDevice, protection)
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board component changed before update."
|
||||
);
|
||||
}
|
||||
database
|
||||
.update(distributionBoardComponents)
|
||||
.set(target.component)
|
||||
.where(eq(distributionBoardComponents.id, expected.component.id))
|
||||
.run();
|
||||
if (
|
||||
expected.protectionDevice !== null &&
|
||||
target.protectionDevice !== null
|
||||
) {
|
||||
database
|
||||
.update(distributionBoardComponentProtectionDevices)
|
||||
.set(target.protectionDevice)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponentProtectionDevices.componentId,
|
||||
expected.component.id
|
||||
)
|
||||
)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
private assertOwnership(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
const list = database
|
||||
.select({ projectId: circuitLists.projectId })
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, snapshot.component.circuitListId))
|
||||
.get();
|
||||
if (!list || list.projectId !== projectId) {
|
||||
throw new Error(
|
||||
"Distribution-board component circuit list does not belong to project."
|
||||
);
|
||||
}
|
||||
if (snapshot.component.sectionId !== null) {
|
||||
const section = database
|
||||
.select({ circuitListId: circuitSections.circuitListId })
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.component.sectionId))
|
||||
.get();
|
||||
if (
|
||||
!section ||
|
||||
section.circuitListId !== snapshot.component.circuitListId
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board component section does not belong to circuit list."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameNullableRecord(
|
||||
expected: object | null,
|
||||
actual: object | undefined
|
||||
) {
|
||||
return expected === null
|
||||
? actual === undefined
|
||||
: actual !== undefined && sameRecord(expected, actual);
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
|
||||
export class DistributionBoardComponentRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByCircuitList(circuitListId: string) {
|
||||
return this.database
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
eq(distributionBoardComponents.circuitListId, circuitListId)
|
||||
)
|
||||
.orderBy(
|
||||
asc(distributionBoardComponents.sortOrder),
|
||||
asc(distributionBoardComponents.id)
|
||||
);
|
||||
}
|
||||
|
||||
async listProtectionByComponentIds(componentIds: string[]) {
|
||||
if (componentIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return this.database
|
||||
.select()
|
||||
.from(distributionBoardComponentProtectionDevices)
|
||||
.where(
|
||||
inArray(
|
||||
distributionBoardComponentProtectionDevices.componentId,
|
||||
componentIds
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,22 @@ import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
assertDistributionBoardDeleteProjectCommand,
|
||||
assertDistributionBoardInsertProjectCommand,
|
||||
createDistributionBoardDeleteProjectCommand,
|
||||
createDistributionBoardInsertProjectCommand,
|
||||
distributionBoardDeleteCommandType,
|
||||
distributionBoardInsertCommandType,
|
||||
invertDistributionBoardStructureProjectCommand,
|
||||
normalizeDistributionBoardStructureProjectCommand,
|
||||
type DistributionBoardStructureProjectCommand,
|
||||
type DistributionBoardStructureSnapshot,
|
||||
type NormalizedDistributionBoardStructureSnapshot,
|
||||
} 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,
|
||||
@@ -17,10 +26,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 { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projects } from "../schema/projects.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 DistributionBoardStructureProjectCommandRepository
|
||||
implements DistributionBoardStructureProjectCommandStore
|
||||
@@ -28,29 +38,28 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteDistributionBoardStructureCommandInput) {
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
executeUpdate(
|
||||
input: Omit<
|
||||
ExecuteDistributionBoardStructureCommandInput,
|
||||
"command"
|
||||
> & {
|
||||
command: DistributionBoardUpdateProjectCommand;
|
||||
}
|
||||
) {
|
||||
assertDistributionBoardUpdateProjectCommand(input.command);
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.update(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
@@ -58,21 +67,15 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
projectId: string,
|
||||
command: DistributionBoardStructureProjectCommand
|
||||
): DistributionBoardStructureProjectCommand {
|
||||
const structure =
|
||||
normalizeDistributionBoardStructureProjectCommand(command);
|
||||
if (command.type === distributionBoardInsertCommandType) {
|
||||
assertDistributionBoardInsertProjectCommand(command);
|
||||
return this.insert(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.structure
|
||||
);
|
||||
this.insert(database, projectId, structure);
|
||||
return invertDistributionBoardStructureProjectCommand(command);
|
||||
}
|
||||
if (command.type === distributionBoardDeleteCommandType) {
|
||||
assertDistributionBoardDeleteProjectCommand(command);
|
||||
return this.delete(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.structure
|
||||
);
|
||||
this.delete(database, projectId, structure);
|
||||
return invertDistributionBoardStructureProjectCommand(command);
|
||||
}
|
||||
throw new Error("Unsupported distribution-board structure command.");
|
||||
}
|
||||
@@ -80,7 +83,7 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
structure: DistributionBoardStructureSnapshot
|
||||
structure: NormalizedDistributionBoardStructureSnapshot
|
||||
) {
|
||||
if (
|
||||
structure.distributionBoard.projectId !== projectId ||
|
||||
@@ -91,13 +94,33 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
);
|
||||
}
|
||||
const project = database
|
||||
.select({ id: projects.id })
|
||||
.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)
|
||||
@@ -119,7 +142,26 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
)
|
||||
.limit(1)
|
||||
.get();
|
||||
if (existingBoard || existingList || existingSection) {
|
||||
const existingComponent =
|
||||
structure.components.length === 0
|
||||
? undefined
|
||||
: database
|
||||
.select({ id: distributionBoardComponents.id })
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
inArray(
|
||||
distributionBoardComponents.id,
|
||||
structure.components.map((component) => component.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.get();
|
||||
if (
|
||||
existingBoard ||
|
||||
existingList ||
|
||||
existingSection ||
|
||||
existingComponent
|
||||
) {
|
||||
throw new Error("Distribution-board structure id already exists.");
|
||||
}
|
||||
|
||||
@@ -128,14 +170,22 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
.values(structure.distributionBoard)
|
||||
.run();
|
||||
database.insert(circuitLists).values(structure.circuitList).run();
|
||||
database.insert(circuitSections).values(structure.sections).run();
|
||||
return createDistributionBoardDeleteProjectCommand(structure);
|
||||
database
|
||||
.insert(circuitSections)
|
||||
.values(structure.sections)
|
||||
.run();
|
||||
if (structure.components.length > 0) {
|
||||
database
|
||||
.insert(distributionBoardComponents)
|
||||
.values(structure.components)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
private delete(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
structure: DistributionBoardStructureSnapshot
|
||||
structure: NormalizedDistributionBoardStructureSnapshot
|
||||
) {
|
||||
const board = database
|
||||
.select()
|
||||
@@ -158,11 +208,30 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
asc(circuitSections.id)
|
||||
)
|
||||
.all();
|
||||
const components = database
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponents.circuitListId,
|
||||
structure.circuitList.id
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
asc(distributionBoardComponents.sortOrder),
|
||||
asc(distributionBoardComponents.id)
|
||||
)
|
||||
.all();
|
||||
if (
|
||||
!board ||
|
||||
!list ||
|
||||
board.projectId !== projectId ||
|
||||
!structureMatches(structure, { board, list, sections })
|
||||
!structureMatches(structure, {
|
||||
board,
|
||||
list,
|
||||
sections,
|
||||
components,
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board structure changed before deletion."
|
||||
@@ -195,22 +264,151 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
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 getDistributionBoardFieldValue<
|
||||
TField extends DistributionBoardUpdateField,
|
||||
>(
|
||||
distributionBoard: typeof distributionBoards.$inferSelect,
|
||||
field: TField
|
||||
): DistributionBoardUpdateValues[TField] {
|
||||
return distributionBoard[field] as DistributionBoardUpdateValues[TField];
|
||||
}
|
||||
|
||||
function structureMatches(
|
||||
expected: DistributionBoardStructureSnapshot,
|
||||
expected: NormalizedDistributionBoardStructureSnapshot,
|
||||
actual: {
|
||||
board: typeof distributionBoards.$inferSelect;
|
||||
list: typeof circuitLists.$inferSelect;
|
||||
sections: Array<typeof circuitSections.$inferSelect>;
|
||||
components: Array<typeof distributionBoardComponents.$inferSelect>;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
simultaneityFactor,
|
||||
...actualDistributionBoard
|
||||
} = actual.board;
|
||||
if (
|
||||
!sameRecord(expected.distributionBoard, actual.board) ||
|
||||
simultaneityFactor !== 1 ||
|
||||
!sameRecord(
|
||||
expected.distributionBoard,
|
||||
actualDistributionBoard
|
||||
) ||
|
||||
!sameRecord(expected.circuitList, actual.list) ||
|
||||
expected.sections.length !== actual.sections.length
|
||||
expected.sections.length !== actual.sections.length ||
|
||||
expected.components.length !== actual.components.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -219,20 +417,32 @@ function structureMatches(
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
return expectedSections.every((section, index) =>
|
||||
const sectionsMatch = expectedSections.every((section, index) =>
|
||||
sameRecord(section, actual.sections[index])
|
||||
);
|
||||
const expectedComponents = [...expected.components].sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
return (
|
||||
sectionsMatch &&
|
||||
expectedComponents.every((component, index) =>
|
||||
sameRecord(component, actual.components[index])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function sameRecord(
|
||||
expected: Record<string, unknown>,
|
||||
actual: Record<string, unknown>
|
||||
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]) => actual[key] === value
|
||||
([key, value]) => actualRecord[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)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { and, asc, desc, eq, lt } 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";
|
||||
@@ -118,6 +119,64 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
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,
|
||||
@@ -9,8 +13,9 @@ import type {
|
||||
} from "../../domain/ports/project-settings-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.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
|
||||
@@ -19,53 +24,143 @@ export class ProjectSettingsProjectCommandRepository
|
||||
|
||||
executeUpdate(input: ExecuteProjectSettingsUpdateCommandInput) {
|
||||
assertProjectSettingsUpdateProjectCommand(input.command);
|
||||
return this.database.transaction((tx) => {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
if (
|
||||
current.singlePhaseVoltageV ===
|
||||
input.command.payload.singlePhaseVoltageV &&
|
||||
current.threePhaseVoltageV ===
|
||||
input.command.payload.threePhaseVoltageV
|
||||
) {
|
||||
throw new Error("Project settings did not change.");
|
||||
}
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
const inverse = createProjectSettingsUpdateProjectCommand({
|
||||
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||
});
|
||||
const updated = tx
|
||||
.update(projects)
|
||||
.set(input.command.payload)
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Project changed before settings update.");
|
||||
}
|
||||
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 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 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(", ")}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,14 @@ import { ProjectRevisionConflictError } from "../../domain/errors/project-revisi
|
||||
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,
|
||||
simultaneityFactorProjectStateSnapshotSchemaVersion,
|
||||
supplyTypesProjectStateSnapshotSchemaVersion,
|
||||
voltageProjectStateSnapshotSchemaVersion,
|
||||
} from "../../domain/models/project-state-snapshot.model.js";
|
||||
import type {
|
||||
CreateNamedProjectSnapshotInput,
|
||||
@@ -138,7 +144,19 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
||||
if (actualChecksum !== stored.payloadSha256) {
|
||||
throw new Error("Project snapshot checksum verification failed.");
|
||||
}
|
||||
if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
||||
if (
|
||||
stored.schemaVersion !== legacyProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !== previousProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
distributionBoardProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
supplyTypesProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
voltageProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
simultaneityFactorProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !== projectStateSnapshotSchemaVersion
|
||||
) {
|
||||
throw new Error("Project snapshot schema version is not supported.");
|
||||
}
|
||||
const targetState = deserializeProjectStateSnapshot(stored.payloadJson);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
createProjectStateRestoreCommand,
|
||||
} from "../../domain/models/project-state-restore-command.model.js";
|
||||
import {
|
||||
serializeProjectStateSnapshot,
|
||||
parseProjectStateSnapshot,
|
||||
type ProjectStateSnapshot,
|
||||
} from "../../domain/models/project-state-snapshot.model.js";
|
||||
import type {
|
||||
@@ -15,20 +15,22 @@ import type {
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { consumers } from "../schema/consumers.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.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 { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import {
|
||||
hashProjectStatePayload,
|
||||
readProjectStateSnapshot,
|
||||
} from "./project-state-snapshot.persistence.js";
|
||||
|
||||
@@ -39,54 +41,145 @@ export class ProjectStateRestoreCommandRepository
|
||||
|
||||
execute(input: ExecuteProjectStateRestoreCommandInput) {
|
||||
assertProjectStateRestoreCommand(input.command);
|
||||
if (input.command.payload.targetState.project.id !== input.projectId) {
|
||||
const targetState = parseProjectStateSnapshot(
|
||||
input.command.payload.targetState
|
||||
);
|
||||
if (targetState.project.id !== input.projectId) {
|
||||
throw new Error("Restore state belongs to a different project.");
|
||||
}
|
||||
|
||||
return this.database.transaction((tx) => {
|
||||
const current = readProjectStateSnapshot(tx, input.projectId);
|
||||
if (!current) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
if (
|
||||
current.payloadSha256 !==
|
||||
input.command.payload.expectedStateSha256
|
||||
) {
|
||||
throw new ProjectStateConflictError(
|
||||
input.projectId,
|
||||
input.command.payload.expectedStateSha256,
|
||||
current.payloadSha256
|
||||
);
|
||||
}
|
||||
|
||||
const targetPayloadJson = serializeProjectStateSnapshot(
|
||||
input.command.payload.targetState
|
||||
);
|
||||
const inverse = createProjectStateRestoreCommand(
|
||||
hashProjectStatePayload(targetPayloadJson),
|
||||
current.state
|
||||
);
|
||||
|
||||
replaceProjectState(tx, input.command.payload.targetState);
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
distributionBoardComponents: byId(
|
||||
state.distributionBoardComponents
|
||||
),
|
||||
circuitProtectionDevices: byKey(
|
||||
state.circuitProtectionDevices,
|
||||
"circuitId"
|
||||
),
|
||||
distributionBoardComponentProtectionDevices: byKey(
|
||||
state.distributionBoardComponentProtectionDevices,
|
||||
"componentId"
|
||||
),
|
||||
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 byKey<
|
||||
TKey extends string,
|
||||
TEntry extends Record<TKey, string>,
|
||||
>(entries: readonly TEntry[], key: TKey) {
|
||||
return [...entries].sort((left, right) =>
|
||||
left[key].localeCompare(right[key])
|
||||
);
|
||||
}
|
||||
|
||||
function replaceProjectState(
|
||||
@@ -118,8 +211,14 @@ function replaceProjectState(
|
||||
.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();
|
||||
@@ -138,6 +237,21 @@ function replaceProjectState(
|
||||
isReserve: circuit.isReserve ? 1 : 0,
|
||||
}))
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
distributionBoardComponents,
|
||||
state.distributionBoardComponents
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
circuitProtectionDevices,
|
||||
state.circuitProtectionDevices
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
distributionBoardComponentProtectionDevices,
|
||||
state.distributionBoardComponentProtectionDevices
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
circuitDeviceRows,
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
@@ -32,8 +35,14 @@ export function readProjectStateSnapshot(
|
||||
.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)
|
||||
@@ -63,6 +72,8 @@ export function readProjectStateSnapshot(
|
||||
displayName: circuitSections.displayName,
|
||||
prefix: circuitSections.prefix,
|
||||
sortOrder: circuitSections.sortOrder,
|
||||
category: circuitSections.category,
|
||||
groupNumber: circuitSections.groupNumber,
|
||||
})
|
||||
.from(circuitSections)
|
||||
.innerJoin(
|
||||
@@ -76,6 +87,56 @@ export function readProjectStateSnapshot(
|
||||
asc(circuitSections.id)
|
||||
)
|
||||
.all();
|
||||
const componentRows = database
|
||||
.select({ component: distributionBoardComponents })
|
||||
.from(distributionBoardComponents)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, distributionBoardComponents.circuitListId)
|
||||
)
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.orderBy(
|
||||
asc(distributionBoardComponents.circuitListId),
|
||||
asc(distributionBoardComponents.placement),
|
||||
asc(distributionBoardComponents.sortOrder),
|
||||
asc(distributionBoardComponents.id)
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.component);
|
||||
const circuitProtectionRows = database
|
||||
.select({ protection: circuitProtectionDevices })
|
||||
.from(circuitProtectionDevices)
|
||||
.innerJoin(
|
||||
circuits,
|
||||
eq(circuits.id, circuitProtectionDevices.circuitId)
|
||||
)
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.orderBy(asc(circuitProtectionDevices.circuitId))
|
||||
.all()
|
||||
.map((row) => row.protection);
|
||||
const componentProtectionRows = database
|
||||
.select({
|
||||
protection: distributionBoardComponentProtectionDevices,
|
||||
})
|
||||
.from(distributionBoardComponentProtectionDevices)
|
||||
.innerJoin(
|
||||
distributionBoardComponents,
|
||||
eq(
|
||||
distributionBoardComponents.id,
|
||||
distributionBoardComponentProtectionDevices.componentId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, distributionBoardComponents.circuitListId)
|
||||
)
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.orderBy(
|
||||
asc(distributionBoardComponentProtectionDevices.componentId)
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.protection);
|
||||
const circuitRows = database
|
||||
.select({
|
||||
id: circuits.id,
|
||||
@@ -172,12 +233,22 @@ export function readProjectStateSnapshot(
|
||||
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,
|
||||
distributionBoardComponents: componentRows,
|
||||
circuitProtectionDevices: circuitProtectionRows,
|
||||
distributionBoardComponentProtectionDevices:
|
||||
componentProtectionRows,
|
||||
circuits: circuitRows.map((circuit) => ({
|
||||
...circuit,
|
||||
isReserve: Boolean(circuit.isReserve),
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
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 { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.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,
|
||||
distributionBoardComponents,
|
||||
state.distributionBoardComponents
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
circuitProtectionDevices,
|
||||
state.circuitProtectionDevices
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
distributionBoardComponentProtectionDevices,
|
||||
state.distributionBoardComponentProtectionDevices
|
||||
);
|
||||
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,23 +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,
|
||||
} 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);
|
||||
@@ -25,11 +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 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||
import { circuitLists } from "./circuit-lists.js";
|
||||
|
||||
export const circuitListEquipmentIdentifiers = sqliteTable(
|
||||
"circuit_list_equipment_identifiers",
|
||||
{
|
||||
ownerType: text("owner_type")
|
||||
.$type<"circuit" | "distribution_board_component">()
|
||||
.notNull(),
|
||||
ownerId: text("owner_id").notNull(),
|
||||
circuitListId: text("circuit_list_id")
|
||||
.notNull()
|
||||
.references(() => circuitLists.id, { onDelete: "cascade" }),
|
||||
equipmentIdentifier: text("equipment_identifier").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
unique("circuit_list_equipment_identifiers_owner_unique").on(
|
||||
table.ownerType,
|
||||
table.ownerId
|
||||
),
|
||||
unique("circuit_list_equipment_identifiers_list_identifier_unique").on(
|
||||
table.circuitListId,
|
||||
table.equipmentIdentifier
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
import { circuits } from "./circuits.js";
|
||||
|
||||
export const circuitProtectionDevices = sqliteTable(
|
||||
"circuit_protection_devices",
|
||||
{
|
||||
circuitId: text("circuit_id")
|
||||
.primaryKey()
|
||||
.references(() => circuits.id, { onDelete: "cascade" }),
|
||||
type: text("type").$type<ProtectionDeviceType>().notNull(),
|
||||
ratedCurrentA: real("rated_current_a").notNull(),
|
||||
fuseUtilizationCategory: text(
|
||||
"fuse_utilization_category"
|
||||
).$type<FuseUtilizationCategory>(),
|
||||
tripCharacteristic: text(
|
||||
"trip_characteristic"
|
||||
).$type<BreakerTripCharacteristic>(),
|
||||
rcdType: text("rcd_type").$type<RcdType>(),
|
||||
ratedResidualCurrentMa: real("rated_residual_current_ma"),
|
||||
}
|
||||
);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||
import { circuitLists } from "./circuit-lists.js";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
|
||||
export const circuitSections = sqliteTable(
|
||||
"circuit_sections",
|
||||
@@ -12,10 +13,17 @@ export const circuitSections = sqliteTable(
|
||||
displayName: text("display_name").notNull(),
|
||||
prefix: text("prefix").notNull(),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
category: text("category").$type<CircuitGroupCategory>(),
|
||||
groupNumber: integer("group_number"),
|
||||
},
|
||||
(table) => [
|
||||
unique("circuit_sections_list_key_unique").on(table.circuitListId, table.key),
|
||||
unique("circuit_sections_list_prefix_unique").on(table.circuitListId, table.prefix),
|
||||
unique("circuit_sections_list_category_group_unique").on(
|
||||
table.circuitListId,
|
||||
table.category,
|
||||
table.groupNumber
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
import { distributionBoardComponents } from "./distribution-board-components.js";
|
||||
|
||||
export const distributionBoardComponentProtectionDevices = sqliteTable(
|
||||
"distribution_board_component_protection_devices",
|
||||
{
|
||||
componentId: text("component_id")
|
||||
.primaryKey()
|
||||
.references(() => distributionBoardComponents.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
type: text("type").$type<ProtectionDeviceType>().notNull(),
|
||||
ratedCurrentA: real("rated_current_a").notNull(),
|
||||
fuseUtilizationCategory: text(
|
||||
"fuse_utilization_category"
|
||||
).$type<FuseUtilizationCategory>(),
|
||||
tripCharacteristic: text(
|
||||
"trip_characteristic"
|
||||
).$type<BreakerTripCharacteristic>(),
|
||||
rcdType: text("rcd_type").$type<RcdType>(),
|
||||
ratedResidualCurrentMa: real("rated_residual_current_ma"),
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||
import type {
|
||||
DistributionBoardComponentPlacement,
|
||||
DistributionBoardComponentRole,
|
||||
} from "../../shared/constants/distribution-board-component.js";
|
||||
import { circuitLists } from "./circuit-lists.js";
|
||||
import { circuitSections } from "./circuit-sections.js";
|
||||
|
||||
export const distributionBoardComponents = sqliteTable(
|
||||
"distribution_board_components",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
circuitListId: text("circuit_list_id")
|
||||
.notNull()
|
||||
.references(() => circuitLists.id, { onDelete: "cascade" }),
|
||||
sectionId: text("section_id").references(() => circuitSections.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
equipmentIdentifier: text("equipment_identifier").notNull(),
|
||||
name: text("name").notNull(),
|
||||
role: text("role").$type<DistributionBoardComponentRole>().notNull(),
|
||||
placement: text("placement")
|
||||
.$type<DistributionBoardComponentPlacement>()
|
||||
.notNull(),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
},
|
||||
(table) => [
|
||||
unique("distribution_board_components_list_identifier_unique").on(
|
||||
table.circuitListId,
|
||||
table.equipmentIdentifier
|
||||
),
|
||||
unique("distribution_board_components_section_role_unique").on(
|
||||
table.sectionId,
|
||||
table.role
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,92 @@
|
||||
import { parseGroupedEquipmentIdentifier } from "../services/circuit-group-numbering.js";
|
||||
import type { CircuitGroupMovePlan } from "../services/circuit-group-move-planning.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitGroupMoveCommandType = "circuit.move-group" as const;
|
||||
export const circuitGroupMoveCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface CircuitGroupMoveProjectCommand
|
||||
extends SerializedProjectCommand<CircuitGroupMovePlan> {
|
||||
schemaVersion: typeof circuitGroupMoveCommandSchemaVersion;
|
||||
type: typeof circuitGroupMoveCommandType;
|
||||
}
|
||||
|
||||
export function createCircuitGroupMoveProjectCommand(
|
||||
plan: CircuitGroupMovePlan
|
||||
): CircuitGroupMoveProjectCommand {
|
||||
const command: CircuitGroupMoveProjectCommand = {
|
||||
schemaVersion: circuitGroupMoveCommandSchemaVersion,
|
||||
type: circuitGroupMoveCommandType,
|
||||
payload: plan,
|
||||
};
|
||||
assertCircuitGroupMoveProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitGroupMoveProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupMoveProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupMoveCommandSchemaVersion ||
|
||||
command.type !== circuitGroupMoveCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 8
|
||||
) {
|
||||
throw new Error("Unsupported circuit group-move command.");
|
||||
}
|
||||
for (const field of [
|
||||
"circuitId",
|
||||
"circuitListId",
|
||||
"expectedSectionId",
|
||||
"targetSectionId",
|
||||
"expectedEquipmentIdentifier",
|
||||
"targetEquipmentIdentifier",
|
||||
] as const) {
|
||||
if (
|
||||
typeof command.payload[field] !== "string" ||
|
||||
!command.payload[field].trim()
|
||||
) {
|
||||
throw new Error(`Circuit group-move ${field} is invalid.`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
command.payload.expectedSectionId === command.payload.targetSectionId ||
|
||||
!Number.isFinite(command.payload.expectedSortOrder) ||
|
||||
!Number.isFinite(command.payload.targetSortOrder)
|
||||
) {
|
||||
throw new Error("Circuit group-move positions are invalid.");
|
||||
}
|
||||
const expected = parseGroupedEquipmentIdentifier(
|
||||
command.payload.expectedEquipmentIdentifier as string
|
||||
);
|
||||
const target = parseGroupedEquipmentIdentifier(
|
||||
command.payload.targetEquipmentIdentifier as string
|
||||
);
|
||||
if (
|
||||
expected?.kind !== "circuit" ||
|
||||
target?.kind !== "circuit" ||
|
||||
expected.category !== target.category ||
|
||||
expected.groupNumber === target.groupNumber
|
||||
) {
|
||||
throw new Error("Circuit group-move BMKs are incompatible.");
|
||||
}
|
||||
}
|
||||
|
||||
export function invertCircuitGroupMovePlan(
|
||||
plan: CircuitGroupMovePlan
|
||||
): CircuitGroupMovePlan {
|
||||
return {
|
||||
circuitId: plan.circuitId,
|
||||
circuitListId: plan.circuitListId,
|
||||
expectedSectionId: plan.targetSectionId,
|
||||
targetSectionId: plan.expectedSectionId,
|
||||
expectedEquipmentIdentifier: plan.targetEquipmentIdentifier,
|
||||
targetEquipmentIdentifier: plan.expectedEquipmentIdentifier,
|
||||
expectedSortOrder: plan.targetSortOrder,
|
||||
targetSortOrder: plan.expectedSortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
circuitGroupCategories,
|
||||
type CircuitGroupCategory,
|
||||
} from "../../shared/constants/circuit-group.js";
|
||||
import {
|
||||
formatCircuitGroupPrefix,
|
||||
parseGroupedEquipmentIdentifier,
|
||||
} from "../services/circuit-group-numbering.js";
|
||||
import type { CircuitGroupRenumberPlan } from "../services/circuit-group-renumbering.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitGroupRenumberCommandType =
|
||||
"circuit-group.renumber" as const;
|
||||
export const circuitGroupRenumberCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface CircuitGroupRenumberCommandPayload
|
||||
extends CircuitGroupRenumberPlan {
|
||||
circuitListId: string;
|
||||
}
|
||||
|
||||
export interface CircuitGroupRenumberProjectCommand
|
||||
extends SerializedProjectCommand<CircuitGroupRenumberCommandPayload> {
|
||||
schemaVersion: typeof circuitGroupRenumberCommandSchemaVersion;
|
||||
type: typeof circuitGroupRenumberCommandType;
|
||||
}
|
||||
|
||||
export function createCircuitGroupRenumberProjectCommand(
|
||||
circuitListId: string,
|
||||
plan: CircuitGroupRenumberPlan
|
||||
): CircuitGroupRenumberProjectCommand {
|
||||
const command: CircuitGroupRenumberProjectCommand = {
|
||||
schemaVersion: circuitGroupRenumberCommandSchemaVersion,
|
||||
type: circuitGroupRenumberCommandType,
|
||||
payload: { circuitListId, groups: plan.groups },
|
||||
};
|
||||
assertCircuitGroupRenumberProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitGroupRenumberProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupRenumberProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupRenumberCommandSchemaVersion ||
|
||||
command.type !== circuitGroupRenumberCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 2 ||
|
||||
typeof command.payload.circuitListId !== "string" ||
|
||||
!command.payload.circuitListId.trim() ||
|
||||
!Array.isArray(command.payload.groups) ||
|
||||
command.payload.groups.length === 0
|
||||
) {
|
||||
throw new Error("Unsupported circuit-group renumber command.");
|
||||
}
|
||||
const groupIds = new Set<string>();
|
||||
const entityIds = new Set<string>();
|
||||
const targetGroupKeys = new Set<string>();
|
||||
const targetIdentifiers = new Set<string>();
|
||||
let hasChange = false;
|
||||
for (const group of command.payload.groups) {
|
||||
if (
|
||||
!isPlainObject(group) ||
|
||||
Object.keys(group).length !== 8 ||
|
||||
typeof group.groupId !== "string" ||
|
||||
!group.groupId.trim() ||
|
||||
groupIds.has(group.groupId) ||
|
||||
!circuitGroupCategories.includes(
|
||||
group.category as CircuitGroupCategory
|
||||
) ||
|
||||
!positiveInteger(group.expectedGroupNumber) ||
|
||||
!positiveInteger(group.targetGroupNumber) ||
|
||||
group.expectedPrefix !==
|
||||
formatCircuitGroupPrefix(
|
||||
group.category as CircuitGroupCategory,
|
||||
group.expectedGroupNumber as number
|
||||
) ||
|
||||
group.targetPrefix !==
|
||||
formatCircuitGroupPrefix(
|
||||
group.category as CircuitGroupCategory,
|
||||
group.targetGroupNumber as number
|
||||
) ||
|
||||
!Array.isArray(group.circuits) ||
|
||||
!Array.isArray(group.components)
|
||||
) {
|
||||
throw new Error("Circuit-group renumber assignment is invalid.");
|
||||
}
|
||||
const targetGroupKey = `${group.category}:${group.targetGroupNumber}`;
|
||||
if (targetGroupKeys.has(targetGroupKey)) {
|
||||
throw new Error(
|
||||
"Circuit-group renumber contains duplicate target group numbers."
|
||||
);
|
||||
}
|
||||
hasChange ||= group.expectedGroupNumber !== group.targetGroupNumber;
|
||||
groupIds.add(group.groupId);
|
||||
targetGroupKeys.add(targetGroupKey);
|
||||
for (const circuit of group.circuits) {
|
||||
assertEntityAssignment(
|
||||
circuit,
|
||||
"circuitId",
|
||||
"circuit",
|
||||
group.category as CircuitGroupCategory,
|
||||
group.expectedGroupNumber as number,
|
||||
group.targetGroupNumber as number,
|
||||
entityIds,
|
||||
targetIdentifiers
|
||||
);
|
||||
}
|
||||
for (const component of group.components) {
|
||||
assertEntityAssignment(
|
||||
component,
|
||||
"componentId",
|
||||
"component",
|
||||
group.category as CircuitGroupCategory,
|
||||
group.expectedGroupNumber as number,
|
||||
group.targetGroupNumber as number,
|
||||
entityIds,
|
||||
targetIdentifiers
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!hasChange) {
|
||||
throw new Error(
|
||||
"Circuit-group renumber must change at least one group number."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEntityAssignment(
|
||||
value: unknown,
|
||||
idField: "circuitId" | "componentId",
|
||||
kind: "circuit" | "component",
|
||||
category: CircuitGroupCategory,
|
||||
expectedGroupNumber: number,
|
||||
targetGroupNumber: number,
|
||||
entityIds: Set<string>,
|
||||
targetIdentifiers: Set<string>
|
||||
) {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
Object.keys(value).length !== 3 ||
|
||||
typeof value[idField] !== "string" ||
|
||||
!(value[idField] as string).trim() ||
|
||||
typeof value.expectedEquipmentIdentifier !== "string" ||
|
||||
typeof value.targetEquipmentIdentifier !== "string" ||
|
||||
entityIds.has(value[idField] as string) ||
|
||||
targetIdentifiers.has(value.targetEquipmentIdentifier)
|
||||
) {
|
||||
throw new Error(`Circuit-group renumber ${kind} assignment is invalid.`);
|
||||
}
|
||||
const expected = parseGroupedEquipmentIdentifier(
|
||||
value.expectedEquipmentIdentifier
|
||||
);
|
||||
const target = parseGroupedEquipmentIdentifier(
|
||||
value.targetEquipmentIdentifier
|
||||
);
|
||||
if (
|
||||
!expected ||
|
||||
!target ||
|
||||
expected.kind !== target.kind ||
|
||||
expected.category !== category ||
|
||||
target.category !== category ||
|
||||
expected.groupNumber !== expectedGroupNumber ||
|
||||
target.groupNumber !== targetGroupNumber ||
|
||||
(kind === "circuit" &&
|
||||
(expected.kind !== "circuit" ||
|
||||
expected.circuitNumber !== target.circuitNumber)) ||
|
||||
(kind === "component" && expected.kind === "circuit")
|
||||
) {
|
||||
throw new Error(
|
||||
`Circuit-group renumber ${kind} identifiers are incompatible.`
|
||||
);
|
||||
}
|
||||
entityIds.add(value[idField] as string);
|
||||
targetIdentifiers.add(value.targetEquipmentIdentifier);
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) > 0;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
circuitGroupCategories,
|
||||
type CircuitGroupCategory,
|
||||
} from "../../shared/constants/circuit-group.js";
|
||||
import { formatCircuitGroupPrefix } from "../services/circuit-group-numbering.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitGroupInsertCommandType = "circuit-group.insert" as const;
|
||||
export const circuitGroupDeleteCommandType = "circuit-group.delete" as const;
|
||||
export const circuitGroupUpdateCommandType = "circuit-group.update" as const;
|
||||
export const circuitGroupReorderCommandType = "circuit-group.reorder" as const;
|
||||
export const circuitGroupStructureCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface CircuitGroupSnapshot {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
key: string;
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
}
|
||||
|
||||
interface SnapshotPayload {
|
||||
snapshot: CircuitGroupSnapshot;
|
||||
}
|
||||
|
||||
interface UpdatePayload {
|
||||
expected: CircuitGroupSnapshot;
|
||||
target: CircuitGroupSnapshot;
|
||||
}
|
||||
|
||||
export interface CircuitGroupReorderAssignment {
|
||||
groupId: string;
|
||||
expectedSortOrder: number;
|
||||
targetSortOrder: number;
|
||||
}
|
||||
|
||||
interface ReorderPayload {
|
||||
circuitListId: string;
|
||||
assignments: CircuitGroupReorderAssignment[];
|
||||
}
|
||||
|
||||
export interface CircuitGroupInsertProjectCommand
|
||||
extends SerializedProjectCommand<SnapshotPayload> {
|
||||
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||
type: typeof circuitGroupInsertCommandType;
|
||||
}
|
||||
|
||||
export interface CircuitGroupDeleteProjectCommand
|
||||
extends SerializedProjectCommand<SnapshotPayload> {
|
||||
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||
type: typeof circuitGroupDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface CircuitGroupUpdateProjectCommand
|
||||
extends SerializedProjectCommand<UpdatePayload> {
|
||||
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||
type: typeof circuitGroupUpdateCommandType;
|
||||
}
|
||||
|
||||
export interface CircuitGroupReorderProjectCommand
|
||||
extends SerializedProjectCommand<ReorderPayload> {
|
||||
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||
type: typeof circuitGroupReorderCommandType;
|
||||
}
|
||||
|
||||
export type CircuitGroupStructureProjectCommand =
|
||||
| CircuitGroupInsertProjectCommand
|
||||
| CircuitGroupDeleteProjectCommand
|
||||
| CircuitGroupUpdateProjectCommand
|
||||
| CircuitGroupReorderProjectCommand;
|
||||
|
||||
export function createCircuitGroupInsertProjectCommand(
|
||||
snapshot: CircuitGroupSnapshot
|
||||
): CircuitGroupInsertProjectCommand {
|
||||
const command: CircuitGroupInsertProjectCommand = {
|
||||
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||
type: circuitGroupInsertCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertCircuitGroupInsertProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createCircuitGroupDeleteProjectCommand(
|
||||
snapshot: CircuitGroupSnapshot
|
||||
): CircuitGroupDeleteProjectCommand {
|
||||
const command: CircuitGroupDeleteProjectCommand = {
|
||||
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||
type: circuitGroupDeleteCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertCircuitGroupDeleteProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createCircuitGroupUpdateProjectCommand(
|
||||
expected: CircuitGroupSnapshot,
|
||||
target: CircuitGroupSnapshot
|
||||
): CircuitGroupUpdateProjectCommand {
|
||||
const command: CircuitGroupUpdateProjectCommand = {
|
||||
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||
type: circuitGroupUpdateCommandType,
|
||||
payload: { expected, target },
|
||||
};
|
||||
assertCircuitGroupUpdateProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createCircuitGroupReorderProjectCommand(
|
||||
circuitListId: string,
|
||||
assignments: CircuitGroupReorderAssignment[]
|
||||
): CircuitGroupReorderProjectCommand {
|
||||
const command: CircuitGroupReorderProjectCommand = {
|
||||
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||
type: circuitGroupReorderCommandType,
|
||||
payload: { circuitListId, assignments },
|
||||
};
|
||||
assertCircuitGroupReorderProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitGroupInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupInsertProjectCommand {
|
||||
assertSnapshotCommand(command, circuitGroupInsertCommandType);
|
||||
}
|
||||
|
||||
export function assertCircuitGroupDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupDeleteProjectCommand {
|
||||
assertSnapshotCommand(command, circuitGroupDeleteCommandType);
|
||||
}
|
||||
|
||||
export function assertCircuitGroupUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
|
||||
command.type !== circuitGroupUpdateCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 2
|
||||
) {
|
||||
throw new Error("Unsupported circuit-group update command.");
|
||||
}
|
||||
assertCircuitGroupSnapshot(command.payload.expected);
|
||||
assertCircuitGroupSnapshot(command.payload.target);
|
||||
for (const field of [
|
||||
"id",
|
||||
"circuitListId",
|
||||
"key",
|
||||
"prefix",
|
||||
"sortOrder",
|
||||
"category",
|
||||
"groupNumber",
|
||||
] as const) {
|
||||
if (command.payload.expected[field] !== command.payload.target[field]) {
|
||||
throw new Error(
|
||||
"Circuit-group updates can only change the display name."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCircuitGroupReorderProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupReorderProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
|
||||
command.type !== circuitGroupReorderCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 2 ||
|
||||
typeof command.payload.circuitListId !== "string" ||
|
||||
!command.payload.circuitListId.trim() ||
|
||||
!Array.isArray(command.payload.assignments) ||
|
||||
command.payload.assignments.length === 0
|
||||
) {
|
||||
throw new Error("Unsupported circuit-group reorder command.");
|
||||
}
|
||||
const groupIds = new Set<string>();
|
||||
let hasChange = false;
|
||||
for (const assignment of command.payload.assignments) {
|
||||
if (
|
||||
!isPlainObject(assignment) ||
|
||||
typeof assignment.groupId !== "string" ||
|
||||
!assignment.groupId.trim() ||
|
||||
!Number.isFinite(assignment.expectedSortOrder) ||
|
||||
!Number.isFinite(assignment.targetSortOrder) ||
|
||||
groupIds.has(assignment.groupId)
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit-group reorder contains an invalid or duplicate assignment."
|
||||
);
|
||||
}
|
||||
hasChange ||= assignment.expectedSortOrder !== assignment.targetSortOrder;
|
||||
groupIds.add(assignment.groupId);
|
||||
}
|
||||
if (!hasChange) {
|
||||
throw new Error("Circuit-group reorder must change at least one position.");
|
||||
}
|
||||
}
|
||||
|
||||
function assertSnapshotCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
type:
|
||||
| typeof circuitGroupInsertCommandType
|
||||
| typeof circuitGroupDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
|
||||
command.type !== type ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported circuit-group structure command.");
|
||||
}
|
||||
assertCircuitGroupSnapshot(command.payload.snapshot);
|
||||
}
|
||||
|
||||
export function assertCircuitGroupSnapshot(
|
||||
value: unknown
|
||||
): asserts value is CircuitGroupSnapshot {
|
||||
if (!isPlainObject(value) || Object.keys(value).length !== 8) {
|
||||
throw new Error("Circuit-group snapshot is invalid.");
|
||||
}
|
||||
for (const field of [
|
||||
"id",
|
||||
"circuitListId",
|
||||
"key",
|
||||
"displayName",
|
||||
"prefix",
|
||||
] as const) {
|
||||
if (typeof value[field] !== "string" || !value[field].trim()) {
|
||||
throw new Error(`${field} must be a non-empty string.`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!circuitGroupCategories.includes(
|
||||
value.category as CircuitGroupCategory
|
||||
) ||
|
||||
!Number.isInteger(value.groupNumber) ||
|
||||
(value.groupNumber as number) < 1 ||
|
||||
!Number.isFinite(value.sortOrder)
|
||||
) {
|
||||
throw new Error("Circuit-group category, number or order is invalid.");
|
||||
}
|
||||
if (
|
||||
value.prefix !==
|
||||
formatCircuitGroupPrefix(
|
||||
value.category as CircuitGroupCategory,
|
||||
value.groupNumber as number
|
||||
)
|
||||
) {
|
||||
throw new Error("Circuit-group prefix does not match its category and number.");
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
assertCircuitGroupSubtreeSnapshot,
|
||||
type CircuitGroupSubtreeSnapshot,
|
||||
} from "./circuit-group-subtree-snapshot.model.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitGroupDeleteSubtreeCommandType =
|
||||
"circuit-group.delete-subtree" as const;
|
||||
export const circuitGroupRestoreSubtreeCommandType =
|
||||
"circuit-group.restore-subtree" as const;
|
||||
export const circuitGroupSubtreeCommandSchemaVersion = 1 as const;
|
||||
|
||||
interface Payload {
|
||||
snapshot: CircuitGroupSubtreeSnapshot;
|
||||
}
|
||||
|
||||
export interface CircuitGroupDeleteSubtreeProjectCommand
|
||||
extends SerializedProjectCommand<Payload> {
|
||||
schemaVersion: typeof circuitGroupSubtreeCommandSchemaVersion;
|
||||
type: typeof circuitGroupDeleteSubtreeCommandType;
|
||||
}
|
||||
|
||||
export interface CircuitGroupRestoreSubtreeProjectCommand
|
||||
extends SerializedProjectCommand<Payload> {
|
||||
schemaVersion: typeof circuitGroupSubtreeCommandSchemaVersion;
|
||||
type: typeof circuitGroupRestoreSubtreeCommandType;
|
||||
}
|
||||
|
||||
export type CircuitGroupSubtreeProjectCommand =
|
||||
| CircuitGroupDeleteSubtreeProjectCommand
|
||||
| CircuitGroupRestoreSubtreeProjectCommand;
|
||||
|
||||
export function createCircuitGroupDeleteSubtreeProjectCommand(
|
||||
snapshot: CircuitGroupSubtreeSnapshot
|
||||
): CircuitGroupDeleteSubtreeProjectCommand {
|
||||
const command: CircuitGroupDeleteSubtreeProjectCommand = {
|
||||
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
|
||||
type: circuitGroupDeleteSubtreeCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertCircuitGroupDeleteSubtreeProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createCircuitGroupRestoreSubtreeProjectCommand(
|
||||
snapshot: CircuitGroupSubtreeSnapshot
|
||||
): CircuitGroupRestoreSubtreeProjectCommand {
|
||||
const command: CircuitGroupRestoreSubtreeProjectCommand = {
|
||||
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
|
||||
type: circuitGroupRestoreSubtreeCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertCircuitGroupRestoreSubtreeProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitGroupDeleteSubtreeProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupDeleteSubtreeProjectCommand {
|
||||
assertCommand(command, circuitGroupDeleteSubtreeCommandType);
|
||||
}
|
||||
|
||||
export function assertCircuitGroupRestoreSubtreeProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupRestoreSubtreeProjectCommand {
|
||||
assertCommand(command, circuitGroupRestoreSubtreeCommandType);
|
||||
}
|
||||
|
||||
function assertCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
type:
|
||||
| typeof circuitGroupDeleteSubtreeCommandType
|
||||
| typeof circuitGroupRestoreSubtreeCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupSubtreeCommandSchemaVersion ||
|
||||
command.type !== type ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported circuit-group subtree command.");
|
||||
}
|
||||
assertCircuitGroupSubtreeSnapshot(command.payload.snapshot);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
|
||||
import {
|
||||
assertCircuitGroupSnapshot,
|
||||
type CircuitGroupSnapshot,
|
||||
} from "./circuit-group-structure-project-command.model.js";
|
||||
import {
|
||||
assertCircuitInsertProjectCommand,
|
||||
circuitInsertCommandType,
|
||||
circuitStructureCommandSchemaVersion,
|
||||
type CircuitSnapshot,
|
||||
} from "./circuit-structure-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardComponentSnapshot,
|
||||
type DistributionBoardComponentSnapshot,
|
||||
} from "./distribution-board-component-structure-project-command.model.js";
|
||||
|
||||
export interface CircuitProtectionDeviceSnapshot {
|
||||
circuitId: string;
|
||||
type: ProtectionDeviceType;
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory: FuseUtilizationCategory | null;
|
||||
tripCharacteristic: BreakerTripCharacteristic | null;
|
||||
rcdType: RcdType | null;
|
||||
ratedResidualCurrentMa: number | null;
|
||||
}
|
||||
|
||||
export interface CircuitGroupSubtreeSnapshot {
|
||||
group: CircuitGroupSnapshot;
|
||||
components: DistributionBoardComponentSnapshot[];
|
||||
circuits: Array<{
|
||||
circuit: CircuitSnapshot;
|
||||
protectionDevice: CircuitProtectionDeviceSnapshot | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CircuitGroupDeletionSummary {
|
||||
hasUpstreamProtection: boolean;
|
||||
hasResidualCurrentProtection: boolean;
|
||||
circuitCount: number;
|
||||
deviceRowCount: number;
|
||||
}
|
||||
|
||||
export function assertCircuitGroupSubtreeSnapshot(
|
||||
value: unknown
|
||||
): asserts value is CircuitGroupSubtreeSnapshot {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
Object.keys(value).length !== 3 ||
|
||||
!Array.isArray(value.components) ||
|
||||
!Array.isArray(value.circuits)
|
||||
) {
|
||||
throw new Error("Circuit-group subtree snapshot is invalid.");
|
||||
}
|
||||
assertCircuitGroupSnapshot(value.group);
|
||||
const group = value.group;
|
||||
const entityIds = new Set<string>();
|
||||
const equipmentIdentifiers = new Set<string>();
|
||||
for (const component of value.components) {
|
||||
assertDistributionBoardComponentSnapshot(component);
|
||||
if (
|
||||
component.component.circuitListId !== group.circuitListId ||
|
||||
component.component.sectionId !== group.id ||
|
||||
entityIds.has(component.component.id) ||
|
||||
equipmentIdentifiers.has(component.component.equipmentIdentifier)
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit-group component snapshot has invalid ownership or duplicates."
|
||||
);
|
||||
}
|
||||
entityIds.add(component.component.id);
|
||||
equipmentIdentifiers.add(component.component.equipmentIdentifier);
|
||||
}
|
||||
for (const entry of value.circuits) {
|
||||
if (
|
||||
!isPlainObject(entry) ||
|
||||
Object.keys(entry).length !== 2 ||
|
||||
!isPlainObject(entry.circuit)
|
||||
) {
|
||||
throw new Error("Circuit-group circuit snapshot is invalid.");
|
||||
}
|
||||
assertCircuitInsertProjectCommand({
|
||||
schemaVersion: circuitStructureCommandSchemaVersion,
|
||||
type: circuitInsertCommandType,
|
||||
payload: { circuit: entry.circuit },
|
||||
});
|
||||
const circuit = entry.circuit as unknown as CircuitSnapshot;
|
||||
if (
|
||||
circuit.circuitListId !== group.circuitListId ||
|
||||
circuit.sectionId !== group.id ||
|
||||
entityIds.has(circuit.id) ||
|
||||
equipmentIdentifiers.has(circuit.equipmentIdentifier)
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit-group circuit snapshot has invalid ownership or duplicates."
|
||||
);
|
||||
}
|
||||
entityIds.add(circuit.id);
|
||||
equipmentIdentifiers.add(circuit.equipmentIdentifier);
|
||||
if (entry.protectionDevice !== null) {
|
||||
assertCircuitProtectionDeviceSnapshot(
|
||||
entry.protectionDevice,
|
||||
circuit.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeCircuitGroupDeletion(
|
||||
snapshot: CircuitGroupSubtreeSnapshot
|
||||
): CircuitGroupDeletionSummary {
|
||||
assertCircuitGroupSubtreeSnapshot(snapshot);
|
||||
return {
|
||||
hasUpstreamProtection: snapshot.components.some(
|
||||
({ component }) =>
|
||||
component.role === "group_upstream_protection"
|
||||
),
|
||||
hasResidualCurrentProtection: snapshot.components.some(
|
||||
({ component }) =>
|
||||
component.role === "group_residual_current_protection"
|
||||
),
|
||||
circuitCount: snapshot.circuits.length,
|
||||
deviceRowCount: snapshot.circuits.reduce(
|
||||
(count, { circuit }) => count + circuit.deviceRows.length,
|
||||
0
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function assertCircuitProtectionDeviceSnapshot(
|
||||
value: unknown,
|
||||
circuitId: string
|
||||
): asserts value is CircuitProtectionDeviceSnapshot {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
Object.keys(value).length !== 7 ||
|
||||
value.circuitId !== circuitId
|
||||
) {
|
||||
throw new Error("Circuit protection-device snapshot is invalid.");
|
||||
}
|
||||
const result = protectionDeviceConfigurationSchema.safeParse({
|
||||
type: value.type,
|
||||
ratedCurrentA: value.ratedCurrentA,
|
||||
...(value.fuseUtilizationCategory === null
|
||||
? {}
|
||||
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
|
||||
...(value.tripCharacteristic === null
|
||||
? {}
|
||||
: { tripCharacteristic: value.tripCharacteristic }),
|
||||
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
|
||||
...(value.ratedResidualCurrentMa === null
|
||||
? {}
|
||||
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
"Circuit protection-device configuration is invalid."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export interface CircuitTreeCircuit {
|
||||
remark?: string;
|
||||
circuitTotalPower: number;
|
||||
deviceRows: CircuitTreeDeviceRow[];
|
||||
protectionDevice?: CircuitTreeProtectionDevice;
|
||||
}
|
||||
|
||||
export interface CircuitTreeSectionBlock {
|
||||
@@ -52,13 +53,24 @@ export interface CircuitTreeSectionBlock {
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
category?: CircuitGroupCategory;
|
||||
groupNumber?: number;
|
||||
sectionTotalPower: number;
|
||||
components: CircuitTreeComponent[];
|
||||
circuits: CircuitTreeCircuit[];
|
||||
}
|
||||
|
||||
export interface CircuitTreeResponse {
|
||||
circuitListId: string;
|
||||
currentRevision: number;
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
distributionBoardSimultaneityFactor: number;
|
||||
distributionBoardTotalPower: number;
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||
headerComponents: CircuitTreeComponent[];
|
||||
sections: CircuitTreeSectionBlock[];
|
||||
footerComponents: CircuitTreeComponent[];
|
||||
}
|
||||
|
||||
export interface LegacyMigrationReport {
|
||||
@@ -72,3 +84,35 @@ export interface LegacyMigrationReport {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
import type {
|
||||
DistributionBoardComponentPlacement,
|
||||
DistributionBoardComponentRole,
|
||||
} from "../../shared/constants/distribution-board-component.js";
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
|
||||
export interface CircuitTreeProtectionDevice {
|
||||
type: ProtectionDeviceType;
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory?: FuseUtilizationCategory;
|
||||
tripCharacteristic?: BreakerTripCharacteristic;
|
||||
rcdType?: RcdType;
|
||||
ratedResidualCurrentMa?: number;
|
||||
}
|
||||
|
||||
export interface CircuitTreeComponent {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
sectionId?: string;
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
role: DistributionBoardComponentRole;
|
||||
placement: DistributionBoardComponentPlacement;
|
||||
sortOrder: number;
|
||||
protectionDevice?: CircuitTreeProtectionDevice;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { distributionBoardComponentPlacements } from "../../shared/constants/distribution-board-component.js";
|
||||
import type { DistributionBoardComponentPlacement } from "../../shared/constants/distribution-board-component.js";
|
||||
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const distributionBoardComponentInsertCommandType =
|
||||
"distribution-board-component.insert" as const;
|
||||
export const distributionBoardComponentDeleteCommandType =
|
||||
"distribution-board-component.delete" as const;
|
||||
export const distributionBoardComponentUpdateCommandType =
|
||||
"distribution-board-component.update" as const;
|
||||
export const distributionBoardComponentStructureCommandSchemaVersion =
|
||||
1 as const;
|
||||
|
||||
type MutableDistributionBoardComponentRole =
|
||||
| "group_upstream_protection"
|
||||
| "group_residual_current_protection"
|
||||
| "auxiliary";
|
||||
|
||||
export interface DistributionBoardComponentSnapshot {
|
||||
component: {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
sectionId: string | null;
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
role: MutableDistributionBoardComponentRole;
|
||||
placement: DistributionBoardComponentPlacement;
|
||||
sortOrder: number;
|
||||
};
|
||||
protectionDevice: {
|
||||
componentId: string;
|
||||
type: ProtectionDeviceType;
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory: FuseUtilizationCategory | null;
|
||||
tripCharacteristic: BreakerTripCharacteristic | null;
|
||||
rcdType: RcdType | null;
|
||||
ratedResidualCurrentMa: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface DistributionBoardComponentStructurePayload {
|
||||
snapshot: DistributionBoardComponentSnapshot;
|
||||
}
|
||||
|
||||
interface DistributionBoardComponentUpdatePayload {
|
||||
expected: DistributionBoardComponentSnapshot;
|
||||
target: DistributionBoardComponentSnapshot;
|
||||
}
|
||||
|
||||
export interface DistributionBoardComponentInsertProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardComponentStructurePayload> {
|
||||
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
|
||||
type: typeof distributionBoardComponentInsertCommandType;
|
||||
}
|
||||
|
||||
export interface DistributionBoardComponentDeleteProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardComponentStructurePayload> {
|
||||
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
|
||||
type: typeof distributionBoardComponentDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface DistributionBoardComponentUpdateProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardComponentUpdatePayload> {
|
||||
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
|
||||
type: typeof distributionBoardComponentUpdateCommandType;
|
||||
}
|
||||
|
||||
export type DistributionBoardComponentStructureProjectCommand =
|
||||
| DistributionBoardComponentInsertProjectCommand
|
||||
| DistributionBoardComponentDeleteProjectCommand
|
||||
| DistributionBoardComponentUpdateProjectCommand;
|
||||
|
||||
export function createDistributionBoardComponentInsertProjectCommand(
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
): DistributionBoardComponentInsertProjectCommand {
|
||||
const command: DistributionBoardComponentInsertProjectCommand = {
|
||||
schemaVersion:
|
||||
distributionBoardComponentStructureCommandSchemaVersion,
|
||||
type: distributionBoardComponentInsertCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertDistributionBoardComponentInsertProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createDistributionBoardComponentDeleteProjectCommand(
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
): DistributionBoardComponentDeleteProjectCommand {
|
||||
const command: DistributionBoardComponentDeleteProjectCommand = {
|
||||
schemaVersion:
|
||||
distributionBoardComponentStructureCommandSchemaVersion,
|
||||
type: distributionBoardComponentDeleteCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertDistributionBoardComponentDeleteProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createDistributionBoardComponentUpdateProjectCommand(
|
||||
expected: DistributionBoardComponentSnapshot,
|
||||
target: DistributionBoardComponentSnapshot
|
||||
): DistributionBoardComponentUpdateProjectCommand {
|
||||
const command: DistributionBoardComponentUpdateProjectCommand = {
|
||||
schemaVersion:
|
||||
distributionBoardComponentStructureCommandSchemaVersion,
|
||||
type: distributionBoardComponentUpdateCommandType,
|
||||
payload: { expected, target },
|
||||
};
|
||||
assertDistributionBoardComponentUpdateProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertDistributionBoardComponentInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardComponentInsertProjectCommand {
|
||||
assertStructureCommand(
|
||||
command,
|
||||
distributionBoardComponentInsertCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertDistributionBoardComponentDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardComponentDeleteProjectCommand {
|
||||
assertStructureCommand(
|
||||
command,
|
||||
distributionBoardComponentDeleteCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertDistributionBoardComponentUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardComponentUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !==
|
||||
distributionBoardComponentStructureCommandSchemaVersion ||
|
||||
command.type !== distributionBoardComponentUpdateCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 2
|
||||
) {
|
||||
throw new Error(
|
||||
"Unsupported distribution-board component update command."
|
||||
);
|
||||
}
|
||||
assertDistributionBoardComponentSnapshot(command.payload.expected);
|
||||
assertDistributionBoardComponentSnapshot(command.payload.target);
|
||||
assertSameComponentIdentity(
|
||||
command.payload.expected,
|
||||
command.payload.target
|
||||
);
|
||||
}
|
||||
|
||||
function assertStructureCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
type:
|
||||
| typeof distributionBoardComponentInsertCommandType
|
||||
| typeof distributionBoardComponentDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !==
|
||||
distributionBoardComponentStructureCommandSchemaVersion ||
|
||||
command.type !== type ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
"Unsupported distribution-board component structure command."
|
||||
);
|
||||
}
|
||||
assertDistributionBoardComponentSnapshot(command.payload.snapshot);
|
||||
}
|
||||
|
||||
function assertSameComponentIdentity(
|
||||
expected: DistributionBoardComponentSnapshot,
|
||||
target: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
for (const field of [
|
||||
"id",
|
||||
"circuitListId",
|
||||
"sectionId",
|
||||
"role",
|
||||
"placement",
|
||||
] as const) {
|
||||
if (expected.component[field] !== target.component[field]) {
|
||||
throw new Error(
|
||||
"Distribution-board component updates cannot change ownership or role."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertDistributionBoardComponentSnapshot(
|
||||
value: unknown
|
||||
): asserts value is DistributionBoardComponentSnapshot {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
Object.keys(value).length !== 2 ||
|
||||
!isPlainObject(value.component)
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board component snapshot is invalid."
|
||||
);
|
||||
}
|
||||
const component = value.component;
|
||||
if (Object.keys(component).length !== 8) {
|
||||
throw new Error(
|
||||
"Distribution-board component snapshot is incomplete."
|
||||
);
|
||||
}
|
||||
for (const field of [
|
||||
"id",
|
||||
"circuitListId",
|
||||
"equipmentIdentifier",
|
||||
"name",
|
||||
] as const) {
|
||||
assertNonEmptyString(component[field], `component.${field}`);
|
||||
}
|
||||
if (
|
||||
component.role !== "group_upstream_protection" &&
|
||||
component.role !== "group_residual_current_protection" &&
|
||||
component.role !== "auxiliary"
|
||||
) {
|
||||
throw new Error(
|
||||
"Fixed distribution-board components cannot use component CRUD commands."
|
||||
);
|
||||
}
|
||||
if (
|
||||
!distributionBoardComponentPlacements.includes(
|
||||
component.placement as DistributionBoardComponentPlacement
|
||||
) ||
|
||||
!Number.isFinite(component.sortOrder)
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board component placement or sort order is invalid."
|
||||
);
|
||||
}
|
||||
const groupComponent = component.role !== "auxiliary";
|
||||
if (
|
||||
groupComponent !==
|
||||
(component.placement === "group" &&
|
||||
typeof component.sectionId === "string" &&
|
||||
Boolean(component.sectionId.trim()))
|
||||
) {
|
||||
throw new Error(
|
||||
"Group protection components require group placement and a section."
|
||||
);
|
||||
}
|
||||
if (
|
||||
!groupComponent &&
|
||||
(component.placement !== "footer" ||
|
||||
component.sectionId !== null)
|
||||
) {
|
||||
throw new Error(
|
||||
"Auxiliary components require footer placement without a section."
|
||||
);
|
||||
}
|
||||
|
||||
if (groupComponent) {
|
||||
if (!isPlainObject(value.protectionDevice)) {
|
||||
throw new Error(
|
||||
"Group protection components require protection data."
|
||||
);
|
||||
}
|
||||
assertProtectionDevice(
|
||||
value.protectionDevice,
|
||||
component.id as string
|
||||
);
|
||||
} else if (value.protectionDevice !== null) {
|
||||
throw new Error(
|
||||
"Auxiliary components cannot contain protection data."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertProtectionDevice(
|
||||
value: Record<string, unknown>,
|
||||
componentId: string
|
||||
) {
|
||||
if (
|
||||
Object.keys(value).length !== 7 ||
|
||||
value.componentId !== componentId
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board component protection snapshot is invalid."
|
||||
);
|
||||
}
|
||||
const result = protectionDeviceConfigurationSchema.safeParse({
|
||||
type: value.type,
|
||||
ratedCurrentA: value.ratedCurrentA,
|
||||
...(value.fuseUtilizationCategory === null
|
||||
? {}
|
||||
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
|
||||
...(value.tripCharacteristic === null
|
||||
? {}
|
||||
: { tripCharacteristic: value.tripCharacteristic }),
|
||||
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
|
||||
...(value.ratedResidualCurrentMa === null
|
||||
? {}
|
||||
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
"Distribution-board component protection configuration is invalid."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonEmptyString(value: unknown, field: string) {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${field} must be a non-empty string.`);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -1,13 +1,28 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import {
|
||||
defaultMainSwitchComponent,
|
||||
defaultSurgeProtectiveDeviceComponent,
|
||||
} from "../../shared/constants/distribution-board-component.js";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
import type {
|
||||
DistributionBoardComponentPlacement,
|
||||
DistributionBoardComponentRole,
|
||||
} from "../../shared/constants/distribution-board-component.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 distributionBoardStructureCommandSchemaVersion = 1 as const;
|
||||
export const legacyDistributionBoardStructureCommandSchemaVersion = 1 as const;
|
||||
export const previousDistributionBoardStructureCommandSchemaVersion = 2 as const;
|
||||
export const distributionBoardStructureCommandSchemaVersion = 3 as const;
|
||||
|
||||
export const defaultCircuitSectionDefinitions = [
|
||||
const legacyDefaultCircuitSectionDefinitions = [
|
||||
{
|
||||
key: "lighting",
|
||||
displayName: "Lighting",
|
||||
@@ -34,11 +49,65 @@ export const defaultCircuitSectionDefinitions = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const defaultCircuitSectionDefinitions = [
|
||||
{
|
||||
key: "lighting",
|
||||
displayName: "Beleuchtung 1",
|
||||
prefix: "-1F1.",
|
||||
sortOrder: 10,
|
||||
category: "lighting",
|
||||
groupNumber: 1,
|
||||
},
|
||||
{
|
||||
key: "single_phase",
|
||||
displayName: "1-phasig 1",
|
||||
prefix: "-2F1.",
|
||||
sortOrder: 20,
|
||||
category: "single_phase",
|
||||
groupNumber: 1,
|
||||
},
|
||||
{
|
||||
key: "three_phase",
|
||||
displayName: "3-phasig 1",
|
||||
prefix: "-3F1.",
|
||||
sortOrder: 30,
|
||||
category: "three_phase",
|
||||
groupNumber: 1,
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface LegacyDistributionBoardStructureSnapshot {
|
||||
distributionBoard: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
};
|
||||
circuitList: DistributionBoardStructureSnapshot["circuitList"];
|
||||
sections: LegacyCircuitSectionSnapshot[];
|
||||
}
|
||||
|
||||
interface PreviousDistributionBoardStructureSnapshot {
|
||||
distributionBoard: DistributionBoardStructureSnapshot["distributionBoard"];
|
||||
circuitList: DistributionBoardStructureSnapshot["circuitList"];
|
||||
sections: LegacyCircuitSectionSnapshot[];
|
||||
}
|
||||
|
||||
interface LegacyCircuitSectionSnapshot {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
key: string;
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface DistributionBoardStructureSnapshot {
|
||||
distributionBoard: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
floorId: string | null;
|
||||
supplyType: DistributionBoardSupplyType | null;
|
||||
};
|
||||
circuitList: {
|
||||
id: string;
|
||||
@@ -53,32 +122,90 @@ export interface DistributionBoardStructureSnapshot {
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
}>;
|
||||
components: Array<{
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
sectionId: null;
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
role: DistributionBoardComponentRole;
|
||||
placement: DistributionBoardComponentPlacement;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DistributionBoardStructureCommandPayload {
|
||||
structure: DistributionBoardStructureSnapshot;
|
||||
export interface NormalizedDistributionBoardStructureSnapshot {
|
||||
distributionBoard: DistributionBoardStructureSnapshot["distributionBoard"];
|
||||
circuitList: DistributionBoardStructureSnapshot["circuitList"];
|
||||
sections: Array<
|
||||
LegacyCircuitSectionSnapshot & {
|
||||
category: CircuitGroupCategory | null;
|
||||
groupNumber: number | null;
|
||||
}
|
||||
>;
|
||||
components: DistributionBoardStructureSnapshot["components"];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface PreviousDistributionBoardStructureProjectCommand
|
||||
extends SerializedProjectCommand<
|
||||
DistributionBoardStructureCommandPayload<PreviousDistributionBoardStructureSnapshot>
|
||||
> {
|
||||
schemaVersion: typeof previousDistributionBoardStructureCommandSchemaVersion;
|
||||
type:
|
||||
| typeof distributionBoardInsertCommandType
|
||||
| typeof distributionBoardDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface DistributionBoardInsertProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
|
||||
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
|
||||
extends CurrentDistributionBoardStructureProjectCommand {
|
||||
type: typeof distributionBoardInsertCommandType;
|
||||
}
|
||||
|
||||
export interface DistributionBoardDeleteProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
|
||||
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
|
||||
extends CurrentDistributionBoardStructureProjectCommand {
|
||||
type: typeof distributionBoardDeleteCommandType;
|
||||
}
|
||||
|
||||
export type DistributionBoardStructureProjectCommand =
|
||||
| DistributionBoardInsertProjectCommand
|
||||
| DistributionBoardDeleteProjectCommand;
|
||||
| DistributionBoardDeleteProjectCommand
|
||||
| PreviousDistributionBoardStructureProjectCommand
|
||||
| LegacyDistributionBoardStructureProjectCommand;
|
||||
|
||||
export function createDistributionBoardStructureSnapshot(
|
||||
projectId: string,
|
||||
name: string
|
||||
name: string,
|
||||
input: {
|
||||
floorId?: string | null;
|
||||
supplyType?: DistributionBoardSupplyType | null;
|
||||
} = {}
|
||||
): DistributionBoardStructureSnapshot {
|
||||
const normalizedName = name.trim();
|
||||
assertNonEmptyString(projectId, "projectId");
|
||||
@@ -89,6 +216,8 @@ export function createDistributionBoardStructureSnapshot(
|
||||
id: structureId,
|
||||
projectId,
|
||||
name: normalizedName,
|
||||
floorId: input.floorId ?? null,
|
||||
supplyType: input.supplyType ?? null,
|
||||
},
|
||||
circuitList: {
|
||||
id: structureId,
|
||||
@@ -101,6 +230,22 @@ export function createDistributionBoardStructureSnapshot(
|
||||
circuitListId: structureId,
|
||||
...section,
|
||||
})),
|
||||
components: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
circuitListId: structureId,
|
||||
sectionId: null,
|
||||
...defaultMainSwitchComponent,
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
circuitListId: structureId,
|
||||
sectionId: null,
|
||||
...defaultSurgeProtectiveDeviceComponent,
|
||||
sortOrder: 20,
|
||||
},
|
||||
],
|
||||
};
|
||||
assertDistributionBoardStructureSnapshot(structure);
|
||||
return structure;
|
||||
@@ -132,7 +277,7 @@ export function createDistributionBoardDeleteProjectCommand(
|
||||
|
||||
export function assertDistributionBoardInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardInsertProjectCommand {
|
||||
): asserts command is DistributionBoardStructureProjectCommand {
|
||||
assertDistributionBoardStructureProjectCommand(
|
||||
command,
|
||||
distributionBoardInsertCommandType
|
||||
@@ -141,32 +286,164 @@ export function assertDistributionBoardInsertProjectCommand(
|
||||
|
||||
export function assertDistributionBoardDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardDeleteProjectCommand {
|
||||
): asserts command is DistributionBoardStructureProjectCommand {
|
||||
assertDistributionBoardStructureProjectCommand(
|
||||
command,
|
||||
distributionBoardDeleteCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeDistributionBoardStructureProjectCommand(
|
||||
command: DistributionBoardStructureProjectCommand
|
||||
): NormalizedDistributionBoardStructureSnapshot {
|
||||
if (
|
||||
command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
return {
|
||||
distributionBoard: {
|
||||
...command.payload.structure.distributionBoard,
|
||||
floorId: null,
|
||||
supplyType: null,
|
||||
},
|
||||
circuitList: command.payload.structure.circuitList,
|
||||
sections: command.payload.structure.sections.map(
|
||||
normalizeLegacySection
|
||||
),
|
||||
components: [],
|
||||
};
|
||||
}
|
||||
if (
|
||||
command.schemaVersion ===
|
||||
previousDistributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
return {
|
||||
...command.payload.structure,
|
||||
sections: command.payload.structure.sections.map(
|
||||
normalizeLegacySection
|
||||
),
|
||||
components: [],
|
||||
};
|
||||
}
|
||||
return command.payload.structure;
|
||||
}
|
||||
|
||||
export function invertDistributionBoardStructureProjectCommand(
|
||||
command: DistributionBoardStructureProjectCommand
|
||||
): DistributionBoardStructureProjectCommand {
|
||||
return {
|
||||
...command,
|
||||
type:
|
||||
command.type === distributionBoardInsertCommandType
|
||||
? distributionBoardDeleteCommandType
|
||||
: distributionBoardInsertCommandType,
|
||||
} as DistributionBoardStructureProjectCommand;
|
||||
}
|
||||
|
||||
function normalizeLegacySection(
|
||||
section: LegacyCircuitSectionSnapshot
|
||||
): NormalizedDistributionBoardStructureSnapshot["sections"][number] {
|
||||
const category =
|
||||
section.key === "lighting" ||
|
||||
section.key === "single_phase" ||
|
||||
section.key === "three_phase"
|
||||
? section.key
|
||||
: null;
|
||||
return {
|
||||
...section,
|
||||
category,
|
||||
groupNumber: category === null ? null : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertDistributionBoardStructureSnapshot(
|
||||
structure: unknown
|
||||
): asserts structure is DistributionBoardStructureSnapshot {
|
||||
if (!isPlainObject(structure) || Object.keys(structure).length !== 3) {
|
||||
assertDistributionBoardStructureSnapshotVersion(structure, "current");
|
||||
}
|
||||
|
||||
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,
|
||||
"legacy"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
command.schemaVersion ===
|
||||
previousDistributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
assertDistributionBoardStructureSnapshotVersion(
|
||||
command.payload.structure,
|
||||
"previous"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
command.schemaVersion !==
|
||||
distributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
throw new Error("Unsupported distribution-board structure command.");
|
||||
}
|
||||
assertDistributionBoardStructureSnapshotVersion(
|
||||
command.payload.structure,
|
||||
"current"
|
||||
);
|
||||
}
|
||||
|
||||
function assertDistributionBoardStructureSnapshotVersion(
|
||||
structure: unknown,
|
||||
version: "legacy" | "previous" | "current"
|
||||
) {
|
||||
const current = version === "current";
|
||||
if (
|
||||
!isPlainObject(structure) ||
|
||||
Object.keys(structure).length !== (current ? 4 : 3)
|
||||
) {
|
||||
throw new Error("Distribution-board structure is invalid.");
|
||||
}
|
||||
const { distributionBoard, circuitList, sections } = structure;
|
||||
const { distributionBoard, circuitList, sections, components } =
|
||||
structure;
|
||||
const expectedSections = current
|
||||
? defaultCircuitSectionDefinitions
|
||||
: legacyDefaultCircuitSectionDefinitions;
|
||||
if (
|
||||
!isPlainObject(distributionBoard) ||
|
||||
Object.keys(distributionBoard).length !== 3 ||
|
||||
Object.keys(distributionBoard).length !==
|
||||
(version === "legacy" ? 3 : 5) ||
|
||||
!isPlainObject(circuitList) ||
|
||||
Object.keys(circuitList).length !== 4 ||
|
||||
!Array.isArray(sections) ||
|
||||
sections.length !== defaultCircuitSectionDefinitions.length
|
||||
sections.length !== expectedSections.length ||
|
||||
(current && (!Array.isArray(components) || components.length !== 2))
|
||||
) {
|
||||
throw new Error("Distribution-board structure is incomplete.");
|
||||
}
|
||||
for (const [field, value] of Object.entries(distributionBoard)) {
|
||||
assertNonEmptyString(value, `distributionBoard.${field}`);
|
||||
for (const field of ["id", "projectId", "name"] as const) {
|
||||
assertNonEmptyString(
|
||||
distributionBoard[field],
|
||||
`distributionBoard.${field}`
|
||||
);
|
||||
}
|
||||
if (version !== "legacy") {
|
||||
assertNullableId(distributionBoard.floorId, "distributionBoard.floorId");
|
||||
assertNullableSupplyType(distributionBoard.supplyType);
|
||||
}
|
||||
for (const [field, value] of Object.entries(circuitList)) {
|
||||
assertNonEmptyString(value, `circuitList.${field}`);
|
||||
@@ -174,21 +451,18 @@ export function assertDistributionBoardStructureSnapshot(
|
||||
if (
|
||||
circuitList.projectId !== distributionBoard.projectId ||
|
||||
circuitList.distributionBoardId !== distributionBoard.id ||
|
||||
circuitList.name !==
|
||||
`${distributionBoard.name} Stromkreisliste`
|
||||
circuitList.name !== `${distributionBoard.name} Stromkreisliste`
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution board and circuit list are inconsistent."
|
||||
);
|
||||
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];
|
||||
const expected = expectedSections[index];
|
||||
if (
|
||||
!isPlainObject(section) ||
|
||||
Object.keys(section).length !== 6
|
||||
Object.keys(section).length !== (current ? 8 : 6)
|
||||
) {
|
||||
throw new Error("Default circuit section is invalid.");
|
||||
}
|
||||
@@ -202,31 +476,83 @@ export function assertDistributionBoardStructureSnapshot(
|
||||
section.key !== expected.key ||
|
||||
section.displayName !== expected.displayName ||
|
||||
section.prefix !== expected.prefix ||
|
||||
section.sortOrder !== expected.sortOrder
|
||||
section.sortOrder !== expected.sortOrder ||
|
||||
(current &&
|
||||
(!("category" in expected) ||
|
||||
section.category !== expected.category ||
|
||||
section.groupNumber !== expected.groupNumber))
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board structure has invalid default sections."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
assertDefaultComponents(components, circuitList.id);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDistributionBoardStructureProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
expectedType:
|
||||
| typeof distributionBoardInsertCommandType
|
||||
| typeof distributionBoardDeleteCommandType
|
||||
function assertDefaultComponents(
|
||||
components: unknown,
|
||||
circuitListId: unknown
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !==
|
||||
distributionBoardStructureCommandSchemaVersion ||
|
||||
command.type !== expectedType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported distribution-board structure command.");
|
||||
if (!Array.isArray(components)) {
|
||||
throw new Error("Default distribution-board components are invalid.");
|
||||
}
|
||||
const expectedComponents = [
|
||||
{ ...defaultMainSwitchComponent, sortOrder: 10 },
|
||||
{ ...defaultSurgeProtectiveDeviceComponent, sortOrder: 20 },
|
||||
];
|
||||
const componentIds = new Set<string>();
|
||||
for (let index = 0; index < expectedComponents.length; index += 1) {
|
||||
const component = components[index];
|
||||
const expected = expectedComponents[index];
|
||||
if (!isPlainObject(component) || Object.keys(component).length !== 8) {
|
||||
throw new Error(
|
||||
"Default distribution-board component is invalid."
|
||||
);
|
||||
}
|
||||
assertNonEmptyString(component.id, "component.id");
|
||||
if (componentIds.has(component.id)) {
|
||||
throw new Error(
|
||||
"Default distribution-board component ids must be unique."
|
||||
);
|
||||
}
|
||||
componentIds.add(component.id);
|
||||
if (
|
||||
component.circuitListId !== circuitListId ||
|
||||
component.sectionId !== null ||
|
||||
component.equipmentIdentifier !== expected.equipmentIdentifier ||
|
||||
component.name !== expected.name ||
|
||||
component.role !== expected.role ||
|
||||
component.placement !== expected.placement ||
|
||||
component.sortOrder !== expected.sortOrder
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board structure has invalid default components."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
assertDistributionBoardStructureSnapshot(command.payload.structure);
|
||||
}
|
||||
|
||||
function assertNonEmptyString(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,24 +1,59 @@
|
||||
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 projectSettingsUpdateCommandSchemaVersion = 1 as const;
|
||||
export const legacyProjectSettingsUpdateCommandSchemaVersion = 1 as const;
|
||||
export const previousProjectSettingsUpdateCommandSchemaVersion = 2 as const;
|
||||
export const projectSettingsUpdateCommandSchemaVersion = 3 as const;
|
||||
|
||||
export interface ProjectSettingsValues {
|
||||
export interface LegacyProjectSettingsValues {
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
}
|
||||
|
||||
export interface ProjectSettingsUpdateProjectCommand
|
||||
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
|
||||
): ProjectSettingsUpdateProjectCommand {
|
||||
const command: ProjectSettingsUpdateProjectCommand = {
|
||||
): CurrentProjectSettingsUpdateProjectCommand {
|
||||
const command: CurrentProjectSettingsUpdateProjectCommand = {
|
||||
schemaVersion: projectSettingsUpdateCommandSchemaVersion,
|
||||
type: projectSettingsUpdateCommandType,
|
||||
payload: { ...values },
|
||||
@@ -31,16 +66,100 @@ export function assertProjectSettingsUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectSettingsUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion ||
|
||||
command.type !== projectSettingsUpdateCommandType
|
||||
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 (
|
||||
!isPlainObject(command.payload) ||
|
||||
!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 !== 2
|
||||
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."
|
||||
@@ -59,3 +178,26 @@ function isPositiveFiniteNumber(value: unknown): value is number {
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defaultDistributionBoardSupplyTypes,
|
||||
distributionBoardSupplyTypes,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import {
|
||||
circuitGroupCategories,
|
||||
type CircuitGroupCategory,
|
||||
} from "../../shared/constants/circuit-group.js";
|
||||
import {
|
||||
distributionBoardComponentPlacements,
|
||||
distributionBoardComponentRoles,
|
||||
} from "../../shared/constants/distribution-board-component.js";
|
||||
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
|
||||
import {
|
||||
breakerTripCharacteristics,
|
||||
fuseUtilizationCategories,
|
||||
protectionDeviceTypes,
|
||||
rcdTypes,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
import {
|
||||
resolveCircuitPhaseType,
|
||||
resolveProjectVoltage,
|
||||
normalizeKnownElectricalPhaseType,
|
||||
} from "../services/project-voltage.service.js";
|
||||
|
||||
export const projectStateSnapshotSchemaVersion = 1 as const;
|
||||
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 simultaneityFactorProjectStateSnapshotSchemaVersion = 6 as const;
|
||||
export const projectStateSnapshotSchemaVersion = 7 as const;
|
||||
|
||||
const idSchema = z.string().trim().min(1);
|
||||
const nullableStringSchema = z.string().nullable();
|
||||
const finiteNumberSchema = z.number().finite();
|
||||
|
||||
const projectSchema = z
|
||||
const legacyProjectSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
name: z.string().trim().min(1),
|
||||
@@ -15,7 +45,21 @@ const projectSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const distributionBoardSchema = z
|
||||
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,
|
||||
@@ -23,6 +67,19 @@ const distributionBoardSchema = z
|
||||
})
|
||||
.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,
|
||||
@@ -32,7 +89,7 @@ const circuitListSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const circuitSectionSchema = z
|
||||
const legacyCircuitSectionSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
circuitListId: idSchema,
|
||||
@@ -43,6 +100,22 @@ const circuitSectionSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const circuitSectionSchema = legacyCircuitSectionSchema
|
||||
.extend({
|
||||
category: z.enum(circuitGroupCategories).nullable(),
|
||||
groupNumber: z.number().int().positive().nullable(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((section, context) => {
|
||||
if ((section.category === null) !== (section.groupNumber === null)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Circuit-section category and group number must both be set or both be null.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const circuitDeviceRowSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
@@ -132,20 +205,159 @@ const roomSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const projectStateSnapshotSchema = z
|
||||
const distributionBoardComponentSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
circuitLists: z.array(circuitListSchema),
|
||||
circuitSections: z.array(circuitSectionSchema),
|
||||
circuits: z.array(circuitSchema),
|
||||
projectDevices: z.array(projectDeviceSchema),
|
||||
floors: z.array(floorSchema),
|
||||
rooms: z.array(roomSchema),
|
||||
id: idSchema,
|
||||
circuitListId: idSchema,
|
||||
sectionId: idSchema.nullable(),
|
||||
equipmentIdentifier: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
role: z.enum(distributionBoardComponentRoles),
|
||||
placement: z.enum(distributionBoardComponentPlacements),
|
||||
sortOrder: finiteNumberSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const persistedProtectionDeviceFields = {
|
||||
type: z.enum(protectionDeviceTypes),
|
||||
ratedCurrentA: finiteNumberSchema.positive(),
|
||||
fuseUtilizationCategory: z.enum(fuseUtilizationCategories).nullable(),
|
||||
tripCharacteristic: z.enum(breakerTripCharacteristics).nullable(),
|
||||
rcdType: z.enum(rcdTypes).nullable(),
|
||||
ratedResidualCurrentMa: finiteNumberSchema.positive().nullable(),
|
||||
};
|
||||
|
||||
function validatePersistedProtectionDevice(
|
||||
value: {
|
||||
type: (typeof protectionDeviceTypes)[number];
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory:
|
||||
| (typeof fuseUtilizationCategories)[number]
|
||||
| null;
|
||||
tripCharacteristic:
|
||||
| (typeof breakerTripCharacteristics)[number]
|
||||
| null;
|
||||
rcdType: (typeof rcdTypes)[number] | null;
|
||||
ratedResidualCurrentMa: number | null;
|
||||
},
|
||||
context: z.RefinementCtx
|
||||
) {
|
||||
const result = protectionDeviceConfigurationSchema.safeParse({
|
||||
type: value.type,
|
||||
ratedCurrentA: value.ratedCurrentA,
|
||||
...(value.fuseUtilizationCategory === null
|
||||
? {}
|
||||
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
|
||||
...(value.tripCharacteristic === null
|
||||
? {}
|
||||
: { tripCharacteristic: value.tripCharacteristic }),
|
||||
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
|
||||
...(value.ratedResidualCurrentMa === null
|
||||
? {}
|
||||
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
|
||||
});
|
||||
if (!result.success) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Snapshot protection-device configuration is invalid.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const circuitProtectionDeviceSchema = z
|
||||
.object({
|
||||
circuitId: idSchema,
|
||||
...persistedProtectionDeviceFields,
|
||||
})
|
||||
.strict()
|
||||
.superRefine(validatePersistedProtectionDevice);
|
||||
|
||||
const componentProtectionDeviceSchema = z
|
||||
.object({
|
||||
componentId: idSchema,
|
||||
...persistedProtectionDeviceFields,
|
||||
})
|
||||
.strict()
|
||||
.superRefine(validatePersistedProtectionDevice);
|
||||
|
||||
const legacyProjectStateSnapshotContents = {
|
||||
circuitLists: z.array(circuitListSchema),
|
||||
circuitSections: z.array(legacyCircuitSectionSchema),
|
||||
circuits: z.array(circuitSchema),
|
||||
projectDevices: z.array(projectDeviceSchema),
|
||||
floors: z.array(floorSchema),
|
||||
rooms: z.array(roomSchema),
|
||||
};
|
||||
|
||||
const currentProjectStateSnapshotContents = {
|
||||
...legacyProjectStateSnapshotContents,
|
||||
circuitSections: z.array(circuitSectionSchema),
|
||||
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
||||
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
|
||||
distributionBoardComponentProtectionDevices: z.array(
|
||||
componentProtectionDeviceSchema
|
||||
),
|
||||
};
|
||||
|
||||
const legacyProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||
project: legacyProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...legacyProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const previousProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...legacyProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const distributionBoardProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(
|
||||
distributionBoardProjectStateSnapshotSchemaVersion
|
||||
),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
...legacyProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const supplyTypesProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(supplyTypesProjectStateSnapshotSchemaVersion),
|
||||
project: currentProjectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
...legacyProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const voltageProjectStateSnapshotSchema =
|
||||
supplyTypesProjectStateSnapshotSchema.extend({
|
||||
schemaVersion: z.literal(
|
||||
voltageProjectStateSnapshotSchemaVersion
|
||||
),
|
||||
});
|
||||
|
||||
const simultaneityFactorProjectStateSnapshotSchema =
|
||||
voltageProjectStateSnapshotSchema.extend({
|
||||
schemaVersion: z.literal(
|
||||
simultaneityFactorProjectStateSnapshotSchemaVersion
|
||||
),
|
||||
distributionBoards: z.array(currentDistributionBoardSchema),
|
||||
});
|
||||
|
||||
export const projectStateSnapshotSchema =
|
||||
simultaneityFactorProjectStateSnapshotSchema.extend({
|
||||
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||
...currentProjectStateSnapshotContents,
|
||||
});
|
||||
|
||||
export type ProjectStateSnapshot = z.infer<
|
||||
typeof projectStateSnapshotSchema
|
||||
>;
|
||||
@@ -153,11 +365,245 @@ export type ProjectStateSnapshot = z.infer<
|
||||
export function parseProjectStateSnapshot(
|
||||
value: unknown
|
||||
): ProjectStateSnapshot {
|
||||
const snapshot = projectStateSnapshotSchema.parse(value);
|
||||
const version = isPlainObject(value) ? value.schemaVersion : undefined;
|
||||
const parsedSnapshot =
|
||||
version === legacyProjectStateSnapshotSchemaVersion
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
upgradePreviousProjectStateSnapshot(
|
||||
upgradeLegacyProjectStateSnapshot(
|
||||
legacyProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === previousProjectStateSnapshotSchemaVersion
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
upgradePreviousProjectStateSnapshot(
|
||||
previousProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === distributionBoardProjectStateSnapshotSchemaVersion
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
distributionBoardProjectStateSnapshotSchema.parse(
|
||||
value
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === supplyTypesProjectStateSnapshotSchemaVersion
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
supplyTypesProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === voltageProjectStateSnapshotSchemaVersion
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
voltageProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
: version ===
|
||||
simultaneityFactorProjectStateSnapshotSchemaVersion
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
simultaneityFactorProjectStateSnapshotSchema.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>
|
||||
): z.infer<typeof simultaneityFactorProjectStateSnapshotSchema> {
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: simultaneityFactorProjectStateSnapshotSchemaVersion,
|
||||
distributionBoards: snapshot.distributionBoards.map((board) => ({
|
||||
...board,
|
||||
simultaneityFactor: 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof simultaneityFactorProjectStateSnapshotSchema>
|
||||
): ProjectStateSnapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
circuitSections: snapshot.circuitSections.map((section) => {
|
||||
const category = initialCategoryBySectionKey(section.key);
|
||||
return {
|
||||
...section,
|
||||
category,
|
||||
groupNumber: category === null ? null : 1,
|
||||
};
|
||||
}),
|
||||
distributionBoardComponents: [],
|
||||
circuitProtectionDevices: [],
|
||||
distributionBoardComponentProtectionDevices: [],
|
||||
};
|
||||
}
|
||||
|
||||
function initialCategoryBySectionKey(
|
||||
key: string
|
||||
): CircuitGroupCategory | null {
|
||||
if (
|
||||
key === "lighting" ||
|
||||
key === "single_phase" ||
|
||||
key === "three_phase"
|
||||
) {
|
||||
return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function serializeProjectStateSnapshot(
|
||||
snapshot: ProjectStateSnapshot
|
||||
): string {
|
||||
@@ -192,13 +638,42 @@ function assertProjectStateSnapshotRelations(
|
||||
);
|
||||
const floorIds = uniqueIds(snapshot.floors, "floor");
|
||||
const roomIds = uniqueIds(snapshot.rooms, "room");
|
||||
uniqueIds(snapshot.circuits, "circuit");
|
||||
const circuitIds = uniqueIds(snapshot.circuits, "circuit");
|
||||
const componentIds = uniqueIds(
|
||||
snapshot.distributionBoardComponents,
|
||||
"distribution board component"
|
||||
);
|
||||
const sectionById = new Map(
|
||||
snapshot.circuitSections.map((entry) => [entry.id, entry])
|
||||
);
|
||||
const componentById = new Map(
|
||||
snapshot.distributionBoardComponents.map((entry) => [
|
||||
entry.id,
|
||||
entry,
|
||||
])
|
||||
);
|
||||
const groupKeys = new Set<string>();
|
||||
const equipmentIdentifiersByList = new Map<string, Set<string>>();
|
||||
|
||||
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");
|
||||
@@ -214,9 +689,30 @@ function assertProjectStateSnapshotRelations(
|
||||
section.circuitListId,
|
||||
"circuit section list"
|
||||
);
|
||||
if (section.category !== null && section.groupNumber !== null) {
|
||||
const groupKey = [
|
||||
section.circuitListId,
|
||||
section.category,
|
||||
section.groupNumber,
|
||||
].join(":");
|
||||
if (groupKeys.has(groupKey)) {
|
||||
throw new Error(
|
||||
"Snapshot contains a duplicate circuit group number."
|
||||
);
|
||||
}
|
||||
groupKeys.add(groupKey);
|
||||
}
|
||||
}
|
||||
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");
|
||||
@@ -247,6 +743,23 @@ function assertProjectStateSnapshotRelations(
|
||||
"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."
|
||||
);
|
||||
}
|
||||
registerEquipmentIdentifier(
|
||||
equipmentIdentifiersByList,
|
||||
circuit.circuitListId,
|
||||
circuit.equipmentIdentifier
|
||||
);
|
||||
for (const row of circuit.deviceRows) {
|
||||
if (row.circuitId !== circuit.id) {
|
||||
throw new Error(
|
||||
@@ -269,6 +782,148 @@ function assertProjectStateSnapshotRelations(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fixedComponentRolesByList = new Set<string>();
|
||||
const groupComponentRolesBySection = new Set<string>();
|
||||
for (const component of snapshot.distributionBoardComponents) {
|
||||
assertReference(
|
||||
circuitListIds,
|
||||
component.circuitListId,
|
||||
"distribution board component circuit list"
|
||||
);
|
||||
if (component.sectionId !== null) {
|
||||
assertReference(
|
||||
sectionIds,
|
||||
component.sectionId,
|
||||
"distribution board component section"
|
||||
);
|
||||
if (
|
||||
sectionById.get(component.sectionId)?.circuitListId !==
|
||||
component.circuitListId
|
||||
) {
|
||||
throw new Error(
|
||||
"Snapshot distribution board component section belongs to a different circuit list."
|
||||
);
|
||||
}
|
||||
}
|
||||
assertComponentPlacement(component);
|
||||
registerEquipmentIdentifier(
|
||||
equipmentIdentifiersByList,
|
||||
component.circuitListId,
|
||||
component.equipmentIdentifier
|
||||
);
|
||||
if (
|
||||
component.role === "main_switch" ||
|
||||
component.role === "surge_protective_device"
|
||||
) {
|
||||
assertUniqueKey(
|
||||
fixedComponentRolesByList,
|
||||
`${component.circuitListId}:${component.role}`,
|
||||
"Snapshot contains duplicate fixed distribution board components."
|
||||
);
|
||||
}
|
||||
if (
|
||||
component.role === "group_upstream_protection" ||
|
||||
component.role === "group_residual_current_protection"
|
||||
) {
|
||||
assertUniqueKey(
|
||||
groupComponentRolesBySection,
|
||||
`${component.sectionId}:${component.role}`,
|
||||
"Snapshot contains duplicate group protection components."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const protectedCircuitIds = new Set<string>();
|
||||
for (const protection of snapshot.circuitProtectionDevices) {
|
||||
assertReference(
|
||||
circuitIds,
|
||||
protection.circuitId,
|
||||
"circuit protection device circuit"
|
||||
);
|
||||
assertUniqueKey(
|
||||
protectedCircuitIds,
|
||||
protection.circuitId,
|
||||
"Snapshot contains duplicate circuit protection devices."
|
||||
);
|
||||
}
|
||||
|
||||
const protectedComponentIds = new Set<string>();
|
||||
for (const protection of snapshot.distributionBoardComponentProtectionDevices) {
|
||||
assertReference(
|
||||
componentIds,
|
||||
protection.componentId,
|
||||
"component protection device component"
|
||||
);
|
||||
assertUniqueKey(
|
||||
protectedComponentIds,
|
||||
protection.componentId,
|
||||
"Snapshot contains duplicate component protection devices."
|
||||
);
|
||||
const component = componentById.get(protection.componentId)!;
|
||||
if (
|
||||
component.role !== "group_upstream_protection" &&
|
||||
component.role !== "group_residual_current_protection"
|
||||
) {
|
||||
throw new Error(
|
||||
"Snapshot protection data belongs to a non-protection component."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertComponentPlacement(
|
||||
component: ProjectStateSnapshot["distributionBoardComponents"][number]
|
||||
) {
|
||||
const isFixedHeader =
|
||||
component.role === "main_switch" ||
|
||||
component.role === "surge_protective_device";
|
||||
const isGroupProtection =
|
||||
component.role === "group_upstream_protection" ||
|
||||
component.role === "group_residual_current_protection";
|
||||
if (
|
||||
(isFixedHeader &&
|
||||
(component.placement !== "header" ||
|
||||
component.sectionId !== null)) ||
|
||||
(isGroupProtection &&
|
||||
(component.placement !== "group" ||
|
||||
component.sectionId === null)) ||
|
||||
(component.role === "auxiliary" &&
|
||||
(component.placement !== "footer" ||
|
||||
component.sectionId !== null))
|
||||
) {
|
||||
throw new Error(
|
||||
"Snapshot distribution board component placement is invalid."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function registerEquipmentIdentifier(
|
||||
identifiersByList: Map<string, Set<string>>,
|
||||
circuitListId: string,
|
||||
equipmentIdentifier: string
|
||||
) {
|
||||
const identifiers =
|
||||
identifiersByList.get(circuitListId) ?? new Set<string>();
|
||||
const normalized = equipmentIdentifier.trim().toLocaleLowerCase("de");
|
||||
if (identifiers.has(normalized)) {
|
||||
throw new Error(
|
||||
"Snapshot contains duplicate equipment identifiers in one circuit list."
|
||||
);
|
||||
}
|
||||
identifiers.add(normalized);
|
||||
identifiersByList.set(circuitListId, identifiers);
|
||||
}
|
||||
|
||||
function assertUniqueKey(
|
||||
keys: Set<string>,
|
||||
key: string,
|
||||
message: string
|
||||
) {
|
||||
if (keys.has(key)) {
|
||||
throw new Error(message);
|
||||
}
|
||||
keys.add(key);
|
||||
}
|
||||
|
||||
function uniqueIds(
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
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 componentIds = idMap(
|
||||
source.distributionBoardComponents,
|
||||
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),
|
||||
})),
|
||||
distributionBoardComponents:
|
||||
source.distributionBoardComponents.map((component) => ({
|
||||
...component,
|
||||
id: requiredId(componentIds, component.id),
|
||||
circuitListId: requiredId(
|
||||
listIds,
|
||||
component.circuitListId
|
||||
),
|
||||
sectionId:
|
||||
component.sectionId === null
|
||||
? null
|
||||
: requiredId(sectionIds, component.sectionId),
|
||||
})),
|
||||
circuitProtectionDevices: source.circuitProtectionDevices.map(
|
||||
(protection) => ({
|
||||
...protection,
|
||||
circuitId: requiredId(circuitIds, protection.circuitId),
|
||||
})
|
||||
),
|
||||
distributionBoardComponentProtectionDevices:
|
||||
source.distributionBoardComponentProtectionDevices.map(
|
||||
(protection) => ({
|
||||
...protection,
|
||||
componentId: requiredId(
|
||||
componentIds,
|
||||
protection.componentId
|
||||
),
|
||||
})
|
||||
),
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseProtectionDeviceType,
|
||||
FuseUtilizationCategory,
|
||||
RatedResidualCurrentMa,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
|
||||
export interface FuseProtectionDeviceConfiguration {
|
||||
type: FuseProtectionDeviceType;
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory: FuseUtilizationCategory;
|
||||
}
|
||||
|
||||
export interface BreakerProtectionDeviceConfiguration {
|
||||
type: "LS" | "AFDD";
|
||||
ratedCurrentA: number;
|
||||
tripCharacteristic: BreakerTripCharacteristic;
|
||||
}
|
||||
|
||||
export interface ResidualCurrentDeviceConfiguration {
|
||||
type: "FI";
|
||||
ratedCurrentA: number;
|
||||
rcdType: RcdType;
|
||||
ratedResidualCurrentMa: RatedResidualCurrentMa;
|
||||
}
|
||||
|
||||
export interface CombinedResidualCurrentBreakerConfiguration {
|
||||
type: "FI_LS";
|
||||
ratedCurrentA: number;
|
||||
tripCharacteristic: BreakerTripCharacteristic;
|
||||
rcdType: RcdType;
|
||||
ratedResidualCurrentMa: RatedResidualCurrentMa;
|
||||
}
|
||||
|
||||
export type ProtectionDeviceConfiguration =
|
||||
| FuseProtectionDeviceConfiguration
|
||||
| BreakerProtectionDeviceConfiguration
|
||||
| ResidualCurrentDeviceConfiguration
|
||||
| CombinedResidualCurrentBreakerConfiguration;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CircuitGroupMoveProjectCommand } from "../models/circuit-group-move-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitGroupMoveCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitGroupMoveProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitGroupMoveProjectCommandStore {
|
||||
execute(input: ExecuteCircuitGroupMoveCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitGroupMoveProjectCommand;
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user