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
|
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||||
project data atomically and records a new `restore` revision with a complete
|
project data atomically and records a new `restore` revision with a complete
|
||||||
inverse command. Restore can therefore be undone and redone after a restart.
|
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
|
The central revision boundary creates an automatic logical snapshot after each
|
||||||
25 new revisions and retains only the newest 12 automatic snapshots per
|
25 new revisions and retains only the newest 12 automatic snapshots per
|
||||||
project. Named snapshots are never removed by this retention policy.
|
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`,
|
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
|
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
|
||||||
cannot be disabled.
|
cannot be disabled.
|
||||||
Distribution-board setup uses `distribution-board.insert` with a complete
|
Distribution-board setup uses `distribution-board.insert` schema version 3
|
||||||
stable snapshot of the board, circuit list and four default sections. Its
|
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
|
inverse removes only the same unchanged and still-empty structure; the POST
|
||||||
route requires `expectedRevision` and returns the updated history state.
|
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 floor assignment and a project-enabled supply type use
|
||||||
`distribution-board.update`; both values are snapshot/export fields and one
|
`distribution-board.update`; both values are snapshot/export fields and one
|
||||||
persistent undo step.
|
persistent undo step.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ Limitierungen und Roadmap.
|
|||||||
- [Datenmodellkonzept](spec/03-data-model-concept.md)
|
- [Datenmodellkonzept](spec/03-data-model-concept.md)
|
||||||
- [UI- und Interaktionsanforderungen](spec/04-ui-interaction-requirements.md)
|
- [UI- und Interaktionsanforderungen](spec/04-ui-interaction-requirements.md)
|
||||||
- [Verknüpfte Projektgeräte und Synchronisierung](spec/05-linked-devices-and-sync.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
|
## 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
|
- `supplyType` is one of `AV`, `SV`, `EV`, `USV`, `MSR` or `SiBe` and must
|
||||||
be enabled in the project settings
|
be enabled in the project settings
|
||||||
- executes `distribution-board.insert` with stable ids for the distribution
|
- 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:
|
- response:
|
||||||
`{ "distributionBoard": { ... }, "revision": { ... }, "history": { ... } }`
|
`{ "distributionBoard": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
- persistent Undo removes only the unchanged and still-empty generated
|
- persistent Undo removes only the unchanged and still-empty generated
|
||||||
structure; Redo restores the same ids
|
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`
|
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
||||||
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
||||||
- body:
|
- body:
|
||||||
@@ -227,7 +230,11 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
|||||||
### Tree Endpoint
|
### Tree Endpoint
|
||||||
|
|
||||||
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
- `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:
|
Response sketch:
|
||||||
|
|
||||||
@@ -235,16 +242,47 @@ Response sketch:
|
|||||||
{
|
{
|
||||||
"circuitListId": "cl_1",
|
"circuitListId": "cl_1",
|
||||||
"currentRevision": 12,
|
"currentRevision": 12,
|
||||||
|
"headerComponents": [
|
||||||
|
{
|
||||||
|
"id": "cmp_main",
|
||||||
|
"equipmentIdentifier": "-Q0",
|
||||||
|
"name": "Hauptschalter",
|
||||||
|
"role": "main_switch",
|
||||||
|
"placement": "header"
|
||||||
|
}
|
||||||
|
],
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
"id": "sec_1",
|
"id": "sec_1",
|
||||||
"key": "lighting",
|
"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": [
|
"circuits": [
|
||||||
{
|
{
|
||||||
"id": "cir_1",
|
"id": "cir_1",
|
||||||
"equipmentIdentifier": "-1F1",
|
"equipmentIdentifier": "-1F1.1",
|
||||||
"circuitTotalPower": 4.2,
|
"circuitTotalPower": 4.2,
|
||||||
|
"protectionDevice": {
|
||||||
|
"type": "LS",
|
||||||
|
"ratedCurrentA": 10,
|
||||||
|
"tripCharacteristic": "B"
|
||||||
|
},
|
||||||
"deviceRows": [
|
"deviceRows": [
|
||||||
{
|
{
|
||||||
"id": "row_1",
|
"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`
|
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
|
||||||
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
||||||
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
||||||
Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu
|
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
|
||||||
zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
|
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
|
Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen
|
||||||
innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die
|
innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die
|
||||||
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
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
|
deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das
|
||||||
Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den
|
Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den
|
||||||
projektgebundenen Endpunkt beschränkt.
|
projektgebundenen Endpunkt beschränkt.
|
||||||
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
`distribution-board.insert` versioniert die Anlage einer Verteilung mit
|
||||||
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
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.
|
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
||||||
`distribution-board.delete` ist die persistierte Inverse und entfernt nur den
|
`distribution-board.delete` ist die persistierte Inverse und entfernt nur den
|
||||||
vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
|
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
|
Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und
|
||||||
nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
|
nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
|
||||||
Freigabe der Netzart in den Projekteinstellungen.
|
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
|
`distribution-board.update` versioniert Etage, Netzart und den
|
||||||
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
||||||
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
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
|
gespeicherte Version-1-Strukturcommands werden ohne erfundene Zuordnung
|
||||||
hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als
|
hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als
|
||||||
Projektauswahl hochgestuft.
|
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
|
`project-floor.insert` und `project-room.insert` versionieren die Anlage von
|
||||||
Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden
|
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
|
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
|
- Die vollständige Verteilungs-Testfixture liegt unter
|
||||||
`tests/support/distribution-board-fixture.ts` und ist kein exportierter
|
`tests/support/distribution-board-fixture.ts` und ist kein exportierter
|
||||||
Produktions-Schreibweg.
|
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
|
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
||||||
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
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/03-data-model-concept.md` – Datenmodellkonzept
|
||||||
- `docs/spec/05-linked-devices-and-sync.md` – Regeln für Projektgeräte und
|
- `docs/spec/05-linked-devices-and-sync.md` – Regeln für Projektgeräte und
|
||||||
kontrollierte Synchronisierung
|
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
|
- `docs/circuit-list-editor-known-limitations.md` – noch nicht implementierte
|
||||||
Funktionen
|
Funktionen
|
||||||
|
|||||||
@@ -496,7 +496,41 @@ Acceptance criteria:
|
|||||||
- restoring an older state does not delete intervening history
|
- restoring an older state does not delete intervening history
|
||||||
- concurrent stale commands cannot silently overwrite newer project state
|
- 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:
|
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.
|
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:
|
Goal:
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,39 @@ requirements and intended sequencing, not proof of implementation.
|
|||||||
- Collect the remaining editor interaction issues before changing additional
|
- Collect the remaining editor interaction issues before changing additional
|
||||||
behavior.
|
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
|
## First Release Baseline
|
||||||
|
|
||||||
Complete this immediately before the first supported release, once the schema
|
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.
|
3. [x] Snapshot-to-revision descriptions and history presentation.
|
||||||
4. [x] Distribution-board power summary and project column layout.
|
4. [x] Distribution-board power summary and project column layout.
|
||||||
|
|
||||||
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
|
The Revit/CSV/IFCGUID round-trip follows the distribution-board component
|
||||||
must not be inferred from these tasks.
|
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",
|
"build:web": "next build",
|
||||||
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
|
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
|
||||||
"start": "node dist/server/index.js",
|
"start": "node dist/server/index.js",
|
||||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.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/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:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "tsx scripts/db-backup.ts",
|
"db:backup": "tsx scripts/db-backup.ts",
|
||||||
|
|||||||
@@ -8,12 +8,21 @@ const requiredTables = [
|
|||||||
"circuit_sections",
|
"circuit_sections",
|
||||||
"circuits",
|
"circuits",
|
||||||
"circuit_device_rows",
|
"circuit_device_rows",
|
||||||
|
"circuit_list_equipment_identifiers",
|
||||||
|
"circuit_protection_devices",
|
||||||
|
"distribution_board_components",
|
||||||
|
"distribution_board_component_protection_devices",
|
||||||
"legacy_consumer_circuit_migrations",
|
"legacy_consumer_circuit_migrations",
|
||||||
"legacy_consumer_migration_reports",
|
"legacy_consumer_migration_reports",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const tablePlaceholders = requiredTables.map(() => "?").join(", ");
|
||||||
const rows = db
|
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);
|
.all(...requiredTables);
|
||||||
|
|
||||||
const existing = new Set(rows.map((row) => row.name));
|
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)
|
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
|
||||||
);
|
);
|
||||||
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(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 = [
|
const requiredProjectColumns = [
|
||||||
"internal_project_number",
|
"internal_project_number",
|
||||||
"external_project_number",
|
"external_project_number",
|
||||||
@@ -61,7 +80,11 @@ const projectColumns = new Set(
|
|||||||
const missingProjectColumns = requiredProjectColumns.filter(
|
const missingProjectColumns = requiredProjectColumns.filter(
|
||||||
(name) => !projectColumns.has(name)
|
(name) => !projectColumns.has(name)
|
||||||
);
|
);
|
||||||
const requiredDistributionBoardColumns = ["floor_id", "supply_type"];
|
const requiredDistributionBoardColumns = [
|
||||||
|
"floor_id",
|
||||||
|
"supply_type",
|
||||||
|
"simultaneity_factor",
|
||||||
|
];
|
||||||
const distributionBoardColumns = new Set(
|
const distributionBoardColumns = new Set(
|
||||||
db
|
db
|
||||||
.prepare("PRAGMA table_info(distribution_boards)")
|
.prepare("PRAGMA table_info(distribution_boards)")
|
||||||
@@ -72,6 +95,28 @@ const missingDistributionBoardColumns =
|
|||||||
requiredDistributionBoardColumns.filter(
|
requiredDistributionBoardColumns.filter(
|
||||||
(name) => !distributionBoardColumns.has(name)
|
(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 integrityCheck = db.pragma("integrity_check", { simple: true });
|
||||||
const foreignKeyViolations = db.pragma("foreign_key_check");
|
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("Existing tables:", [...existing].join(", ") || "(none)");
|
||||||
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
|
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
|
||||||
console.log("Required circuit columns:", requiredCircuitColumns.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 project columns:", requiredProjectColumns.join(", "));
|
||||||
console.log(
|
console.log(
|
||||||
"Required distribution-board columns:",
|
"Required distribution-board columns:",
|
||||||
requiredDistributionBoardColumns.join(", ")
|
requiredDistributionBoardColumns.join(", ")
|
||||||
);
|
);
|
||||||
|
console.log(
|
||||||
|
"Required BMK synchronization triggers:",
|
||||||
|
requiredEquipmentIdentifierTriggers.join(", ")
|
||||||
|
);
|
||||||
console.log("Integrity:", integrityCheck);
|
console.log("Integrity:", integrityCheck);
|
||||||
console.log("Foreign-key violations:", foreignKeyViolations.length);
|
console.log("Foreign-key violations:", foreignKeyViolations.length);
|
||||||
|
|
||||||
@@ -111,6 +164,14 @@ if (missingCircuitColumns.length > 0) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (missingCircuitSectionColumns.length > 0) {
|
||||||
|
console.error(
|
||||||
|
"Missing circuit-section columns:",
|
||||||
|
missingCircuitSectionColumns.join(", ")
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (missingProjectColumns.length > 0) {
|
if (missingProjectColumns.length > 0) {
|
||||||
console.error("Missing project columns:", missingProjectColumns.join(", "));
|
console.error("Missing project columns:", missingProjectColumns.join(", "));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -124,9 +185,17 @@ if (missingDistributionBoardColumns.length > 0) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (missingEquipmentIdentifierTriggers.length > 0) {
|
||||||
|
console.error(
|
||||||
|
"Missing BMK synchronization triggers:",
|
||||||
|
missingEquipmentIdentifierTriggers.join(", ")
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (integrityCheck !== "ok" || foreignKeyViolations.length > 0) {
|
if (integrityCheck !== "ok" || foreignKeyViolations.length > 0) {
|
||||||
console.error("Database integrity or foreign-key verification failed.");
|
console.error("Database integrity or foreign-key verification failed.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Circuit-first schema verification passed.");
|
console.log("Circuit-first and distribution-component schema verification passed.");
|
||||||
|
|||||||
+2
-296
@@ -1,303 +1,9 @@
|
|||||||
/* Design-System "Petrol · Volt · Kupfer" v2.1 (H:\design-system-econsult.md),
|
|
||||||
projektübergreifendes Update für die Econsult-Familie (siehe elt-planung-suite).
|
|
||||||
Bootstrap bleibt Basis für Formulare/Tabellen/Modals; wir tönen die
|
|
||||||
vorhandenen Bootstrap-Komponenten über deren eigene --bs-btn-*-Variablen
|
|
||||||
um (globals.css lädt nach bootstrap.min.css, gleiche Spezifität gewinnt
|
|
||||||
per Ladereihenfolge) statt Bootstrap zu ersetzen. */
|
|
||||||
:root {
|
|
||||||
--color-primary: #1c3f52; /* Petrol */
|
|
||||||
--color-primary-dark: #0f2733; /* Petrol Dark */
|
|
||||||
--color-primary-deep: #0a1920; /* Petrol Deep */
|
|
||||||
--color-accent: #4e7e93; /* Petrol Light */
|
|
||||||
--color-accent-pale: #9dbac9; /* Petrol Pale */
|
|
||||||
--color-signal: #3fa66b; /* Volt - generelle aktiv/positiv-Farbe */
|
|
||||||
--color-signal-dark: #237a47;
|
|
||||||
--color-attention: #c97a3b; /* Kupfer - bewusst selten, "braucht Aufmerksamkeit" */
|
|
||||||
--color-attention-dark: #9c5a26;
|
|
||||||
--color-ink: #182229; /* Graphite */
|
|
||||||
--color-ink-soft: #59666d; /* Slate */
|
|
||||||
--color-bg: #ffffff;
|
|
||||||
--color-surface: #f1f2ee; /* Fog */
|
|
||||||
--color-border: #dee2dc;
|
|
||||||
--color-danger: #b04035;
|
|
||||||
--color-danger-bg: #f7e6e3;
|
|
||||||
--color-warn: #a9791e;
|
|
||||||
|
|
||||||
--font-head: system-ui, "Segoe UI", "Helvetica Neue", sans-serif;
|
|
||||||
--font-body: system-ui, "Segoe UI", "Helvetica Neue", sans-serif;
|
|
||||||
|
|
||||||
--space-xs: 4px;
|
|
||||||
--space-sm: 8px;
|
|
||||||
--space-md: 16px;
|
|
||||||
--space-lg: 24px;
|
|
||||||
--space-xl: 40px;
|
|
||||||
|
|
||||||
--radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-surface);
|
background-color: #f5f7fb;
|
||||||
color: var(--color-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bootstrap-Einfärbung auf Petrol/Volt/Kupfer ────────────────────────── */
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
--bs-btn-bg: var(--color-primary);
|
|
||||||
--bs-btn-border-color: var(--color-primary);
|
|
||||||
--bs-btn-hover-bg: var(--color-primary-dark);
|
|
||||||
--bs-btn-hover-border-color: var(--color-primary-dark);
|
|
||||||
--bs-btn-active-bg: var(--color-primary-deep);
|
|
||||||
--bs-btn-active-border-color: var(--color-primary-deep);
|
|
||||||
--bs-btn-disabled-bg: var(--color-primary);
|
|
||||||
--bs-btn-disabled-border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-primary {
|
|
||||||
--bs-btn-color: var(--color-primary);
|
|
||||||
--bs-btn-border-color: var(--color-primary);
|
|
||||||
--bs-btn-hover-bg: var(--color-primary);
|
|
||||||
--bs-btn-hover-border-color: var(--color-primary);
|
|
||||||
--bs-btn-active-bg: var(--color-primary-dark);
|
|
||||||
--bs-btn-active-border-color: var(--color-primary-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success,
|
|
||||||
.btn-outline-success {
|
|
||||||
--bs-btn-bg: var(--color-signal);
|
|
||||||
--bs-btn-border-color: var(--color-signal);
|
|
||||||
--bs-btn-color: #fff;
|
|
||||||
--bs-btn-hover-bg: var(--color-signal-dark);
|
|
||||||
--bs-btn-hover-border-color: var(--color-signal-dark);
|
|
||||||
--bs-btn-active-bg: var(--color-signal-dark);
|
|
||||||
--bs-btn-active-border-color: var(--color-signal-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-success {
|
|
||||||
--bs-btn-bg: transparent;
|
|
||||||
--bs-btn-color: var(--color-signal-dark);
|
|
||||||
--bs-btn-hover-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus,
|
|
||||||
.form-select:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(28, 63, 82, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check-input:checked {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-bg-secondary {
|
|
||||||
background-color: var(--color-surface) !important;
|
|
||||||
color: var(--color-ink-soft) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-success {
|
|
||||||
--bs-alert-color: var(--color-signal-dark);
|
|
||||||
--bs-alert-bg: #eaf6f0;
|
|
||||||
--bs-alert-border-color: #b6e2ce;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── App-Shell / Seitenleiste (nur auf der Projektseite) ────────────────── */
|
|
||||||
|
|
||||||
.app-shell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 260px 1fr;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
background: linear-gradient(
|
|
||||||
160deg,
|
|
||||||
var(--color-primary-deep) 0%,
|
|
||||||
var(--color-primary-dark) 45%,
|
|
||||||
var(--color-primary) 100%
|
|
||||||
);
|
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
height: 100vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-brand {
|
|
||||||
padding: 22px 22px 18px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-wordmark {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-bar {
|
|
||||||
height: 3px;
|
|
||||||
background: linear-gradient(90deg, var(--color-signal), var(--color-attention));
|
|
||||||
margin-top: 9px;
|
|
||||||
width: 42px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tag {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.13em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-accent-pale);
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-section-label {
|
|
||||||
font-size: 0.66rem;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(199, 214, 224, 0.7);
|
|
||||||
padding: 16px 14px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #c7d6e0;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-link:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-link.active {
|
|
||||||
background: rgba(255, 255, 255, 0.14);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: inset 3px 0 0 var(--color-signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-icon {
|
|
||||||
width: 1.1em;
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell-content {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-md);
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kicker {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-accent);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── KPI-Kacheln ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.kpi-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
||||||
gap: var(--space-md);
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi {
|
|
||||||
background: var(--color-bg);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 9px;
|
|
||||||
padding: 13px 15px;
|
|
||||||
display: block;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: box-shadow 0.15s, transform 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.kpi:hover {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
box-shadow: 0 3px 8px rgba(15, 39, 51, 0.07), 0 16px 32px -14px rgba(15, 39, 51, 0.28);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-label {
|
|
||||||
font-size: 0.66rem;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-ink-soft);
|
|
||||||
margin-bottom: var(--space-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-value {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.1;
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
border-radius: var(--radius);
|
border-radius: 0.5rem;
|
||||||
border-top: 3px solid var(--color-primary);
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(15, 39, 51, 0.05),
|
|
||||||
0 10px 24px -14px rgba(15, 39, 51, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-primary);
|
|
||||||
background: var(--color-bg);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table td input.form-control-sm,
|
.table td input.form-control-sm,
|
||||||
|
|||||||
+1
-4
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "bootstrap/dist/css/bootstrap.min.css";
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AppShell } from "../frontend/components/AppShell";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Leistungsbilanz",
|
title: "Leistungsbilanz",
|
||||||
@@ -11,9 +10,7 @@ export const metadata: Metadata = {
|
|||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<body>
|
<body>{children}</body>
|
||||||
<AppShell>{children}</AppShell>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -661,10 +661,9 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container py-4">
|
<main className="container py-4">
|
||||||
<div className="page-header">
|
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="kicker">Projekt</div>
|
<h1 className="h3 mb-1">{project?.name ?? "Projekt"}</h1>
|
||||||
<h1>{project?.name ?? "Projekt"}</h1>
|
|
||||||
<p className="text-secondary mb-0">
|
<p className="text-secondary mb-0">
|
||||||
{project?.internalProjectNumber
|
{project?.internalProjectNumber
|
||||||
? `Projektnummer ${project.internalProjectNumber} · `
|
? `Projektnummer ${project.internalProjectNumber} · `
|
||||||
@@ -689,28 +688,9 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
{error ? <div className="alert alert-warning">{error}</div> : null}
|
{error ? <div className="alert alert-warning">{error}</div> : null}
|
||||||
|
|
||||||
<div className="kpi-grid">
|
|
||||||
<a className="kpi" href="#verteilungen">
|
|
||||||
<div className="kpi-label">Verteilungen</div>
|
|
||||||
<div className="kpi-value">{boardCount}</div>
|
|
||||||
</a>
|
|
||||||
<a className="kpi" href="#etagen">
|
|
||||||
<div className="kpi-label">Etagen</div>
|
|
||||||
<div className="kpi-value">{floorCount}</div>
|
|
||||||
</a>
|
|
||||||
<a className="kpi" href="#raeume">
|
|
||||||
<div className="kpi-label">Räume</div>
|
|
||||||
<div className="kpi-value">{roomCount}</div>
|
|
||||||
</a>
|
|
||||||
<a className="kpi" href="#projektgeraete">
|
|
||||||
<div className="kpi-label">Projektgeräte</div>
|
|
||||||
<div className="kpi-value">{projectDeviceCount}</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row g-4">
|
<div className="row g-4">
|
||||||
{project ? (
|
{project ? (
|
||||||
<section className="col-12" id="verlauf">
|
<section className="col-12">
|
||||||
<ProjectVersionHistory
|
<ProjectVersionHistory
|
||||||
currentRevision={project.currentRevision}
|
currentRevision={project.currentRevision}
|
||||||
onProjectStateChange={() => window.location.reload()}
|
onProjectStateChange={() => window.location.reload()}
|
||||||
@@ -719,7 +699,7 @@ export default function ProjectDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="col-12" id="verteilungen">
|
<section className="col-12">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm">
|
||||||
<div className="card-header d-flex justify-content-between align-items-center">
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -812,7 +792,7 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="col-12 col-lg-5" id="etagen">
|
<section className="col-12 col-lg-5">
|
||||||
<div className="card shadow-sm h-100">
|
<div className="card shadow-sm h-100">
|
||||||
<div className="card-header d-flex justify-content-between align-items-center">
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -840,7 +820,7 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="col-12 col-lg-7" id="raeume">
|
<section className="col-12 col-lg-7">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm">
|
||||||
<div className="card-header d-flex justify-content-between align-items-center">
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -886,7 +866,7 @@ export default function ProjectDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="card shadow-sm mt-4" id="projektgeraete">
|
<section className="card shadow-sm mt-4">
|
||||||
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<span>Projektgeräte</span>
|
<span>Projektgeräte</span>
|
||||||
|
|||||||
+54
-35
@@ -8,15 +8,15 @@ import {
|
|||||||
deleteGlobalDevice,
|
deleteGlobalDevice,
|
||||||
importProjectTransferAsNew,
|
importProjectTransferAsNew,
|
||||||
listGlobalDevices,
|
listGlobalDevices,
|
||||||
|
listProjects,
|
||||||
updateGlobalDevice,
|
updateGlobalDevice,
|
||||||
} from "../../frontend/utils/api";
|
} from "../../frontend/utils/api";
|
||||||
import type {
|
import type {
|
||||||
CreateGlobalDeviceInput,
|
CreateGlobalDeviceInput,
|
||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
|
ProjectDto,
|
||||||
} from "../../frontend/types";
|
} from "../../frontend/types";
|
||||||
|
|
||||||
const PROJECTS_CHANGED_EVENT = "leistungsbilanz:projects-changed";
|
|
||||||
|
|
||||||
const emptyGlobalDevice: CreateGlobalDeviceInput = {
|
const emptyGlobalDevice: CreateGlobalDeviceInput = {
|
||||||
name: "",
|
name: "",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
@@ -38,6 +38,7 @@ function toOptionalNumber(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
|
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
||||||
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
|
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
|
||||||
const [projectName, setProjectName] = useState("");
|
const [projectName, setProjectName] = useState("");
|
||||||
const [globalDeviceForm, setGlobalDeviceForm] = useState<Record<string, string>>({
|
const [globalDeviceForm, setGlobalDeviceForm] = useState<Record<string, string>>({
|
||||||
@@ -63,14 +64,12 @@ export default function ProjectsPage() {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [createdProject, setCreatedProject] = useState<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setGlobalDevices(await listGlobalDevices());
|
const [loadedProjects, loadedGlobalDevices] = await Promise.all([listProjects(), listGlobalDevices()]);
|
||||||
|
setProjects(loadedProjects);
|
||||||
|
setGlobalDevices(loadedGlobalDevices);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,12 +85,10 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setCreatedProject(null);
|
|
||||||
try {
|
try {
|
||||||
const created = await createProject(projectName.trim());
|
const created = await createProject(projectName.trim());
|
||||||
setCreatedProject({ id: created.id, name: created.name });
|
setProjects((current) => [...current, created]);
|
||||||
setProjectName("");
|
setProjectName("");
|
||||||
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Projekt konnte nicht erstellt werden.");
|
setError(err instanceof Error ? err.message : "Projekt konnte nicht erstellt werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -126,11 +123,11 @@ export default function ProjectsPage() {
|
|||||||
setImportedProject(null);
|
setImportedProject(null);
|
||||||
try {
|
try {
|
||||||
const imported = await importProjectTransferAsNew(projectImport);
|
const imported = await importProjectTransferAsNew(projectImport);
|
||||||
|
setProjects(await listProjects());
|
||||||
setImportedProject(imported);
|
setImportedProject(imported);
|
||||||
setProjectImport(null);
|
setProjectImport(null);
|
||||||
setProjectImportFilename("");
|
setProjectImportFilename("");
|
||||||
setProjectImportInputKey((current) => current + 1);
|
setProjectImportInputKey((current) => current + 1);
|
||||||
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
@@ -225,20 +222,20 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container py-4">
|
<main className="container py-4">
|
||||||
<div className="page-header">
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="kicker">Leistungsbilanz</div>
|
<h1 className="h3 mb-1">Projekte</h1>
|
||||||
<h1>Projekte</h1>
|
|
||||||
<p className="text-secondary mb-0">Projektübersicht und globale Geräteverwaltung</p>
|
<p className="text-secondary mb-0">Projektübersicht und globale Geräteverwaltung</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <div className="alert alert-warning">{error}</div> : null}
|
{error ? <div className="alert alert-warning">{error}</div> : null}
|
||||||
|
|
||||||
<div className="d-flex flex-column gap-4">
|
<div className="row g-4">
|
||||||
<section className="card shadow-sm">
|
<section className="col-12 col-lg-4">
|
||||||
|
<div className="card shadow-sm mb-4">
|
||||||
<div className="card-header">Neues Projekt</div>
|
<div className="card-header">Neues Projekt</div>
|
||||||
<div className="card-body" style={{ maxWidth: 480 }}>
|
<div className="card-body">
|
||||||
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Projektname</label>
|
<label className="form-label">Projektname</label>
|
||||||
@@ -253,25 +250,11 @@ export default function ProjectsPage() {
|
|||||||
Projekt erstellen
|
Projekt erstellen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{createdProject ? (
|
|
||||||
<div className="alert alert-success mt-3 mb-0">
|
|
||||||
<div className="small mb-2">
|
|
||||||
„{createdProject.name}“ wurde angelegt.
|
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
className="btn btn-sm btn-success"
|
|
||||||
href={`/projects/${createdProject.id}`}
|
|
||||||
>
|
|
||||||
Projekt öffnen
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div className="card shadow-sm">
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card shadow-sm">
|
|
||||||
<div className="card-header">Projekt importieren</div>
|
<div className="card-header">Projekt importieren</div>
|
||||||
<div className="card-body" style={{ maxWidth: 480 }}>
|
<div className="card-body">
|
||||||
<p className="text-secondary small">
|
<p className="text-secondary small">
|
||||||
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
|
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
|
||||||
mit neuen internen IDs angelegt.
|
mit neuen internen IDs angelegt.
|
||||||
@@ -322,9 +305,46 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card shadow-sm">
|
<section className="col-12 col-lg-8">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-header">Alle Projekte</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Projekt</th>
|
||||||
|
<th className="text-end">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<tr key={project.id}>
|
||||||
|
<td>{project.name}</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<Link className="btn btn-sm btn-outline-primary" href={`/projects/${project.id}`}>
|
||||||
|
Projekt öffnen
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!projects.length ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={2} className="text-center text-secondary py-4">
|
||||||
|
Noch keine Projekte vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="card shadow-sm mt-4">
|
||||||
<div className="card-header">Globale Geräte / Verbraucher</div>
|
<div className="card-header">Globale Geräte / Verbraucher</div>
|
||||||
<div className="card-body border-bottom">
|
<div className="card-body border-bottom">
|
||||||
<form className="row g-2" onSubmit={handleCreateGlobalDevice}>
|
<form className="row g-2" onSubmit={handleCreateGlobalDevice}>
|
||||||
@@ -504,7 +524,6 @@ export default function ProjectsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
"when": 1785343645615,
|
||||||
"tag": "0023_cheerful_night_thrasher",
|
"tag": "0023_cheerful_night_thrasher",
|
||||||
"breakpoints": true
|
"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 {
|
import {
|
||||||
assertDistributionBoardDeleteProjectCommand,
|
assertDistributionBoardDeleteProjectCommand,
|
||||||
assertDistributionBoardInsertProjectCommand,
|
assertDistributionBoardInsertProjectCommand,
|
||||||
createDistributionBoardDeleteProjectCommand,
|
|
||||||
createDistributionBoardInsertProjectCommand,
|
|
||||||
distributionBoardDeleteCommandType,
|
distributionBoardDeleteCommandType,
|
||||||
distributionBoardInsertCommandType,
|
distributionBoardInsertCommandType,
|
||||||
|
invertDistributionBoardStructureProjectCommand,
|
||||||
normalizeDistributionBoardStructureProjectCommand,
|
normalizeDistributionBoardStructureProjectCommand,
|
||||||
type DistributionBoardStructureProjectCommand,
|
type DistributionBoardStructureProjectCommand,
|
||||||
type DistributionBoardStructureSnapshot,
|
type NormalizedDistributionBoardStructureSnapshot,
|
||||||
legacyDistributionBoardStructureCommandSchemaVersion,
|
|
||||||
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
assertDistributionBoardUpdateProjectCommand,
|
assertDistributionBoardUpdateProjectCommand,
|
||||||
@@ -28,6 +26,7 @@ import type { AppDatabase } from "../database-context.js";
|
|||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
import { circuitSections } from "../schema/circuit-sections.js";
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
import { floors } from "../schema/floors.js";
|
import { floors } from "../schema/floors.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
@@ -68,31 +67,15 @@ export class DistributionBoardStructureProjectCommandRepository
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
command: DistributionBoardStructureProjectCommand
|
command: DistributionBoardStructureProjectCommand
|
||||||
): DistributionBoardStructureProjectCommand {
|
): DistributionBoardStructureProjectCommand {
|
||||||
const normalized =
|
const structure =
|
||||||
normalizeDistributionBoardStructureProjectCommand(command);
|
normalizeDistributionBoardStructureProjectCommand(command);
|
||||||
if (normalized.type === distributionBoardInsertCommandType) {
|
if (command.type === distributionBoardInsertCommandType) {
|
||||||
assertDistributionBoardInsertProjectCommand(normalized);
|
this.insert(database, projectId, structure);
|
||||||
const inverse = this.insert(
|
return invertDistributionBoardStructureProjectCommand(command);
|
||||||
database,
|
|
||||||
projectId,
|
|
||||||
normalized.payload.structure
|
|
||||||
);
|
|
||||||
return command.schemaVersion ===
|
|
||||||
legacyDistributionBoardStructureCommandSchemaVersion
|
|
||||||
? toLegacyStructureCommand(inverse)
|
|
||||||
: inverse;
|
|
||||||
}
|
}
|
||||||
if (normalized.type === distributionBoardDeleteCommandType) {
|
if (command.type === distributionBoardDeleteCommandType) {
|
||||||
assertDistributionBoardDeleteProjectCommand(normalized);
|
this.delete(database, projectId, structure);
|
||||||
const inverse = this.delete(
|
return invertDistributionBoardStructureProjectCommand(command);
|
||||||
database,
|
|
||||||
projectId,
|
|
||||||
normalized.payload.structure
|
|
||||||
);
|
|
||||||
return command.schemaVersion ===
|
|
||||||
legacyDistributionBoardStructureCommandSchemaVersion
|
|
||||||
? toLegacyStructureCommand(inverse)
|
|
||||||
: inverse;
|
|
||||||
}
|
}
|
||||||
throw new Error("Unsupported distribution-board structure command.");
|
throw new Error("Unsupported distribution-board structure command.");
|
||||||
}
|
}
|
||||||
@@ -100,7 +83,7 @@ export class DistributionBoardStructureProjectCommandRepository
|
|||||||
private insert(
|
private insert(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
structure: DistributionBoardStructureSnapshot
|
structure: NormalizedDistributionBoardStructureSnapshot
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
structure.distributionBoard.projectId !== projectId ||
|
structure.distributionBoard.projectId !== projectId ||
|
||||||
@@ -159,7 +142,26 @@ export class DistributionBoardStructureProjectCommandRepository
|
|||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get();
|
.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.");
|
throw new Error("Distribution-board structure id already exists.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,14 +170,22 @@ export class DistributionBoardStructureProjectCommandRepository
|
|||||||
.values(structure.distributionBoard)
|
.values(structure.distributionBoard)
|
||||||
.run();
|
.run();
|
||||||
database.insert(circuitLists).values(structure.circuitList).run();
|
database.insert(circuitLists).values(structure.circuitList).run();
|
||||||
database.insert(circuitSections).values(structure.sections).run();
|
database
|
||||||
return createDistributionBoardDeleteProjectCommand(structure);
|
.insert(circuitSections)
|
||||||
|
.values(structure.sections)
|
||||||
|
.run();
|
||||||
|
if (structure.components.length > 0) {
|
||||||
|
database
|
||||||
|
.insert(distributionBoardComponents)
|
||||||
|
.values(structure.components)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private delete(
|
private delete(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
structure: DistributionBoardStructureSnapshot
|
structure: NormalizedDistributionBoardStructureSnapshot
|
||||||
) {
|
) {
|
||||||
const board = database
|
const board = database
|
||||||
.select()
|
.select()
|
||||||
@@ -198,11 +208,30 @@ export class DistributionBoardStructureProjectCommandRepository
|
|||||||
asc(circuitSections.id)
|
asc(circuitSections.id)
|
||||||
)
|
)
|
||||||
.all();
|
.all();
|
||||||
|
const components = database
|
||||||
|
.select()
|
||||||
|
.from(distributionBoardComponents)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
distributionBoardComponents.circuitListId,
|
||||||
|
structure.circuitList.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
asc(distributionBoardComponents.sortOrder),
|
||||||
|
asc(distributionBoardComponents.id)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
if (
|
if (
|
||||||
!board ||
|
!board ||
|
||||||
!list ||
|
!list ||
|
||||||
board.projectId !== projectId ||
|
board.projectId !== projectId ||
|
||||||
!structureMatches(structure, { board, list, sections })
|
!structureMatches(structure, {
|
||||||
|
board,
|
||||||
|
list,
|
||||||
|
sections,
|
||||||
|
components,
|
||||||
|
})
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Distribution-board structure changed before deletion."
|
"Distribution-board structure changed before deletion."
|
||||||
@@ -235,7 +264,6 @@ export class DistributionBoardStructureProjectCommandRepository
|
|||||||
if (deleted.changes !== 1) {
|
if (deleted.changes !== 1) {
|
||||||
throw new Error("Distribution board could not be deleted.");
|
throw new Error("Distribution board could not be deleted.");
|
||||||
}
|
}
|
||||||
return createDistributionBoardInsertProjectCommand(structure);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private update(
|
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<
|
function getDistributionBoardFieldValue<
|
||||||
TField extends DistributionBoardUpdateField,
|
TField extends DistributionBoardUpdateField,
|
||||||
>(
|
>(
|
||||||
@@ -382,11 +388,12 @@ function getDistributionBoardFieldValue<
|
|||||||
}
|
}
|
||||||
|
|
||||||
function structureMatches(
|
function structureMatches(
|
||||||
expected: DistributionBoardStructureSnapshot,
|
expected: NormalizedDistributionBoardStructureSnapshot,
|
||||||
actual: {
|
actual: {
|
||||||
board: typeof distributionBoards.$inferSelect;
|
board: typeof distributionBoards.$inferSelect;
|
||||||
list: typeof circuitLists.$inferSelect;
|
list: typeof circuitLists.$inferSelect;
|
||||||
sections: Array<typeof circuitSections.$inferSelect>;
|
sections: Array<typeof circuitSections.$inferSelect>;
|
||||||
|
components: Array<typeof distributionBoardComponents.$inferSelect>;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
@@ -400,7 +407,8 @@ function structureMatches(
|
|||||||
actualDistributionBoard
|
actualDistributionBoard
|
||||||
) ||
|
) ||
|
||||||
!sameRecord(expected.circuitList, actual.list) ||
|
!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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -409,20 +417,32 @@ function structureMatches(
|
|||||||
left.sortOrder - right.sortOrder ||
|
left.sortOrder - right.sortOrder ||
|
||||||
left.id.localeCompare(right.id)
|
left.id.localeCompare(right.id)
|
||||||
);
|
);
|
||||||
return expectedSections.every((section, index) =>
|
const sectionsMatch = expectedSections.every((section, index) =>
|
||||||
sameRecord(section, actual.sections[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(
|
function sameRecord(
|
||||||
expected: Record<string, unknown>,
|
expected: object,
|
||||||
actual: Record<string, unknown>
|
actual: object
|
||||||
) {
|
) {
|
||||||
const expectedEntries = Object.entries(expected);
|
const expectedEntries = Object.entries(expected);
|
||||||
|
const actualRecord = actual as Record<string, unknown>;
|
||||||
return (
|
return (
|
||||||
expectedEntries.length === Object.keys(actual).length &&
|
expectedEntries.length === Object.keys(actual).length &&
|
||||||
expectedEntries.every(
|
expectedEntries.every(
|
||||||
([key, value]) => actual[key] === value
|
([key, value]) => actualRecord[key] === value
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
legacyProjectStateSnapshotSchemaVersion,
|
legacyProjectStateSnapshotSchemaVersion,
|
||||||
previousProjectStateSnapshotSchemaVersion,
|
previousProjectStateSnapshotSchemaVersion,
|
||||||
projectStateSnapshotSchemaVersion,
|
projectStateSnapshotSchemaVersion,
|
||||||
|
simultaneityFactorProjectStateSnapshotSchemaVersion,
|
||||||
supplyTypesProjectStateSnapshotSchemaVersion,
|
supplyTypesProjectStateSnapshotSchemaVersion,
|
||||||
voltageProjectStateSnapshotSchemaVersion,
|
voltageProjectStateSnapshotSchemaVersion,
|
||||||
} from "../../domain/models/project-state-snapshot.model.js";
|
} from "../../domain/models/project-state-snapshot.model.js";
|
||||||
@@ -152,6 +153,8 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
|||||||
supplyTypesProjectStateSnapshotSchemaVersion &&
|
supplyTypesProjectStateSnapshotSchemaVersion &&
|
||||||
stored.schemaVersion !==
|
stored.schemaVersion !==
|
||||||
voltageProjectStateSnapshotSchemaVersion &&
|
voltageProjectStateSnapshotSchemaVersion &&
|
||||||
|
stored.schemaVersion !==
|
||||||
|
simultaneityFactorProjectStateSnapshotSchemaVersion &&
|
||||||
stored.schemaVersion !== projectStateSnapshotSchemaVersion
|
stored.schemaVersion !== projectStateSnapshotSchemaVersion
|
||||||
) {
|
) {
|
||||||
throw new Error("Project snapshot schema version is not supported.");
|
throw new Error("Project snapshot schema version is not supported.");
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ import type {
|
|||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||||
import { circuitSections } from "../schema/circuit-sections.js";
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
import { consumers } from "../schema/consumers.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 { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
import { floors } from "../schema/floors.js";
|
import { floors } from "../schema/floors.js";
|
||||||
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
||||||
@@ -141,6 +144,17 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
|
|||||||
distributionBoards: byId(state.distributionBoards),
|
distributionBoards: byId(state.distributionBoards),
|
||||||
circuitLists: byId(state.circuitLists),
|
circuitLists: byId(state.circuitLists),
|
||||||
circuitSections: byId(state.circuitSections),
|
circuitSections: byId(state.circuitSections),
|
||||||
|
distributionBoardComponents: byId(
|
||||||
|
state.distributionBoardComponents
|
||||||
|
),
|
||||||
|
circuitProtectionDevices: byKey(
|
||||||
|
state.circuitProtectionDevices,
|
||||||
|
"circuitId"
|
||||||
|
),
|
||||||
|
distributionBoardComponentProtectionDevices: byKey(
|
||||||
|
state.distributionBoardComponentProtectionDevices,
|
||||||
|
"componentId"
|
||||||
|
),
|
||||||
circuits: byId(
|
circuits: byId(
|
||||||
state.circuits.map((circuit) => ({
|
state.circuits.map((circuit) => ({
|
||||||
...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(
|
function replaceProjectState(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
state: ProjectStateSnapshot
|
state: ProjectStateSnapshot
|
||||||
@@ -214,6 +237,21 @@ function replaceProjectState(
|
|||||||
isReserve: circuit.isReserve ? 1 : 0,
|
isReserve: circuit.isReserve ? 1 : 0,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
distributionBoardComponents,
|
||||||
|
state.distributionBoardComponents
|
||||||
|
);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
circuitProtectionDevices,
|
||||||
|
state.circuitProtectionDevices
|
||||||
|
);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
distributionBoardComponentProtectionDevices,
|
||||||
|
state.distributionBoardComponentProtectionDevices
|
||||||
|
);
|
||||||
insertMany(
|
insertMany(
|
||||||
database,
|
database,
|
||||||
circuitDeviceRows,
|
circuitDeviceRows,
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import {
|
|||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||||
import { circuitSections } from "../schema/circuit-sections.js";
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
import { circuits } from "../schema/circuits.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 { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
import { floors } from "../schema/floors.js";
|
import { floors } from "../schema/floors.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
@@ -69,6 +72,8 @@ export function readProjectStateSnapshot(
|
|||||||
displayName: circuitSections.displayName,
|
displayName: circuitSections.displayName,
|
||||||
prefix: circuitSections.prefix,
|
prefix: circuitSections.prefix,
|
||||||
sortOrder: circuitSections.sortOrder,
|
sortOrder: circuitSections.sortOrder,
|
||||||
|
category: circuitSections.category,
|
||||||
|
groupNumber: circuitSections.groupNumber,
|
||||||
})
|
})
|
||||||
.from(circuitSections)
|
.from(circuitSections)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
@@ -82,6 +87,56 @@ export function readProjectStateSnapshot(
|
|||||||
asc(circuitSections.id)
|
asc(circuitSections.id)
|
||||||
)
|
)
|
||||||
.all();
|
.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
|
const circuitRows = database
|
||||||
.select({
|
.select({
|
||||||
id: circuits.id,
|
id: circuits.id,
|
||||||
@@ -190,6 +245,10 @@ export function readProjectStateSnapshot(
|
|||||||
distributionBoards: boardRows,
|
distributionBoards: boardRows,
|
||||||
circuitLists: listRows,
|
circuitLists: listRows,
|
||||||
circuitSections: sectionRows,
|
circuitSections: sectionRows,
|
||||||
|
distributionBoardComponents: componentRows,
|
||||||
|
circuitProtectionDevices: circuitProtectionRows,
|
||||||
|
distributionBoardComponentProtectionDevices:
|
||||||
|
componentProtectionRows,
|
||||||
circuits: circuitRows.map((circuit) => ({
|
circuits: circuitRows.map((circuit) => ({
|
||||||
...circuit,
|
...circuit,
|
||||||
isReserve: Boolean(circuit.isReserve),
|
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 type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||||
import { circuitSections } from "../schema/circuit-sections.js";
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
import { circuits } from "../schema/circuits.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 { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
import { floors } from "../schema/floors.js";
|
import { floors } from "../schema/floors.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
@@ -127,6 +130,21 @@ function insertProjectState(
|
|||||||
isReserve: circuit.isReserve ? 1 : 0,
|
isReserve: circuit.isReserve ? 1 : 0,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
distributionBoardComponents,
|
||||||
|
state.distributionBoardComponents
|
||||||
|
);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
circuitProtectionDevices,
|
||||||
|
state.circuitProtectionDevices
|
||||||
|
);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
distributionBoardComponentProtectionDevices,
|
||||||
|
state.distributionBoardComponentProtectionDevices
|
||||||
|
);
|
||||||
insertMany(
|
insertMany(
|
||||||
database,
|
database,
|
||||||
circuitDeviceRows,
|
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 { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||||
import { circuitLists } from "./circuit-lists.js";
|
import { circuitLists } from "./circuit-lists.js";
|
||||||
|
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||||
|
|
||||||
export const circuitSections = sqliteTable(
|
export const circuitSections = sqliteTable(
|
||||||
"circuit_sections",
|
"circuit_sections",
|
||||||
@@ -12,10 +13,17 @@ export const circuitSections = sqliteTable(
|
|||||||
displayName: text("display_name").notNull(),
|
displayName: text("display_name").notNull(),
|
||||||
prefix: text("prefix").notNull(),
|
prefix: text("prefix").notNull(),
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
category: text("category").$type<CircuitGroupCategory>(),
|
||||||
|
groupNumber: integer("group_number"),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
unique("circuit_sections_list_key_unique").on(table.circuitListId, table.key),
|
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_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;
|
remark?: string;
|
||||||
circuitTotalPower: number;
|
circuitTotalPower: number;
|
||||||
deviceRows: CircuitTreeDeviceRow[];
|
deviceRows: CircuitTreeDeviceRow[];
|
||||||
|
protectionDevice?: CircuitTreeProtectionDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CircuitTreeSectionBlock {
|
export interface CircuitTreeSectionBlock {
|
||||||
@@ -52,7 +53,10 @@ export interface CircuitTreeSectionBlock {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
category?: CircuitGroupCategory;
|
||||||
|
groupNumber?: number;
|
||||||
sectionTotalPower: number;
|
sectionTotalPower: number;
|
||||||
|
components: CircuitTreeComponent[];
|
||||||
circuits: CircuitTreeCircuit[];
|
circuits: CircuitTreeCircuit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +68,9 @@ export interface CircuitTreeResponse {
|
|||||||
distributionBoardSimultaneityFactor: number;
|
distributionBoardSimultaneityFactor: number;
|
||||||
distributionBoardTotalPower: number;
|
distributionBoardTotalPower: number;
|
||||||
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||||
|
headerComponents: CircuitTreeComponent[];
|
||||||
sections: CircuitTreeSectionBlock[];
|
sections: CircuitTreeSectionBlock[];
|
||||||
|
footerComponents: CircuitTreeComponent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LegacyMigrationReport {
|
export interface LegacyMigrationReport {
|
||||||
@@ -78,3 +84,35 @@ export interface LegacyMigrationReport {
|
|||||||
warnings: string[];
|
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,
|
distributionBoardSupplyTypes,
|
||||||
type DistributionBoardSupplyType,
|
type DistributionBoardSupplyType,
|
||||||
} from "../../shared/constants/distribution-board.js";
|
} 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";
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
export const distributionBoardInsertCommandType =
|
export const distributionBoardInsertCommandType =
|
||||||
@@ -10,9 +19,10 @@ export const distributionBoardInsertCommandType =
|
|||||||
export const distributionBoardDeleteCommandType =
|
export const distributionBoardDeleteCommandType =
|
||||||
"distribution-board.delete" as const;
|
"distribution-board.delete" as const;
|
||||||
export const legacyDistributionBoardStructureCommandSchemaVersion = 1 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",
|
key: "lighting",
|
||||||
displayName: "Lighting",
|
displayName: "Lighting",
|
||||||
@@ -39,6 +49,33 @@ export const defaultCircuitSectionDefinitions = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] 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 {
|
interface LegacyDistributionBoardStructureSnapshot {
|
||||||
distributionBoard: {
|
distributionBoard: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,7 +83,22 @@ interface LegacyDistributionBoardStructureSnapshot {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
circuitList: DistributionBoardStructureSnapshot["circuitList"];
|
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 {
|
export interface DistributionBoardStructureSnapshot {
|
||||||
@@ -70,7 +122,31 @@ export interface DistributionBoardStructureSnapshot {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
sortOrder: number;
|
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<
|
interface DistributionBoardStructureCommandPayload<
|
||||||
@@ -97,6 +173,16 @@ interface LegacyDistributionBoardStructureProjectCommand
|
|||||||
| typeof distributionBoardDeleteCommandType;
|
| typeof distributionBoardDeleteCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PreviousDistributionBoardStructureProjectCommand
|
||||||
|
extends SerializedProjectCommand<
|
||||||
|
DistributionBoardStructureCommandPayload<PreviousDistributionBoardStructureSnapshot>
|
||||||
|
> {
|
||||||
|
schemaVersion: typeof previousDistributionBoardStructureCommandSchemaVersion;
|
||||||
|
type:
|
||||||
|
| typeof distributionBoardInsertCommandType
|
||||||
|
| typeof distributionBoardDeleteCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DistributionBoardInsertProjectCommand
|
export interface DistributionBoardInsertProjectCommand
|
||||||
extends CurrentDistributionBoardStructureProjectCommand {
|
extends CurrentDistributionBoardStructureProjectCommand {
|
||||||
type: typeof distributionBoardInsertCommandType;
|
type: typeof distributionBoardInsertCommandType;
|
||||||
@@ -110,6 +196,7 @@ export interface DistributionBoardDeleteProjectCommand
|
|||||||
export type DistributionBoardStructureProjectCommand =
|
export type DistributionBoardStructureProjectCommand =
|
||||||
| DistributionBoardInsertProjectCommand
|
| DistributionBoardInsertProjectCommand
|
||||||
| DistributionBoardDeleteProjectCommand
|
| DistributionBoardDeleteProjectCommand
|
||||||
|
| PreviousDistributionBoardStructureProjectCommand
|
||||||
| LegacyDistributionBoardStructureProjectCommand;
|
| LegacyDistributionBoardStructureProjectCommand;
|
||||||
|
|
||||||
export function createDistributionBoardStructureSnapshot(
|
export function createDistributionBoardStructureSnapshot(
|
||||||
@@ -143,6 +230,22 @@ export function createDistributionBoardStructureSnapshot(
|
|||||||
circuitListId: structureId,
|
circuitListId: structureId,
|
||||||
...section,
|
...section,
|
||||||
})),
|
})),
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
circuitListId: structureId,
|
||||||
|
sectionId: null,
|
||||||
|
...defaultMainSwitchComponent,
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
circuitListId: structureId,
|
||||||
|
sectionId: null,
|
||||||
|
...defaultSurgeProtectiveDeviceComponent,
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
assertDistributionBoardStructureSnapshot(structure);
|
assertDistributionBoardStructureSnapshot(structure);
|
||||||
return structure;
|
return structure;
|
||||||
@@ -192,32 +295,71 @@ export function assertDistributionBoardDeleteProjectCommand(
|
|||||||
|
|
||||||
export function normalizeDistributionBoardStructureProjectCommand(
|
export function normalizeDistributionBoardStructureProjectCommand(
|
||||||
command: DistributionBoardStructureProjectCommand
|
command: DistributionBoardStructureProjectCommand
|
||||||
): DistributionBoardInsertProjectCommand | DistributionBoardDeleteProjectCommand {
|
): NormalizedDistributionBoardStructureSnapshot {
|
||||||
if (
|
if (
|
||||||
command.schemaVersion ===
|
command.schemaVersion ===
|
||||||
legacyDistributionBoardStructureCommandSchemaVersion
|
legacyDistributionBoardStructureCommandSchemaVersion
|
||||||
) {
|
) {
|
||||||
const structure: DistributionBoardStructureSnapshot = {
|
return {
|
||||||
...command.payload.structure,
|
|
||||||
distributionBoard: {
|
distributionBoard: {
|
||||||
...command.payload.structure.distributionBoard,
|
...command.payload.structure.distributionBoard,
|
||||||
floorId: null,
|
floorId: null,
|
||||||
supplyType: 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
|
if (
|
||||||
| DistributionBoardInsertProjectCommand
|
command.schemaVersion ===
|
||||||
| DistributionBoardDeleteProjectCommand;
|
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(
|
export function assertDistributionBoardStructureSnapshot(
|
||||||
structure: unknown
|
structure: unknown
|
||||||
): asserts structure is DistributionBoardStructureSnapshot {
|
): asserts structure is DistributionBoardStructureSnapshot {
|
||||||
assertDistributionBoardStructureSnapshotVersion(structure, false);
|
assertDistributionBoardStructureSnapshotVersion(structure, "current");
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertDistributionBoardStructureProjectCommand(
|
function assertDistributionBoardStructureProjectCommand(
|
||||||
@@ -239,36 +381,57 @@ function assertDistributionBoardStructureProjectCommand(
|
|||||||
) {
|
) {
|
||||||
assertDistributionBoardStructureSnapshotVersion(
|
assertDistributionBoardStructureSnapshotVersion(
|
||||||
command.payload.structure,
|
command.payload.structure,
|
||||||
true
|
"legacy"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
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.");
|
throw new Error("Unsupported distribution-board structure command.");
|
||||||
}
|
}
|
||||||
assertDistributionBoardStructureSnapshotVersion(
|
assertDistributionBoardStructureSnapshotVersion(
|
||||||
command.payload.structure,
|
command.payload.structure,
|
||||||
false
|
"current"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertDistributionBoardStructureSnapshotVersion(
|
function assertDistributionBoardStructureSnapshotVersion(
|
||||||
structure: unknown,
|
structure: unknown,
|
||||||
legacy: boolean
|
version: "legacy" | "previous" | "current"
|
||||||
|
) {
|
||||||
|
const current = version === "current";
|
||||||
|
if (
|
||||||
|
!isPlainObject(structure) ||
|
||||||
|
Object.keys(structure).length !== (current ? 4 : 3)
|
||||||
) {
|
) {
|
||||||
if (!isPlainObject(structure) || Object.keys(structure).length !== 3) {
|
|
||||||
throw new Error("Distribution-board structure is invalid.");
|
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 (
|
if (
|
||||||
!isPlainObject(distributionBoard) ||
|
!isPlainObject(distributionBoard) ||
|
||||||
Object.keys(distributionBoard).length !== (legacy ? 3 : 5) ||
|
Object.keys(distributionBoard).length !==
|
||||||
|
(version === "legacy" ? 3 : 5) ||
|
||||||
!isPlainObject(circuitList) ||
|
!isPlainObject(circuitList) ||
|
||||||
Object.keys(circuitList).length !== 4 ||
|
Object.keys(circuitList).length !== 4 ||
|
||||||
!Array.isArray(sections) ||
|
!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.");
|
throw new Error("Distribution-board structure is incomplete.");
|
||||||
}
|
}
|
||||||
@@ -278,7 +441,7 @@ function assertDistributionBoardStructureSnapshotVersion(
|
|||||||
`distributionBoard.${field}`
|
`distributionBoard.${field}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!legacy) {
|
if (version !== "legacy") {
|
||||||
assertNullableId(distributionBoard.floorId, "distributionBoard.floorId");
|
assertNullableId(distributionBoard.floorId, "distributionBoard.floorId");
|
||||||
assertNullableSupplyType(distributionBoard.supplyType);
|
assertNullableSupplyType(distributionBoard.supplyType);
|
||||||
}
|
}
|
||||||
@@ -296,8 +459,11 @@ function assertDistributionBoardStructureSnapshotVersion(
|
|||||||
const sectionIds = new Set<string>();
|
const sectionIds = new Set<string>();
|
||||||
for (let index = 0; index < sections.length; index += 1) {
|
for (let index = 0; index < sections.length; index += 1) {
|
||||||
const section = sections[index];
|
const section = sections[index];
|
||||||
const expected = defaultCircuitSectionDefinitions[index];
|
const expected = expectedSections[index];
|
||||||
if (!isPlainObject(section) || Object.keys(section).length !== 6) {
|
if (
|
||||||
|
!isPlainObject(section) ||
|
||||||
|
Object.keys(section).length !== (current ? 8 : 6)
|
||||||
|
) {
|
||||||
throw new Error("Default circuit section is invalid.");
|
throw new Error("Default circuit section is invalid.");
|
||||||
}
|
}
|
||||||
assertNonEmptyString(section.id, "section.id");
|
assertNonEmptyString(section.id, "section.id");
|
||||||
@@ -310,13 +476,64 @@ function assertDistributionBoardStructureSnapshotVersion(
|
|||||||
section.key !== expected.key ||
|
section.key !== expected.key ||
|
||||||
section.displayName !== expected.displayName ||
|
section.displayName !== expected.displayName ||
|
||||||
section.prefix !== expected.prefix ||
|
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(
|
throw new Error(
|
||||||
"Distribution-board structure has invalid default sections."
|
"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) {
|
function assertNullableId(value: unknown, field: string) {
|
||||||
|
|||||||
@@ -3,6 +3,21 @@ import {
|
|||||||
defaultDistributionBoardSupplyTypes,
|
defaultDistributionBoardSupplyTypes,
|
||||||
distributionBoardSupplyTypes,
|
distributionBoardSupplyTypes,
|
||||||
} from "../../shared/constants/distribution-board.js";
|
} 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 {
|
import {
|
||||||
resolveCircuitPhaseType,
|
resolveCircuitPhaseType,
|
||||||
resolveProjectVoltage,
|
resolveProjectVoltage,
|
||||||
@@ -14,7 +29,8 @@ export const previousProjectStateSnapshotSchemaVersion = 2 as const;
|
|||||||
export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const;
|
export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const;
|
||||||
export const supplyTypesProjectStateSnapshotSchemaVersion = 4 as const;
|
export const supplyTypesProjectStateSnapshotSchemaVersion = 4 as const;
|
||||||
export const voltageProjectStateSnapshotSchemaVersion = 5 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 idSchema = z.string().trim().min(1);
|
||||||
const nullableStringSchema = z.string().nullable();
|
const nullableStringSchema = z.string().nullable();
|
||||||
@@ -73,7 +89,7 @@ const circuitListSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const circuitSectionSchema = z
|
const legacyCircuitSectionSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
circuitListId: idSchema,
|
circuitListId: idSchema,
|
||||||
@@ -84,6 +100,22 @@ const circuitSectionSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.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
|
const circuitDeviceRowSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
@@ -173,21 +205,106 @@ const roomSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.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),
|
circuitLists: z.array(circuitListSchema),
|
||||||
circuitSections: z.array(circuitSectionSchema),
|
circuitSections: z.array(legacyCircuitSectionSchema),
|
||||||
circuits: z.array(circuitSchema),
|
circuits: z.array(circuitSchema),
|
||||||
projectDevices: z.array(projectDeviceSchema),
|
projectDevices: z.array(projectDeviceSchema),
|
||||||
floors: z.array(floorSchema),
|
floors: z.array(floorSchema),
|
||||||
rooms: z.array(roomSchema),
|
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
|
const legacyProjectStateSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||||
project: legacyProjectSchema,
|
project: legacyProjectSchema,
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||||
...commonProjectStateSnapshotContents,
|
...legacyProjectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -196,7 +313,7 @@ const previousProjectStateSnapshotSchema = z
|
|||||||
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||||
...commonProjectStateSnapshotContents,
|
...legacyProjectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -207,7 +324,7 @@ const distributionBoardProjectStateSnapshotSchema = z
|
|||||||
),
|
),
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
...commonProjectStateSnapshotContents,
|
...legacyProjectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -216,7 +333,7 @@ const supplyTypesProjectStateSnapshotSchema = z
|
|||||||
schemaVersion: z.literal(supplyTypesProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(supplyTypesProjectStateSnapshotSchemaVersion),
|
||||||
project: currentProjectSchema,
|
project: currentProjectSchema,
|
||||||
distributionBoards: z.array(distributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
...commonProjectStateSnapshotContents,
|
...legacyProjectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -227,12 +344,20 @@ const voltageProjectStateSnapshotSchema =
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const projectStateSnapshotSchema =
|
const simultaneityFactorProjectStateSnapshotSchema =
|
||||||
voltageProjectStateSnapshotSchema.extend({
|
voltageProjectStateSnapshotSchema.extend({
|
||||||
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(
|
||||||
|
simultaneityFactorProjectStateSnapshotSchemaVersion
|
||||||
|
),
|
||||||
distributionBoards: z.array(currentDistributionBoardSchema),
|
distributionBoards: z.array(currentDistributionBoardSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const projectStateSnapshotSchema =
|
||||||
|
simultaneityFactorProjectStateSnapshotSchema.extend({
|
||||||
|
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||||
|
...currentProjectStateSnapshotContents,
|
||||||
|
});
|
||||||
|
|
||||||
export type ProjectStateSnapshot = z.infer<
|
export type ProjectStateSnapshot = z.infer<
|
||||||
typeof projectStateSnapshotSchema
|
typeof projectStateSnapshotSchema
|
||||||
>;
|
>;
|
||||||
@@ -243,7 +368,8 @@ export function parseProjectStateSnapshot(
|
|||||||
const version = isPlainObject(value) ? value.schemaVersion : undefined;
|
const version = isPlainObject(value) ? value.schemaVersion : undefined;
|
||||||
const parsedSnapshot =
|
const parsedSnapshot =
|
||||||
version === legacyProjectStateSnapshotSchemaVersion
|
version === legacyProjectStateSnapshotSchemaVersion
|
||||||
? upgradeVoltageProjectStateSnapshot(
|
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||||
|
upgradeVoltageProjectStateSnapshot(
|
||||||
upgradeSupplyTypesProjectStateSnapshot(
|
upgradeSupplyTypesProjectStateSnapshot(
|
||||||
upgradeDistributionBoardProjectStateSnapshot(
|
upgradeDistributionBoardProjectStateSnapshot(
|
||||||
upgradePreviousProjectStateSnapshot(
|
upgradePreviousProjectStateSnapshot(
|
||||||
@@ -254,8 +380,10 @@ export function parseProjectStateSnapshot(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
: version === previousProjectStateSnapshotSchemaVersion
|
: version === previousProjectStateSnapshotSchemaVersion
|
||||||
? upgradeVoltageProjectStateSnapshot(
|
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||||
|
upgradeVoltageProjectStateSnapshot(
|
||||||
upgradeSupplyTypesProjectStateSnapshot(
|
upgradeSupplyTypesProjectStateSnapshot(
|
||||||
upgradeDistributionBoardProjectStateSnapshot(
|
upgradeDistributionBoardProjectStateSnapshot(
|
||||||
upgradePreviousProjectStateSnapshot(
|
upgradePreviousProjectStateSnapshot(
|
||||||
@@ -264,24 +392,40 @@ export function parseProjectStateSnapshot(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
: version === distributionBoardProjectStateSnapshotSchemaVersion
|
: version === distributionBoardProjectStateSnapshotSchemaVersion
|
||||||
? upgradeVoltageProjectStateSnapshot(
|
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||||
|
upgradeVoltageProjectStateSnapshot(
|
||||||
upgradeSupplyTypesProjectStateSnapshot(
|
upgradeSupplyTypesProjectStateSnapshot(
|
||||||
upgradeDistributionBoardProjectStateSnapshot(
|
upgradeDistributionBoardProjectStateSnapshot(
|
||||||
distributionBoardProjectStateSnapshotSchema.parse(value)
|
distributionBoardProjectStateSnapshotSchema.parse(
|
||||||
|
value
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
: version === supplyTypesProjectStateSnapshotSchemaVersion
|
: version === supplyTypesProjectStateSnapshotSchemaVersion
|
||||||
? upgradeVoltageProjectStateSnapshot(
|
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||||
|
upgradeVoltageProjectStateSnapshot(
|
||||||
upgradeSupplyTypesProjectStateSnapshot(
|
upgradeSupplyTypesProjectStateSnapshot(
|
||||||
supplyTypesProjectStateSnapshotSchema.parse(value)
|
supplyTypesProjectStateSnapshotSchema.parse(value)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
: version === voltageProjectStateSnapshotSchemaVersion
|
: version === voltageProjectStateSnapshotSchemaVersion
|
||||||
? upgradeVoltageProjectStateSnapshot(
|
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||||
|
upgradeVoltageProjectStateSnapshot(
|
||||||
voltageProjectStateSnapshotSchema.parse(value)
|
voltageProjectStateSnapshotSchema.parse(value)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
: version ===
|
||||||
|
simultaneityFactorProjectStateSnapshotSchemaVersion
|
||||||
|
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||||
|
simultaneityFactorProjectStateSnapshotSchema.parse(
|
||||||
|
value
|
||||||
|
)
|
||||||
|
)
|
||||||
: projectStateSnapshotSchema.parse(value);
|
: projectStateSnapshotSchema.parse(value);
|
||||||
const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot);
|
const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot);
|
||||||
assertProjectStateSnapshotRelations(snapshot);
|
assertProjectStateSnapshotRelations(snapshot);
|
||||||
@@ -412,10 +556,10 @@ function upgradeSupplyTypesProjectStateSnapshot(
|
|||||||
|
|
||||||
function upgradeVoltageProjectStateSnapshot(
|
function upgradeVoltageProjectStateSnapshot(
|
||||||
snapshot: z.infer<typeof voltageProjectStateSnapshotSchema>
|
snapshot: z.infer<typeof voltageProjectStateSnapshotSchema>
|
||||||
): ProjectStateSnapshot {
|
): z.infer<typeof simultaneityFactorProjectStateSnapshotSchema> {
|
||||||
return {
|
return {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
schemaVersion: simultaneityFactorProjectStateSnapshotSchemaVersion,
|
||||||
distributionBoards: snapshot.distributionBoards.map((board) => ({
|
distributionBoards: snapshot.distributionBoards.map((board) => ({
|
||||||
...board,
|
...board,
|
||||||
simultaneityFactor: 1,
|
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> {
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -461,10 +638,22 @@ function assertProjectStateSnapshotRelations(
|
|||||||
);
|
);
|
||||||
const floorIds = uniqueIds(snapshot.floors, "floor");
|
const floorIds = uniqueIds(snapshot.floors, "floor");
|
||||||
const roomIds = uniqueIds(snapshot.rooms, "room");
|
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(
|
const sectionById = new Map(
|
||||||
snapshot.circuitSections.map((entry) => [entry.id, entry])
|
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) {
|
for (const board of snapshot.distributionBoards) {
|
||||||
assertProjectOwnership(board.projectId, projectId, "distribution board");
|
assertProjectOwnership(board.projectId, projectId, "distribution board");
|
||||||
@@ -500,6 +689,19 @@ function assertProjectStateSnapshotRelations(
|
|||||||
section.circuitListId,
|
section.circuitListId,
|
||||||
"circuit section list"
|
"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) {
|
for (const device of snapshot.projectDevices) {
|
||||||
assertProjectOwnership(device.projectId, projectId, "project device");
|
assertProjectOwnership(device.projectId, projectId, "project device");
|
||||||
@@ -553,6 +755,11 @@ function assertProjectStateSnapshotRelations(
|
|||||||
"Snapshot circuit voltage must match project settings."
|
"Snapshot circuit voltage must match project settings."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
registerEquipmentIdentifier(
|
||||||
|
equipmentIdentifiersByList,
|
||||||
|
circuit.circuitListId,
|
||||||
|
circuit.equipmentIdentifier
|
||||||
|
);
|
||||||
for (const row of circuit.deviceRows) {
|
for (const row of circuit.deviceRows) {
|
||||||
if (row.circuitId !== circuit.id) {
|
if (row.circuitId !== circuit.id) {
|
||||||
throw new Error(
|
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(
|
function uniqueIds(
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export function remapProjectState(
|
|||||||
const listIds = idMap(source.circuitLists, createId);
|
const listIds = idMap(source.circuitLists, createId);
|
||||||
const sectionIds = idMap(source.circuitSections, createId);
|
const sectionIds = idMap(source.circuitSections, createId);
|
||||||
const circuitIds = idMap(source.circuits, createId);
|
const circuitIds = idMap(source.circuits, createId);
|
||||||
|
const componentIds = idMap(
|
||||||
|
source.distributionBoardComponents,
|
||||||
|
createId
|
||||||
|
);
|
||||||
const deviceIds = idMap(source.projectDevices, createId);
|
const deviceIds = idMap(source.projectDevices, createId);
|
||||||
const floorIds = idMap(source.floors, createId);
|
const floorIds = idMap(source.floors, createId);
|
||||||
const roomIds = idMap(source.rooms, createId);
|
const roomIds = idMap(source.rooms, createId);
|
||||||
@@ -110,6 +114,35 @@ export function remapProjectState(
|
|||||||
id: requiredId(sectionIds, section.id),
|
id: requiredId(sectionIds, section.id),
|
||||||
circuitListId: requiredId(listIds, section.circuitListId),
|
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) => ({
|
circuits: source.circuits.map((circuit) => ({
|
||||||
...circuit,
|
...circuit,
|
||||||
id: requiredId(circuitIds, circuit.id),
|
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,
|
assertDistributionBoardUpdateProjectCommand,
|
||||||
distributionBoardUpdateCommandType,
|
distributionBoardUpdateCommandType,
|
||||||
} from "../models/distribution-board-project-command.model.js";
|
} 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 {
|
import {
|
||||||
assertDistributionBoardDeleteProjectCommand,
|
assertDistributionBoardDeleteProjectCommand,
|
||||||
assertDistributionBoardInsertProjectCommand,
|
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 { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
|
||||||
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
|
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
|
||||||
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
|
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
|
||||||
|
import type { 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 { 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 {
|
import type {
|
||||||
ExecuteProjectHistoryCommandInput,
|
ExecuteProjectHistoryCommandInput,
|
||||||
ExecutedProjectCommand,
|
ExecutedProjectCommand,
|
||||||
@@ -101,6 +114,30 @@ import type {
|
|||||||
ProjectHistoryDirection,
|
ProjectHistoryDirection,
|
||||||
ProjectHistoryStore,
|
ProjectHistoryStore,
|
||||||
} from "../ports/project-history.store.js";
|
} 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 { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js";
|
||||||
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-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";
|
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 projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
|
||||||
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
|
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
|
||||||
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
|
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
|
private readonly historyStore: ProjectHistoryStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -296,6 +338,89 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).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: {
|
case projectFloorInsertCommandType: {
|
||||||
assertProjectFloorInsertProjectCommand(input.command);
|
assertProjectFloorInsertProjectCommand(input.command);
|
||||||
return this.projectLocationStructureStore.execute({
|
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,22 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { Sidebar } from "./Sidebar";
|
|
||||||
|
|
||||||
const PROJECT_AREA_PATTERN = /^\/projects(\/[^/]+)?$/;
|
|
||||||
|
|
||||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const showSidebar = PROJECT_AREA_PATTERN.test(pathname ?? "");
|
|
||||||
|
|
||||||
if (!showSidebar) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app-shell">
|
|
||||||
<Sidebar />
|
|
||||||
<div className="app-shell-content">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { listProjects } from "../utils/api";
|
|
||||||
import type { ProjectDto } from "../types";
|
|
||||||
|
|
||||||
const PROJECT_SECTIONS = [
|
|
||||||
{ id: "verlauf", label: "Verlauf", icon: "↺" },
|
|
||||||
{ id: "verteilungen", label: "Verteilungen", icon: "⌁" },
|
|
||||||
{ id: "etagen", label: "Etagen", icon: "▥" },
|
|
||||||
{ id: "raeume", label: "Räume", icon: "⌂" },
|
|
||||||
{ id: "projektgeraete", label: "Projektgeräte", icon: "⚙" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function Sidebar() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/);
|
|
||||||
const activeProjectId = projectMatch ? projectMatch[1] : null;
|
|
||||||
|
|
||||||
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
|
||||||
const [activeSection, setActiveSection] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function refreshProjects() {
|
|
||||||
listProjects()
|
|
||||||
.then(setProjects)
|
|
||||||
.catch(() => setProjects([]));
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshProjects();
|
|
||||||
window.addEventListener("leistungsbilanz:projects-changed", refreshProjects);
|
|
||||||
return () =>
|
|
||||||
window.removeEventListener("leistungsbilanz:projects-changed", refreshProjects);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeProjectId) {
|
|
||||||
setActiveSection(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elements = PROJECT_SECTIONS.map((section) =>
|
|
||||||
document.getElementById(section.id)
|
|
||||||
).filter((element): element is HTMLElement => element !== null);
|
|
||||||
|
|
||||||
if (!elements.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
const visible = entries
|
|
||||||
.filter((entry) => entry.isIntersecting)
|
|
||||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
|
||||||
if (visible[0]) {
|
|
||||||
setActiveSection(visible[0].target.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
|
|
||||||
);
|
|
||||||
|
|
||||||
elements.forEach((element) => observer.observe(element));
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [activeProjectId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="sidebar">
|
|
||||||
<div className="sidebar-brand">
|
|
||||||
<div className="sidebar-wordmark">Leistungsbilanz</div>
|
|
||||||
<div className="sidebar-bar" />
|
|
||||||
<div className="sidebar-tag">Stromkreis- & Lastberechnung</div>
|
|
||||||
</div>
|
|
||||||
<nav className="sidebar-nav">
|
|
||||||
<Link
|
|
||||||
href="/projects"
|
|
||||||
className={pathname === "/projects" ? "sidebar-link active" : "sidebar-link"}
|
|
||||||
>
|
|
||||||
<span className="sidebar-icon">▦</span>
|
|
||||||
Alle Projekte
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="sidebar-section-label">Projekte</div>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<Link
|
|
||||||
key={project.id}
|
|
||||||
href={`/projects/${project.id}`}
|
|
||||||
className={
|
|
||||||
project.id === activeProjectId ? "sidebar-link active" : "sidebar-link"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="sidebar-icon">▣</span>
|
|
||||||
{project.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
{!projects.length ? (
|
|
||||||
<div className="sidebar-section-label" style={{ opacity: 0.6 }}>
|
|
||||||
Noch keine Projekte
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeProjectId && (
|
|
||||||
<>
|
|
||||||
<div className="sidebar-section-label">In diesem Projekt</div>
|
|
||||||
{PROJECT_SECTIONS.map(({ id, label, icon }) => (
|
|
||||||
<a
|
|
||||||
key={id}
|
|
||||||
href={`#${id}`}
|
|
||||||
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
|
|
||||||
>
|
|
||||||
<span className="sidebar-icon">{icon}</span>
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
import type { DistributionBoardSupplyType } from "../shared/constants/distribution-board";
|
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 {
|
export interface ProjectDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -267,6 +278,27 @@ export interface CircuitTreeDeviceRowDto {
|
|||||||
rowTotalPower: number;
|
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 {
|
export interface CircuitTreeCircuitDto {
|
||||||
id: string;
|
id: string;
|
||||||
circuitListId: string;
|
circuitListId: string;
|
||||||
@@ -289,6 +321,7 @@ export interface CircuitTreeCircuitDto {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
circuitTotalPower: number;
|
circuitTotalPower: number;
|
||||||
deviceRows: CircuitTreeDeviceRowDto[];
|
deviceRows: CircuitTreeDeviceRowDto[];
|
||||||
|
protectionDevice?: CircuitTreeProtectionDeviceDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CircuitTreeSectionDto {
|
export interface CircuitTreeSectionDto {
|
||||||
@@ -297,7 +330,10 @@ export interface CircuitTreeSectionDto {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
category?: CircuitGroupCategory;
|
||||||
|
groupNumber?: number;
|
||||||
sectionTotalPower: number;
|
sectionTotalPower: number;
|
||||||
|
components: CircuitTreeComponentDto[];
|
||||||
circuits: CircuitTreeCircuitDto[];
|
circuits: CircuitTreeCircuitDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +356,9 @@ export interface CircuitTreeResponseDto {
|
|||||||
distributionBoardSimultaneityFactor: number;
|
distributionBoardSimultaneityFactor: number;
|
||||||
distributionBoardTotalPower: number;
|
distributionBoardTotalPower: number;
|
||||||
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||||
|
headerComponents: CircuitTreeComponentDto[];
|
||||||
sections: CircuitTreeSectionDto[];
|
sections: CircuitTreeSectionDto[];
|
||||||
|
footerComponents: CircuitTreeComponentDto[];
|
||||||
migrationReport?: CircuitTreeMigrationReportDto;
|
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.insert": "Verteilung angelegt",
|
||||||
"distribution-board.update": "Verteilung bearbeitet",
|
"distribution-board.update": "Verteilung bearbeitet",
|
||||||
"distribution-board.delete": "Verteilung entfernt",
|
"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.insert": "Geschoss angelegt",
|
||||||
"project-floor.delete": "Geschoss entfernt",
|
"project-floor.delete": "Geschoss entfernt",
|
||||||
"project-room.insert": "Raum angelegt",
|
"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 { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||||
import { CircuitRepository } from "../../db/repositories/circuit.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 { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||||
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
||||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.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 circuitListRepository = new CircuitListRepository(db);
|
||||||
export const circuitSectionRepository = new CircuitSectionRepository(db);
|
export const circuitSectionRepository = new CircuitSectionRepository(db);
|
||||||
export const circuitRepository = new CircuitRepository(db);
|
export const circuitRepository = new CircuitRepository(db);
|
||||||
|
export const circuitProtectionDeviceRepository =
|
||||||
|
new CircuitProtectionDeviceRepository(db);
|
||||||
|
export const distributionBoardComponentRepository =
|
||||||
|
new DistributionBoardComponentRepository(db);
|
||||||
export const distributionBoardRepository =
|
export const distributionBoardRepository =
|
||||||
new DistributionBoardRepository(db);
|
new DistributionBoardRepository(db);
|
||||||
export const floorRepository = new FloorRepository(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 { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||||
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
|
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||||
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
|
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
|
||||||
|
import { 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 { 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 { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
|
||||||
import { ProjectLocationStructureProjectCommandRepository } from "../../db/repositories/project-location-structure-project-command.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";
|
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
|
||||||
@@ -25,8 +30,18 @@ export const circuitDeviceRowMoveProjectCommandStore =
|
|||||||
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
||||||
export const circuitStructureProjectCommandStore =
|
export const circuitStructureProjectCommandStore =
|
||||||
new CircuitStructureProjectCommandRepository(db);
|
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 =
|
export const distributionBoardStructureProjectCommandStore =
|
||||||
new DistributionBoardStructureProjectCommandRepository(db);
|
new DistributionBoardStructureProjectCommandRepository(db);
|
||||||
|
export const distributionBoardComponentStructureProjectCommandStore =
|
||||||
|
new DistributionBoardComponentStructureProjectCommandRepository(db);
|
||||||
export const projectLocationStructureProjectCommandStore =
|
export const projectLocationStructureProjectCommandStore =
|
||||||
new ProjectLocationStructureProjectCommandRepository(db);
|
new ProjectLocationStructureProjectCommandRepository(db);
|
||||||
export const circuitSectionReorderProjectCommandStore =
|
export const circuitSectionReorderProjectCommandStore =
|
||||||
@@ -59,5 +74,10 @@ export const projectCommandService = new ProjectCommandService(
|
|||||||
projectDeviceRowSyncProjectCommandStore,
|
projectDeviceRowSyncProjectCommandStore,
|
||||||
projectSettingsProjectCommandStore,
|
projectSettingsProjectCommandStore,
|
||||||
projectStateRestoreCommandStore,
|
projectStateRestoreCommandStore,
|
||||||
|
distributionBoardComponentStructureProjectCommandStore,
|
||||||
|
circuitGroupStructureProjectCommandStore,
|
||||||
|
circuitGroupRenumberProjectCommandStore,
|
||||||
|
circuitGroupMoveProjectCommandStore,
|
||||||
|
circuitGroupSubtreeProjectCommandStore,
|
||||||
projectHistoryStore
|
projectHistoryStore
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import {
|
|||||||
calculateSectionTotalPower,
|
calculateSectionTotalPower,
|
||||||
} from "../../domain/calculations/circuit-power-calculation.js";
|
} from "../../domain/calculations/circuit-power-calculation.js";
|
||||||
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
|
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
|
||||||
|
import type { CircuitTreeProtectionDevice } from "../../domain/models/circuit-tree.model.js";
|
||||||
import {
|
import {
|
||||||
circuitDeviceRowRepository,
|
circuitDeviceRowRepository,
|
||||||
circuitListRepository,
|
circuitListRepository,
|
||||||
|
circuitProtectionDeviceRepository,
|
||||||
circuitRepository,
|
circuitRepository,
|
||||||
circuitSectionRepository,
|
circuitSectionRepository,
|
||||||
|
distributionBoardComponentRepository,
|
||||||
distributionBoardRepository,
|
distributionBoardRepository,
|
||||||
projectRepository,
|
projectRepository,
|
||||||
} from "../composition/application-repositories.js";
|
} from "../composition/application-repositories.js";
|
||||||
@@ -23,7 +26,9 @@ export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
|
|||||||
return (
|
return (
|
||||||
error.message.includes("no such table: circuit_sections") ||
|
error.message.includes("no such table: circuit_sections") ||
|
||||||
error.message.includes("no such table: circuits") ||
|
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 sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
||||||
const circuits = await circuitRepository.listByCircuitList(circuitListId);
|
const circuits = await circuitRepository.listByCircuitList(circuitListId);
|
||||||
const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id));
|
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 sectionById = new Map(sections.map((section) => [section.id, section]));
|
||||||
const rowsByCircuitId = new Map<string, typeof rows>();
|
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) {
|
for (const row of rows) {
|
||||||
if (!rowsByCircuitId.has(row.circuitId)) {
|
if (!rowsByCircuitId.has(row.circuitId)) {
|
||||||
rowsByCircuitId.set(row.circuitId, []);
|
rowsByCircuitId.set(row.circuitId, []);
|
||||||
@@ -75,17 +99,46 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
distributionBoard.simultaneityFactor,
|
distributionBoard.simultaneityFactor,
|
||||||
distributionBoardTotalPower: 0,
|
distributionBoardTotalPower: 0,
|
||||||
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
||||||
|
headerComponents: [],
|
||||||
sections: sections.map((section) => ({
|
sections: sections.map((section) => ({
|
||||||
id: section.id,
|
id: section.id,
|
||||||
key: section.key,
|
key: section.key,
|
||||||
displayName: section.displayName,
|
displayName: section.displayName,
|
||||||
prefix: section.prefix,
|
prefix: section.prefix,
|
||||||
sortOrder: section.sortOrder,
|
sortOrder: section.sortOrder,
|
||||||
|
category: section.category ?? undefined,
|
||||||
|
groupNumber: section.groupNumber ?? undefined,
|
||||||
sectionTotalPower: 0,
|
sectionTotalPower: 0,
|
||||||
|
components: [],
|
||||||
circuits: [],
|
circuits: [],
|
||||||
})),
|
})),
|
||||||
|
footerComponents: [],
|
||||||
};
|
};
|
||||||
const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section]));
|
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) {
|
for (const circuit of circuits) {
|
||||||
const section = sectionById.get(circuit.sectionId);
|
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),
|
rowTotalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
|
||||||
}));
|
}));
|
||||||
const circuitTotalPower = calculateCircuitTotalPower(deviceRows);
|
const circuitTotalPower = calculateCircuitTotalPower(deviceRows);
|
||||||
|
const protection = circuitProtectionById.get(circuit.id);
|
||||||
|
|
||||||
sectionBlocks.get(section.id)?.circuits.push({
|
sectionBlocks.get(section.id)?.circuits.push({
|
||||||
id: circuit.id,
|
id: circuit.id,
|
||||||
@@ -139,6 +193,9 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
remark: circuit.remark ?? undefined,
|
remark: circuit.remark ?? undefined,
|
||||||
circuitTotalPower,
|
circuitTotalPower,
|
||||||
deviceRows,
|
deviceRows,
|
||||||
|
protectionDevice: protection
|
||||||
|
? toProtectionDeviceDto(protection)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +224,9 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
distributionBoard.simultaneityFactor,
|
distributionBoard.simultaneityFactor,
|
||||||
distributionBoardTotalPower: 0,
|
distributionBoardTotalPower: 0,
|
||||||
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
||||||
|
headerComponents: [],
|
||||||
sections: [],
|
sections: [],
|
||||||
|
footerComponents: [],
|
||||||
warning:
|
warning:
|
||||||
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
||||||
});
|
});
|
||||||
@@ -175,3 +234,23 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
throw error;
|
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,
|
CircuitTreeDeviceRowDto,
|
||||||
CircuitTreeSectionDto,
|
CircuitTreeSectionDto,
|
||||||
} from "../src/frontend/types.js";
|
} from "../src/frontend/types.js";
|
||||||
|
import "./circuit-structure-projection.test.js";
|
||||||
|
|
||||||
function makeDevice(
|
function makeDevice(
|
||||||
id: string,
|
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 assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
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", () => {
|
describe("circuit tree controller", () => {
|
||||||
it("detects missing circuit-first schema errors", () => {
|
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")),
|
isMissingCircuitTreeSchemaError(new Error("SqliteError: no such table: circuit_device_rows")),
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
isMissingCircuitTreeSchemaError(
|
||||||
|
new Error("SqliteError: no such table: distribution_board_components")
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
assert.equal(isMissingCircuitTreeSchemaError(new Error("Some other error")), false);
|
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 { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||||
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||||
import { floors } from "../src/db/schema/floors.js";
|
import { floors } from "../src/db/schema/floors.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.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,
|
boards: context.db.select().from(distributionBoards).all().length,
|
||||||
lists: context.db.select().from(circuitLists).all().length,
|
lists: context.db.select().from(circuitLists).all().length,
|
||||||
sections: context.db.select().from(circuitSections).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,
|
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", () => {
|
describe("distribution-board structure project command", () => {
|
||||||
it("builds the fixed setup and sends the expected frontend revision", async () => {
|
it("builds the fixed setup and sends the expected frontend revision", async () => {
|
||||||
const structure = createDistributionBoardStructureSnapshot(
|
const structure = createDistributionBoardStructureSnapshot(
|
||||||
@@ -74,17 +116,26 @@ describe("distribution-board structure project command", () => {
|
|||||||
section.prefix,
|
section.prefix,
|
||||||
]),
|
]),
|
||||||
[
|
[
|
||||||
["lighting", "-1F"],
|
["lighting", "-1F1."],
|
||||||
["single_phase", "-2F"],
|
["single_phase", "-2F1."],
|
||||||
["three_phase", "-3F"],
|
["three_phase", "-3F1."],
|
||||||
["unassigned", "-UF"],
|
]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
structure.components.map((component) => [
|
||||||
|
component.equipmentIdentifier,
|
||||||
|
component.name,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
["-Q0", "Hauptschalter"],
|
||||||
|
["-FA", "Überspannungsableiter"],
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
createDistributionBoardInsertProjectCommand({
|
createDistributionBoardInsertProjectCommand({
|
||||||
...structure,
|
...structure,
|
||||||
sections: structure.sections.slice(0, 3),
|
sections: structure.sections.slice(0, 2),
|
||||||
}),
|
}),
|
||||||
/incomplete/
|
/incomplete/
|
||||||
);
|
);
|
||||||
@@ -150,7 +201,8 @@ describe("distribution-board structure project command", () => {
|
|||||||
assert.deepEqual(getStructureCounts(context), {
|
assert.deepEqual(getStructureCounts(context), {
|
||||||
boards: 1,
|
boards: 1,
|
||||||
lists: 1,
|
lists: 1,
|
||||||
sections: 4,
|
sections: 3,
|
||||||
|
components: 2,
|
||||||
revisions: 1,
|
revisions: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,6 +219,7 @@ describe("distribution-board structure project command", () => {
|
|||||||
boards: 0,
|
boards: 0,
|
||||||
lists: 0,
|
lists: 0,
|
||||||
sections: 0,
|
sections: 0,
|
||||||
|
components: 0,
|
||||||
revisions: 2,
|
revisions: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,7 +235,8 @@ describe("distribution-board structure project command", () => {
|
|||||||
assert.deepEqual(getStructureCounts(context), {
|
assert.deepEqual(getStructureCounts(context), {
|
||||||
boards: 1,
|
boards: 1,
|
||||||
lists: 1,
|
lists: 1,
|
||||||
sections: 4,
|
sections: 3,
|
||||||
|
components: 2,
|
||||||
revisions: 3,
|
revisions: 3,
|
||||||
});
|
});
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -274,8 +328,9 @@ describe("distribution-board structure project command", () => {
|
|||||||
type: "distribution-board.insert",
|
type: "distribution-board.insert",
|
||||||
payload: {
|
payload: {
|
||||||
structure: {
|
structure: {
|
||||||
...current,
|
|
||||||
distributionBoard: legacyBoard,
|
distributionBoard: legacyBoard,
|
||||||
|
circuitList: current.circuitList,
|
||||||
|
sections: legacySections(current.circuitList.id),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as DistributionBoardStructureProjectCommand;
|
} as unknown as DistributionBoardStructureProjectCommand;
|
||||||
@@ -291,6 +346,59 @@ describe("distribution-board structure project command", () => {
|
|||||||
.get();
|
.get();
|
||||||
assert.equal(inserted?.floorId, null);
|
assert.equal(inserted?.floorId, null);
|
||||||
assert.equal(inserted?.supplyType, 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 {
|
} finally {
|
||||||
context.close();
|
context.close();
|
||||||
}
|
}
|
||||||
@@ -545,6 +653,7 @@ describe("distribution-board structure project command", () => {
|
|||||||
boards: 0,
|
boards: 0,
|
||||||
lists: 0,
|
lists: 0,
|
||||||
sections: 0,
|
sections: 0,
|
||||||
|
components: 0,
|
||||||
revisions: 0,
|
revisions: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -661,6 +770,7 @@ describe("distribution-board structure project command", () => {
|
|||||||
boards: 0,
|
boards: 0,
|
||||||
lists: 0,
|
lists: 0,
|
||||||
sections: 0,
|
sections: 0,
|
||||||
|
components: 0,
|
||||||
revisions: 0,
|
revisions: 0,
|
||||||
});
|
});
|
||||||
} finally {
|
} 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 { 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 { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||||
|
import { 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 { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||||
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.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";
|
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 { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||||
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
@@ -45,10 +51,24 @@ import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/
|
|||||||
import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
|
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 { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
|
||||||
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
|
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
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 {
|
import {
|
||||||
createDistributionBoardInsertProjectCommand,
|
createDistributionBoardInsertProjectCommand,
|
||||||
createDistributionBoardStructureSnapshot,
|
createDistributionBoardStructureSnapshot,
|
||||||
} from "../src/domain/models/distribution-board-structure-project-command.model.js";
|
} 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 {
|
import {
|
||||||
createProjectFloorInsertProjectCommand,
|
createProjectFloorInsertProjectCommand,
|
||||||
createProjectFloorSnapshot,
|
createProjectFloorSnapshot,
|
||||||
@@ -122,6 +142,13 @@ function createService(context: DatabaseContext) {
|
|||||||
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
|
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
|
||||||
new ProjectSettingsProjectCommandRepository(context.db),
|
new ProjectSettingsProjectCommandRepository(context.db),
|
||||||
new ProjectStateRestoreCommandRepository(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)
|
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", () => {
|
it("dispatches floor and room setup with their persisted inverses", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
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 { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../src/db/schema/circuit-lists.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 { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { 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 { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||||
import { floors } from "../src/db/schema/floors.js";
|
import { floors } from "../src/db/schema/floors.js";
|
||||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
@@ -129,9 +132,58 @@ function createTestDatabase(): DatabaseContext {
|
|||||||
cosPhi: 0.9,
|
cosPhi: 0.9,
|
||||||
})
|
})
|
||||||
.run();
|
.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;
|
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", () => {
|
describe("project snapshot repository", () => {
|
||||||
it("captures, hashes and lists a complete named logical snapshot", () => {
|
it("captures, hashes and lists a complete named logical snapshot", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
@@ -190,6 +242,18 @@ describe("project snapshot repository", () => {
|
|||||||
context.db.select().from(circuitSections).all().length
|
context.db.select().from(circuitSections).all().length
|
||||||
);
|
);
|
||||||
assert.equal(state.circuits.length, 1);
|
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.length, 1);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
state.circuits[0].deviceRows[0].linkedProjectDeviceId,
|
state.circuits[0].deviceRows[0].linkedProjectDeviceId,
|
||||||
@@ -291,6 +355,7 @@ describe("project snapshot repository", () => {
|
|||||||
project: Record<string, unknown>;
|
project: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
currentPayload.schemaVersion = 1;
|
currentPayload.schemaVersion = 1;
|
||||||
|
removeVersionSevenFields(currentPayload);
|
||||||
delete currentPayload.project.internalProjectNumber;
|
delete currentPayload.project.internalProjectNumber;
|
||||||
delete currentPayload.project.externalProjectNumber;
|
delete currentPayload.project.externalProjectNumber;
|
||||||
delete currentPayload.project.buildingOwner;
|
delete currentPayload.project.buildingOwner;
|
||||||
@@ -368,6 +433,27 @@ describe("portable project transfer", () => {
|
|||||||
copied.state.circuits[0].id,
|
copied.state.circuits[0].id,
|
||||||
source.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(
|
assert.equal(
|
||||||
copied.state.circuits[0].deviceRows[0].linkedProjectDeviceId,
|
copied.state.circuits[0].deviceRows[0].linkedProjectDeviceId,
|
||||||
copied.state.projectDevices[0].id
|
copied.state.projectDevices[0].id
|
||||||
@@ -438,6 +524,7 @@ describe("portable project transfer", () => {
|
|||||||
distributionBoards: Array<Record<string, unknown>>;
|
distributionBoards: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
legacyState.schemaVersion = 2;
|
legacyState.schemaVersion = 2;
|
||||||
|
removeVersionSevenFields(legacyState);
|
||||||
delete (
|
delete (
|
||||||
legacyState.project as Record<string, unknown>
|
legacyState.project as Record<string, unknown>
|
||||||
).enabledDistributionBoardSupplyTypes;
|
).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 { 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 { 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 { 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 { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||||
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.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 { 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 { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
|
||||||
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
|
import { 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 { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../src/db/schema/circuit-lists.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 { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
import { consumers } from "../src/db/schema/consumers.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 { floors } from "../src/db/schema/floors.js";
|
||||||
import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js";
|
import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js";
|
||||||
import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js";
|
import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js";
|
||||||
@@ -132,6 +140,43 @@ function createTestDatabase(): DatabaseContext {
|
|||||||
simultaneityFactor: 0.8,
|
simultaneityFactor: 0.8,
|
||||||
})
|
})
|
||||||
.run();
|
.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;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +196,13 @@ function createService(context: DatabaseContext) {
|
|||||||
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
|
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
|
||||||
new ProjectSettingsProjectCommandRepository(context.db),
|
new ProjectSettingsProjectCommandRepository(context.db),
|
||||||
new ProjectStateRestoreCommandRepository(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)
|
new ProjectHistoryRepository(context.db)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -173,6 +225,16 @@ function changeCompleteProjectState(context: DatabaseContext) {
|
|||||||
})
|
})
|
||||||
.where(eq(circuits.id, "circuit-1"))
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
.run();
|
.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
|
context.db
|
||||||
.insert(floors)
|
.insert(floors)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ function minimalSnapshot() {
|
|||||||
distributionBoards: [],
|
distributionBoards: [],
|
||||||
circuitLists: [],
|
circuitLists: [],
|
||||||
circuitSections: [],
|
circuitSections: [],
|
||||||
|
distributionBoardComponents: [],
|
||||||
|
circuitProtectionDevices: [],
|
||||||
|
distributionBoardComponentProtectionDevices: [],
|
||||||
circuits: [],
|
circuits: [],
|
||||||
projectDevices: [],
|
projectDevices: [],
|
||||||
floors: [],
|
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", () => {
|
describe("project state snapshot model", () => {
|
||||||
it("round-trips a versioned logical project state", () => {
|
it("round-trips a versioned logical project state", () => {
|
||||||
const snapshot = parseProjectStateSnapshot(minimalSnapshot());
|
const snapshot = parseProjectStateSnapshot(minimalSnapshot());
|
||||||
@@ -52,7 +160,7 @@ describe("project state snapshot model", () => {
|
|||||||
|
|
||||||
it("upgrades version-one snapshots with empty project metadata", () => {
|
it("upgrades version-one snapshots with empty project metadata", () => {
|
||||||
const legacy = {
|
const legacy = {
|
||||||
...minimalSnapshot(),
|
...legacyMinimalSnapshot(),
|
||||||
schemaVersion: 1 as const,
|
schemaVersion: 1 as const,
|
||||||
project: {
|
project: {
|
||||||
id: "project-1",
|
id: "project-1",
|
||||||
@@ -82,7 +190,7 @@ describe("project state snapshot model", () => {
|
|||||||
|
|
||||||
it("upgrades version-two distribution boards without inferred assignments", () => {
|
it("upgrades version-two distribution boards without inferred assignments", () => {
|
||||||
const previous = {
|
const previous = {
|
||||||
...minimalSnapshot(),
|
...legacyMinimalSnapshot(),
|
||||||
schemaVersion: 2 as const,
|
schemaVersion: 2 as const,
|
||||||
project: {
|
project: {
|
||||||
...minimalSnapshot().project,
|
...minimalSnapshot().project,
|
||||||
@@ -110,7 +218,7 @@ describe("project state snapshot model", () => {
|
|||||||
|
|
||||||
it("upgrades version-three snapshots with all project supply types", () => {
|
it("upgrades version-three snapshots with all project supply types", () => {
|
||||||
const previous = {
|
const previous = {
|
||||||
...minimalSnapshot(),
|
...legacyMinimalSnapshot(),
|
||||||
schemaVersion: 3 as const,
|
schemaVersion: 3 as const,
|
||||||
project: {
|
project: {
|
||||||
...minimalSnapshot().project,
|
...minimalSnapshot().project,
|
||||||
@@ -131,7 +239,7 @@ describe("project state snapshot model", () => {
|
|||||||
|
|
||||||
it("normalizes version-four device voltages to project settings", () => {
|
it("normalizes version-four device voltages to project settings", () => {
|
||||||
const previous = {
|
const previous = {
|
||||||
...minimalSnapshot(),
|
...legacyMinimalSnapshot(),
|
||||||
schemaVersion: 4 as const,
|
schemaVersion: 4 as const,
|
||||||
projectDevices: [
|
projectDevices: [
|
||||||
{
|
{
|
||||||
@@ -161,7 +269,7 @@ describe("project state snapshot model", () => {
|
|||||||
|
|
||||||
it("upgrades version-five distribution boards with a neutral factor", () => {
|
it("upgrades version-five distribution boards with a neutral factor", () => {
|
||||||
const previous = {
|
const previous = {
|
||||||
...minimalSnapshot(),
|
...legacyMinimalSnapshot(),
|
||||||
schemaVersion: 5 as const,
|
schemaVersion: 5 as const,
|
||||||
distributionBoards: [
|
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", () => {
|
it("rejects duplicate ids and cross-project ownership", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -115,6 +115,82 @@ describe("project version history presentation", () => {
|
|||||||
),
|
),
|
||||||
"Projekteinstellungen bearbeitet"
|
"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("named"), "Benannt");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getProjectSnapshotKindLabel("automatic"),
|
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 type { AppDatabase } from "../../src/db/database-context.js";
|
||||||
import { circuitLists } from "../../src/db/schema/circuit-lists.js";
|
import { circuitLists } from "../../src/db/schema/circuit-lists.js";
|
||||||
import { circuitSections } from "../../src/db/schema/circuit-sections.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 { distributionBoards } from "../../src/db/schema/distribution-boards.js";
|
||||||
import { createDistributionBoardStructureSnapshot } from "../../src/domain/models/distribution-board-structure-project-command.model.js";
|
import { createDistributionBoardStructureSnapshot } from "../../src/domain/models/distribution-board-structure-project-command.model.js";
|
||||||
|
|
||||||
@@ -21,6 +22,9 @@ export class DistributionBoardFixtureRepository {
|
|||||||
.run();
|
.run();
|
||||||
tx.insert(circuitLists).values(structure.circuitList).run();
|
tx.insert(circuitLists).values(structure.circuitList).run();
|
||||||
tx.insert(circuitSections).values(structure.sections).run();
|
tx.insert(circuitSections).values(structure.sections).run();
|
||||||
|
tx.insert(distributionBoardComponents)
|
||||||
|
.values(structure.components)
|
||||||
|
.run();
|
||||||
});
|
});
|
||||||
return structure.distributionBoard;
|
return structure.distributionBoard;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user