Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac21d6eb5d | |||
| 7a9530a914 | |||
| 99bdf6491b | |||
| db4c757018 | |||
| 3399fcd88b | |||
| 9ad91887b2 | |||
| 07925b2fd9 | |||
| 63650a623c | |||
| e702712bd8 | |||
| 1250f9a6af | |||
| 69b020339e | |||
| 604604f056 | |||
| 9d4d4082fe | |||
| 012308984d | |||
| e7607c90c4 | |||
| a88c4b2086 | |||
| a72d2c76e0 |
@@ -255,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.
|
||||
@@ -289,10 +294,48 @@ change in one revision and Undo/Redo restores them together; the project PUT
|
||||
route requires `expectedRevision`. The system catalog is `AV`, `SV`, `EV`,
|
||||
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
|
||||
cannot be disabled.
|
||||
Distribution-board setup uses `distribution-board.insert` with a complete
|
||||
stable snapshot of the board, circuit list and four default sections. Its
|
||||
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.
|
||||
|
||||
@@ -43,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
|
||||
|
||||
|
||||
@@ -194,11 +194,14 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||
- `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:
|
||||
@@ -227,7 +230,11 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||
### 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:
|
||||
|
||||
@@ -235,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",
|
||||
@@ -255,6 +293,15 @@ Response sketch:
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"footerComponents": [
|
||||
{
|
||||
"id": "cmp_actor",
|
||||
"equipmentIdentifier": "-K1",
|
||||
"name": "KNX Schaltaktor",
|
||||
"role": "auxiliary",
|
||||
"placement": "footer"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -261,8 +261,9 @@ mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
||||
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
|
||||
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
||||
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
||||
Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu
|
||||
zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
|
||||
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
|
||||
@@ -270,8 +271,10 @@ Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
||||
deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das
|
||||
Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den
|
||||
projektgebundenen Endpunkt beschränkt.
|
||||
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
||||
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
||||
`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
|
||||
@@ -280,6 +283,78 @@ 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
|
||||
@@ -290,6 +365,13 @@ 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
|
||||
@@ -335,6 +417,18 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
||||
- 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
|
||||
|
||||
@@ -264,6 +264,6 @@ nur Architekturvorschläge des LLM sind.
|
||||
- `docs/spec/03-data-model-concept.md` – Datenmodellkonzept
|
||||
- `docs/spec/05-linked-devices-and-sync.md` – Regeln für Projektgeräte und
|
||||
kontrollierte Synchronisierung
|
||||
- `docs/spec/07-implementation-phases-todo.md` – Roadmap, insbesondere Phase 13
|
||||
- `docs/spec/07-implementation-phases-todo.md` – Roadmap, insbesondere Phase 14
|
||||
- `docs/circuit-list-editor-known-limitations.md` – noch nicht implementierte
|
||||
Funktionen
|
||||
|
||||
@@ -496,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:
|
||||
|
||||
@@ -521,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:
|
||||
|
||||
|
||||
@@ -8,6 +8,39 @@ requirements and intended sequencing, not proof of implementation.
|
||||
- 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
|
||||
@@ -90,5 +123,6 @@ and initial feature set are frozen:
|
||||
3. [x] Snapshot-to-revision descriptions and history presentation.
|
||||
4. [x] Distribution-board power summary and project column layout.
|
||||
|
||||
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
|
||||
must not be inferred from these tasks.
|
||||
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
|
||||
+2
-2
@@ -15,8 +15,8 @@
|
||||
"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.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"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",
|
||||
|
||||
@@ -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,6 +57,16 @@ 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",
|
||||
@@ -61,7 +80,11 @@ const projectColumns = new Set(
|
||||
const missingProjectColumns = requiredProjectColumns.filter(
|
||||
(name) => !projectColumns.has(name)
|
||||
);
|
||||
const requiredDistributionBoardColumns = ["floor_id", "supply_type"];
|
||||
const requiredDistributionBoardColumns = [
|
||||
"floor_id",
|
||||
"supply_type",
|
||||
"simultaneity_factor",
|
||||
];
|
||||
const distributionBoardColumns = new Set(
|
||||
db
|
||||
.prepare("PRAGMA table_info(distribution_boards)")
|
||||
@@ -72,6 +95,28 @@ 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");
|
||||
|
||||
@@ -80,11 +125,19 @@ 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);
|
||||
|
||||
@@ -111,6 +164,14 @@ if (missingCircuitColumns.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -124,9 +185,17 @@ if (missingDistributionBoardColumns.length > 0) {
|
||||
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 schema verification passed.");
|
||||
console.log("Circuit-first and distribution-component schema verification passed.");
|
||||
|
||||
@@ -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
@@ -169,6 +169,13 @@
|
||||
"when": 1785343645615,
|
||||
"tag": "0023_cheerful_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "6",
|
||||
"when": 1785431250141,
|
||||
"tag": "0024_damp_mister_sinister",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+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,14 +2,12 @@ import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
assertDistributionBoardDeleteProjectCommand,
|
||||
assertDistributionBoardInsertProjectCommand,
|
||||
createDistributionBoardDeleteProjectCommand,
|
||||
createDistributionBoardInsertProjectCommand,
|
||||
distributionBoardDeleteCommandType,
|
||||
distributionBoardInsertCommandType,
|
||||
invertDistributionBoardStructureProjectCommand,
|
||||
normalizeDistributionBoardStructureProjectCommand,
|
||||
type DistributionBoardStructureProjectCommand,
|
||||
type DistributionBoardStructureSnapshot,
|
||||
legacyDistributionBoardStructureCommandSchemaVersion,
|
||||
type NormalizedDistributionBoardStructureSnapshot,
|
||||
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardUpdateProjectCommand,
|
||||
@@ -28,6 +26,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 { 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";
|
||||
@@ -68,31 +67,15 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
projectId: string,
|
||||
command: DistributionBoardStructureProjectCommand
|
||||
): DistributionBoardStructureProjectCommand {
|
||||
const normalized =
|
||||
const structure =
|
||||
normalizeDistributionBoardStructureProjectCommand(command);
|
||||
if (normalized.type === distributionBoardInsertCommandType) {
|
||||
assertDistributionBoardInsertProjectCommand(normalized);
|
||||
const inverse = this.insert(
|
||||
database,
|
||||
projectId,
|
||||
normalized.payload.structure
|
||||
);
|
||||
return command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
? toLegacyStructureCommand(inverse)
|
||||
: inverse;
|
||||
if (command.type === distributionBoardInsertCommandType) {
|
||||
this.insert(database, projectId, structure);
|
||||
return invertDistributionBoardStructureProjectCommand(command);
|
||||
}
|
||||
if (normalized.type === distributionBoardDeleteCommandType) {
|
||||
assertDistributionBoardDeleteProjectCommand(normalized);
|
||||
const inverse = this.delete(
|
||||
database,
|
||||
projectId,
|
||||
normalized.payload.structure
|
||||
);
|
||||
return command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
? toLegacyStructureCommand(inverse)
|
||||
: inverse;
|
||||
if (command.type === distributionBoardDeleteCommandType) {
|
||||
this.delete(database, projectId, structure);
|
||||
return invertDistributionBoardStructureProjectCommand(command);
|
||||
}
|
||||
throw new Error("Unsupported distribution-board structure command.");
|
||||
}
|
||||
@@ -100,7 +83,7 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
structure: DistributionBoardStructureSnapshot
|
||||
structure: NormalizedDistributionBoardStructureSnapshot
|
||||
) {
|
||||
if (
|
||||
structure.distributionBoard.projectId !== projectId ||
|
||||
@@ -159,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.");
|
||||
}
|
||||
|
||||
@@ -168,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()
|
||||
@@ -198,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."
|
||||
@@ -235,7 +264,6 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Distribution board could not be deleted.");
|
||||
}
|
||||
return createDistributionBoardInsertProjectCommand(structure);
|
||||
}
|
||||
|
||||
private update(
|
||||
@@ -350,28 +378,6 @@ function assertSupplyTypeEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
function toLegacyStructureCommand(
|
||||
command:
|
||||
| ReturnType<typeof createDistributionBoardInsertProjectCommand>
|
||||
| ReturnType<typeof createDistributionBoardDeleteProjectCommand>
|
||||
): DistributionBoardStructureProjectCommand {
|
||||
const {
|
||||
floorId: _floorId,
|
||||
supplyType: _supplyType,
|
||||
...distributionBoard
|
||||
} = command.payload.structure.distributionBoard;
|
||||
return {
|
||||
schemaVersion: legacyDistributionBoardStructureCommandSchemaVersion,
|
||||
type: command.type,
|
||||
payload: {
|
||||
structure: {
|
||||
...command.payload.structure,
|
||||
distributionBoard,
|
||||
},
|
||||
},
|
||||
} as DistributionBoardStructureProjectCommand;
|
||||
}
|
||||
|
||||
function getDistributionBoardFieldValue<
|
||||
TField extends DistributionBoardUpdateField,
|
||||
>(
|
||||
@@ -382,11 +388,12 @@ function getDistributionBoardFieldValue<
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -400,7 +407,8 @@ function structureMatches(
|
||||
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;
|
||||
}
|
||||
@@ -409,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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
legacyProjectStateSnapshotSchemaVersion,
|
||||
previousProjectStateSnapshotSchemaVersion,
|
||||
projectStateSnapshotSchemaVersion,
|
||||
simultaneityFactorProjectStateSnapshotSchemaVersion,
|
||||
supplyTypesProjectStateSnapshotSchemaVersion,
|
||||
voltageProjectStateSnapshotSchemaVersion,
|
||||
} from "../../domain/models/project-state-snapshot.model.js";
|
||||
@@ -152,6 +153,8 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
||||
supplyTypesProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
voltageProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
simultaneityFactorProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !== projectStateSnapshotSchemaVersion
|
||||
) {
|
||||
throw new Error("Project snapshot schema version is not supported.");
|
||||
|
||||
@@ -15,9 +15,12 @@ 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";
|
||||
@@ -141,6 +144,17 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||
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,
|
||||
@@ -159,6 +173,15 @@ function byId<T extends { id: string }>(entries: readonly T[]) {
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
database: AppDatabase,
|
||||
state: ProjectStateSnapshot
|
||||
@@ -214,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";
|
||||
@@ -69,6 +72,8 @@ export function readProjectStateSnapshot(
|
||||
displayName: circuitSections.displayName,
|
||||
prefix: circuitSections.prefix,
|
||||
sortOrder: circuitSections.sortOrder,
|
||||
category: circuitSections.category,
|
||||
groupNumber: circuitSections.groupNumber,
|
||||
})
|
||||
.from(circuitSections)
|
||||
.innerJoin(
|
||||
@@ -82,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,
|
||||
@@ -190,6 +245,10 @@ export function readProjectStateSnapshot(
|
||||
distributionBoards: boardRows,
|
||||
circuitLists: listRows,
|
||||
circuitSections: sectionRows,
|
||||
distributionBoardComponents: componentRows,
|
||||
circuitProtectionDevices: circuitProtectionRows,
|
||||
distributionBoardComponentProtectionDevices:
|
||||
componentProtectionRows,
|
||||
circuits: circuitRows.map((circuit) => ({
|
||||
...circuit,
|
||||
isReserve: Boolean(circuit.isReserve),
|
||||
|
||||
@@ -10,8 +10,11 @@ import type { ProjectTransferStore } from "../../domain/ports/project-transfer.s
|
||||
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";
|
||||
@@ -127,6 +130,21 @@ function insertProjectState(
|
||||
isReserve: circuit.isReserve ? 1 : 0,
|
||||
}))
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
distributionBoardComponents,
|
||||
state.distributionBoardComponents
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
circuitProtectionDevices,
|
||||
state.circuitProtectionDevices
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
distributionBoardComponentProtectionDevices,
|
||||
state.distributionBoardComponentProtectionDevices
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
circuitDeviceRows,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -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,7 +53,10 @@ export interface CircuitTreeSectionBlock {
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
category?: CircuitGroupCategory;
|
||||
groupNumber?: number;
|
||||
sectionTotalPower: number;
|
||||
components: CircuitTreeComponent[];
|
||||
circuits: CircuitTreeCircuit[];
|
||||
}
|
||||
|
||||
@@ -64,7 +68,9 @@ export interface CircuitTreeResponse {
|
||||
distributionBoardSimultaneityFactor: number;
|
||||
distributionBoardTotalPower: number;
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||
headerComponents: CircuitTreeComponent[];
|
||||
sections: CircuitTreeSectionBlock[];
|
||||
footerComponents: CircuitTreeComponent[];
|
||||
}
|
||||
|
||||
export interface LegacyMigrationReport {
|
||||
@@ -78,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);
|
||||
}
|
||||
@@ -3,6 +3,15 @@ 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 =
|
||||
@@ -10,9 +19,10 @@ export const distributionBoardInsertCommandType =
|
||||
export const distributionBoardDeleteCommandType =
|
||||
"distribution-board.delete" as const;
|
||||
export const legacyDistributionBoardStructureCommandSchemaVersion = 1 as const;
|
||||
export const distributionBoardStructureCommandSchemaVersion = 2 as const;
|
||||
export const previousDistributionBoardStructureCommandSchemaVersion = 2 as const;
|
||||
export const distributionBoardStructureCommandSchemaVersion = 3 as const;
|
||||
|
||||
export const defaultCircuitSectionDefinitions = [
|
||||
const legacyDefaultCircuitSectionDefinitions = [
|
||||
{
|
||||
key: "lighting",
|
||||
displayName: "Lighting",
|
||||
@@ -39,6 +49,33 @@ 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;
|
||||
@@ -46,7 +83,22 @@ interface LegacyDistributionBoardStructureSnapshot {
|
||||
name: string;
|
||||
};
|
||||
circuitList: DistributionBoardStructureSnapshot["circuitList"];
|
||||
sections: DistributionBoardStructureSnapshot["sections"];
|
||||
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 {
|
||||
@@ -70,7 +122,31 @@ 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;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface NormalizedDistributionBoardStructureSnapshot {
|
||||
distributionBoard: DistributionBoardStructureSnapshot["distributionBoard"];
|
||||
circuitList: DistributionBoardStructureSnapshot["circuitList"];
|
||||
sections: Array<
|
||||
LegacyCircuitSectionSnapshot & {
|
||||
category: CircuitGroupCategory | null;
|
||||
groupNumber: number | null;
|
||||
}
|
||||
>;
|
||||
components: DistributionBoardStructureSnapshot["components"];
|
||||
}
|
||||
|
||||
interface DistributionBoardStructureCommandPayload<
|
||||
@@ -97,6 +173,16 @@ interface LegacyDistributionBoardStructureProjectCommand
|
||||
| typeof distributionBoardDeleteCommandType;
|
||||
}
|
||||
|
||||
interface PreviousDistributionBoardStructureProjectCommand
|
||||
extends SerializedProjectCommand<
|
||||
DistributionBoardStructureCommandPayload<PreviousDistributionBoardStructureSnapshot>
|
||||
> {
|
||||
schemaVersion: typeof previousDistributionBoardStructureCommandSchemaVersion;
|
||||
type:
|
||||
| typeof distributionBoardInsertCommandType
|
||||
| typeof distributionBoardDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface DistributionBoardInsertProjectCommand
|
||||
extends CurrentDistributionBoardStructureProjectCommand {
|
||||
type: typeof distributionBoardInsertCommandType;
|
||||
@@ -110,6 +196,7 @@ export interface DistributionBoardDeleteProjectCommand
|
||||
export type DistributionBoardStructureProjectCommand =
|
||||
| DistributionBoardInsertProjectCommand
|
||||
| DistributionBoardDeleteProjectCommand
|
||||
| PreviousDistributionBoardStructureProjectCommand
|
||||
| LegacyDistributionBoardStructureProjectCommand;
|
||||
|
||||
export function createDistributionBoardStructureSnapshot(
|
||||
@@ -143,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;
|
||||
@@ -192,32 +295,71 @@ export function assertDistributionBoardDeleteProjectCommand(
|
||||
|
||||
export function normalizeDistributionBoardStructureProjectCommand(
|
||||
command: DistributionBoardStructureProjectCommand
|
||||
): DistributionBoardInsertProjectCommand | DistributionBoardDeleteProjectCommand {
|
||||
): NormalizedDistributionBoardStructureSnapshot {
|
||||
if (
|
||||
command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
const structure: DistributionBoardStructureSnapshot = {
|
||||
...command.payload.structure,
|
||||
return {
|
||||
distributionBoard: {
|
||||
...command.payload.structure.distributionBoard,
|
||||
floorId: null,
|
||||
supplyType: null,
|
||||
},
|
||||
circuitList: command.payload.structure.circuitList,
|
||||
sections: command.payload.structure.sections.map(
|
||||
normalizeLegacySection
|
||||
),
|
||||
components: [],
|
||||
};
|
||||
return command.type === distributionBoardInsertCommandType
|
||||
? createDistributionBoardInsertProjectCommand(structure)
|
||||
: createDistributionBoardDeleteProjectCommand(structure);
|
||||
}
|
||||
return command as
|
||||
| DistributionBoardInsertProjectCommand
|
||||
| DistributionBoardDeleteProjectCommand;
|
||||
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 {
|
||||
assertDistributionBoardStructureSnapshotVersion(structure, false);
|
||||
assertDistributionBoardStructureSnapshotVersion(structure, "current");
|
||||
}
|
||||
|
||||
function assertDistributionBoardStructureProjectCommand(
|
||||
@@ -239,36 +381,57 @@ function assertDistributionBoardStructureProjectCommand(
|
||||
) {
|
||||
assertDistributionBoardStructureSnapshotVersion(
|
||||
command.payload.structure,
|
||||
true
|
||||
"legacy"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
command.schemaVersion !== distributionBoardStructureCommandSchemaVersion
|
||||
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,
|
||||
false
|
||||
"current"
|
||||
);
|
||||
}
|
||||
|
||||
function assertDistributionBoardStructureSnapshotVersion(
|
||||
structure: unknown,
|
||||
legacy: boolean
|
||||
version: "legacy" | "previous" | "current"
|
||||
) {
|
||||
if (!isPlainObject(structure) || Object.keys(structure).length !== 3) {
|
||||
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 !== (legacy ? 3 : 5) ||
|
||||
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.");
|
||||
}
|
||||
@@ -278,7 +441,7 @@ function assertDistributionBoardStructureSnapshotVersion(
|
||||
`distributionBoard.${field}`
|
||||
);
|
||||
}
|
||||
if (!legacy) {
|
||||
if (version !== "legacy") {
|
||||
assertNullableId(distributionBoard.floorId, "distributionBoard.floorId");
|
||||
assertNullableSupplyType(distributionBoard.supplyType);
|
||||
}
|
||||
@@ -296,8 +459,11 @@ function assertDistributionBoardStructureSnapshotVersion(
|
||||
const sectionIds = new Set<string>();
|
||||
for (let index = 0; index < sections.length; index += 1) {
|
||||
const section = sections[index];
|
||||
const expected = defaultCircuitSectionDefinitions[index];
|
||||
if (!isPlainObject(section) || Object.keys(section).length !== 6) {
|
||||
const expected = expectedSections[index];
|
||||
if (
|
||||
!isPlainObject(section) ||
|
||||
Object.keys(section).length !== (current ? 8 : 6)
|
||||
) {
|
||||
throw new Error("Default circuit section is invalid.");
|
||||
}
|
||||
assertNonEmptyString(section.id, "section.id");
|
||||
@@ -310,13 +476,64 @@ function assertDistributionBoardStructureSnapshotVersion(
|
||||
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 assertDefaultComponents(
|
||||
components: unknown,
|
||||
circuitListId: unknown
|
||||
) {
|
||||
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) {
|
||||
|
||||
@@ -3,6 +3,21 @@ 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,
|
||||
@@ -14,7 +29,8 @@ export const previousProjectStateSnapshotSchemaVersion = 2 as const;
|
||||
export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const;
|
||||
export const supplyTypesProjectStateSnapshotSchemaVersion = 4 as const;
|
||||
export const voltageProjectStateSnapshotSchemaVersion = 5 as const;
|
||||
export const projectStateSnapshotSchemaVersion = 6 as const;
|
||||
export const simultaneityFactorProjectStateSnapshotSchemaVersion = 6 as const;
|
||||
export const projectStateSnapshotSchemaVersion = 7 as const;
|
||||
|
||||
const idSchema = z.string().trim().min(1);
|
||||
const nullableStringSchema = z.string().nullable();
|
||||
@@ -73,7 +89,7 @@ const circuitListSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const circuitSectionSchema = z
|
||||
const legacyCircuitSectionSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
circuitListId: idSchema,
|
||||
@@ -84,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,
|
||||
@@ -173,21 +205,106 @@ const roomSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const commonProjectStateSnapshotContents = {
|
||||
const distributionBoardComponentSchema = z
|
||||
.object({
|
||||
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(circuitSectionSchema),
|
||||
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),
|
||||
...commonProjectStateSnapshotContents,
|
||||
...legacyProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -196,7 +313,7 @@ const previousProjectStateSnapshotSchema = z
|
||||
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
...legacyProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -207,7 +324,7 @@ const distributionBoardProjectStateSnapshotSchema = z
|
||||
),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
...legacyProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -216,7 +333,7 @@ const supplyTypesProjectStateSnapshotSchema = z
|
||||
schemaVersion: z.literal(supplyTypesProjectStateSnapshotSchemaVersion),
|
||||
project: currentProjectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
...legacyProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -227,12 +344,20 @@ const voltageProjectStateSnapshotSchema =
|
||||
),
|
||||
});
|
||||
|
||||
export const projectStateSnapshotSchema =
|
||||
const simultaneityFactorProjectStateSnapshotSchema =
|
||||
voltageProjectStateSnapshotSchema.extend({
|
||||
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||
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
|
||||
>;
|
||||
@@ -243,46 +368,65 @@ export function parseProjectStateSnapshot(
|
||||
const version = isPlainObject(value) ? value.schemaVersion : undefined;
|
||||
const parsedSnapshot =
|
||||
version === legacyProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
upgradePreviousProjectStateSnapshot(
|
||||
upgradeLegacyProjectStateSnapshot(
|
||||
legacyProjectStateSnapshotSchema.parse(value)
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
upgradePreviousProjectStateSnapshot(
|
||||
upgradeLegacyProjectStateSnapshot(
|
||||
legacyProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === previousProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
upgradePreviousProjectStateSnapshot(
|
||||
previousProjectStateSnapshotSchema.parse(value)
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
upgradePreviousProjectStateSnapshot(
|
||||
previousProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === distributionBoardProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
distributionBoardProjectStateSnapshotSchema.parse(value)
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
distributionBoardProjectStateSnapshotSchema.parse(
|
||||
value
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === supplyTypesProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
supplyTypesProjectStateSnapshotSchema.parse(value)
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
supplyTypesProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === voltageProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
voltageProjectStateSnapshotSchema.parse(value)
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
voltageProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
: projectStateSnapshotSchema.parse(value);
|
||||
: version ===
|
||||
simultaneityFactorProjectStateSnapshotSchemaVersion
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
simultaneityFactorProjectStateSnapshotSchema.parse(
|
||||
value
|
||||
)
|
||||
)
|
||||
: projectStateSnapshotSchema.parse(value);
|
||||
const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot);
|
||||
assertProjectStateSnapshotRelations(snapshot);
|
||||
return snapshot;
|
||||
@@ -412,10 +556,10 @@ function upgradeSupplyTypesProjectStateSnapshot(
|
||||
|
||||
function upgradeVoltageProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof voltageProjectStateSnapshotSchema>
|
||||
): ProjectStateSnapshot {
|
||||
): z.infer<typeof simultaneityFactorProjectStateSnapshotSchema> {
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
schemaVersion: simultaneityFactorProjectStateSnapshotSchemaVersion,
|
||||
distributionBoards: snapshot.distributionBoards.map((board) => ({
|
||||
...board,
|
||||
simultaneityFactor: 1,
|
||||
@@ -423,6 +567,39 @@ function upgradeVoltageProjectStateSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -461,10 +638,22 @@ 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");
|
||||
@@ -500,6 +689,19 @@ 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");
|
||||
@@ -553,6 +755,11 @@ function assertProjectStateSnapshotRelations(
|
||||
"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(
|
||||
@@ -575,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(
|
||||
|
||||
@@ -79,6 +79,10 @@ export function remapProjectState(
|
||||
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);
|
||||
@@ -110,6 +114,35 @@ export function remapProjectState(
|
||||
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),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CircuitGroupRenumberProjectCommand } from "../models/circuit-group-renumber-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitGroupRenumberCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitGroupRenumberProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitGroupRenumberProjectCommandStore {
|
||||
execute(input: ExecuteCircuitGroupRenumberCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitGroupRenumberProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CircuitGroupStructureProjectCommand } from "../models/circuit-group-structure-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitGroupStructureCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitGroupStructureProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitGroupStructureProjectCommandStore {
|
||||
execute(input: ExecuteCircuitGroupStructureCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitGroupStructureProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CircuitGroupSubtreeSnapshot } from "../models/circuit-group-subtree-snapshot.model.js";
|
||||
import type { CircuitGroupSubtreeProjectCommand } from "../models/circuit-group-subtree-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitGroupSubtreeCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitGroupSubtreeProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitGroupSubtreeProjectCommandStore {
|
||||
capture(projectId: string, groupId: string): CircuitGroupSubtreeSnapshot;
|
||||
execute(input: ExecuteCircuitGroupSubtreeCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitGroupSubtreeProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { DistributionBoardComponentStructureProjectCommand } from "../models/distribution-board-component-structure-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteDistributionBoardComponentStructureCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: DistributionBoardComponentStructureProjectCommand;
|
||||
}
|
||||
|
||||
export interface DistributionBoardComponentStructureProjectCommandStore {
|
||||
execute(
|
||||
input: ExecuteDistributionBoardComponentStructureCommandInput
|
||||
): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: DistributionBoardComponentStructureProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
import {
|
||||
formatCircuitGroupPrefix,
|
||||
getNextGroupedCircuitIdentifier,
|
||||
parseGroupedEquipmentIdentifier,
|
||||
} from "./circuit-group-numbering.js";
|
||||
|
||||
export interface CircuitMoveGroupIdentity {
|
||||
id: string;
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export interface CircuitGroupMovePlan {
|
||||
circuitId: string;
|
||||
circuitListId: string;
|
||||
expectedSectionId: string;
|
||||
targetSectionId: string;
|
||||
expectedEquipmentIdentifier: string;
|
||||
targetEquipmentIdentifier: string;
|
||||
expectedSortOrder: number;
|
||||
targetSortOrder: number;
|
||||
}
|
||||
|
||||
export function createCircuitGroupMovePlan(input: {
|
||||
circuit: {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
sectionId: string;
|
||||
equipmentIdentifier: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
sourceGroup: CircuitMoveGroupIdentity;
|
||||
targetGroup: CircuitMoveGroupIdentity;
|
||||
targetCircuitEquipmentIdentifiers: Iterable<string>;
|
||||
targetSortOrder: number;
|
||||
}): CircuitGroupMovePlan {
|
||||
assertGroupIdentity(input.sourceGroup);
|
||||
assertGroupIdentity(input.targetGroup);
|
||||
if (
|
||||
input.sourceGroup.id === input.targetGroup.id ||
|
||||
input.sourceGroup.category !== input.targetGroup.category
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuits can move only between different groups of the same category."
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.circuit.sectionId !== input.sourceGroup.id ||
|
||||
!input.circuit.id.trim() ||
|
||||
!input.circuit.circuitListId.trim() ||
|
||||
!Number.isFinite(input.circuit.sortOrder) ||
|
||||
!Number.isFinite(input.targetSortOrder)
|
||||
) {
|
||||
throw new Error("Circuit move source or target position is invalid.");
|
||||
}
|
||||
const parsed = parseGroupedEquipmentIdentifier(
|
||||
input.circuit.equipmentIdentifier
|
||||
);
|
||||
if (
|
||||
parsed?.kind !== "circuit" ||
|
||||
parsed.category !== input.sourceGroup.category ||
|
||||
parsed.groupNumber !== input.sourceGroup.groupNumber
|
||||
) {
|
||||
throw new Error("Circuit BMK does not match its source group.");
|
||||
}
|
||||
|
||||
return {
|
||||
circuitId: input.circuit.id,
|
||||
circuitListId: input.circuit.circuitListId,
|
||||
expectedSectionId: input.sourceGroup.id,
|
||||
targetSectionId: input.targetGroup.id,
|
||||
expectedEquipmentIdentifier:
|
||||
input.circuit.equipmentIdentifier,
|
||||
targetEquipmentIdentifier:
|
||||
getNextGroupedCircuitIdentifier(
|
||||
input.targetGroup.category,
|
||||
input.targetGroup.groupNumber,
|
||||
input.targetCircuitEquipmentIdentifiers
|
||||
),
|
||||
expectedSortOrder: input.circuit.sortOrder,
|
||||
targetSortOrder: input.targetSortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
function assertGroupIdentity(group: CircuitMoveGroupIdentity) {
|
||||
if (
|
||||
!group.id.trim() ||
|
||||
!Number.isInteger(group.groupNumber) ||
|
||||
group.groupNumber < 1 ||
|
||||
group.prefix !==
|
||||
formatCircuitGroupPrefix(group.category, group.groupNumber)
|
||||
) {
|
||||
throw new Error("Circuit move group identity is invalid.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
circuitGroupCategoryNumbers,
|
||||
type CircuitGroupCategory,
|
||||
} from "../../shared/constants/circuit-group.js";
|
||||
|
||||
export type GroupedEquipmentIdentifierKind =
|
||||
| "group_upstream_protection"
|
||||
| "group_rcd"
|
||||
| "circuit";
|
||||
|
||||
export interface ParsedGroupedEquipmentIdentifier {
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
kind: GroupedEquipmentIdentifierKind;
|
||||
circuitNumber: number | null;
|
||||
}
|
||||
|
||||
const categoryByNumber: Record<string, CircuitGroupCategory> = {
|
||||
"1": "lighting",
|
||||
"2": "single_phase",
|
||||
"3": "three_phase",
|
||||
};
|
||||
|
||||
function requirePositiveInteger(value: number, fieldName: string): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new RangeError(`${fieldName} must be a positive integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function getGroupStem(
|
||||
category: CircuitGroupCategory,
|
||||
functionLetter: "F" | "Q",
|
||||
groupNumber: number
|
||||
): string {
|
||||
requirePositiveInteger(groupNumber, "groupNumber");
|
||||
return `-${circuitGroupCategoryNumbers[category]}${functionLetter}${groupNumber}`;
|
||||
}
|
||||
|
||||
export function formatCircuitGroupPrefix(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number
|
||||
) {
|
||||
return `${getGroupStem(category, "F", groupNumber)}.`;
|
||||
}
|
||||
|
||||
export function formatGroupUpstreamProtectionIdentifier(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number
|
||||
): string {
|
||||
return `${getGroupStem(category, "F", groupNumber)}.0`;
|
||||
}
|
||||
|
||||
export function formatGroupRcdIdentifier(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number
|
||||
): string {
|
||||
return `${getGroupStem(category, "Q", groupNumber)}.0`;
|
||||
}
|
||||
|
||||
export function formatGroupedCircuitIdentifier(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number,
|
||||
circuitNumber: number
|
||||
): string {
|
||||
requirePositiveInteger(circuitNumber, "circuitNumber");
|
||||
return `${getGroupStem(category, "F", groupNumber)}.${circuitNumber}`;
|
||||
}
|
||||
|
||||
export function parseGroupedEquipmentIdentifier(
|
||||
equipmentIdentifier: string
|
||||
): ParsedGroupedEquipmentIdentifier | null {
|
||||
const match = /^-(1|2|3)(F|Q)([1-9]\d*)\.(0|[1-9]\d*)$/.exec(
|
||||
equipmentIdentifier
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, categoryNumber, functionLetter, groupNumberValue, suffixValue] =
|
||||
match;
|
||||
const category = categoryByNumber[categoryNumber];
|
||||
const groupNumber = Number(groupNumberValue);
|
||||
const suffix = Number(suffixValue);
|
||||
|
||||
if (functionLetter === "Q") {
|
||||
return suffix === 0
|
||||
? {
|
||||
category,
|
||||
groupNumber,
|
||||
kind: "group_rcd",
|
||||
circuitNumber: null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (suffix === 0) {
|
||||
return {
|
||||
category,
|
||||
groupNumber,
|
||||
kind: "group_upstream_protection",
|
||||
circuitNumber: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
category,
|
||||
groupNumber,
|
||||
kind: "circuit",
|
||||
circuitNumber: suffix,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNextGroupedCircuitNumber(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number,
|
||||
equipmentIdentifiers: Iterable<string>
|
||||
): number {
|
||||
requirePositiveInteger(groupNumber, "groupNumber");
|
||||
let highestCircuitNumber = 0;
|
||||
|
||||
for (const equipmentIdentifier of equipmentIdentifiers) {
|
||||
const parsed = parseGroupedEquipmentIdentifier(equipmentIdentifier);
|
||||
if (
|
||||
parsed?.kind === "circuit" &&
|
||||
parsed.category === category &&
|
||||
parsed.groupNumber === groupNumber &&
|
||||
parsed.circuitNumber !== null
|
||||
) {
|
||||
highestCircuitNumber = Math.max(
|
||||
highestCircuitNumber,
|
||||
parsed.circuitNumber
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return highestCircuitNumber + 1;
|
||||
}
|
||||
|
||||
export function getNextGroupedCircuitIdentifier(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number,
|
||||
equipmentIdentifiers: Iterable<string>
|
||||
): string {
|
||||
return formatGroupedCircuitIdentifier(
|
||||
category,
|
||||
groupNumber,
|
||||
getNextGroupedCircuitNumber(
|
||||
category,
|
||||
groupNumber,
|
||||
equipmentIdentifiers
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getNextCircuitGroupNumber(
|
||||
category: CircuitGroupCategory,
|
||||
groups: Iterable<{
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
}>
|
||||
): number {
|
||||
let highestGroupNumber = 0;
|
||||
|
||||
for (const group of groups) {
|
||||
if (group.category === category) {
|
||||
requirePositiveInteger(group.groupNumber, "groupNumber");
|
||||
highestGroupNumber = Math.max(highestGroupNumber, group.groupNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return highestGroupNumber + 1;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
import {
|
||||
formatCircuitGroupPrefix,
|
||||
formatGroupRcdIdentifier,
|
||||
formatGroupedCircuitIdentifier,
|
||||
formatGroupUpstreamProtectionIdentifier,
|
||||
parseGroupedEquipmentIdentifier,
|
||||
} from "./circuit-group-numbering.js";
|
||||
|
||||
export interface RenumberableCircuitGroup {
|
||||
id: string;
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
prefix: string;
|
||||
circuits: Array<{
|
||||
id: string;
|
||||
equipmentIdentifier: string;
|
||||
}>;
|
||||
components: Array<{
|
||||
id: string;
|
||||
role:
|
||||
| "group_upstream_protection"
|
||||
| "group_residual_current_protection";
|
||||
equipmentIdentifier: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CircuitGroupRenumberRequest {
|
||||
groupId: string;
|
||||
targetGroupNumber: number;
|
||||
}
|
||||
|
||||
export interface CircuitGroupRenumberPlan {
|
||||
groups: Array<{
|
||||
groupId: string;
|
||||
category: CircuitGroupCategory;
|
||||
expectedGroupNumber: number;
|
||||
targetGroupNumber: number;
|
||||
expectedPrefix: string;
|
||||
targetPrefix: string;
|
||||
circuits: Array<{
|
||||
circuitId: string;
|
||||
expectedEquipmentIdentifier: string;
|
||||
targetEquipmentIdentifier: string;
|
||||
}>;
|
||||
components: Array<{
|
||||
componentId: string;
|
||||
expectedEquipmentIdentifier: string;
|
||||
targetEquipmentIdentifier: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function createCircuitGroupRenumberPlan(
|
||||
groups: RenumberableCircuitGroup[],
|
||||
requests: CircuitGroupRenumberRequest[]
|
||||
): CircuitGroupRenumberPlan {
|
||||
if (requests.length === 0) {
|
||||
throw new Error("Circuit-group renumbering requires requests.");
|
||||
}
|
||||
const groupsById = new Map(groups.map((group) => [group.id, group]));
|
||||
if (groupsById.size !== groups.length) {
|
||||
throw new Error("Circuit-group renumbering contains duplicate group ids.");
|
||||
}
|
||||
const requestGroupIds = new Set<string>();
|
||||
const targetKeys = new Set<string>();
|
||||
const selected = requests.map((request) => {
|
||||
const group = groupsById.get(request.groupId);
|
||||
if (
|
||||
!group ||
|
||||
requestGroupIds.has(request.groupId) ||
|
||||
!Number.isInteger(request.targetGroupNumber) ||
|
||||
request.targetGroupNumber < 1
|
||||
) {
|
||||
throw new Error("Circuit-group renumber request is invalid.");
|
||||
}
|
||||
const targetKey = `${group.category}:${request.targetGroupNumber}`;
|
||||
if (targetKeys.has(targetKey)) {
|
||||
throw new Error(
|
||||
"Circuit-group renumbering contains duplicate target numbers."
|
||||
);
|
||||
}
|
||||
requestGroupIds.add(request.groupId);
|
||||
targetKeys.add(targetKey);
|
||||
return { group, targetGroupNumber: request.targetGroupNumber };
|
||||
});
|
||||
if (
|
||||
selected.every(
|
||||
({ group, targetGroupNumber }) =>
|
||||
group.groupNumber === targetGroupNumber
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit-group renumbering must change at least one number."
|
||||
);
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
if (
|
||||
!requestGroupIds.has(group.id) &&
|
||||
targetKeys.has(`${group.category}:${group.groupNumber}`)
|
||||
) {
|
||||
throw new Error(
|
||||
"Target group number belongs to an unchanged group."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groups: selected.map(({ group, targetGroupNumber }) => {
|
||||
assertCurrentGroupIdentity(group);
|
||||
return {
|
||||
groupId: group.id,
|
||||
category: group.category,
|
||||
expectedGroupNumber: group.groupNumber,
|
||||
targetGroupNumber,
|
||||
expectedPrefix: group.prefix,
|
||||
targetPrefix: formatCircuitGroupPrefix(
|
||||
group.category,
|
||||
targetGroupNumber
|
||||
),
|
||||
circuits: group.circuits.map((circuit) => {
|
||||
const parsed = parseGroupedEquipmentIdentifier(
|
||||
circuit.equipmentIdentifier
|
||||
);
|
||||
if (
|
||||
parsed?.kind !== "circuit" ||
|
||||
parsed.category !== group.category ||
|
||||
parsed.groupNumber !== group.groupNumber ||
|
||||
parsed.circuitNumber === null
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit BMK does not match its current group."
|
||||
);
|
||||
}
|
||||
return {
|
||||
circuitId: circuit.id,
|
||||
expectedEquipmentIdentifier:
|
||||
circuit.equipmentIdentifier,
|
||||
targetEquipmentIdentifier:
|
||||
formatGroupedCircuitIdentifier(
|
||||
group.category,
|
||||
targetGroupNumber,
|
||||
parsed.circuitNumber
|
||||
),
|
||||
};
|
||||
}),
|
||||
components: group.components.map((component) => {
|
||||
const expected =
|
||||
component.role === "group_upstream_protection"
|
||||
? formatGroupUpstreamProtectionIdentifier(
|
||||
group.category,
|
||||
group.groupNumber
|
||||
)
|
||||
: formatGroupRcdIdentifier(
|
||||
group.category,
|
||||
group.groupNumber
|
||||
);
|
||||
if (component.equipmentIdentifier !== expected) {
|
||||
throw new Error(
|
||||
"Group component BMK does not match its current group."
|
||||
);
|
||||
}
|
||||
return {
|
||||
componentId: component.id,
|
||||
expectedEquipmentIdentifier: expected,
|
||||
targetEquipmentIdentifier:
|
||||
component.role === "group_upstream_protection"
|
||||
? formatGroupUpstreamProtectionIdentifier(
|
||||
group.category,
|
||||
targetGroupNumber
|
||||
)
|
||||
: formatGroupRcdIdentifier(
|
||||
group.category,
|
||||
targetGroupNumber
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function assertCurrentGroupIdentity(group: RenumberableCircuitGroup) {
|
||||
if (
|
||||
!Number.isInteger(group.groupNumber) ||
|
||||
group.groupNumber < 1 ||
|
||||
group.prefix !==
|
||||
formatCircuitGroupPrefix(group.category, group.groupNumber)
|
||||
) {
|
||||
throw new Error("Circuit group has an invalid current identity.");
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,14 @@ import {
|
||||
assertDistributionBoardUpdateProjectCommand,
|
||||
distributionBoardUpdateCommandType,
|
||||
} from "../models/distribution-board-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardComponentDeleteProjectCommand,
|
||||
assertDistributionBoardComponentInsertProjectCommand,
|
||||
assertDistributionBoardComponentUpdateProjectCommand,
|
||||
distributionBoardComponentDeleteCommandType,
|
||||
distributionBoardComponentInsertCommandType,
|
||||
distributionBoardComponentUpdateCommandType,
|
||||
} from "../models/distribution-board-component-structure-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardDeleteProjectCommand,
|
||||
assertDistributionBoardInsertProjectCommand,
|
||||
@@ -90,7 +98,12 @@ import type { CircuitProjectCommandStore } from "../ports/circuit-project-comman
|
||||
import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
|
||||
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
|
||||
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
|
||||
import type { CircuitGroupStructureProjectCommandStore } from "../ports/circuit-group-structure-project-command.store.js";
|
||||
import type { CircuitGroupRenumberProjectCommandStore } from "../ports/circuit-group-renumber-project-command.store.js";
|
||||
import type { CircuitGroupMoveProjectCommandStore } from "../ports/circuit-group-move-project-command.store.js";
|
||||
import type { CircuitGroupSubtreeProjectCommandStore } from "../ports/circuit-group-subtree-project-command.store.js";
|
||||
import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js";
|
||||
import type { DistributionBoardComponentStructureProjectCommandStore } from "../ports/distribution-board-component-structure-project-command.store.js";
|
||||
import type {
|
||||
ExecuteProjectHistoryCommandInput,
|
||||
ExecutedProjectCommand,
|
||||
@@ -101,6 +114,30 @@ import type {
|
||||
ProjectHistoryDirection,
|
||||
ProjectHistoryStore,
|
||||
} from "../ports/project-history.store.js";
|
||||
import {
|
||||
assertCircuitGroupDeleteProjectCommand,
|
||||
assertCircuitGroupInsertProjectCommand,
|
||||
assertCircuitGroupReorderProjectCommand,
|
||||
assertCircuitGroupUpdateProjectCommand,
|
||||
circuitGroupDeleteCommandType,
|
||||
circuitGroupInsertCommandType,
|
||||
circuitGroupReorderCommandType,
|
||||
circuitGroupUpdateCommandType,
|
||||
} from "../models/circuit-group-structure-project-command.model.js";
|
||||
import {
|
||||
assertCircuitGroupRenumberProjectCommand,
|
||||
circuitGroupRenumberCommandType,
|
||||
} from "../models/circuit-group-renumber-project-command.model.js";
|
||||
import {
|
||||
assertCircuitGroupMoveProjectCommand,
|
||||
circuitGroupMoveCommandType,
|
||||
} from "../models/circuit-group-move-project-command.model.js";
|
||||
import {
|
||||
assertCircuitGroupDeleteSubtreeProjectCommand,
|
||||
assertCircuitGroupRestoreSubtreeProjectCommand,
|
||||
circuitGroupDeleteSubtreeCommandType,
|
||||
circuitGroupRestoreSubtreeCommandType,
|
||||
} from "../models/circuit-group-subtree-project-command.model.js";
|
||||
import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js";
|
||||
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
|
||||
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
|
||||
@@ -135,6 +172,11 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
|
||||
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
|
||||
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
|
||||
private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore,
|
||||
private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore,
|
||||
private readonly circuitGroupRenumberStore: CircuitGroupRenumberProjectCommandStore,
|
||||
private readonly circuitGroupMoveStore: CircuitGroupMoveProjectCommandStore,
|
||||
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
|
||||
private readonly historyStore: ProjectHistoryStore
|
||||
) {}
|
||||
|
||||
@@ -296,6 +338,89 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case distributionBoardComponentInsertCommandType: {
|
||||
assertDistributionBoardComponentInsertProjectCommand(
|
||||
input.command
|
||||
);
|
||||
return this.distributionBoardComponentStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case distributionBoardComponentDeleteCommandType: {
|
||||
assertDistributionBoardComponentDeleteProjectCommand(
|
||||
input.command
|
||||
);
|
||||
return this.distributionBoardComponentStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case distributionBoardComponentUpdateCommandType: {
|
||||
assertDistributionBoardComponentUpdateProjectCommand(
|
||||
input.command
|
||||
);
|
||||
return this.distributionBoardComponentStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupInsertCommandType: {
|
||||
assertCircuitGroupInsertProjectCommand(input.command);
|
||||
return this.circuitGroupStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupDeleteCommandType: {
|
||||
assertCircuitGroupDeleteProjectCommand(input.command);
|
||||
return this.circuitGroupStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupUpdateCommandType: {
|
||||
assertCircuitGroupUpdateProjectCommand(input.command);
|
||||
return this.circuitGroupStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupReorderCommandType: {
|
||||
assertCircuitGroupReorderProjectCommand(input.command);
|
||||
return this.circuitGroupStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupRenumberCommandType: {
|
||||
assertCircuitGroupRenumberProjectCommand(input.command);
|
||||
return this.circuitGroupRenumberStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupMoveCommandType: {
|
||||
assertCircuitGroupMoveProjectCommand(input.command);
|
||||
return this.circuitGroupMoveStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupDeleteSubtreeCommandType: {
|
||||
assertCircuitGroupDeleteSubtreeProjectCommand(input.command);
|
||||
return this.circuitGroupSubtreeStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupRestoreSubtreeCommandType: {
|
||||
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
|
||||
return this.circuitGroupSubtreeStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case projectFloorInsertCommandType: {
|
||||
assertProjectFloorInsertProjectCommand(input.command);
|
||||
return this.projectLocationStructureStore.execute({
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ProtectionDeviceConfiguration } from "../models/protection-device.model.js";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
|
||||
const defaultCircuitProtectionByCategory = {
|
||||
lighting: {
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
tripCharacteristic: "B",
|
||||
},
|
||||
single_phase: {
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
three_phase: {
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
} as const satisfies Record<CircuitGroupCategory, ProtectionDeviceConfiguration>;
|
||||
|
||||
const defaultGroupRcd = {
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
} as const satisfies ProtectionDeviceConfiguration;
|
||||
|
||||
export function createDefaultCircuitProtection(
|
||||
category: CircuitGroupCategory
|
||||
): ProtectionDeviceConfiguration {
|
||||
return { ...defaultCircuitProtectionByCategory[category] };
|
||||
}
|
||||
|
||||
export function createDefaultGroupRcd(): ProtectionDeviceConfiguration {
|
||||
return { ...defaultGroupRcd };
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
import type { DistributionBoardSupplyType } from "../shared/constants/distribution-board";
|
||||
import type { CircuitGroupCategory } from "../shared/constants/circuit-group";
|
||||
import type {
|
||||
DistributionBoardComponentPlacement,
|
||||
DistributionBoardComponentRole,
|
||||
} from "../shared/constants/distribution-board-component";
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../shared/constants/protection-device";
|
||||
|
||||
export interface ProjectDto {
|
||||
id: string;
|
||||
@@ -267,6 +278,27 @@ export interface CircuitTreeDeviceRowDto {
|
||||
rowTotalPower: number;
|
||||
}
|
||||
|
||||
export interface CircuitTreeProtectionDeviceDto {
|
||||
type: ProtectionDeviceType;
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory?: FuseUtilizationCategory;
|
||||
tripCharacteristic?: BreakerTripCharacteristic;
|
||||
rcdType?: RcdType;
|
||||
ratedResidualCurrentMa?: number;
|
||||
}
|
||||
|
||||
export interface CircuitTreeComponentDto {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
sectionId?: string;
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
role: DistributionBoardComponentRole;
|
||||
placement: DistributionBoardComponentPlacement;
|
||||
sortOrder: number;
|
||||
protectionDevice?: CircuitTreeProtectionDeviceDto;
|
||||
}
|
||||
|
||||
export interface CircuitTreeCircuitDto {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
@@ -289,6 +321,7 @@ export interface CircuitTreeCircuitDto {
|
||||
remark?: string;
|
||||
circuitTotalPower: number;
|
||||
deviceRows: CircuitTreeDeviceRowDto[];
|
||||
protectionDevice?: CircuitTreeProtectionDeviceDto;
|
||||
}
|
||||
|
||||
export interface CircuitTreeSectionDto {
|
||||
@@ -297,7 +330,10 @@ export interface CircuitTreeSectionDto {
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
category?: CircuitGroupCategory;
|
||||
groupNumber?: number;
|
||||
sectionTotalPower: number;
|
||||
components: CircuitTreeComponentDto[];
|
||||
circuits: CircuitTreeCircuitDto[];
|
||||
}
|
||||
|
||||
@@ -320,7 +356,9 @@ export interface CircuitTreeResponseDto {
|
||||
distributionBoardSimultaneityFactor: number;
|
||||
distributionBoardTotalPower: number;
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||
headerComponents: CircuitTreeComponentDto[];
|
||||
sections: CircuitTreeSectionDto[];
|
||||
footerComponents: CircuitTreeComponentDto[];
|
||||
migrationReport?: CircuitTreeMigrationReportDto;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
CircuitTreeCircuitDto,
|
||||
CircuitTreeComponentDto,
|
||||
CircuitTreeResponseDto,
|
||||
CircuitTreeSectionDto,
|
||||
} from "../types.js";
|
||||
|
||||
export type CircuitStructureProjectionRow =
|
||||
| {
|
||||
rowKey: string;
|
||||
rowType: "headerComponent" | "footerComponent";
|
||||
zone: "header" | "footer";
|
||||
component: CircuitTreeComponentDto;
|
||||
}
|
||||
| {
|
||||
rowKey: string;
|
||||
rowType: "groupHeader" | "groupPlaceholder";
|
||||
zone: "group";
|
||||
section: CircuitTreeSectionDto;
|
||||
}
|
||||
| {
|
||||
rowKey: string;
|
||||
rowType: "groupComponent";
|
||||
zone: "group";
|
||||
section: CircuitTreeSectionDto;
|
||||
component: CircuitTreeComponentDto;
|
||||
}
|
||||
| {
|
||||
rowKey: string;
|
||||
rowType: "circuitBlock";
|
||||
zone: "group";
|
||||
section: CircuitTreeSectionDto;
|
||||
circuit: CircuitTreeCircuitDto;
|
||||
};
|
||||
|
||||
export function buildCircuitStructureProjection(
|
||||
tree: Pick<
|
||||
CircuitTreeResponseDto,
|
||||
"headerComponents" | "sections" | "footerComponents"
|
||||
>
|
||||
): CircuitStructureProjectionRow[] {
|
||||
const rows: CircuitStructureProjectionRow[] = [];
|
||||
for (const component of ordered(tree.headerComponents)) {
|
||||
rows.push({
|
||||
rowKey: `header-component:${component.id}`,
|
||||
rowType: "headerComponent",
|
||||
zone: "header",
|
||||
component,
|
||||
});
|
||||
}
|
||||
for (const section of ordered(tree.sections)) {
|
||||
rows.push({
|
||||
rowKey: `group:${section.id}`,
|
||||
rowType: "groupHeader",
|
||||
zone: "group",
|
||||
section,
|
||||
});
|
||||
for (const component of ordered(section.components)) {
|
||||
rows.push({
|
||||
rowKey: `group-component:${component.id}`,
|
||||
rowType: "groupComponent",
|
||||
zone: "group",
|
||||
section,
|
||||
component,
|
||||
});
|
||||
}
|
||||
for (const circuit of section.circuits) {
|
||||
rows.push({
|
||||
rowKey: `circuit-block:${circuit.id}`,
|
||||
rowType: "circuitBlock",
|
||||
zone: "group",
|
||||
section,
|
||||
circuit,
|
||||
});
|
||||
}
|
||||
rows.push({
|
||||
rowKey: `group-placeholder:${section.id}`,
|
||||
rowType: "groupPlaceholder",
|
||||
zone: "group",
|
||||
section,
|
||||
});
|
||||
}
|
||||
for (const component of ordered(tree.footerComponents)) {
|
||||
rows.push({
|
||||
rowKey: `footer-component:${component.id}`,
|
||||
rowType: "footerComponent",
|
||||
zone: "footer",
|
||||
component,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function ordered<T extends { id: string; sortOrder: number }>(
|
||||
values: readonly T[]
|
||||
) {
|
||||
return [...values].sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,17 @@ const commandTypeLabels: Record<string, string> = {
|
||||
"distribution-board.insert": "Verteilung angelegt",
|
||||
"distribution-board.update": "Verteilung bearbeitet",
|
||||
"distribution-board.delete": "Verteilung entfernt",
|
||||
"distribution-board-component.insert": "Verteilergerät angelegt",
|
||||
"distribution-board-component.delete": "Verteilergerät entfernt",
|
||||
"distribution-board-component.update": "Verteilergerät bearbeitet",
|
||||
"circuit-group.insert": "Stromkreisgruppe angelegt",
|
||||
"circuit-group.delete": "Stromkreisgruppe entfernt",
|
||||
"circuit-group.update": "Stromkreisgruppe bearbeitet",
|
||||
"circuit-group.reorder": "Stromkreisgruppen sortiert",
|
||||
"circuit-group.renumber": "Stromkreisgruppen neu nummeriert",
|
||||
"circuit.move-group": "Stromkreis in andere Gruppe verschoben",
|
||||
"circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt",
|
||||
"circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt",
|
||||
"project-floor.insert": "Geschoss angelegt",
|
||||
"project-floor.delete": "Geschoss entfernt",
|
||||
"project-room.insert": "Raum angelegt",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device
|
||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||
import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit-protection-device.repository.js";
|
||||
import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js";
|
||||
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
||||
@@ -15,6 +17,10 @@ export const circuitDeviceRowRepository =
|
||||
export const circuitListRepository = new CircuitListRepository(db);
|
||||
export const circuitSectionRepository = new CircuitSectionRepository(db);
|
||||
export const circuitRepository = new CircuitRepository(db);
|
||||
export const circuitProtectionDeviceRepository =
|
||||
new CircuitProtectionDeviceRepository(db);
|
||||
export const distributionBoardComponentRepository =
|
||||
new DistributionBoardComponentRepository(db);
|
||||
export const distributionBoardRepository =
|
||||
new DistributionBoardRepository(db);
|
||||
export const floorRepository = new FloorRepository(db);
|
||||
|
||||
@@ -6,7 +6,12 @@ import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-p
|
||||
import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
|
||||
import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-structure-project-command.repository.js";
|
||||
import { CircuitGroupRenumberProjectCommandRepository } from "../../db/repositories/circuit-group-renumber-project-command.repository.js";
|
||||
import { CircuitGroupMoveProjectCommandRepository } from "../../db/repositories/circuit-group-move-project-command.repository.js";
|
||||
import { CircuitGroupSubtreeProjectCommandRepository } from "../../db/repositories/circuit-group-subtree-project-command.repository.js";
|
||||
import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js";
|
||||
import { DistributionBoardComponentStructureProjectCommandRepository } from "../../db/repositories/distribution-board-component-structure-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
|
||||
import { ProjectLocationStructureProjectCommandRepository } from "../../db/repositories/project-location-structure-project-command.repository.js";
|
||||
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
|
||||
@@ -25,8 +30,18 @@ export const circuitDeviceRowMoveProjectCommandStore =
|
||||
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
||||
export const circuitStructureProjectCommandStore =
|
||||
new CircuitStructureProjectCommandRepository(db);
|
||||
export const circuitGroupStructureProjectCommandStore =
|
||||
new CircuitGroupStructureProjectCommandRepository(db);
|
||||
export const circuitGroupRenumberProjectCommandStore =
|
||||
new CircuitGroupRenumberProjectCommandRepository(db);
|
||||
export const circuitGroupMoveProjectCommandStore =
|
||||
new CircuitGroupMoveProjectCommandRepository(db);
|
||||
export const circuitGroupSubtreeProjectCommandStore =
|
||||
new CircuitGroupSubtreeProjectCommandRepository(db);
|
||||
export const distributionBoardStructureProjectCommandStore =
|
||||
new DistributionBoardStructureProjectCommandRepository(db);
|
||||
export const distributionBoardComponentStructureProjectCommandStore =
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(db);
|
||||
export const projectLocationStructureProjectCommandStore =
|
||||
new ProjectLocationStructureProjectCommandRepository(db);
|
||||
export const circuitSectionReorderProjectCommandStore =
|
||||
@@ -59,5 +74,10 @@ export const projectCommandService = new ProjectCommandService(
|
||||
projectDeviceRowSyncProjectCommandStore,
|
||||
projectSettingsProjectCommandStore,
|
||||
projectStateRestoreCommandStore,
|
||||
distributionBoardComponentStructureProjectCommandStore,
|
||||
circuitGroupStructureProjectCommandStore,
|
||||
circuitGroupRenumberProjectCommandStore,
|
||||
circuitGroupMoveProjectCommandStore,
|
||||
circuitGroupSubtreeProjectCommandStore,
|
||||
projectHistoryStore
|
||||
);
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
calculateSectionTotalPower,
|
||||
} from "../../domain/calculations/circuit-power-calculation.js";
|
||||
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
|
||||
import type { CircuitTreeProtectionDevice } from "../../domain/models/circuit-tree.model.js";
|
||||
import {
|
||||
circuitDeviceRowRepository,
|
||||
circuitListRepository,
|
||||
circuitProtectionDeviceRepository,
|
||||
circuitRepository,
|
||||
circuitSectionRepository,
|
||||
distributionBoardComponentRepository,
|
||||
distributionBoardRepository,
|
||||
projectRepository,
|
||||
} from "../composition/application-repositories.js";
|
||||
@@ -23,7 +26,9 @@ export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
|
||||
return (
|
||||
error.message.includes("no such table: circuit_sections") ||
|
||||
error.message.includes("no such table: circuits") ||
|
||||
error.message.includes("no such table: circuit_device_rows")
|
||||
error.message.includes("no such table: circuit_device_rows") ||
|
||||
error.message.includes("no such table: distribution_board_components") ||
|
||||
error.message.includes("no such table: circuit_protection_devices")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,9 +61,28 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
||||
const circuits = await circuitRepository.listByCircuitList(circuitListId);
|
||||
const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id));
|
||||
const components =
|
||||
await distributionBoardComponentRepository.listByCircuitList(
|
||||
circuitListId
|
||||
);
|
||||
const [circuitProtections, componentProtections] =
|
||||
await Promise.all([
|
||||
circuitProtectionDeviceRepository.listByCircuitIds(
|
||||
circuits.map((entry) => entry.id)
|
||||
),
|
||||
distributionBoardComponentRepository.listProtectionByComponentIds(
|
||||
components.map((entry) => entry.id)
|
||||
),
|
||||
]);
|
||||
|
||||
const sectionById = new Map(sections.map((section) => [section.id, section]));
|
||||
const rowsByCircuitId = new Map<string, typeof rows>();
|
||||
const circuitProtectionById = new Map(
|
||||
circuitProtections.map((entry) => [entry.circuitId, entry])
|
||||
);
|
||||
const componentProtectionById = new Map(
|
||||
componentProtections.map((entry) => [entry.componentId, entry])
|
||||
);
|
||||
for (const row of rows) {
|
||||
if (!rowsByCircuitId.has(row.circuitId)) {
|
||||
rowsByCircuitId.set(row.circuitId, []);
|
||||
@@ -75,17 +99,46 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
distributionBoard.simultaneityFactor,
|
||||
distributionBoardTotalPower: 0,
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
||||
headerComponents: [],
|
||||
sections: sections.map((section) => ({
|
||||
id: section.id,
|
||||
key: section.key,
|
||||
displayName: section.displayName,
|
||||
prefix: section.prefix,
|
||||
sortOrder: section.sortOrder,
|
||||
category: section.category ?? undefined,
|
||||
groupNumber: section.groupNumber ?? undefined,
|
||||
sectionTotalPower: 0,
|
||||
components: [],
|
||||
circuits: [],
|
||||
})),
|
||||
footerComponents: [],
|
||||
};
|
||||
const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section]));
|
||||
for (const component of components) {
|
||||
const protection =
|
||||
componentProtectionById.get(component.id);
|
||||
const dto = {
|
||||
id: component.id,
|
||||
circuitListId: component.circuitListId,
|
||||
sectionId: component.sectionId ?? undefined,
|
||||
equipmentIdentifier: component.equipmentIdentifier,
|
||||
name: component.name,
|
||||
role: component.role,
|
||||
placement: component.placement,
|
||||
sortOrder: component.sortOrder,
|
||||
protectionDevice: protection
|
||||
? toProtectionDeviceDto(protection)
|
||||
: undefined,
|
||||
};
|
||||
if (component.placement === "header") {
|
||||
tree.headerComponents.push(dto);
|
||||
} else if (component.placement === "footer") {
|
||||
tree.footerComponents.push(dto);
|
||||
} else if (component.sectionId !== null) {
|
||||
sectionBlocks.get(component.sectionId)?.components.push(dto);
|
||||
}
|
||||
}
|
||||
|
||||
for (const circuit of circuits) {
|
||||
const section = sectionById.get(circuit.sectionId);
|
||||
@@ -116,6 +169,7 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
rowTotalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
|
||||
}));
|
||||
const circuitTotalPower = calculateCircuitTotalPower(deviceRows);
|
||||
const protection = circuitProtectionById.get(circuit.id);
|
||||
|
||||
sectionBlocks.get(section.id)?.circuits.push({
|
||||
id: circuit.id,
|
||||
@@ -139,6 +193,9 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
remark: circuit.remark ?? undefined,
|
||||
circuitTotalPower,
|
||||
deviceRows,
|
||||
protectionDevice: protection
|
||||
? toProtectionDeviceDto(protection)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,7 +224,9 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
distributionBoard.simultaneityFactor,
|
||||
distributionBoardTotalPower: 0,
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
||||
headerComponents: [],
|
||||
sections: [],
|
||||
footerComponents: [],
|
||||
warning:
|
||||
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
||||
});
|
||||
@@ -175,3 +234,23 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function toProtectionDeviceDto(value: {
|
||||
type: CircuitTreeProtectionDevice["type"];
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory: CircuitTreeProtectionDevice["fuseUtilizationCategory"] | null;
|
||||
tripCharacteristic: CircuitTreeProtectionDevice["tripCharacteristic"] | null;
|
||||
rcdType: CircuitTreeProtectionDevice["rcdType"] | null;
|
||||
ratedResidualCurrentMa: number | null;
|
||||
}): CircuitTreeProtectionDevice {
|
||||
return {
|
||||
type: value.type,
|
||||
ratedCurrentA: value.ratedCurrentA,
|
||||
fuseUtilizationCategory:
|
||||
value.fuseUtilizationCategory ?? undefined,
|
||||
tripCharacteristic: value.tripCharacteristic ?? undefined,
|
||||
rcdType: value.rcdType ?? undefined,
|
||||
ratedResidualCurrentMa:
|
||||
value.ratedResidualCurrentMa ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export const circuitGroupCategories = [
|
||||
"lighting",
|
||||
"single_phase",
|
||||
"three_phase",
|
||||
] as const;
|
||||
|
||||
export type CircuitGroupCategory = (typeof circuitGroupCategories)[number];
|
||||
|
||||
export const circuitGroupCategoryNumbers: Record<CircuitGroupCategory, 1 | 2 | 3> =
|
||||
{
|
||||
lighting: 1,
|
||||
single_phase: 2,
|
||||
three_phase: 3,
|
||||
};
|
||||
|
||||
export const circuitGroupCategoryLabels: Record<CircuitGroupCategory, string> = {
|
||||
lighting: "Beleuchtung",
|
||||
single_phase: "1-phasig",
|
||||
three_phase: "3-phasig",
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
export const distributionBoardComponentRoles = [
|
||||
"main_switch",
|
||||
"surge_protective_device",
|
||||
"group_upstream_protection",
|
||||
"group_residual_current_protection",
|
||||
"auxiliary",
|
||||
] as const;
|
||||
|
||||
export type DistributionBoardComponentRole =
|
||||
(typeof distributionBoardComponentRoles)[number];
|
||||
|
||||
export const distributionBoardComponentPlacements = [
|
||||
"header",
|
||||
"group",
|
||||
"footer",
|
||||
] as const;
|
||||
|
||||
export type DistributionBoardComponentPlacement =
|
||||
(typeof distributionBoardComponentPlacements)[number];
|
||||
|
||||
export const distributionBoardComponentRoleLabels: Record<
|
||||
DistributionBoardComponentRole,
|
||||
string
|
||||
> = {
|
||||
main_switch: "Hauptschalter",
|
||||
surge_protective_device: "Überspannungsableiter",
|
||||
group_upstream_protection: "Gruppenvorsicherung",
|
||||
group_residual_current_protection: "Gruppen-FI",
|
||||
auxiliary: "Verteilergerät",
|
||||
};
|
||||
|
||||
export const defaultMainSwitchComponent = {
|
||||
equipmentIdentifier: "-Q0",
|
||||
name: "Hauptschalter",
|
||||
role: "main_switch",
|
||||
placement: "header",
|
||||
} as const satisfies {
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
role: DistributionBoardComponentRole;
|
||||
placement: DistributionBoardComponentPlacement;
|
||||
};
|
||||
|
||||
export const defaultSurgeProtectiveDeviceComponent = {
|
||||
equipmentIdentifier: "-FA",
|
||||
name: "Überspannungsableiter",
|
||||
role: "surge_protective_device",
|
||||
placement: "header",
|
||||
} as const satisfies {
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
role: DistributionBoardComponentRole;
|
||||
placement: DistributionBoardComponentPlacement;
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
export const fuseProtectionDeviceTypes = [
|
||||
"D02",
|
||||
"NH000",
|
||||
"NH00",
|
||||
"NH0",
|
||||
"NH1",
|
||||
"NH2",
|
||||
"NH3",
|
||||
] as const;
|
||||
|
||||
export type FuseProtectionDeviceType =
|
||||
(typeof fuseProtectionDeviceTypes)[number];
|
||||
|
||||
export const breakerProtectionDeviceTypes = ["LS", "FI_LS", "AFDD"] as const;
|
||||
|
||||
export type BreakerProtectionDeviceType =
|
||||
(typeof breakerProtectionDeviceTypes)[number];
|
||||
|
||||
export const protectionDeviceTypes = [
|
||||
...fuseProtectionDeviceTypes,
|
||||
"LS",
|
||||
"FI",
|
||||
"FI_LS",
|
||||
"AFDD",
|
||||
] as const;
|
||||
|
||||
export type ProtectionDeviceType = (typeof protectionDeviceTypes)[number];
|
||||
|
||||
export const fuseUtilizationCategories = ["gG", "gR"] as const;
|
||||
export type FuseUtilizationCategory =
|
||||
(typeof fuseUtilizationCategories)[number];
|
||||
|
||||
export const breakerTripCharacteristics = ["B", "C", "D", "Z"] as const;
|
||||
export type BreakerTripCharacteristic =
|
||||
(typeof breakerTripCharacteristics)[number];
|
||||
|
||||
export const rcdTypes = ["A", "AC", "B", "B+"] as const;
|
||||
export type RcdType = (typeof rcdTypes)[number];
|
||||
|
||||
export const ratedResidualCurrentsMa = [10, 30, 100, 300, 500] as const;
|
||||
export type RatedResidualCurrentMa =
|
||||
(typeof ratedResidualCurrentsMa)[number];
|
||||
|
||||
const d02RatedCurrentsA = [20, 25, 32, 35, 40, 50, 63] as const;
|
||||
const nh000AndNh00RatedCurrentsA = [
|
||||
6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160,
|
||||
] as const;
|
||||
const nh0RatedCurrentsA = [
|
||||
6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250,
|
||||
] as const;
|
||||
const nh1RatedCurrentsA = [
|
||||
16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250,
|
||||
] as const;
|
||||
const nh2RatedCurrentsA = [
|
||||
25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250, 300, 315, 355,
|
||||
400,
|
||||
] as const;
|
||||
const nh3RatedCurrentsA = [
|
||||
50, 63, 80, 100, 125, 160, 200, 224, 250, 315, 355, 400, 500, 630, 800,
|
||||
] as const;
|
||||
const breakerRatedCurrentsA = [
|
||||
6, 10, 13, 16, 20, 25, 32, 40, 50, 63, 80, 100, 125,
|
||||
] as const;
|
||||
const rcdRatedCurrentsA = [16, 25, 40, 63, 80, 100, 125] as const;
|
||||
|
||||
export const allowedRatedCurrentsAByProtectionDeviceType = {
|
||||
D02: d02RatedCurrentsA,
|
||||
NH000: nh000AndNh00RatedCurrentsA,
|
||||
NH00: nh000AndNh00RatedCurrentsA,
|
||||
NH0: nh0RatedCurrentsA,
|
||||
NH1: nh1RatedCurrentsA,
|
||||
NH2: nh2RatedCurrentsA,
|
||||
NH3: nh3RatedCurrentsA,
|
||||
LS: breakerRatedCurrentsA,
|
||||
FI: rcdRatedCurrentsA,
|
||||
FI_LS: breakerRatedCurrentsA,
|
||||
AFDD: breakerRatedCurrentsA,
|
||||
} as const satisfies Record<ProtectionDeviceType, readonly number[]>;
|
||||
|
||||
export const protectionDeviceTypeLabels: Record<ProtectionDeviceType, string> = {
|
||||
D02: "D02 / Neozed",
|
||||
NH000: "NH000",
|
||||
NH00: "NH00",
|
||||
NH0: "NH0",
|
||||
NH1: "NH1",
|
||||
NH2: "NH2",
|
||||
NH3: "NH3",
|
||||
LS: "LS",
|
||||
FI: "FI",
|
||||
FI_LS: "FI/LS",
|
||||
AFDD: "AFDD",
|
||||
};
|
||||
|
||||
export function isAllowedRatedCurrentA(
|
||||
type: ProtectionDeviceType,
|
||||
ratedCurrentA: number
|
||||
): boolean {
|
||||
const allowedValues: readonly number[] =
|
||||
allowedRatedCurrentsAByProtectionDeviceType[type];
|
||||
return allowedValues.includes(ratedCurrentA);
|
||||
}
|
||||
|
||||
export function isAllowedRatedResidualCurrentMa(
|
||||
ratedResidualCurrentMa: number
|
||||
): ratedResidualCurrentMa is RatedResidualCurrentMa {
|
||||
const allowedValues: readonly number[] = ratedResidualCurrentsMa;
|
||||
return allowedValues.includes(ratedResidualCurrentMa);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
breakerTripCharacteristics,
|
||||
fuseProtectionDeviceTypes,
|
||||
fuseUtilizationCategories,
|
||||
isAllowedRatedCurrentA,
|
||||
isAllowedRatedResidualCurrentMa,
|
||||
protectionDeviceTypes,
|
||||
rcdTypes,
|
||||
} from "../constants/protection-device.js";
|
||||
|
||||
function hasValue(value: unknown): boolean {
|
||||
return value !== undefined;
|
||||
}
|
||||
|
||||
export const protectionDeviceConfigurationSchema = z
|
||||
.object({
|
||||
type: z.enum(protectionDeviceTypes),
|
||||
ratedCurrentA: z.number().finite().positive(),
|
||||
fuseUtilizationCategory: z.enum(fuseUtilizationCategories).optional(),
|
||||
tripCharacteristic: z.enum(breakerTripCharacteristics).optional(),
|
||||
rcdType: z.enum(rcdTypes).optional(),
|
||||
ratedResidualCurrentMa: z
|
||||
.number()
|
||||
.int()
|
||||
.refine(isAllowedRatedResidualCurrentMa, {
|
||||
message: "Der Bemessungsdifferenzstrom ist nicht zulässig.",
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, context) => {
|
||||
if (!isAllowedRatedCurrentA(value.type, value.ratedCurrentA)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["ratedCurrentA"],
|
||||
message: "Der Bemessungsstrom ist für diesen Schutzgerätetyp nicht zulässig.",
|
||||
});
|
||||
}
|
||||
|
||||
const isFuse = (fuseProtectionDeviceTypes as readonly string[]).includes(
|
||||
value.type
|
||||
);
|
||||
const hasBreakerFunction =
|
||||
value.type === "LS" || value.type === "FI_LS" || value.type === "AFDD";
|
||||
const hasResidualCurrentFunction =
|
||||
value.type === "FI" || value.type === "FI_LS";
|
||||
|
||||
if (isFuse !== hasValue(value.fuseUtilizationCategory)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["fuseUtilizationCategory"],
|
||||
message: isFuse
|
||||
? "Für diesen Sicherungstyp ist eine Sicherungscharakteristik erforderlich."
|
||||
: "Dieser Schutzgerätetyp darf keine Sicherungscharakteristik enthalten.",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasBreakerFunction !== hasValue(value.tripCharacteristic)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["tripCharacteristic"],
|
||||
message: hasBreakerFunction
|
||||
? "Für diesen Schutzgerätetyp ist eine Auslösecharakteristik erforderlich."
|
||||
: "Dieser Schutzgerätetyp darf keine Auslösecharakteristik enthalten.",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasResidualCurrentFunction !== hasValue(value.rcdType)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["rcdType"],
|
||||
message: hasResidualCurrentFunction
|
||||
? "Für diesen Schutzgerätetyp ist ein FI-Typ erforderlich."
|
||||
: "Dieser Schutzgerätetyp darf keinen FI-Typ enthalten.",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
hasResidualCurrentFunction !==
|
||||
hasValue(value.ratedResidualCurrentMa)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["ratedResidualCurrentMa"],
|
||||
message: hasResidualCurrentFunction
|
||||
? "Für diesen Schutzgerätetyp ist ein Bemessungsdifferenzstrom erforderlich."
|
||||
: "Dieser Schutzgerätetyp darf keinen Bemessungsdifferenzstrom enthalten.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type ProtectionDeviceConfigurationInput = z.infer<
|
||||
typeof protectionDeviceConfigurationSchema
|
||||
>;
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
CircuitTreeDeviceRowDto,
|
||||
CircuitTreeSectionDto,
|
||||
} from "../src/frontend/types.js";
|
||||
import "./circuit-structure-projection.test.js";
|
||||
|
||||
function makeDevice(
|
||||
id: string,
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import { createCircuitGroupMoveProjectCommand } from "../src/domain/models/circuit-group-move-project-command.model.js";
|
||||
import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values({ id: "project-1", name: "Projekt" })
|
||||
.run();
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
const list = context.db.select().from(circuitLists).get()!;
|
||||
const source = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.category, "lighting"))
|
||||
.get()!;
|
||||
context.db
|
||||
.insert(circuitSections)
|
||||
.values({
|
||||
id: "lighting-move-target",
|
||||
circuitListId: list.id,
|
||||
key: "lighting_2",
|
||||
displayName: "Beleuchtung 2",
|
||||
prefix: "-1F2.",
|
||||
sortOrder: 40,
|
||||
category: "lighting",
|
||||
groupNumber: 2,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values([
|
||||
{
|
||||
id: "circuit-move",
|
||||
circuitListId: list.id,
|
||||
sectionId: source.id,
|
||||
equipmentIdentifier: "-1F1.7",
|
||||
displayName: "Zu verschieben",
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
id: "circuit-target-existing",
|
||||
circuitListId: list.id,
|
||||
sectionId: "lighting-move-target",
|
||||
equipmentIdentifier: "-1F2.4",
|
||||
displayName: "Bestand",
|
||||
sortOrder: 10,
|
||||
},
|
||||
])
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitDeviceRows)
|
||||
.values({
|
||||
id: "row-move",
|
||||
circuitId: "circuit-move",
|
||||
sortOrder: 10,
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte",
|
||||
quantity: 2,
|
||||
powerPerUnit: 0.1,
|
||||
simultaneityFactor: 1,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitProtectionDevices)
|
||||
.values({
|
||||
circuitId: "circuit-move",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
tripCharacteristic: "B",
|
||||
})
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
function createMoveCommand(context: DatabaseContext) {
|
||||
const circuit = context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.get()!;
|
||||
const source = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, circuit.sectionId))
|
||||
.get()!;
|
||||
const target = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, "lighting-move-target"))
|
||||
.get()!;
|
||||
return createCircuitGroupMoveProjectCommand(
|
||||
createCircuitGroupMovePlan({
|
||||
circuit,
|
||||
sourceGroup: {
|
||||
id: source.id,
|
||||
category: source.category!,
|
||||
groupNumber: source.groupNumber!,
|
||||
prefix: source.prefix,
|
||||
},
|
||||
targetGroup: {
|
||||
id: target.id,
|
||||
category: target.category!,
|
||||
groupNumber: target.groupNumber!,
|
||||
prefix: target.prefix,
|
||||
},
|
||||
targetCircuitEquipmentIdentifiers: context.db
|
||||
.select({ value: circuits.equipmentIdentifier })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, target.id))
|
||||
.all()
|
||||
.map(({ value }) => value),
|
||||
targetSortOrder: 30,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe("circuit group-move project command", () => {
|
||||
it("moves a complete circuit while preserving rows and protection through undo/redo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const command = createMoveCommand(context);
|
||||
const repository =
|
||||
new CircuitGroupMoveProjectCommandRepository(context.db);
|
||||
const moved = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select({
|
||||
sectionId: circuits.sectionId,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
sortOrder: circuits.sortOrder,
|
||||
})
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.get(),
|
||||
{
|
||||
sectionId: "lighting-move-target",
|
||||
equipmentIdentifier: "-1F2.5",
|
||||
sortOrder: 30,
|
||||
}
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, "row-move"))
|
||||
.get()?.circuitId,
|
||||
"circuit-move"
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-move"))
|
||||
.get()?.ratedCurrentA,
|
||||
10
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: moved.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F1.7"
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "redo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "redo")?.changeSetId,
|
||||
command,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F2.5"
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects stale state without moving the circuit", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const command = createMoveCommand(context);
|
||||
context.db
|
||||
.update(circuits)
|
||||
.set({ sortOrder: 99 })
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.run();
|
||||
assert.throws(
|
||||
() =>
|
||||
new CircuitGroupMoveProjectCommandRepository(context.db).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
}),
|
||||
/changed before group move/
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.get()?.sectionId,
|
||||
command.payload.expectedSectionId
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back the circuit move when history persistence fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const command = createMoveCommand(context);
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_circuit_group_move_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced circuit group move history failure');
|
||||
END;
|
||||
`);
|
||||
assert.throws(
|
||||
() =>
|
||||
new CircuitGroupMoveProjectCommandRepository(context.db).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
}),
|
||||
/forced circuit group move history failure/
|
||||
);
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select({
|
||||
sectionId: circuits.sectionId,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
sortOrder: circuits.sortOrder,
|
||||
})
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.get(),
|
||||
{
|
||||
sectionId: command.payload.expectedSectionId,
|
||||
equipmentIdentifier: "-1F1.7",
|
||||
sortOrder: 20,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
formatGroupedCircuitIdentifier,
|
||||
formatGroupRcdIdentifier,
|
||||
formatGroupUpstreamProtectionIdentifier,
|
||||
getNextCircuitGroupNumber,
|
||||
getNextGroupedCircuitIdentifier,
|
||||
parseGroupedEquipmentIdentifier,
|
||||
} from "../src/domain/services/circuit-group-numbering.js";
|
||||
import "./circuit-group-structure-project-command.repository.test.js";
|
||||
import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js";
|
||||
import "./circuit-group-renumber-project-command.repository.test.js";
|
||||
import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js";
|
||||
import "./circuit-group-move-project-command.repository.test.js";
|
||||
import "./circuit-group-subtree-snapshot.test.js";
|
||||
import "./circuit-group-subtree-project-command.repository.test.js";
|
||||
|
||||
describe("circuit group numbering", () => {
|
||||
it("formats the agreed identifiers including the leading hyphen", () => {
|
||||
assert.equal(
|
||||
formatGroupUpstreamProtectionIdentifier("lighting", 1),
|
||||
"-1F1.0"
|
||||
);
|
||||
assert.equal(formatGroupRcdIdentifier("lighting", 1), "-1Q1.0");
|
||||
assert.equal(
|
||||
formatGroupedCircuitIdentifier("lighting", 1, 2),
|
||||
"-1F1.2"
|
||||
);
|
||||
assert.equal(
|
||||
formatGroupedCircuitIdentifier("single_phase", 3, 7),
|
||||
"-2F3.7"
|
||||
);
|
||||
assert.equal(
|
||||
formatGroupedCircuitIdentifier("three_phase", 2, 1),
|
||||
"-3F2.1"
|
||||
);
|
||||
});
|
||||
|
||||
it("parses circuit and group component identifiers", () => {
|
||||
assert.deepEqual(parseGroupedEquipmentIdentifier("-1F2.4"), {
|
||||
category: "lighting",
|
||||
groupNumber: 2,
|
||||
kind: "circuit",
|
||||
circuitNumber: 4,
|
||||
});
|
||||
assert.deepEqual(parseGroupedEquipmentIdentifier("-2F3.0"), {
|
||||
category: "single_phase",
|
||||
groupNumber: 3,
|
||||
kind: "group_upstream_protection",
|
||||
circuitNumber: null,
|
||||
});
|
||||
assert.deepEqual(parseGroupedEquipmentIdentifier("-3Q1.0"), {
|
||||
category: "three_phase",
|
||||
groupNumber: 1,
|
||||
kind: "group_rcd",
|
||||
circuitNumber: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects legacy, malformed and unsupported identifiers", () => {
|
||||
for (const identifier of [
|
||||
"-1F1",
|
||||
"1F1.1",
|
||||
"-1Q1.2",
|
||||
"-4F1.1",
|
||||
"-1F0.1",
|
||||
"-1F1.01",
|
||||
]) {
|
||||
assert.equal(parseGroupedEquipmentIdentifier(identifier), null);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the highest target-group suffix plus one without filling gaps", () => {
|
||||
assert.equal(
|
||||
getNextGroupedCircuitIdentifier("lighting", 2, [
|
||||
"-1F2.1",
|
||||
"-1F2.3",
|
||||
"-1F2.7",
|
||||
"-1F1.12",
|
||||
"-2F2.9",
|
||||
"-1F2.0",
|
||||
"-1Q2.0",
|
||||
"-1F8",
|
||||
]),
|
||||
"-1F2.8"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the highest group number in the selected category plus one", () => {
|
||||
assert.equal(
|
||||
getNextCircuitGroupNumber("lighting", [
|
||||
{ category: "lighting", groupNumber: 1 },
|
||||
{ category: "lighting", groupNumber: 4 },
|
||||
{ category: "single_phase", groupNumber: 9 },
|
||||
]),
|
||||
5
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-positive group and circuit numbers", () => {
|
||||
assert.throws(
|
||||
() => formatGroupedCircuitIdentifier("lighting", 0, 1),
|
||||
RangeError
|
||||
);
|
||||
assert.throws(
|
||||
() => formatGroupedCircuitIdentifier("lighting", 1, 0),
|
||||
RangeError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("circuit group renumber planning", () => {
|
||||
const groups = [
|
||||
{
|
||||
id: "lighting-1",
|
||||
category: "lighting" as const,
|
||||
groupNumber: 1,
|
||||
prefix: "-1F1.",
|
||||
circuits: [
|
||||
{ id: "circuit-1", equipmentIdentifier: "-1F1.1" },
|
||||
{ id: "circuit-2", equipmentIdentifier: "-1F1.7" },
|
||||
],
|
||||
components: [
|
||||
{
|
||||
id: "fuse-1",
|
||||
role: "group_upstream_protection" as const,
|
||||
equipmentIdentifier: "-1F1.0",
|
||||
},
|
||||
{
|
||||
id: "rcd-1",
|
||||
role: "group_residual_current_protection" as const,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lighting-2",
|
||||
category: "lighting" as const,
|
||||
groupNumber: 2,
|
||||
prefix: "-1F2.",
|
||||
circuits: [
|
||||
{ id: "circuit-3", equipmentIdentifier: "-1F2.4" },
|
||||
],
|
||||
components: [],
|
||||
},
|
||||
{
|
||||
id: "single-1",
|
||||
category: "single_phase" as const,
|
||||
groupNumber: 1,
|
||||
prefix: "-2F1.",
|
||||
circuits: [],
|
||||
components: [],
|
||||
},
|
||||
];
|
||||
|
||||
it("preserves circuit suffixes and maps optional group components", () => {
|
||||
const plan = createCircuitGroupRenumberPlan(groups, [
|
||||
{ groupId: "lighting-1", targetGroupNumber: 3 },
|
||||
]);
|
||||
assert.deepEqual(plan.groups[0], {
|
||||
groupId: "lighting-1",
|
||||
category: "lighting",
|
||||
expectedGroupNumber: 1,
|
||||
targetGroupNumber: 3,
|
||||
expectedPrefix: "-1F1.",
|
||||
targetPrefix: "-1F3.",
|
||||
circuits: [
|
||||
{
|
||||
circuitId: "circuit-1",
|
||||
expectedEquipmentIdentifier: "-1F1.1",
|
||||
targetEquipmentIdentifier: "-1F3.1",
|
||||
},
|
||||
{
|
||||
circuitId: "circuit-2",
|
||||
expectedEquipmentIdentifier: "-1F1.7",
|
||||
targetEquipmentIdentifier: "-1F3.7",
|
||||
},
|
||||
],
|
||||
components: [
|
||||
{
|
||||
componentId: "fuse-1",
|
||||
expectedEquipmentIdentifier: "-1F1.0",
|
||||
targetEquipmentIdentifier: "-1F3.0",
|
||||
},
|
||||
{
|
||||
componentId: "rcd-1",
|
||||
expectedEquipmentIdentifier: "-1Q1.0",
|
||||
targetEquipmentIdentifier: "-1Q3.0",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a number swap when every affected group participates", () => {
|
||||
const plan = createCircuitGroupRenumberPlan(groups, [
|
||||
{ groupId: "lighting-1", targetGroupNumber: 2 },
|
||||
{ groupId: "lighting-2", targetGroupNumber: 1 },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
plan.groups.map((group) => [
|
||||
group.expectedGroupNumber,
|
||||
group.targetGroupNumber,
|
||||
]),
|
||||
[
|
||||
[1, 2],
|
||||
[2, 1],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects collisions, no-op requests and mismatched current BMKs", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupRenumberPlan(groups, [
|
||||
{ groupId: "lighting-1", targetGroupNumber: 2 },
|
||||
]),
|
||||
/unchanged group/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupRenumberPlan(groups, [
|
||||
{ groupId: "lighting-1", targetGroupNumber: 1 },
|
||||
]),
|
||||
/must change/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupRenumberPlan(
|
||||
[
|
||||
{
|
||||
...groups[0],
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit-1",
|
||||
equipmentIdentifier: "-1F9.1",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[{ groupId: "lighting-1", targetGroupNumber: 3 }]
|
||||
),
|
||||
/Circuit BMK does not match/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("circuit group move planning", () => {
|
||||
const sourceGroup = {
|
||||
id: "lighting-1",
|
||||
category: "lighting" as const,
|
||||
groupNumber: 1,
|
||||
prefix: "-1F1.",
|
||||
};
|
||||
const targetGroup = {
|
||||
id: "lighting-2",
|
||||
category: "lighting" as const,
|
||||
groupNumber: 2,
|
||||
prefix: "-1F2.",
|
||||
};
|
||||
|
||||
it("uses the highest target suffix plus one and stores both positions", () => {
|
||||
assert.deepEqual(
|
||||
createCircuitGroupMovePlan({
|
||||
circuit: {
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-1",
|
||||
equipmentIdentifier: "-1F1.7",
|
||||
sortOrder: 20,
|
||||
},
|
||||
sourceGroup,
|
||||
targetGroup,
|
||||
targetCircuitEquipmentIdentifiers: [
|
||||
"-1F2.1",
|
||||
"-1F2.4",
|
||||
"-1F2.9",
|
||||
],
|
||||
targetSortOrder: 40,
|
||||
}),
|
||||
{
|
||||
circuitId: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
expectedSectionId: "lighting-1",
|
||||
targetSectionId: "lighting-2",
|
||||
expectedEquipmentIdentifier: "-1F1.7",
|
||||
targetEquipmentIdentifier: "-1F2.10",
|
||||
expectedSortOrder: 20,
|
||||
targetSortOrder: 40,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fill target gaps", () => {
|
||||
assert.equal(
|
||||
createCircuitGroupMovePlan({
|
||||
circuit: {
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-1",
|
||||
equipmentIdentifier: "-1F1.2",
|
||||
sortOrder: 10,
|
||||
},
|
||||
sourceGroup,
|
||||
targetGroup,
|
||||
targetCircuitEquipmentIdentifiers: ["-1F2.1", "-1F2.3"],
|
||||
targetSortOrder: 20,
|
||||
}).targetEquipmentIdentifier,
|
||||
"-1F2.4"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects cross-category, same-group and mismatched source moves", () => {
|
||||
const base = {
|
||||
circuit: {
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-1",
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
sortOrder: 10,
|
||||
},
|
||||
sourceGroup,
|
||||
targetGroup,
|
||||
targetCircuitEquipmentIdentifiers: [] as string[],
|
||||
targetSortOrder: 10,
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupMovePlan({
|
||||
...base,
|
||||
targetGroup: {
|
||||
id: "single-1",
|
||||
category: "single_phase",
|
||||
groupNumber: 1,
|
||||
prefix: "-2F1.",
|
||||
},
|
||||
}),
|
||||
/same category/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupMovePlan({
|
||||
...base,
|
||||
targetGroup: sourceGroup,
|
||||
}),
|
||||
/different groups/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupMovePlan({
|
||||
...base,
|
||||
circuit: {
|
||||
...base.circuit,
|
||||
equipmentIdentifier: "-1F9.1",
|
||||
},
|
||||
}),
|
||||
/does not match/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import { createCircuitGroupRenumberProjectCommand } from "../src/domain/models/circuit-group-renumber-project-command.model.js";
|
||||
import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values({ id: "project-1", name: "Projekt" })
|
||||
.run();
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
const list = context.db.select().from(circuitLists).get()!;
|
||||
const first = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.category, "lighting"))
|
||||
.get()!;
|
||||
context.db
|
||||
.insert(circuitSections)
|
||||
.values({
|
||||
id: "lighting-2",
|
||||
circuitListId: list.id,
|
||||
key: "lighting_2",
|
||||
displayName: "Beleuchtung 2",
|
||||
prefix: "-1F2.",
|
||||
sortOrder: 40,
|
||||
category: "lighting",
|
||||
groupNumber: 2,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values([
|
||||
{
|
||||
id: "circuit-lighting-1",
|
||||
circuitListId: list.id,
|
||||
sectionId: first.id,
|
||||
equipmentIdentifier: "-1F1.7",
|
||||
displayName: "Licht 1",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: "circuit-lighting-2",
|
||||
circuitListId: list.id,
|
||||
sectionId: "lighting-2",
|
||||
equipmentIdentifier: "-1F2.4",
|
||||
displayName: "Licht 2",
|
||||
sortOrder: 10,
|
||||
},
|
||||
])
|
||||
.run();
|
||||
context.db
|
||||
.insert(distributionBoardComponents)
|
||||
.values([
|
||||
{
|
||||
id: "lighting-1-fuse",
|
||||
circuitListId: list.id,
|
||||
sectionId: first.id,
|
||||
equipmentIdentifier: "-1F1.0",
|
||||
name: "Vorsicherung",
|
||||
role: "group_upstream_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: "lighting-1-rcd",
|
||||
circuitListId: list.id,
|
||||
sectionId: first.id,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 20,
|
||||
},
|
||||
])
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
function createSwapCommand(context: DatabaseContext) {
|
||||
const list = context.db.select().from(circuitLists).get()!;
|
||||
const groups = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.category, "lighting"))
|
||||
.all()
|
||||
.sort((left, right) => left.groupNumber! - right.groupNumber!)
|
||||
.map((group) => ({
|
||||
id: group.id,
|
||||
category: group.category!,
|
||||
groupNumber: group.groupNumber!,
|
||||
prefix: group.prefix,
|
||||
circuits: context.db
|
||||
.select({
|
||||
id: circuits.id,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
})
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, group.id))
|
||||
.all(),
|
||||
components: context.db
|
||||
.select({
|
||||
id: distributionBoardComponents.id,
|
||||
role: distributionBoardComponents.role,
|
||||
equipmentIdentifier:
|
||||
distributionBoardComponents.equipmentIdentifier,
|
||||
})
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.sectionId, group.id))
|
||||
.all()
|
||||
.map((component) => ({
|
||||
...component,
|
||||
role: component.role as
|
||||
| "group_upstream_protection"
|
||||
| "group_residual_current_protection",
|
||||
})),
|
||||
}));
|
||||
return createCircuitGroupRenumberProjectCommand(
|
||||
list.id,
|
||||
createCircuitGroupRenumberPlan(groups, [
|
||||
{ groupId: groups[0].id, targetGroupNumber: 2 },
|
||||
{ groupId: groups[1].id, targetGroupNumber: 1 },
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
describe("circuit-group renumber project command", () => {
|
||||
it("swaps group numbers and every derived BMK through undo and redo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const command = createSwapCommand(context);
|
||||
const repository =
|
||||
new CircuitGroupRenumberProjectCommandRepository(context.db);
|
||||
const executed = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, command.payload.groups[0].groupId))
|
||||
.get()?.groupNumber,
|
||||
2
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-lighting-1"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F2.7"
|
||||
);
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select({ value: distributionBoardComponents.equipmentIdentifier })
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponents.sectionId,
|
||||
command.payload.groups[0].groupId
|
||||
)
|
||||
)
|
||||
.all()
|
||||
.map(({ value }) => value)
|
||||
.sort(),
|
||||
["-1F2.0", "-1Q2.0"]
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: executed.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-lighting-1"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F1.7"
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "redo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "redo")?.changeSetId,
|
||||
command,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-lighting-2"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F1.4"
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects incomplete child snapshots without partial changes", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const command = createSwapCommand(context);
|
||||
command.payload.groups[0].components.pop();
|
||||
assert.throws(
|
||||
() =>
|
||||
new CircuitGroupRenumberProjectCommandRepository(
|
||||
context.db
|
||||
).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
}),
|
||||
/include every unchanged child/
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-lighting-1"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F1.7"
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back every group and BMK when history persistence fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const command = createSwapCommand(context);
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_group_renumber_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced group renumber history failure');
|
||||
END;
|
||||
`);
|
||||
assert.throws(
|
||||
() =>
|
||||
new CircuitGroupRenumberProjectCommandRepository(
|
||||
context.db
|
||||
).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
}),
|
||||
/forced group renumber history failure/
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-lighting-1"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F1.7"
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, "lighting-1-rcd"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1Q1.0"
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import {
|
||||
createCircuitGroupDeleteProjectCommand,
|
||||
createCircuitGroupInsertProjectCommand,
|
||||
createCircuitGroupReorderProjectCommand,
|
||||
createCircuitGroupUpdateProjectCommand,
|
||||
type CircuitGroupSnapshot,
|
||||
} from "../src/domain/models/circuit-group-structure-project-command.model.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values([
|
||||
{ id: "project-1", name: "Projekt" },
|
||||
{ id: "project-2", name: "Fremdprojekt" },
|
||||
])
|
||||
.run();
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-2", "UV-02");
|
||||
return context;
|
||||
}
|
||||
|
||||
function getProjectList(context: DatabaseContext, projectId: string) {
|
||||
const list = context.db
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.get();
|
||||
assert.ok(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
function groupSnapshot(circuitListId: string): CircuitGroupSnapshot {
|
||||
return {
|
||||
id: "group-lighting-2",
|
||||
circuitListId,
|
||||
key: "lighting_2",
|
||||
displayName: "Beleuchtung 2",
|
||||
prefix: "-1F2.",
|
||||
sortOrder: 40,
|
||||
category: "lighting",
|
||||
groupNumber: 2,
|
||||
};
|
||||
}
|
||||
|
||||
describe("circuit-group structure project command", () => {
|
||||
it("inserts, renames and restores a group through persistent history", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const expected = groupSnapshot(
|
||||
getProjectList(context, "project-1").id
|
||||
);
|
||||
const target = {
|
||||
...expected,
|
||||
displayName: "Beleuchtung Besprechungsräume",
|
||||
};
|
||||
const repository =
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(expected),
|
||||
});
|
||||
const updated = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitGroupUpdateProjectCommand(
|
||||
expected,
|
||||
target
|
||||
),
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, expected.id))
|
||||
.get()?.displayName,
|
||||
target.displayName
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: updated.inverse,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, expected.id))
|
||||
.get(),
|
||||
expected
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes only an exact empty group and restores the same UUID", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const snapshot = groupSnapshot(
|
||||
getProjectList(context, "project-1").id
|
||||
);
|
||||
const repository =
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||
});
|
||||
const deleted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitGroupDeleteProjectCommand(snapshot),
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: deleted.inverse,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.get(),
|
||||
snapshot
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects foreign lists, invalid prefixes, stale updates and populated deletes", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const snapshot = groupSnapshot(
|
||||
getProjectList(context, "project-1").id
|
||||
);
|
||||
const repository =
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupInsertProjectCommand({
|
||||
...snapshot,
|
||||
prefix: "-1F9.",
|
||||
}),
|
||||
/prefix does not match/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-2",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||
}),
|
||||
/does not belong/
|
||||
);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||
});
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values({
|
||||
id: "group-circuit",
|
||||
circuitListId: snapshot.circuitListId,
|
||||
sectionId: snapshot.id,
|
||||
equipmentIdentifier: "-1F2.1",
|
||||
displayName: "Beleuchtung",
|
||||
sortOrder: 10,
|
||||
})
|
||||
.run();
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitGroupDeleteProjectCommand(snapshot),
|
||||
}),
|
||||
/Only empty circuit groups/
|
||||
);
|
||||
context.db
|
||||
.update(circuitSections)
|
||||
.set({ displayName: "Direkt geändert" })
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.run();
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitGroupUpdateProjectCommand(snapshot, {
|
||||
...snapshot,
|
||||
displayName: "Ziel",
|
||||
}),
|
||||
}),
|
||||
/changed before update/
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
1
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back group insertion when history persistence fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const snapshot = groupSnapshot(
|
||||
getProjectList(context, "project-1").id
|
||||
);
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_group_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced group history failure');
|
||||
END;
|
||||
`);
|
||||
assert.throws(
|
||||
() =>
|
||||
new CircuitGroupStructureProjectCommandRepository(
|
||||
context.db
|
||||
).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||
}),
|
||||
/forced group history failure/
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("reorders every group atomically without changing group identity", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const circuitListId = getProjectList(context, "project-1").id;
|
||||
const groups = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||
.all();
|
||||
const expectedPrefixes = new Map(
|
||||
groups.map((group) => [group.id, group.prefix])
|
||||
);
|
||||
const assignments = groups.map((group, index) => ({
|
||||
groupId: group.id,
|
||||
expectedSortOrder: group.sortOrder,
|
||||
targetSortOrder: (groups.length - index) * 10,
|
||||
}));
|
||||
const repository =
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||
const reordered = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupReorderProjectCommand(
|
||||
circuitListId,
|
||||
assignments
|
||||
),
|
||||
});
|
||||
for (const assignment of assignments) {
|
||||
const group = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, assignment.groupId))
|
||||
.get();
|
||||
assert.equal(group?.sortOrder, assignment.targetSortOrder);
|
||||
assert.equal(
|
||||
group?.prefix,
|
||||
expectedPrefixes.get(assignment.groupId)
|
||||
);
|
||||
}
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: reordered.inverse,
|
||||
});
|
||||
for (const assignment of assignments) {
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, assignment.groupId))
|
||||
.get()?.sortOrder,
|
||||
assignment.expectedSortOrder
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects incomplete reorders and rolls back late failures", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const circuitListId = getProjectList(context, "project-1").id;
|
||||
const groups = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||
.all();
|
||||
const assignments = groups.map((group, index) => ({
|
||||
groupId: group.id,
|
||||
expectedSortOrder: group.sortOrder,
|
||||
targetSortOrder: (groups.length - index) * 10,
|
||||
}));
|
||||
const repository =
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupReorderProjectCommand(
|
||||
circuitListId,
|
||||
assignments.slice(1)
|
||||
),
|
||||
}),
|
||||
/include every unchanged group/
|
||||
);
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_group_reorder_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced group reorder history failure');
|
||||
END;
|
||||
`);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupReorderProjectCommand(
|
||||
circuitListId,
|
||||
assignments
|
||||
),
|
||||
}),
|
||||
/forced group reorder history failure/
|
||||
);
|
||||
for (const assignment of assignments) {
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, assignment.groupId))
|
||||
.get()?.sortOrder,
|
||||
assignment.expectedSortOrder
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values({ id: "project-1", name: "Projekt" })
|
||||
.run();
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
const list = context.db.select().from(circuitLists).get()!;
|
||||
const group = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.category, "lighting"))
|
||||
.get()!;
|
||||
context.db
|
||||
.insert(distributionBoardComponents)
|
||||
.values({
|
||||
id: "subtree-rcd",
|
||||
circuitListId: list.id,
|
||||
sectionId: group.id,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(distributionBoardComponentProtectionDevices)
|
||||
.values({
|
||||
componentId: "subtree-rcd",
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values({
|
||||
id: "subtree-circuit",
|
||||
circuitListId: list.id,
|
||||
sectionId: group.id,
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
displayName: "Beleuchtung",
|
||||
sortOrder: 10,
|
||||
voltage: 230,
|
||||
isReserve: 0,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitProtectionDevices)
|
||||
.values({
|
||||
circuitId: "subtree-circuit",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
tripCharacteristic: "B",
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitDeviceRows)
|
||||
.values({
|
||||
id: "subtree-row",
|
||||
circuitId: "subtree-circuit",
|
||||
linkedProjectDeviceId: null,
|
||||
legacyConsumerId: null,
|
||||
sortOrder: 10,
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte",
|
||||
phaseType: "single_phase",
|
||||
connectionKind: null,
|
||||
costGroup: null,
|
||||
category: null,
|
||||
level: null,
|
||||
roomId: null,
|
||||
roomNumberSnapshot: null,
|
||||
roomNameSnapshot: null,
|
||||
quantity: 2,
|
||||
powerPerUnit: 0.03,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 0.95,
|
||||
remark: null,
|
||||
overriddenFields: "[\"displayName\"]",
|
||||
})
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
describe("circuit-group subtree project command", () => {
|
||||
it("deletes and restores the exact populated subtree through undo/redo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new CircuitGroupSubtreeProjectCommandRepository(context.db);
|
||||
const groupId = context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "subtree-circuit"))
|
||||
.get()!.sectionId;
|
||||
const snapshot = repository.capture("project-1", groupId);
|
||||
const command =
|
||||
createCircuitGroupDeleteSubtreeProjectCommand(snapshot);
|
||||
const deleted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, groupId))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, "subtree-row"))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: deleted.inverse,
|
||||
});
|
||||
assert.deepEqual(
|
||||
repository.capture("project-1", groupId),
|
||||
snapshot
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "redo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "redo")?.changeSetId,
|
||||
command,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "subtree-circuit"))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a subtree changed after confirmation", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new CircuitGroupSubtreeProjectCommandRepository(context.db);
|
||||
const groupId = context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "subtree-circuit"))
|
||||
.get()!.sectionId;
|
||||
const snapshot = repository.capture("project-1", groupId);
|
||||
context.db
|
||||
.update(circuitDeviceRows)
|
||||
.set({ displayName: "Geändert" })
|
||||
.where(eq(circuitDeviceRows.id, "subtree-row"))
|
||||
.run();
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
|
||||
}),
|
||||
/changed before deletion/
|
||||
);
|
||||
assert.ok(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, groupId))
|
||||
.get()
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back a cascaded deletion when history persistence fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new CircuitGroupSubtreeProjectCommandRepository(context.db);
|
||||
const groupId = context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "subtree-circuit"))
|
||||
.get()!.sectionId;
|
||||
const snapshot = repository.capture("project-1", groupId);
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_group_subtree_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced group subtree history failure');
|
||||
END;
|
||||
`);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
|
||||
}),
|
||||
/forced group subtree history failure/
|
||||
);
|
||||
assert.deepEqual(
|
||||
repository.capture("project-1", groupId),
|
||||
snapshot
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
assertCircuitGroupSubtreeSnapshot,
|
||||
summarizeCircuitGroupDeletion,
|
||||
type CircuitGroupSubtreeSnapshot,
|
||||
} from "../src/domain/models/circuit-group-subtree-snapshot.model.js";
|
||||
|
||||
function createSnapshot(): CircuitGroupSubtreeSnapshot {
|
||||
return {
|
||||
group: {
|
||||
id: "group-1",
|
||||
circuitListId: "list-1",
|
||||
key: "lighting",
|
||||
displayName: "Beleuchtung 1",
|
||||
prefix: "-1F1.",
|
||||
sortOrder: 10,
|
||||
category: "lighting",
|
||||
groupNumber: 1,
|
||||
},
|
||||
components: [
|
||||
{
|
||||
component: {
|
||||
id: "fuse-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "group-1",
|
||||
equipmentIdentifier: "-1F1.0",
|
||||
name: "Vorsicherung",
|
||||
role: "group_upstream_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
},
|
||||
protectionDevice: {
|
||||
componentId: "fuse-1",
|
||||
type: "D02",
|
||||
ratedCurrentA: 35,
|
||||
fuseUtilizationCategory: "gG",
|
||||
tripCharacteristic: null,
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: {
|
||||
id: "rcd-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "group-1",
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 20,
|
||||
},
|
||||
protectionDevice: {
|
||||
componentId: "rcd-1",
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: null,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
},
|
||||
],
|
||||
circuits: [
|
||||
{
|
||||
circuit: {
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "group-1",
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
displayName: "Beleuchtung",
|
||||
sortOrder: 10,
|
||||
protectionType: null,
|
||||
protectionRatedCurrent: null,
|
||||
protectionCharacteristic: null,
|
||||
cableType: null,
|
||||
cableCrossSection: null,
|
||||
cableLength: null,
|
||||
rcdAssignment: null,
|
||||
terminalDesignation: null,
|
||||
voltage: 230,
|
||||
controlRequirement: null,
|
||||
status: null,
|
||||
isReserve: false,
|
||||
remark: null,
|
||||
deviceRows: [
|
||||
{
|
||||
id: "row-1",
|
||||
circuitId: "circuit-1",
|
||||
linkedProjectDeviceId: "device-1",
|
||||
legacyConsumerId: null,
|
||||
sortOrder: 10,
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte Flur",
|
||||
phaseType: "single_phase",
|
||||
connectionKind: null,
|
||||
costGroup: "440",
|
||||
category: "Licht",
|
||||
level: "EG",
|
||||
roomId: "room-1",
|
||||
roomNumberSnapshot: "001",
|
||||
roomNameSnapshot: "Flur",
|
||||
quantity: 2,
|
||||
powerPerUnit: 0.03,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 0.95,
|
||||
remark: null,
|
||||
overriddenFields: "[\"displayName\"]",
|
||||
},
|
||||
],
|
||||
},
|
||||
protectionDevice: {
|
||||
circuitId: "circuit-1",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("circuit-group subtree snapshot", () => {
|
||||
it("validates complete relations and derives the deletion warning summary", () => {
|
||||
const snapshot = createSnapshot();
|
||||
assert.doesNotThrow(() =>
|
||||
assertCircuitGroupSubtreeSnapshot(snapshot)
|
||||
);
|
||||
assert.deepEqual(summarizeCircuitGroupDeletion(snapshot), {
|
||||
hasUpstreamProtection: true,
|
||||
hasResidualCurrentProtection: true,
|
||||
circuitCount: 1,
|
||||
deviceRowCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects foreign children, duplicate BMKs and invalid protection", () => {
|
||||
const foreign = createSnapshot();
|
||||
foreign.circuits[0].circuit.sectionId = "group-2";
|
||||
assert.throws(
|
||||
() => assertCircuitGroupSubtreeSnapshot(foreign),
|
||||
/ownership/
|
||||
);
|
||||
|
||||
const duplicate = createSnapshot();
|
||||
duplicate.circuits[0].circuit.equipmentIdentifier = "-1F1.0";
|
||||
assert.throws(
|
||||
() => assertCircuitGroupSubtreeSnapshot(duplicate),
|
||||
/duplicates/
|
||||
);
|
||||
|
||||
const invalidProtection = createSnapshot();
|
||||
invalidProtection.circuits[0].protectionDevice = {
|
||||
...invalidProtection.circuits[0].protectionDevice!,
|
||||
type: "AFDD",
|
||||
ratedResidualCurrentMa: 30,
|
||||
};
|
||||
assert.throws(
|
||||
() => assertCircuitGroupSubtreeSnapshot(invalidProtection),
|
||||
/configuration/
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves device link and override metadata", () => {
|
||||
const row = createSnapshot().circuits[0].circuit.deviceRows[0];
|
||||
assert.equal(row.linkedProjectDeviceId, "device-1");
|
||||
assert.equal(row.overriddenFields, "[\"displayName\"]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { buildCircuitStructureProjection } from "../src/frontend/utils/circuit-structure-projection.js";
|
||||
import type {
|
||||
CircuitTreeCircuitDto,
|
||||
CircuitTreeComponentDto,
|
||||
CircuitTreeSectionDto,
|
||||
} from "../src/frontend/types.js";
|
||||
|
||||
function component(
|
||||
id: string,
|
||||
placement: CircuitTreeComponentDto["placement"],
|
||||
sortOrder: number,
|
||||
sectionId?: string
|
||||
): CircuitTreeComponentDto {
|
||||
return {
|
||||
id,
|
||||
circuitListId: "list-1",
|
||||
sectionId,
|
||||
equipmentIdentifier: `-${id}`,
|
||||
name: id,
|
||||
role:
|
||||
placement === "header"
|
||||
? "main_switch"
|
||||
: placement === "footer"
|
||||
? "auxiliary"
|
||||
: "group_residual_current_protection",
|
||||
placement,
|
||||
sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
function circuit(
|
||||
id: string,
|
||||
sectionId: string
|
||||
): CircuitTreeCircuitDto {
|
||||
return {
|
||||
id,
|
||||
circuitListId: "list-1",
|
||||
sectionId,
|
||||
equipmentIdentifier: `-${id}`,
|
||||
sortOrder: 10,
|
||||
isReserve: true,
|
||||
circuitTotalPower: 0,
|
||||
deviceRows: [],
|
||||
};
|
||||
}
|
||||
|
||||
function section(
|
||||
id: string,
|
||||
sortOrder: number
|
||||
): CircuitTreeSectionDto {
|
||||
return {
|
||||
id,
|
||||
key: id,
|
||||
displayName: id,
|
||||
prefix: "-1F1.",
|
||||
sortOrder,
|
||||
category: "lighting",
|
||||
groupNumber: sortOrder / 10,
|
||||
sectionTotalPower: 0,
|
||||
components: [
|
||||
component(`${id}-rcd-late`, "group", 20, id),
|
||||
component(`${id}-rcd-first`, "group", 10, id),
|
||||
],
|
||||
circuits: [circuit(`${id}-circuit`, id)],
|
||||
};
|
||||
}
|
||||
|
||||
describe("circuit structure projection", () => {
|
||||
it("projects fixed header, complete groups and auxiliary footer in stable order", () => {
|
||||
const rows = buildCircuitStructureProjection({
|
||||
headerComponents: [
|
||||
component("surge", "header", 20),
|
||||
component("main", "header", 10),
|
||||
],
|
||||
sections: [section("group-2", 20), section("group-1", 10)],
|
||||
footerComponents: [
|
||||
component("actor-2", "footer", 20),
|
||||
component("actor-1", "footer", 10),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.rowKey),
|
||||
[
|
||||
"header-component:main",
|
||||
"header-component:surge",
|
||||
"group:group-1",
|
||||
"group-component:group-1-rcd-first",
|
||||
"group-component:group-1-rcd-late",
|
||||
"circuit-block:group-1-circuit",
|
||||
"group-placeholder:group-1",
|
||||
"group:group-2",
|
||||
"group-component:group-2-rcd-first",
|
||||
"group-component:group-2-rcd-late",
|
||||
"circuit-block:group-2-circuit",
|
||||
"group-placeholder:group-2",
|
||||
"footer-component:actor-1",
|
||||
"footer-component:actor-2",
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps structural rows around an empty group", () => {
|
||||
const empty = section("group-empty", 10);
|
||||
empty.components = [];
|
||||
empty.circuits = [];
|
||||
assert.deepEqual(
|
||||
buildCircuitStructureProjection({
|
||||
headerComponents: [],
|
||||
sections: [empty],
|
||||
footerComponents: [],
|
||||
}).map((row) => row.rowType),
|
||||
["groupHeader", "groupPlaceholder"]
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps circuit device rows inside their circuit block", () => {
|
||||
const grouped = section("group-1", 10);
|
||||
grouped.circuits[0].deviceRows = [
|
||||
{
|
||||
id: "device-1",
|
||||
sortOrder: 10,
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte",
|
||||
quantity: 1,
|
||||
powerPerUnit: 0.1,
|
||||
simultaneityFactor: 1,
|
||||
rowTotalPower: 0.1,
|
||||
},
|
||||
];
|
||||
const block = buildCircuitStructureProjection({
|
||||
headerComponents: [],
|
||||
sections: [grouped],
|
||||
footerComponents: [],
|
||||
}).find((row) => row.rowType === "circuitBlock");
|
||||
assert.equal(
|
||||
block?.rowType === "circuitBlock"
|
||||
? block.circuit.deviceRows[0].id
|
||||
: null,
|
||||
"device-1"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,19 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { isMissingCircuitTreeSchemaError } from "../src/server/controllers/circuit-tree.controller.js";
|
||||
import {
|
||||
getCircuitTree,
|
||||
isMissingCircuitTreeSchemaError,
|
||||
} from "../src/server/controllers/circuit-tree.controller.js";
|
||||
import {
|
||||
circuitDeviceRowRepository,
|
||||
circuitListRepository,
|
||||
circuitProtectionDeviceRepository,
|
||||
circuitRepository,
|
||||
circuitSectionRepository,
|
||||
distributionBoardComponentRepository,
|
||||
distributionBoardRepository,
|
||||
projectRepository,
|
||||
} from "../src/server/composition/application-repositories.js";
|
||||
|
||||
describe("circuit tree controller", () => {
|
||||
it("detects missing circuit-first schema errors", () => {
|
||||
@@ -13,7 +26,192 @@ describe("circuit tree controller", () => {
|
||||
isMissingCircuitTreeSchemaError(new Error("SqliteError: no such table: circuit_device_rows")),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isMissingCircuitTreeSchemaError(
|
||||
new Error("SqliteError: no such table: distribution_board_components")
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(isMissingCircuitTreeSchemaError(new Error("Some other error")), false);
|
||||
});
|
||||
|
||||
it("returns header, grouped and footer components with both protection layers", async () => {
|
||||
const originals = {
|
||||
findList: circuitListRepository.findById,
|
||||
findProject: projectRepository.findById,
|
||||
findBoard: distributionBoardRepository.findById,
|
||||
listSections: circuitSectionRepository.listByCircuitList,
|
||||
listCircuits: circuitRepository.listByCircuitList,
|
||||
listRows: circuitDeviceRowRepository.listByCircuitList,
|
||||
listComponents:
|
||||
distributionBoardComponentRepository.listByCircuitList,
|
||||
listComponentProtection:
|
||||
distributionBoardComponentRepository.listProtectionByComponentIds,
|
||||
listCircuitProtection:
|
||||
circuitProtectionDeviceRepository.listByCircuitIds,
|
||||
};
|
||||
circuitListRepository.findById = async () =>
|
||||
({
|
||||
id: "list-1",
|
||||
projectId: "project-1",
|
||||
distributionBoardId: "board-1",
|
||||
name: "UV-01",
|
||||
}) as never;
|
||||
projectRepository.findById = async () =>
|
||||
({
|
||||
id: "project-1",
|
||||
name: "Projekt",
|
||||
currentRevision: 4,
|
||||
singlePhaseVoltageV: 230,
|
||||
threePhaseVoltageV: 400,
|
||||
}) as never;
|
||||
distributionBoardRepository.findById = async () =>
|
||||
({ id: "board-1", simultaneityFactor: 0.8 }) as never;
|
||||
circuitSectionRepository.listByCircuitList = async () =>
|
||||
[
|
||||
{
|
||||
id: "group-1",
|
||||
circuitListId: "list-1",
|
||||
key: "lighting",
|
||||
displayName: "Beleuchtung 1",
|
||||
prefix: "-1F1.",
|
||||
sortOrder: 10,
|
||||
category: "lighting",
|
||||
groupNumber: 1,
|
||||
},
|
||||
] as never;
|
||||
circuitRepository.listByCircuitList = async () =>
|
||||
[
|
||||
{
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "group-1",
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
displayName: "Licht",
|
||||
sortOrder: 10,
|
||||
isReserve: 0,
|
||||
},
|
||||
] as never;
|
||||
circuitDeviceRowRepository.listByCircuitList = async () => [];
|
||||
distributionBoardComponentRepository.listByCircuitList =
|
||||
async () =>
|
||||
[
|
||||
{
|
||||
id: "main",
|
||||
circuitListId: "list-1",
|
||||
sectionId: null,
|
||||
equipmentIdentifier: "-Q0",
|
||||
name: "Hauptschalter",
|
||||
role: "main_switch",
|
||||
placement: "header",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: "rcd",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "group-1",
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: "actor",
|
||||
circuitListId: "list-1",
|
||||
sectionId: null,
|
||||
equipmentIdentifier: "-K1",
|
||||
name: "KNX Aktor",
|
||||
role: "auxiliary",
|
||||
placement: "footer",
|
||||
sortOrder: 10,
|
||||
},
|
||||
] as never;
|
||||
distributionBoardComponentRepository.listProtectionByComponentIds =
|
||||
async () =>
|
||||
[
|
||||
{
|
||||
componentId: "rcd",
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: null,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
] as never;
|
||||
circuitProtectionDeviceRepository.listByCircuitIds = async () =>
|
||||
[
|
||||
{
|
||||
circuitId: "circuit-1",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
},
|
||||
] as never;
|
||||
|
||||
let responseBody: unknown;
|
||||
const response = {
|
||||
status() {
|
||||
return this;
|
||||
},
|
||||
json(value: unknown) {
|
||||
responseBody = value;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
try {
|
||||
await getCircuitTree(
|
||||
{ params: { projectId: "project-1", circuitListId: "list-1" } } as never,
|
||||
response as never
|
||||
);
|
||||
} finally {
|
||||
circuitListRepository.findById = originals.findList;
|
||||
projectRepository.findById = originals.findProject;
|
||||
distributionBoardRepository.findById = originals.findBoard;
|
||||
circuitSectionRepository.listByCircuitList =
|
||||
originals.listSections;
|
||||
circuitRepository.listByCircuitList = originals.listCircuits;
|
||||
circuitDeviceRowRepository.listByCircuitList = originals.listRows;
|
||||
distributionBoardComponentRepository.listByCircuitList =
|
||||
originals.listComponents;
|
||||
distributionBoardComponentRepository.listProtectionByComponentIds =
|
||||
originals.listComponentProtection;
|
||||
circuitProtectionDeviceRepository.listByCircuitIds =
|
||||
originals.listCircuitProtection;
|
||||
}
|
||||
|
||||
const tree = responseBody as {
|
||||
headerComponents: Array<{ equipmentIdentifier: string }>;
|
||||
footerComponents: Array<{ equipmentIdentifier: string }>;
|
||||
sections: Array<{
|
||||
category: string;
|
||||
groupNumber: number;
|
||||
components: Array<{
|
||||
protectionDevice: { ratedResidualCurrentMa: number };
|
||||
}>;
|
||||
circuits: Array<{
|
||||
protectionDevice: { tripCharacteristic: string };
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
assert.equal(tree.headerComponents[0].equipmentIdentifier, "-Q0");
|
||||
assert.equal(tree.footerComponents[0].equipmentIdentifier, "-K1");
|
||||
assert.equal(tree.sections[0].category, "lighting");
|
||||
assert.equal(tree.sections[0].groupNumber, 1);
|
||||
assert.equal(
|
||||
tree.sections[0].components[0].protectionDevice
|
||||
.ratedResidualCurrentMa,
|
||||
30
|
||||
);
|
||||
assert.equal(
|
||||
tree.sections[0].circuits[0].protectionDevice
|
||||
.tripCharacteristic,
|
||||
"B"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import Database from "better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||
|
||||
const migrationPath = path.resolve(
|
||||
"src",
|
||||
"db",
|
||||
"migrations",
|
||||
"0024_damp_mister_sinister.sql"
|
||||
);
|
||||
|
||||
function applyComponentSchemaMigration(sqlite: Database.Database): void {
|
||||
const migrationSql = fs.readFileSync(migrationPath, "utf8");
|
||||
for (const statement of migrationSql.split("--> statement-breakpoint")) {
|
||||
if (statement.trim()) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createLegacySchema(sqlite: Database.Database): void {
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE circuit_lists (
|
||||
id text PRIMARY KEY NOT NULL
|
||||
);
|
||||
CREATE TABLE circuit_sections (
|
||||
id text PRIMARY KEY NOT NULL,
|
||||
circuit_list_id text NOT NULL REFERENCES circuit_lists(id) ON DELETE CASCADE,
|
||||
key text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
prefix text NOT NULL,
|
||||
sort_order integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
CREATE TABLE circuits (
|
||||
id text PRIMARY KEY NOT NULL,
|
||||
circuit_list_id text NOT NULL REFERENCES circuit_lists(id) ON DELETE CASCADE,
|
||||
equipment_identifier text NOT NULL,
|
||||
protection_type text,
|
||||
protection_rated_current real,
|
||||
protection_characteristic text
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
describe("distribution board component schema migration", () => {
|
||||
it("creates the additive schema on an empty database", () => {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
try {
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
|
||||
const tables = (
|
||||
context.sqlite
|
||||
.prepare(
|
||||
`SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
ORDER BY name`
|
||||
)
|
||||
.all() as Array<{ name: string }>
|
||||
).map((table) => table.name);
|
||||
|
||||
assert.equal(tables.includes("distribution_board_components"), true);
|
||||
assert.equal(tables.includes("circuit_protection_devices"), true);
|
||||
assert.deepEqual(
|
||||
tables.includes("distribution_board_component_protection_devices"),
|
||||
true
|
||||
);
|
||||
assert.deepEqual(
|
||||
tables.includes("circuit_list_equipment_identifiers"),
|
||||
true
|
||||
);
|
||||
|
||||
const sectionColumns = (
|
||||
context.sqlite
|
||||
.prepare("PRAGMA table_info(circuit_sections)")
|
||||
.all() as Array<{ name: string }>
|
||||
).map((column) => column.name);
|
||||
assert.equal(sectionColumns.includes("category"), true);
|
||||
assert.equal(sectionColumns.includes("group_number"), true);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("backfills groups and the BMK registry without rewriting existing planning data", () => {
|
||||
const sqlite = new Database(":memory:");
|
||||
try {
|
||||
createLegacySchema(sqlite);
|
||||
sqlite.exec(`
|
||||
INSERT INTO circuit_lists (id) VALUES ('list-1');
|
||||
INSERT INTO circuit_sections (
|
||||
id,
|
||||
circuit_list_id,
|
||||
key,
|
||||
display_name,
|
||||
prefix,
|
||||
sort_order
|
||||
) VALUES
|
||||
('lighting', 'list-1', 'lighting', 'Beleuchtung', '-1F', 10),
|
||||
('single', 'list-1', 'single_phase', '1-phasig', '-2F', 20),
|
||||
('three', 'list-1', 'three_phase', '3-phasig', '-3F', 30),
|
||||
('unassigned', 'list-1', 'unassigned', 'Nicht zugeordnet', '-9F', 40);
|
||||
INSERT INTO circuits (
|
||||
id,
|
||||
circuit_list_id,
|
||||
equipment_identifier,
|
||||
protection_type,
|
||||
protection_rated_current,
|
||||
protection_characteristic
|
||||
) VALUES
|
||||
('circuit-1', 'list-1', '-1F1', 'legacy fuse', 13, 'legacy'),
|
||||
('circuit-2', 'list-1', '-2F4', NULL, NULL, NULL);
|
||||
`);
|
||||
|
||||
applyComponentSchemaMigration(sqlite);
|
||||
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT
|
||||
id,
|
||||
category,
|
||||
group_number AS groupNumber
|
||||
FROM circuit_sections
|
||||
ORDER BY sort_order`
|
||||
)
|
||||
.all(),
|
||||
[
|
||||
{ id: "lighting", category: "lighting", groupNumber: 1 },
|
||||
{ id: "single", category: "single_phase", groupNumber: 1 },
|
||||
{ id: "three", category: "three_phase", groupNumber: 1 },
|
||||
{ id: "unassigned", category: null, groupNumber: null },
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT
|
||||
id,
|
||||
equipment_identifier AS equipmentIdentifier,
|
||||
protection_type AS protectionType,
|
||||
protection_rated_current AS protectionRatedCurrent,
|
||||
protection_characteristic AS protectionCharacteristic
|
||||
FROM circuits
|
||||
ORDER BY id`
|
||||
)
|
||||
.all(),
|
||||
[
|
||||
{
|
||||
id: "circuit-1",
|
||||
equipmentIdentifier: "-1F1",
|
||||
protectionType: "legacy fuse",
|
||||
protectionRatedCurrent: 13,
|
||||
protectionCharacteristic: "legacy",
|
||||
},
|
||||
{
|
||||
id: "circuit-2",
|
||||
equipmentIdentifier: "-2F4",
|
||||
protectionType: null,
|
||||
protectionRatedCurrent: null,
|
||||
protectionCharacteristic: null,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT
|
||||
owner_type AS ownerType,
|
||||
owner_id AS ownerId,
|
||||
equipment_identifier AS equipmentIdentifier
|
||||
FROM circuit_list_equipment_identifiers
|
||||
ORDER BY owner_id`
|
||||
)
|
||||
.all(),
|
||||
[
|
||||
{
|
||||
ownerType: "circuit",
|
||||
ownerId: "circuit-1",
|
||||
equipmentIdentifier: "-1F1",
|
||||
},
|
||||
{
|
||||
ownerType: "circuit",
|
||||
ownerId: "circuit-2",
|
||||
equipmentIdentifier: "-2F4",
|
||||
},
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare("SELECT count(*) AS count FROM circuit_protection_devices")
|
||||
.get(),
|
||||
{ count: 0 }
|
||||
);
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the shared BMK registry synchronized and rejects normalized collisions", () => {
|
||||
const sqlite = new Database(":memory:");
|
||||
try {
|
||||
createLegacySchema(sqlite);
|
||||
sqlite.exec(`
|
||||
INSERT INTO circuit_lists (id) VALUES ('list-1');
|
||||
INSERT INTO circuit_sections (
|
||||
id,
|
||||
circuit_list_id,
|
||||
key,
|
||||
display_name,
|
||||
prefix
|
||||
) VALUES ('lighting', 'list-1', 'lighting', 'Beleuchtung', '-1F');
|
||||
INSERT INTO circuits (
|
||||
id,
|
||||
circuit_list_id,
|
||||
equipment_identifier
|
||||
) VALUES ('circuit-1', 'list-1', '-1F1');
|
||||
`);
|
||||
applyComponentSchemaMigration(sqlite);
|
||||
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO distribution_board_components (
|
||||
id,
|
||||
circuit_list_id,
|
||||
equipment_identifier,
|
||||
name,
|
||||
role,
|
||||
placement,
|
||||
sort_order
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
"main-switch",
|
||||
"list-1",
|
||||
"-Q0",
|
||||
"Hauptschalter",
|
||||
"main_switch",
|
||||
"header",
|
||||
10
|
||||
);
|
||||
|
||||
assert.throws(() =>
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO distribution_board_components (
|
||||
id,
|
||||
circuit_list_id,
|
||||
equipment_identifier,
|
||||
name,
|
||||
role,
|
||||
placement
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
"duplicate",
|
||||
"list-1",
|
||||
" -q0 ",
|
||||
"Duplikat",
|
||||
"auxiliary",
|
||||
"footer"
|
||||
)
|
||||
);
|
||||
assert.throws(() =>
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO distribution_board_components (
|
||||
id,
|
||||
circuit_list_id,
|
||||
equipment_identifier,
|
||||
name,
|
||||
role,
|
||||
placement
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
"circuit-duplicate",
|
||||
"list-1",
|
||||
"-1f1",
|
||||
"Duplikat",
|
||||
"auxiliary",
|
||||
"footer"
|
||||
)
|
||||
);
|
||||
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO circuits (
|
||||
id,
|
||||
circuit_list_id,
|
||||
equipment_identifier
|
||||
) VALUES (?, ?, ?)`
|
||||
)
|
||||
.run("circuit-2", "list-1", "-1F2");
|
||||
sqlite
|
||||
.prepare(
|
||||
`UPDATE circuits
|
||||
SET equipment_identifier = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run("-1F3", "circuit-2");
|
||||
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT equipment_identifier AS equipmentIdentifier
|
||||
FROM circuit_list_equipment_identifiers
|
||||
WHERE owner_type = 'circuit' AND owner_id = 'circuit-2'`
|
||||
)
|
||||
.get(),
|
||||
{ equipmentIdentifier: "-1F3" }
|
||||
);
|
||||
|
||||
assert.throws(() =>
|
||||
sqlite
|
||||
.prepare(
|
||||
`UPDATE circuits
|
||||
SET equipment_identifier = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run("-q0", "circuit-2")
|
||||
);
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT equipment_identifier AS equipmentIdentifier
|
||||
FROM circuits
|
||||
WHERE id = 'circuit-2'`
|
||||
)
|
||||
.get(),
|
||||
{ equipmentIdentifier: "-1F3" }
|
||||
);
|
||||
|
||||
sqlite
|
||||
.prepare("DELETE FROM distribution_board_components WHERE id = ?")
|
||||
.run("main-switch");
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM circuit_list_equipment_identifiers
|
||||
WHERE owner_type = 'distribution_board_component'`
|
||||
)
|
||||
.get(),
|
||||
{ count: 0 }
|
||||
);
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("stores independent one-to-one circuit and component protection state", () => {
|
||||
const sqlite = new Database(":memory:");
|
||||
try {
|
||||
createLegacySchema(sqlite);
|
||||
sqlite.exec(`
|
||||
INSERT INTO circuit_lists (id) VALUES ('list-1');
|
||||
INSERT INTO circuit_sections (
|
||||
id,
|
||||
circuit_list_id,
|
||||
key,
|
||||
display_name,
|
||||
prefix
|
||||
) VALUES ('lighting', 'list-1', 'lighting', 'Beleuchtung', '-1F');
|
||||
INSERT INTO circuits (
|
||||
id,
|
||||
circuit_list_id,
|
||||
equipment_identifier
|
||||
) VALUES ('circuit-1', 'list-1', '-1F1');
|
||||
`);
|
||||
applyComponentSchemaMigration(sqlite);
|
||||
sqlite.exec(`
|
||||
INSERT INTO circuit_protection_devices (
|
||||
circuit_id,
|
||||
type,
|
||||
rated_current_a,
|
||||
trip_characteristic
|
||||
) VALUES ('circuit-1', 'LS', 10, 'B');
|
||||
INSERT INTO distribution_board_components (
|
||||
id,
|
||||
circuit_list_id,
|
||||
section_id,
|
||||
equipment_identifier,
|
||||
name,
|
||||
role,
|
||||
placement
|
||||
) VALUES (
|
||||
'group-rcd',
|
||||
'list-1',
|
||||
'lighting',
|
||||
'-1Q1.0',
|
||||
'Gruppen-FI',
|
||||
'group_residual_current_protection',
|
||||
'group'
|
||||
);
|
||||
INSERT INTO distribution_board_component_protection_devices (
|
||||
component_id,
|
||||
type,
|
||||
rated_current_a,
|
||||
rcd_type,
|
||||
rated_residual_current_ma
|
||||
) VALUES ('group-rcd', 'FI', 40, 'A', 30);
|
||||
`);
|
||||
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare("SELECT * FROM circuit_protection_devices")
|
||||
.get(),
|
||||
{
|
||||
circuit_id: "circuit-1",
|
||||
type: "LS",
|
||||
rated_current_a: 10,
|
||||
fuse_utilization_category: null,
|
||||
trip_characteristic: "B",
|
||||
rcd_type: null,
|
||||
rated_residual_current_ma: null,
|
||||
}
|
||||
);
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT * FROM distribution_board_component_protection_devices"
|
||||
)
|
||||
.get(),
|
||||
{
|
||||
component_id: "group-rcd",
|
||||
type: "FI",
|
||||
rated_current_a: 40,
|
||||
fuse_utilization_category: null,
|
||||
trip_characteristic: null,
|
||||
rcd_type: "A",
|
||||
rated_residual_current_ma: 30,
|
||||
}
|
||||
);
|
||||
|
||||
sqlite.prepare("DELETE FROM circuits WHERE id = ?").run("circuit-1");
|
||||
sqlite
|
||||
.prepare("DELETE FROM distribution_board_components WHERE id = ?")
|
||||
.run("group-rcd");
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare("SELECT count(*) AS count FROM circuit_protection_devices")
|
||||
.get(),
|
||||
{ count: 0 }
|
||||
);
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM distribution_board_component_protection_devices`
|
||||
)
|
||||
.get(),
|
||||
{ count: 0 }
|
||||
);
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,700 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import {
|
||||
createDistributionBoardComponentInsertProjectCommand,
|
||||
createDistributionBoardComponentUpdateProjectCommand,
|
||||
type DistributionBoardComponentSnapshot,
|
||||
} from "../src/domain/models/distribution-board-component-structure-project-command.model.js";
|
||||
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values([
|
||||
{ id: "project-1", name: "Projekt" },
|
||||
{ id: "project-2", name: "Fremdprojekt" },
|
||||
])
|
||||
.run();
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
return context;
|
||||
}
|
||||
|
||||
function getListAndSection(context: DatabaseContext) {
|
||||
const list = context.db.select().from(circuitLists).get();
|
||||
assert.ok(list);
|
||||
const section = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, list.id))
|
||||
.get();
|
||||
assert.ok(section);
|
||||
return { list, section };
|
||||
}
|
||||
|
||||
describe("distribution-board component structure project command", () => {
|
||||
it("inserts an auxiliary component and preserves it through undo and redo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const { list } = getListAndSection(context);
|
||||
const snapshot: DistributionBoardComponentSnapshot = {
|
||||
component: {
|
||||
id: "component-aux",
|
||||
circuitListId: list.id,
|
||||
sectionId: null,
|
||||
equipmentIdentifier: "-K1",
|
||||
name: "KNX Schaltaktor",
|
||||
role: "auxiliary",
|
||||
placement: "footer",
|
||||
sortOrder: 10,
|
||||
},
|
||||
protectionDevice: null,
|
||||
};
|
||||
const repository =
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const history = new ProjectHistoryRepository(context.db);
|
||||
const command =
|
||||
createDistributionBoardComponentInsertProjectCommand(snapshot);
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, "component-aux"))
|
||||
.get(),
|
||||
snapshot.component
|
||||
);
|
||||
|
||||
const undo = history.getNextCommand("project-1", "undo");
|
||||
assert.ok(undo);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: undo.changeSetId,
|
||||
command: inserted.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, "component-aux"))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
|
||||
const redo = history.getNextCommand("project-1", "redo");
|
||||
assert.ok(redo);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "redo",
|
||||
historyTargetChangeSetId: redo.changeSetId,
|
||||
command,
|
||||
});
|
||||
assert.ok(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, "component-aux"))
|
||||
.get()
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("stores and restores a group RCD with its protection data atomically", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const { list, section } = getListAndSection(context);
|
||||
const snapshot: DistributionBoardComponentSnapshot = {
|
||||
component: {
|
||||
id: "component-rcd",
|
||||
circuitListId: list.id,
|
||||
sectionId: section.id,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
},
|
||||
protectionDevice: {
|
||||
componentId: "component-rcd",
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: null,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
};
|
||||
const repository =
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardComponentInsertProjectCommand(
|
||||
snapshot
|
||||
),
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponentProtectionDevices)
|
||||
.get(),
|
||||
snapshot.protectionDevice
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: inserted.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponentProtectionDevices)
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("updates an auxiliary component and restores its name, BMK and order", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const { list } = getListAndSection(context);
|
||||
const expected: DistributionBoardComponentSnapshot = {
|
||||
component: {
|
||||
id: "component-update-aux",
|
||||
circuitListId: list.id,
|
||||
sectionId: null,
|
||||
equipmentIdentifier: "-K20",
|
||||
name: "KNX Aktor",
|
||||
role: "auxiliary",
|
||||
placement: "footer",
|
||||
sortOrder: 10,
|
||||
},
|
||||
protectionDevice: null,
|
||||
};
|
||||
const target: DistributionBoardComponentSnapshot = {
|
||||
component: {
|
||||
...expected.component,
|
||||
equipmentIdentifier: "-K21",
|
||||
name: "KNX Jalousieaktor",
|
||||
sortOrder: 30,
|
||||
},
|
||||
protectionDevice: null,
|
||||
};
|
||||
const repository =
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardComponentInsertProjectCommand(
|
||||
expected
|
||||
),
|
||||
});
|
||||
const updated = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardComponentUpdateProjectCommand(
|
||||
expected,
|
||||
target
|
||||
),
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, expected.component.id))
|
||||
.get(),
|
||||
target.component
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: updated.inverse,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, expected.component.id))
|
||||
.get(),
|
||||
expected.component
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("updates group protection data atomically and rejects stale state", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const { list, section } = getListAndSection(context);
|
||||
const expected: DistributionBoardComponentSnapshot = {
|
||||
component: {
|
||||
id: "component-update-protection",
|
||||
circuitListId: list.id,
|
||||
sectionId: section.id,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
},
|
||||
protectionDevice: {
|
||||
componentId: "component-update-protection",
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: null,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
};
|
||||
const target: DistributionBoardComponentSnapshot = {
|
||||
component: {
|
||||
...expected.component,
|
||||
name: "Gruppen-FI 63 A",
|
||||
},
|
||||
protectionDevice: {
|
||||
...expected.protectionDevice!,
|
||||
ratedCurrentA: 63,
|
||||
},
|
||||
};
|
||||
const repository =
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardComponentInsertProjectCommand(
|
||||
expected
|
||||
),
|
||||
});
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardComponentUpdateProjectCommand(
|
||||
expected,
|
||||
target
|
||||
),
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponentProtectionDevices)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponentProtectionDevices.componentId,
|
||||
expected.component.id
|
||||
)
|
||||
)
|
||||
.get(),
|
||||
target.protectionDevice
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardComponentUpdateProjectCommand(
|
||||
expected,
|
||||
target
|
||||
),
|
||||
}),
|
||||
/changed before update/
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
2
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createDistributionBoardComponentUpdateProjectCommand(
|
||||
target,
|
||||
{
|
||||
...target,
|
||||
component: {
|
||||
...target.component,
|
||||
sectionId: "another-section",
|
||||
},
|
||||
}
|
||||
),
|
||||
/cannot change ownership or role/
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back a component update when history persistence fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const { list } = getListAndSection(context);
|
||||
const expected: DistributionBoardComponentSnapshot = {
|
||||
component: {
|
||||
id: "component-update-rollback",
|
||||
circuitListId: list.id,
|
||||
sectionId: null,
|
||||
equipmentIdentifier: "-K30",
|
||||
name: "Aktor",
|
||||
role: "auxiliary",
|
||||
placement: "footer",
|
||||
sortOrder: 10,
|
||||
},
|
||||
protectionDevice: null,
|
||||
};
|
||||
const target: DistributionBoardComponentSnapshot = {
|
||||
component: {
|
||||
...expected.component,
|
||||
name: "Geänderter Aktor",
|
||||
sortOrder: 20,
|
||||
},
|
||||
protectionDevice: null,
|
||||
};
|
||||
const repository =
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardComponentInsertProjectCommand(
|
||||
expected
|
||||
),
|
||||
});
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_component_update_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced component update history failure');
|
||||
END;
|
||||
`);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardComponentUpdateProjectCommand(
|
||||
expected,
|
||||
target
|
||||
),
|
||||
}),
|
||||
/forced component update history failure/
|
||||
);
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, expected.component.id))
|
||||
.get(),
|
||||
expected.component
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
1
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects fixed roles, foreign sections, duplicate BMKs and stale revisions", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const { list } = getListAndSection(context);
|
||||
assert.throws(
|
||||
() =>
|
||||
createDistributionBoardComponentInsertProjectCommand({
|
||||
component: {
|
||||
id: "fixed",
|
||||
circuitListId: list.id,
|
||||
sectionId: null,
|
||||
equipmentIdentifier: "-Q1",
|
||||
name: "Hauptschalter",
|
||||
role: "main_switch",
|
||||
placement: "header",
|
||||
sortOrder: 10,
|
||||
} as never,
|
||||
protectionDevice: null,
|
||||
}),
|
||||
/Fixed distribution-board components/
|
||||
);
|
||||
const snapshot: DistributionBoardComponentSnapshot = {
|
||||
component: {
|
||||
id: "duplicate",
|
||||
circuitListId: list.id,
|
||||
sectionId: null,
|
||||
equipmentIdentifier: " -q0 ",
|
||||
name: "Doppelt",
|
||||
role: "auxiliary",
|
||||
placement: "footer",
|
||||
sortOrder: 10,
|
||||
},
|
||||
protectionDevice: null,
|
||||
};
|
||||
const repository =
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const foreignBoard = new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections(
|
||||
"project-2",
|
||||
"UV fremd"
|
||||
);
|
||||
const foreignSection = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(
|
||||
eq(circuitSections.circuitListId, foreignBoard.id)
|
||||
)
|
||||
.get();
|
||||
assert.ok(foreignSection);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardComponentInsertProjectCommand({
|
||||
component: {
|
||||
id: "foreign-section",
|
||||
circuitListId: list.id,
|
||||
sectionId: foreignSection.id,
|
||||
equipmentIdentifier: "-1Q2.0",
|
||||
name: "Fremder Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
},
|
||||
protectionDevice: {
|
||||
componentId: "foreign-section",
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: null,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
/section does not belong/
|
||||
);
|
||||
const staleSnapshot: DistributionBoardComponentSnapshot = {
|
||||
component: {
|
||||
...snapshot.component,
|
||||
id: "stale",
|
||||
equipmentIdentifier: "-K-stale",
|
||||
},
|
||||
protectionDevice: null,
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardComponentInsertProjectCommand(
|
||||
staleSnapshot
|
||||
),
|
||||
}),
|
||||
ProjectRevisionConflictError
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardComponentInsertProjectCommand(
|
||||
snapshot
|
||||
),
|
||||
}),
|
||||
/UNIQUE constraint failed/
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects changed deletes and rolls back late history failures", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const { list } = getListAndSection(context);
|
||||
const snapshot: DistributionBoardComponentSnapshot = {
|
||||
component: {
|
||||
id: "component-change",
|
||||
circuitListId: list.id,
|
||||
sectionId: null,
|
||||
equipmentIdentifier: "-K2",
|
||||
name: "Aktor",
|
||||
role: "auxiliary",
|
||||
placement: "footer",
|
||||
sortOrder: 20,
|
||||
},
|
||||
protectionDevice: null,
|
||||
};
|
||||
const repository =
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardComponentInsertProjectCommand(
|
||||
snapshot
|
||||
),
|
||||
});
|
||||
context.db
|
||||
.update(distributionBoardComponents)
|
||||
.set({ name: "Direkt geändert" })
|
||||
.where(eq(distributionBoardComponents.id, snapshot.component.id))
|
||||
.run();
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: inserted.inverse,
|
||||
}),
|
||||
/changed before deletion/
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
|
||||
const rollback = createTestDatabase();
|
||||
try {
|
||||
const { list } = getListAndSection(rollback);
|
||||
rollback.sqlite.exec(`
|
||||
CREATE TRIGGER fail_component_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced component history failure');
|
||||
END;
|
||||
`);
|
||||
const repository =
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||
rollback.db
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardComponentInsertProjectCommand({
|
||||
component: {
|
||||
id: "component-rollback",
|
||||
circuitListId: list.id,
|
||||
sectionId: null,
|
||||
equipmentIdentifier: "-K3",
|
||||
name: "Aktor",
|
||||
role: "auxiliary",
|
||||
placement: "footer",
|
||||
sortOrder: 30,
|
||||
},
|
||||
protectionDevice: null,
|
||||
}),
|
||||
}),
|
||||
/forced component history failure/
|
||||
);
|
||||
assert.equal(
|
||||
rollback.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponents.id,
|
||||
"component-rollback"
|
||||
)
|
||||
)
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
rollback.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
defaultMainSwitchComponent,
|
||||
defaultSurgeProtectiveDeviceComponent,
|
||||
distributionBoardComponentPlacements,
|
||||
distributionBoardComponentRoles,
|
||||
} from "../src/shared/constants/distribution-board-component.js";
|
||||
import { circuitGroupCategories } from "../src/shared/constants/circuit-group.js";
|
||||
import "./distribution-board-component-structure-project-command.repository.test.js";
|
||||
|
||||
describe("distribution board component catalog", () => {
|
||||
it("defines the agreed component roles and placement zones", () => {
|
||||
assert.deepEqual(distributionBoardComponentRoles, [
|
||||
"main_switch",
|
||||
"surge_protective_device",
|
||||
"group_upstream_protection",
|
||||
"group_residual_current_protection",
|
||||
"auxiliary",
|
||||
]);
|
||||
assert.deepEqual(distributionBoardComponentPlacements, [
|
||||
"header",
|
||||
"group",
|
||||
"footer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("defines exactly the three supported circuit group categories", () => {
|
||||
assert.deepEqual(circuitGroupCategories, [
|
||||
"lighting",
|
||||
"single_phase",
|
||||
"three_phase",
|
||||
]);
|
||||
});
|
||||
|
||||
it("provides the fixed header component defaults", () => {
|
||||
assert.deepEqual(defaultMainSwitchComponent, {
|
||||
equipmentIdentifier: "-Q0",
|
||||
name: "Hauptschalter",
|
||||
role: "main_switch",
|
||||
placement: "header",
|
||||
});
|
||||
assert.deepEqual(defaultSurgeProtectiveDeviceComponent, {
|
||||
equipmentIdentifier: "-FA",
|
||||
name: "Überspannungsableiter",
|
||||
role: "surge_protective_device",
|
||||
placement: "header",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||
import { floors } from "../src/db/schema/floors.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
@@ -51,10 +52,51 @@ function getStructureCounts(context: DatabaseContext) {
|
||||
boards: context.db.select().from(distributionBoards).all().length,
|
||||
lists: context.db.select().from(circuitLists).all().length,
|
||||
sections: context.db.select().from(circuitSections).all().length,
|
||||
components: context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.all().length,
|
||||
revisions: context.db.select().from(projectRevisions).all().length,
|
||||
};
|
||||
}
|
||||
|
||||
function legacySections(circuitListId: string) {
|
||||
return [
|
||||
{
|
||||
id: "legacy-lighting",
|
||||
circuitListId,
|
||||
key: "lighting",
|
||||
displayName: "Lighting",
|
||||
prefix: "-1F",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: "legacy-single-phase",
|
||||
circuitListId,
|
||||
key: "single_phase",
|
||||
displayName: "Single-phase circuits",
|
||||
prefix: "-2F",
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
id: "legacy-three-phase",
|
||||
circuitListId,
|
||||
key: "three_phase",
|
||||
displayName: "Three-phase circuits",
|
||||
prefix: "-3F",
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
id: "legacy-unassigned",
|
||||
circuitListId,
|
||||
key: "unassigned",
|
||||
displayName: "Unassigned",
|
||||
prefix: "-UF",
|
||||
sortOrder: 90,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("distribution-board structure project command", () => {
|
||||
it("builds the fixed setup and sends the expected frontend revision", async () => {
|
||||
const structure = createDistributionBoardStructureSnapshot(
|
||||
@@ -74,17 +116,26 @@ describe("distribution-board structure project command", () => {
|
||||
section.prefix,
|
||||
]),
|
||||
[
|
||||
["lighting", "-1F"],
|
||||
["single_phase", "-2F"],
|
||||
["three_phase", "-3F"],
|
||||
["unassigned", "-UF"],
|
||||
["lighting", "-1F1."],
|
||||
["single_phase", "-2F1."],
|
||||
["three_phase", "-3F1."],
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
structure.components.map((component) => [
|
||||
component.equipmentIdentifier,
|
||||
component.name,
|
||||
]),
|
||||
[
|
||||
["-Q0", "Hauptschalter"],
|
||||
["-FA", "Überspannungsableiter"],
|
||||
]
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createDistributionBoardInsertProjectCommand({
|
||||
...structure,
|
||||
sections: structure.sections.slice(0, 3),
|
||||
sections: structure.sections.slice(0, 2),
|
||||
}),
|
||||
/incomplete/
|
||||
);
|
||||
@@ -150,7 +201,8 @@ describe("distribution-board structure project command", () => {
|
||||
assert.deepEqual(getStructureCounts(context), {
|
||||
boards: 1,
|
||||
lists: 1,
|
||||
sections: 4,
|
||||
sections: 3,
|
||||
components: 2,
|
||||
revisions: 1,
|
||||
});
|
||||
|
||||
@@ -167,6 +219,7 @@ describe("distribution-board structure project command", () => {
|
||||
boards: 0,
|
||||
lists: 0,
|
||||
sections: 0,
|
||||
components: 0,
|
||||
revisions: 2,
|
||||
});
|
||||
|
||||
@@ -182,7 +235,8 @@ describe("distribution-board structure project command", () => {
|
||||
assert.deepEqual(getStructureCounts(context), {
|
||||
boards: 1,
|
||||
lists: 1,
|
||||
sections: 4,
|
||||
sections: 3,
|
||||
components: 2,
|
||||
revisions: 3,
|
||||
});
|
||||
assert.ok(
|
||||
@@ -274,8 +328,9 @@ describe("distribution-board structure project command", () => {
|
||||
type: "distribution-board.insert",
|
||||
payload: {
|
||||
structure: {
|
||||
...current,
|
||||
distributionBoard: legacyBoard,
|
||||
circuitList: current.circuitList,
|
||||
sections: legacySections(current.circuitList.id),
|
||||
},
|
||||
},
|
||||
} as unknown as DistributionBoardStructureProjectCommand;
|
||||
@@ -291,6 +346,59 @@ describe("distribution-board structure project command", () => {
|
||||
.get();
|
||||
assert.equal(inserted?.floorId, null);
|
||||
assert.equal(inserted?.supplyType, null);
|
||||
assert.equal(
|
||||
context.db.select().from(circuitSections).all().length,
|
||||
4
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(distributionBoardComponents).all()
|
||||
.length,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps stored version-two setup commands component-free", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new DistributionBoardStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const current = createDistributionBoardStructureSnapshot(
|
||||
"project-1",
|
||||
"UV Version 2"
|
||||
);
|
||||
const command = {
|
||||
schemaVersion: 2,
|
||||
type: "distribution-board.insert",
|
||||
payload: {
|
||||
structure: {
|
||||
distributionBoard: current.distributionBoard,
|
||||
circuitList: current.circuitList,
|
||||
sections: legacySections(current.circuitList.id),
|
||||
},
|
||||
},
|
||||
} as DistributionBoardStructureProjectCommand;
|
||||
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
assert.equal(inserted.inverse.schemaVersion, 2);
|
||||
assert.equal(
|
||||
context.db.select().from(circuitSections).all().length,
|
||||
4
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(distributionBoardComponents).all()
|
||||
.length,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
@@ -545,6 +653,7 @@ describe("distribution-board structure project command", () => {
|
||||
boards: 0,
|
||||
lists: 0,
|
||||
sections: 0,
|
||||
components: 0,
|
||||
revisions: 0,
|
||||
});
|
||||
|
||||
@@ -661,6 +770,7 @@ describe("distribution-board structure project command", () => {
|
||||
boards: 0,
|
||||
lists: 0,
|
||||
sections: 0,
|
||||
components: 0,
|
||||
revisions: 0,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -14,8 +14,13 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-
|
||||
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||
import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
|
||||
import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js";
|
||||
import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js";
|
||||
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
|
||||
import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
|
||||
import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js";
|
||||
@@ -26,6 +31,7 @@ import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
@@ -45,10 +51,24 @@ import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/
|
||||
import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
|
||||
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
|
||||
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
|
||||
import {
|
||||
createCircuitGroupInsertProjectCommand,
|
||||
createCircuitGroupReorderProjectCommand,
|
||||
createCircuitGroupUpdateProjectCommand,
|
||||
} from "../src/domain/models/circuit-group-structure-project-command.model.js";
|
||||
import { createCircuitGroupRenumberProjectCommand } from "../src/domain/models/circuit-group-renumber-project-command.model.js";
|
||||
import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js";
|
||||
import { createCircuitGroupMoveProjectCommand } from "../src/domain/models/circuit-group-move-project-command.model.js";
|
||||
import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js";
|
||||
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
|
||||
import {
|
||||
createDistributionBoardInsertProjectCommand,
|
||||
createDistributionBoardStructureSnapshot,
|
||||
} from "../src/domain/models/distribution-board-structure-project-command.model.js";
|
||||
import {
|
||||
createDistributionBoardComponentInsertProjectCommand,
|
||||
createDistributionBoardComponentUpdateProjectCommand,
|
||||
} from "../src/domain/models/distribution-board-component-structure-project-command.model.js";
|
||||
import {
|
||||
createProjectFloorInsertProjectCommand,
|
||||
createProjectFloorSnapshot,
|
||||
@@ -122,6 +142,13 @@ function createService(context: DatabaseContext) {
|
||||
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
|
||||
new ProjectSettingsProjectCommandRepository(context.db),
|
||||
new ProjectStateRestoreCommandRepository(context.db),
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||
context.db
|
||||
),
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db),
|
||||
new CircuitGroupRenumberProjectCommandRepository(context.db),
|
||||
new CircuitGroupMoveProjectCommandRepository(context.db),
|
||||
new CircuitGroupSubtreeProjectCommandRepository(context.db),
|
||||
new ProjectHistoryRepository(context.db)
|
||||
);
|
||||
}
|
||||
@@ -1059,6 +1086,411 @@ describe("project command service", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches distribution-board component insertion and deletion", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const service = createService(context);
|
||||
const created = service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
command:
|
||||
createDistributionBoardComponentInsertProjectCommand({
|
||||
component: {
|
||||
id: "component-service",
|
||||
circuitListId: context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.get()!.circuitListId,
|
||||
sectionId: null,
|
||||
equipmentIdentifier: "-K10",
|
||||
name: "KNX Aktor",
|
||||
role: "auxiliary",
|
||||
placement: "footer",
|
||||
sortOrder: 10,
|
||||
},
|
||||
protectionDevice: null,
|
||||
}),
|
||||
});
|
||||
assert.equal(created.history.currentRevision, 1);
|
||||
assert.ok(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
eq(distributionBoardComponents.id, "component-service")
|
||||
)
|
||||
.get()
|
||||
);
|
||||
|
||||
const undone = createService(context).undo({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
});
|
||||
assert.equal(undone.history.currentRevision, 2);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
eq(distributionBoardComponents.id, "component-service")
|
||||
)
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches distribution-board component updates", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const service = createService(context);
|
||||
const circuitListId = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.get()!.circuitListId;
|
||||
const expected = {
|
||||
component: {
|
||||
id: "component-service-update",
|
||||
circuitListId,
|
||||
sectionId: null,
|
||||
equipmentIdentifier: "-K11",
|
||||
name: "KNX Aktor",
|
||||
role: "auxiliary" as const,
|
||||
placement: "footer" as const,
|
||||
sortOrder: 10,
|
||||
},
|
||||
protectionDevice: null,
|
||||
};
|
||||
const target = {
|
||||
component: {
|
||||
...expected.component,
|
||||
name: "KNX Jalousieaktor",
|
||||
sortOrder: 20,
|
||||
},
|
||||
protectionDevice: null,
|
||||
};
|
||||
service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
command:
|
||||
createDistributionBoardComponentInsertProjectCommand(
|
||||
expected
|
||||
),
|
||||
});
|
||||
const updated = service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
command:
|
||||
createDistributionBoardComponentUpdateProjectCommand(
|
||||
expected,
|
||||
target
|
||||
),
|
||||
});
|
||||
assert.equal(updated.history.currentRevision, 2);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponents.id,
|
||||
expected.component.id
|
||||
)
|
||||
)
|
||||
.get()?.name,
|
||||
target.component.name
|
||||
);
|
||||
|
||||
createService(context).undo({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponents.id,
|
||||
expected.component.id
|
||||
)
|
||||
)
|
||||
.get()?.name,
|
||||
expected.component.name
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches circuit-group structure commands", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const service = createService(context);
|
||||
const circuitListId = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.get()!.circuitListId;
|
||||
const expected = {
|
||||
id: "group-service",
|
||||
circuitListId,
|
||||
key: "lighting_2",
|
||||
displayName: "Beleuchtung 2",
|
||||
prefix: "-1F2.",
|
||||
sortOrder: 40,
|
||||
category: "lighting" as const,
|
||||
groupNumber: 2,
|
||||
};
|
||||
const target = {
|
||||
...expected,
|
||||
displayName: "Beleuchtung Nebenräume",
|
||||
};
|
||||
service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
command: createCircuitGroupInsertProjectCommand(expected),
|
||||
});
|
||||
const updated = service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
command: createCircuitGroupUpdateProjectCommand(
|
||||
expected,
|
||||
target
|
||||
),
|
||||
});
|
||||
assert.equal(updated.history.currentRevision, 2);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, expected.id))
|
||||
.get()?.displayName,
|
||||
target.displayName
|
||||
);
|
||||
|
||||
createService(context).undo({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, expected.id))
|
||||
.get()?.displayName,
|
||||
expected.displayName
|
||||
);
|
||||
|
||||
const groups = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||
.all();
|
||||
const reorderAssignments = groups.map((group, index) => ({
|
||||
groupId: group.id,
|
||||
expectedSortOrder: group.sortOrder,
|
||||
targetSortOrder: (groups.length - index) * 10,
|
||||
}));
|
||||
const reordered = createService(context).executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 3,
|
||||
command: createCircuitGroupReorderProjectCommand(
|
||||
circuitListId,
|
||||
reorderAssignments
|
||||
),
|
||||
});
|
||||
assert.equal(reordered.history.currentRevision, 4);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches explicit circuit-group renumbering", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const service = createService(context);
|
||||
const existing = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.category, "three_phase"))
|
||||
.get()!;
|
||||
const added = {
|
||||
id: "group-three-phase-2",
|
||||
circuitListId: existing.circuitListId,
|
||||
key: "three_phase_2",
|
||||
displayName: "3-phasig 2",
|
||||
prefix: "-3F2.",
|
||||
sortOrder: 40,
|
||||
category: "three_phase" as const,
|
||||
groupNumber: 2,
|
||||
};
|
||||
service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
command: createCircuitGroupInsertProjectCommand(added),
|
||||
});
|
||||
const plan = createCircuitGroupRenumberPlan(
|
||||
[
|
||||
{
|
||||
id: existing.id,
|
||||
category: "three_phase",
|
||||
groupNumber: 1,
|
||||
prefix: "-3F1.",
|
||||
circuits: [],
|
||||
components: [],
|
||||
},
|
||||
{
|
||||
id: added.id,
|
||||
category: "three_phase",
|
||||
groupNumber: 2,
|
||||
prefix: "-3F2.",
|
||||
circuits: [],
|
||||
components: [],
|
||||
},
|
||||
],
|
||||
[
|
||||
{ groupId: existing.id, targetGroupNumber: 2 },
|
||||
{ groupId: added.id, targetGroupNumber: 1 },
|
||||
]
|
||||
);
|
||||
const result = service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
command: createCircuitGroupRenumberProjectCommand(
|
||||
existing.circuitListId,
|
||||
plan
|
||||
),
|
||||
});
|
||||
assert.equal(result.history.currentRevision, 2);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, existing.id))
|
||||
.get()?.prefix,
|
||||
"-3F2."
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches same-category circuit group moves", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const service = createService(context);
|
||||
const source = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.category, "three_phase"))
|
||||
.get()!;
|
||||
const target = {
|
||||
id: "group-move-service-target",
|
||||
circuitListId: source.circuitListId,
|
||||
key: "three_phase_2",
|
||||
displayName: "3-phasig 2",
|
||||
prefix: "-3F2.",
|
||||
sortOrder: 40,
|
||||
category: "three_phase" as const,
|
||||
groupNumber: 2,
|
||||
};
|
||||
service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
command: createCircuitGroupInsertProjectCommand(target),
|
||||
});
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values({
|
||||
id: "circuit-group-move-service",
|
||||
circuitListId: source.circuitListId,
|
||||
sectionId: source.id,
|
||||
equipmentIdentifier: "-3F1.1",
|
||||
displayName: "Drehstrom",
|
||||
sortOrder: 10,
|
||||
})
|
||||
.run();
|
||||
const result = service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
command: createCircuitGroupMoveProjectCommand(
|
||||
createCircuitGroupMovePlan({
|
||||
circuit: {
|
||||
id: "circuit-group-move-service",
|
||||
circuitListId: source.circuitListId,
|
||||
sectionId: source.id,
|
||||
equipmentIdentifier: "-3F1.1",
|
||||
sortOrder: 10,
|
||||
},
|
||||
sourceGroup: {
|
||||
id: source.id,
|
||||
category: "three_phase",
|
||||
groupNumber: 1,
|
||||
prefix: "-3F1.",
|
||||
},
|
||||
targetGroup: target,
|
||||
targetCircuitEquipmentIdentifiers: [],
|
||||
targetSortOrder: 20,
|
||||
})
|
||||
),
|
||||
});
|
||||
assert.equal(result.history.currentRevision, 2);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-group-move-service"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-3F2.1"
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches populated circuit-group deletion and restoration", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new CircuitGroupSubtreeProjectCommandRepository(context.db);
|
||||
const groupId = context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-1"))
|
||||
.get()!.sectionId;
|
||||
const snapshot = repository.capture("project-1", groupId);
|
||||
const deleted = createService(context).executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
command:
|
||||
createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
|
||||
});
|
||||
assert.equal(deleted.history.currentRevision, 1);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, groupId))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
|
||||
const restored = createService(context).undo({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
});
|
||||
assert.equal(restored.history.currentRevision, 2);
|
||||
assert.deepEqual(
|
||||
repository.capture("project-1", groupId),
|
||||
snapshot
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches floor and room setup with their persisted inverses", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
|
||||
@@ -14,8 +14,11 @@ import { ProjectTransferRepository } from "../src/db/repositories/project-transf
|
||||
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||
import { floors } from "../src/db/schema/floors.js";
|
||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||
@@ -129,9 +132,58 @@ function createTestDatabase(): DatabaseContext {
|
||||
cosPhi: 0.9,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitProtectionDevices)
|
||||
.values({
|
||||
circuitId: "circuit-1",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(distributionBoardComponents)
|
||||
.values({
|
||||
id: "component-1",
|
||||
circuitListId: list.id,
|
||||
sectionId: section.id,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(distributionBoardComponentProtectionDevices)
|
||||
.values({
|
||||
componentId: "component-1",
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: null,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
})
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
function removeVersionSevenFields(state: Record<string, unknown>) {
|
||||
delete state.distributionBoardComponents;
|
||||
delete state.circuitProtectionDevices;
|
||||
delete state.distributionBoardComponentProtectionDevices;
|
||||
for (const section of state.circuitSections as Array<
|
||||
Record<string, unknown>
|
||||
>) {
|
||||
delete section.category;
|
||||
delete section.groupNumber;
|
||||
}
|
||||
}
|
||||
|
||||
describe("project snapshot repository", () => {
|
||||
it("captures, hashes and lists a complete named logical snapshot", () => {
|
||||
const context = createTestDatabase();
|
||||
@@ -190,6 +242,18 @@ describe("project snapshot repository", () => {
|
||||
context.db.select().from(circuitSections).all().length
|
||||
);
|
||||
assert.equal(state.circuits.length, 1);
|
||||
assert.equal(state.circuitProtectionDevices.length, 1);
|
||||
assert.equal(
|
||||
state.distributionBoardComponents.length,
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.all().length
|
||||
);
|
||||
assert.equal(
|
||||
state.distributionBoardComponentProtectionDevices.length,
|
||||
1
|
||||
);
|
||||
assert.equal(state.circuits[0].deviceRows.length, 1);
|
||||
assert.equal(
|
||||
state.circuits[0].deviceRows[0].linkedProjectDeviceId,
|
||||
@@ -291,6 +355,7 @@ describe("project snapshot repository", () => {
|
||||
project: Record<string, unknown>;
|
||||
};
|
||||
currentPayload.schemaVersion = 1;
|
||||
removeVersionSevenFields(currentPayload);
|
||||
delete currentPayload.project.internalProjectNumber;
|
||||
delete currentPayload.project.externalProjectNumber;
|
||||
delete currentPayload.project.buildingOwner;
|
||||
@@ -368,6 +433,27 @@ describe("portable project transfer", () => {
|
||||
copied.state.circuits[0].id,
|
||||
source.state.circuits[0].id
|
||||
);
|
||||
assert.equal(copied.state.distributionBoardComponents.length, 3);
|
||||
assert.equal(
|
||||
copied.state.circuitProtectionDevices[0].circuitId,
|
||||
copied.state.circuits[0].id
|
||||
);
|
||||
assert.equal(
|
||||
copied.state.distributionBoardComponents.some(
|
||||
(component) =>
|
||||
component.id ===
|
||||
copied.state
|
||||
.distributionBoardComponentProtectionDevices[0]
|
||||
.componentId
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.notEqual(
|
||||
copied.state.distributionBoardComponentProtectionDevices[0]
|
||||
.componentId,
|
||||
source.state.distributionBoardComponentProtectionDevices[0]
|
||||
.componentId
|
||||
);
|
||||
assert.equal(
|
||||
copied.state.circuits[0].deviceRows[0].linkedProjectDeviceId,
|
||||
copied.state.projectDevices[0].id
|
||||
@@ -438,6 +524,7 @@ describe("portable project transfer", () => {
|
||||
distributionBoards: Array<Record<string, unknown>>;
|
||||
};
|
||||
legacyState.schemaVersion = 2;
|
||||
removeVersionSevenFields(legacyState);
|
||||
delete (
|
||||
legacyState.project as Record<string, unknown>
|
||||
).enabledDistributionBoardSupplyTypes;
|
||||
|
||||
@@ -14,8 +14,13 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-
|
||||
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||
import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
|
||||
import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js";
|
||||
import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js";
|
||||
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
|
||||
import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
|
||||
import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js";
|
||||
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
|
||||
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
|
||||
@@ -27,9 +32,12 @@ import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/pro
|
||||
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { consumers } from "../src/db/schema/consumers.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||
import { floors } from "../src/db/schema/floors.js";
|
||||
import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js";
|
||||
import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js";
|
||||
@@ -132,6 +140,43 @@ function createTestDatabase(): DatabaseContext {
|
||||
simultaneityFactor: 0.8,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitProtectionDevices)
|
||||
.values({
|
||||
circuitId: "circuit-1",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(distributionBoardComponents)
|
||||
.values({
|
||||
id: "component-1",
|
||||
circuitListId: list.id,
|
||||
sectionId: section.id,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(distributionBoardComponentProtectionDevices)
|
||||
.values({
|
||||
componentId: "component-1",
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: null,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
})
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -151,6 +196,13 @@ function createService(context: DatabaseContext) {
|
||||
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
|
||||
new ProjectSettingsProjectCommandRepository(context.db),
|
||||
new ProjectStateRestoreCommandRepository(context.db),
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||
context.db
|
||||
),
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db),
|
||||
new CircuitGroupRenumberProjectCommandRepository(context.db),
|
||||
new CircuitGroupMoveProjectCommandRepository(context.db),
|
||||
new CircuitGroupSubtreeProjectCommandRepository(context.db),
|
||||
new ProjectHistoryRepository(context.db)
|
||||
);
|
||||
}
|
||||
@@ -173,6 +225,16 @@ function changeCompleteProjectState(context: DatabaseContext) {
|
||||
})
|
||||
.where(eq(circuits.id, "circuit-1"))
|
||||
.run();
|
||||
context.db
|
||||
.update(circuitProtectionDevices)
|
||||
.set({ ratedCurrentA: 16 })
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-1"))
|
||||
.run();
|
||||
context.db
|
||||
.update(distributionBoardComponents)
|
||||
.set({ name: "Geänderter Gruppen-FI" })
|
||||
.where(eq(distributionBoardComponents.id, "component-1"))
|
||||
.run();
|
||||
context.db
|
||||
.insert(floors)
|
||||
.values({
|
||||
|
||||
@@ -32,6 +32,9 @@ function minimalSnapshot() {
|
||||
distributionBoards: [],
|
||||
circuitLists: [],
|
||||
circuitSections: [],
|
||||
distributionBoardComponents: [],
|
||||
circuitProtectionDevices: [],
|
||||
distributionBoardComponentProtectionDevices: [],
|
||||
circuits: [],
|
||||
projectDevices: [],
|
||||
floors: [],
|
||||
@@ -39,6 +42,111 @@ function minimalSnapshot() {
|
||||
};
|
||||
}
|
||||
|
||||
function legacyMinimalSnapshot() {
|
||||
const {
|
||||
distributionBoardComponents: _distributionBoardComponents,
|
||||
circuitProtectionDevices: _circuitProtectionDevices,
|
||||
distributionBoardComponentProtectionDevices:
|
||||
_distributionBoardComponentProtectionDevices,
|
||||
...snapshot
|
||||
} = minimalSnapshot();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function protectedSnapshot() {
|
||||
return {
|
||||
...minimalSnapshot(),
|
||||
distributionBoards: [
|
||||
{
|
||||
id: "board-1",
|
||||
projectId: "project-1",
|
||||
name: "UV 1",
|
||||
floorId: null,
|
||||
supplyType: null,
|
||||
simultaneityFactor: 1,
|
||||
},
|
||||
],
|
||||
circuitLists: [
|
||||
{
|
||||
id: "list-1",
|
||||
projectId: "project-1",
|
||||
distributionBoardId: "board-1",
|
||||
name: "UV 1 Stromkreisliste",
|
||||
},
|
||||
],
|
||||
circuitSections: [
|
||||
{
|
||||
id: "section-1",
|
||||
circuitListId: "list-1",
|
||||
key: "lighting",
|
||||
displayName: "Beleuchtung 1",
|
||||
prefix: "-1F1.",
|
||||
sortOrder: 10,
|
||||
category: "lighting" as const,
|
||||
groupNumber: 1,
|
||||
},
|
||||
],
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "section-1",
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
displayName: "Reserve",
|
||||
sortOrder: 10,
|
||||
protectionType: null,
|
||||
protectionRatedCurrent: null,
|
||||
protectionCharacteristic: null,
|
||||
cableType: null,
|
||||
cableCrossSection: null,
|
||||
cableLength: null,
|
||||
rcdAssignment: null,
|
||||
terminalDesignation: null,
|
||||
voltage: 230,
|
||||
controlRequirement: null,
|
||||
status: null,
|
||||
isReserve: true,
|
||||
remark: null,
|
||||
deviceRows: [],
|
||||
},
|
||||
],
|
||||
distributionBoardComponents: [
|
||||
{
|
||||
id: "component-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "section-1",
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection" as const,
|
||||
placement: "group" as const,
|
||||
sortOrder: 10,
|
||||
},
|
||||
],
|
||||
circuitProtectionDevices: [
|
||||
{
|
||||
circuitId: "circuit-1",
|
||||
type: "LS" as const,
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B" as const,
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
},
|
||||
],
|
||||
distributionBoardComponentProtectionDevices: [
|
||||
{
|
||||
componentId: "component-1",
|
||||
type: "FI" as const,
|
||||
ratedCurrentA: 40,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: null,
|
||||
rcdType: "A" as const,
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("project state snapshot model", () => {
|
||||
it("round-trips a versioned logical project state", () => {
|
||||
const snapshot = parseProjectStateSnapshot(minimalSnapshot());
|
||||
@@ -52,7 +160,7 @@ describe("project state snapshot model", () => {
|
||||
|
||||
it("upgrades version-one snapshots with empty project metadata", () => {
|
||||
const legacy = {
|
||||
...minimalSnapshot(),
|
||||
...legacyMinimalSnapshot(),
|
||||
schemaVersion: 1 as const,
|
||||
project: {
|
||||
id: "project-1",
|
||||
@@ -82,7 +190,7 @@ describe("project state snapshot model", () => {
|
||||
|
||||
it("upgrades version-two distribution boards without inferred assignments", () => {
|
||||
const previous = {
|
||||
...minimalSnapshot(),
|
||||
...legacyMinimalSnapshot(),
|
||||
schemaVersion: 2 as const,
|
||||
project: {
|
||||
...minimalSnapshot().project,
|
||||
@@ -110,7 +218,7 @@ describe("project state snapshot model", () => {
|
||||
|
||||
it("upgrades version-three snapshots with all project supply types", () => {
|
||||
const previous = {
|
||||
...minimalSnapshot(),
|
||||
...legacyMinimalSnapshot(),
|
||||
schemaVersion: 3 as const,
|
||||
project: {
|
||||
...minimalSnapshot().project,
|
||||
@@ -131,7 +239,7 @@ describe("project state snapshot model", () => {
|
||||
|
||||
it("normalizes version-four device voltages to project settings", () => {
|
||||
const previous = {
|
||||
...minimalSnapshot(),
|
||||
...legacyMinimalSnapshot(),
|
||||
schemaVersion: 4 as const,
|
||||
projectDevices: [
|
||||
{
|
||||
@@ -161,7 +269,7 @@ describe("project state snapshot model", () => {
|
||||
|
||||
it("upgrades version-five distribution boards with a neutral factor", () => {
|
||||
const previous = {
|
||||
...minimalSnapshot(),
|
||||
...legacyMinimalSnapshot(),
|
||||
schemaVersion: 5 as const,
|
||||
distributionBoards: [
|
||||
{
|
||||
@@ -183,6 +291,116 @@ describe("project state snapshot model", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("upgrades version-six sections without inventing component data", () => {
|
||||
const previous = {
|
||||
...legacyMinimalSnapshot(),
|
||||
schemaVersion: 6 as const,
|
||||
circuitSections: [
|
||||
{
|
||||
id: "section-lighting",
|
||||
circuitListId: "list-1",
|
||||
key: "lighting",
|
||||
displayName: "Beleuchtung",
|
||||
prefix: "-1F",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: "section-unassigned",
|
||||
circuitListId: "list-1",
|
||||
key: "unassigned",
|
||||
displayName: "Nicht zugeordnet",
|
||||
prefix: "-UF",
|
||||
sortOrder: 90,
|
||||
},
|
||||
],
|
||||
distributionBoards: [
|
||||
{
|
||||
id: "board-1",
|
||||
projectId: "project-1",
|
||||
name: "UV 1",
|
||||
floorId: null,
|
||||
supplyType: null,
|
||||
simultaneityFactor: 1,
|
||||
},
|
||||
],
|
||||
circuitLists: [
|
||||
{
|
||||
id: "list-1",
|
||||
projectId: "project-1",
|
||||
distributionBoardId: "board-1",
|
||||
name: "UV 1 Stromkreisliste",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const snapshot = parseProjectStateSnapshot(previous);
|
||||
|
||||
assert.deepEqual(
|
||||
snapshot.circuitSections.map(
|
||||
({ key, category, groupNumber }) => ({
|
||||
key,
|
||||
category,
|
||||
groupNumber,
|
||||
})
|
||||
),
|
||||
[
|
||||
{
|
||||
key: "lighting",
|
||||
category: "lighting",
|
||||
groupNumber: 1,
|
||||
},
|
||||
{
|
||||
key: "unassigned",
|
||||
category: null,
|
||||
groupNumber: null,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert.deepEqual(snapshot.distributionBoardComponents, []);
|
||||
assert.deepEqual(snapshot.circuitProtectionDevices, []);
|
||||
assert.deepEqual(
|
||||
snapshot.distributionBoardComponentProtectionDevices,
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
it("validates the complete group, component and protection graph", () => {
|
||||
const snapshot = parseProjectStateSnapshot(protectedSnapshot());
|
||||
|
||||
assert.equal(snapshot.circuitSections[0]?.groupNumber, 1);
|
||||
assert.equal(
|
||||
snapshot.distributionBoardComponents[0]?.sectionId,
|
||||
"section-1"
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.circuitProtectionDevices[0]?.tripCharacteristic,
|
||||
"B"
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.distributionBoardComponentProtectionDevices[0]
|
||||
?.ratedResidualCurrentMa,
|
||||
30
|
||||
);
|
||||
|
||||
const duplicateBmk = protectedSnapshot();
|
||||
duplicateBmk.distributionBoardComponents[0]!.equipmentIdentifier =
|
||||
" -1f1.1 ";
|
||||
assert.throws(
|
||||
() => parseProjectStateSnapshot(duplicateBmk),
|
||||
/duplicate equipment identifiers/
|
||||
);
|
||||
|
||||
const invalidProtection = protectedSnapshot();
|
||||
invalidProtection.circuitProtectionDevices[0] = {
|
||||
...invalidProtection.circuitProtectionDevices[0]!,
|
||||
ratedCurrentA: 35,
|
||||
};
|
||||
assert.throws(
|
||||
() => parseProjectStateSnapshot(invalidProtection),
|
||||
/protection-device configuration is invalid/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate ids and cross-project ownership", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -115,6 +115,82 @@ describe("project version history presentation", () => {
|
||||
),
|
||||
"Projekteinstellungen bearbeitet"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(5, {
|
||||
commandType: "distribution-board-component.insert",
|
||||
})
|
||||
),
|
||||
"Verteilergerät angelegt"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(6, {
|
||||
commandType: "distribution-board-component.delete",
|
||||
})
|
||||
),
|
||||
"Verteilergerät entfernt"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(7, {
|
||||
commandType: "distribution-board-component.update",
|
||||
})
|
||||
),
|
||||
"Verteilergerät bearbeitet"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(8, { commandType: "circuit-group.insert" })
|
||||
),
|
||||
"Stromkreisgruppe angelegt"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(9, { commandType: "circuit-group.delete" })
|
||||
),
|
||||
"Stromkreisgruppe entfernt"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(10, { commandType: "circuit-group.update" })
|
||||
),
|
||||
"Stromkreisgruppe bearbeitet"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(11, { commandType: "circuit-group.reorder" })
|
||||
),
|
||||
"Stromkreisgruppen sortiert"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(12, { commandType: "circuit-group.renumber" })
|
||||
),
|
||||
"Stromkreisgruppen neu nummeriert"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(13, { commandType: "circuit.move-group" })
|
||||
),
|
||||
"Stromkreis in andere Gruppe verschoben"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(14, {
|
||||
commandType: "circuit-group.delete-subtree",
|
||||
})
|
||||
),
|
||||
"Stromkreisgruppe vollständig entfernt"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(15, {
|
||||
commandType: "circuit-group.restore-subtree",
|
||||
})
|
||||
),
|
||||
"Stromkreisgruppe vollständig wiederhergestellt"
|
||||
);
|
||||
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
|
||||
assert.equal(
|
||||
getProjectSnapshotKindLabel("automatic"),
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
allowedRatedCurrentsAByProtectionDeviceType,
|
||||
breakerTripCharacteristics,
|
||||
fuseUtilizationCategories,
|
||||
protectionDeviceTypes,
|
||||
ratedResidualCurrentsMa,
|
||||
rcdTypes,
|
||||
} from "../src/shared/constants/protection-device.js";
|
||||
import {
|
||||
createDefaultCircuitProtection,
|
||||
createDefaultGroupRcd,
|
||||
} from "../src/domain/services/protection-device-defaults.js";
|
||||
import { protectionDeviceConfigurationSchema } from "../src/shared/validation/protection-device.schemas.js";
|
||||
|
||||
describe("protection device catalog", () => {
|
||||
it("contains exactly the agreed protection device types", () => {
|
||||
assert.deepEqual(protectionDeviceTypes, [
|
||||
"D02",
|
||||
"NH000",
|
||||
"NH00",
|
||||
"NH0",
|
||||
"NH1",
|
||||
"NH2",
|
||||
"NH3",
|
||||
"LS",
|
||||
"FI",
|
||||
"FI_LS",
|
||||
"AFDD",
|
||||
]);
|
||||
assert.equal("D01" in allowedRatedCurrentsAByProtectionDeviceType, false);
|
||||
assert.equal("NH4" in allowedRatedCurrentsAByProtectionDeviceType, false);
|
||||
});
|
||||
|
||||
it("exposes the agreed device-specific rated currents", () => {
|
||||
assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.D02, [
|
||||
20, 25, 32, 35, 40, 50, 63,
|
||||
]);
|
||||
assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.NH000, [
|
||||
6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
allowedRatedCurrentsAByProtectionDeviceType.NH00,
|
||||
allowedRatedCurrentsAByProtectionDeviceType.NH000
|
||||
);
|
||||
assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.NH0, [
|
||||
6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224,
|
||||
250,
|
||||
]);
|
||||
assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.NH1, [
|
||||
16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250,
|
||||
]);
|
||||
assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.NH2, [
|
||||
25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250, 300, 315,
|
||||
355, 400,
|
||||
]);
|
||||
assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.NH3, [
|
||||
50, 63, 80, 100, 125, 160, 200, 224, 250, 315, 355, 400, 500, 630,
|
||||
800,
|
||||
]);
|
||||
assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.FI, [
|
||||
16, 25, 40, 63, 80, 100, 125,
|
||||
]);
|
||||
assert.deepEqual(allowedRatedCurrentsAByProtectionDeviceType.LS, [
|
||||
6, 10, 13, 16, 20, 25, 32, 40, 50, 63, 80, 100, 125,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
allowedRatedCurrentsAByProtectionDeviceType.FI_LS,
|
||||
allowedRatedCurrentsAByProtectionDeviceType.LS
|
||||
);
|
||||
assert.deepEqual(
|
||||
allowedRatedCurrentsAByProtectionDeviceType.AFDD,
|
||||
allowedRatedCurrentsAByProtectionDeviceType.LS
|
||||
);
|
||||
assert.deepEqual(fuseUtilizationCategories, ["gG", "gR"]);
|
||||
assert.deepEqual(breakerTripCharacteristics, ["B", "C", "D", "Z"]);
|
||||
assert.deepEqual(rcdTypes, ["A", "AC", "B", "B+"]);
|
||||
assert.deepEqual(ratedResidualCurrentsMa, [10, 30, 100, 300, 500]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("protection device validation", () => {
|
||||
it("accepts valid fuse, breaker, RCD, combined and AFDD configurations", () => {
|
||||
const configurations = [
|
||||
{
|
||||
type: "D02",
|
||||
ratedCurrentA: 20,
|
||||
fuseUtilizationCategory: "gG",
|
||||
},
|
||||
{
|
||||
type: "NH3",
|
||||
ratedCurrentA: 800,
|
||||
fuseUtilizationCategory: "gR",
|
||||
},
|
||||
{
|
||||
type: "LS",
|
||||
ratedCurrentA: 13,
|
||||
tripCharacteristic: "Z",
|
||||
},
|
||||
{
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
{
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
{
|
||||
type: "AFDD",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
},
|
||||
];
|
||||
|
||||
for (const configuration of configurations) {
|
||||
const result =
|
||||
protectionDeviceConfigurationSchema.safeParse(configuration);
|
||||
assert.equal(
|
||||
result.success,
|
||||
true,
|
||||
result.success
|
||||
? undefined
|
||||
: `${configuration.type}: ${result.error.message}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects rated currents that do not belong to the selected type", () => {
|
||||
assert.equal(
|
||||
protectionDeviceConfigurationSchema.safeParse({
|
||||
type: "D02",
|
||||
ratedCurrentA: 16,
|
||||
fuseUtilizationCategory: "gG",
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("requires and forbids fields according to device function", () => {
|
||||
assert.equal(
|
||||
protectionDeviceConfigurationSchema.safeParse({
|
||||
type: "LS",
|
||||
ratedCurrentA: 16,
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
protectionDeviceConfigurationSchema.safeParse({
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
protectionDeviceConfigurationSchema.safeParse({
|
||||
type: "AFDD",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("protection device defaults", () => {
|
||||
it("creates the agreed circuit defaults", () => {
|
||||
assert.deepEqual(createDefaultCircuitProtection("lighting"), {
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
tripCharacteristic: "B",
|
||||
});
|
||||
assert.deepEqual(createDefaultCircuitProtection("single_phase"), {
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
});
|
||||
assert.deepEqual(createDefaultCircuitProtection("three_phase"), {
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a 40 A, type A, 30 mA group RCD", () => {
|
||||
assert.deepEqual(createDefaultGroupRcd(), {
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns independent default objects", () => {
|
||||
const first = createDefaultCircuitProtection("lighting");
|
||||
const second = createDefaultCircuitProtection("lighting");
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AppDatabase } from "../../src/db/database-context.js";
|
||||
import { circuitLists } from "../../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../../src/db/schema/circuit-sections.js";
|
||||
import { distributionBoardComponents } from "../../src/db/schema/distribution-board-components.js";
|
||||
import { distributionBoards } from "../../src/db/schema/distribution-boards.js";
|
||||
import { createDistributionBoardStructureSnapshot } from "../../src/domain/models/distribution-board-structure-project-command.model.js";
|
||||
|
||||
@@ -21,6 +22,9 @@ export class DistributionBoardFixtureRepository {
|
||||
.run();
|
||||
tx.insert(circuitLists).values(structure.circuitList).run();
|
||||
tx.insert(circuitSections).values(structure.sections).run();
|
||||
tx.insert(distributionBoardComponents)
|
||||
.values(structure.components)
|
||||
.run();
|
||||
});
|
||||
return structure.distributionBoard;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user