30 Commits

Author SHA1 Message Date
jappel f4bfef9071 Merge branch 'main' into feature/multiplesections 2026-07-31 09:00:39 +02:00
jappel 4a859f31b8 Complete protected circuit group phase 2026-07-31 08:59:12 +02:00
jappel 2d97fafce9 Restore circuit drag placement 2026-07-31 08:33:41 +02:00
jappel 7c8d4a7ddf Document protected circuit groups 2026-07-31 08:06:10 +02:00
jappel ab989cc637 Delete populated circuit groups safely 2026-07-31 08:01:55 +02:00
jappel 93ec294759 Move circuits between groups 2026-07-31 07:58:42 +02:00
jappel be9c6f0d52 Renumber circuit groups explicitly 2026-07-31 07:53:07 +02:00
jappel 0cb280ddb2 Reorder circuit groups 2026-07-31 07:47:45 +02:00
jappel 8898117ed4 Configure circuit protection 2026-07-31 07:45:48 +02:00
jappel 18ca5eb3d4 Persist circuit protection updates 2026-07-31 07:37:38 +02:00
jappel 0c92fbd09e Manage circuit groups 2026-07-31 07:32:46 +02:00
jappel 756e307bd8 Manage distribution board components 2026-07-31 07:29:25 +02:00
jappel 31589875b5 Render protected circuit structure 2026-07-31 07:22:26 +02:00
jappel ac21d6eb5d WIP multiple sections 2026-07-30 20:27:17 +02:00
jappel 7a9530a914 Expose protected tree structure 2026-07-30 20:14:45 +02:00
jappel 99bdf6491b Persist populated group deletion 2026-07-30 20:10:10 +02:00
jappel db4c757018 Define circuit group subtrees 2026-07-30 20:06:09 +02:00
jappel 3399fcd88b Persist circuit group moves 2026-07-30 20:03:11 +02:00
jappel 9ad91887b2 Plan circuit group moves 2026-07-30 19:58:45 +02:00
jappel 07925b2fd9 Persist circuit group renumbering 2026-07-30 19:56:59 +02:00
jappel 63650a623c Plan circuit group renumbering 2026-07-30 19:51:44 +02:00
jappel e702712bd8 Add circuit group reordering 2026-07-30 19:49:27 +02:00
jappel 1250f9a6af Add circuit group commands 2026-07-30 19:46:55 +02:00
jappel 69b020339e Add component update commands 2026-07-30 19:42:09 +02:00
jappel 604604f056 Add component structure commands 2026-07-30 19:38:48 +02:00
jappel 9d4d4082fe Create protected board defaults 2026-07-30 19:30:53 +02:00
jappel 012308984d Include components in project snapshots 2026-07-30 19:24:28 +02:00
jappel e7607c90c4 Add component persistence foundation 2026-07-30 19:15:00 +02:00
jappel a88c4b2086 Add protection domain foundation 2026-07-30 19:03:36 +02:00
jappel a72d2c76e0 Specify protected circuit groups 2026-07-30 18:52:26 +02:00
111 changed files with 17066 additions and 261 deletions
+45 -2
View File
@@ -255,6 +255,11 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart.
Snapshot schema version 7 also contains circuit-group identity,
distribution-board components and the separate circuit/component protection
records. Versions 1 through 6 remain readable. Portable duplicate imports
remap component ids and protection-owner references together with the existing
project graph.
The central revision boundary creates an automatic logical snapshot after each
25 new revisions and retains only the newest 12 automatic snapshots per
project. Named snapshots are never removed by this retention policy.
@@ -289,10 +294,48 @@ change in one revision and Undo/Redo restores them together; the project PUT
route requires `expectedRevision`. The system catalog is `AV`, `SV`, `EV`,
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
cannot be disabled.
Distribution-board setup uses `distribution-board.insert` with a complete
stable snapshot of the board, circuit list and four default sections. Its
Distribution-board setup uses `distribution-board.insert` schema version 3
with a complete stable snapshot of the board, circuit list, three default
groups, main switch `-Q0` and surge protective device `-FA`. Its
inverse removes only the same unchanged and still-empty structure; the POST
route requires `expectedRevision` and returns the updated history state.
Stored schema-version 1 and 2 setup commands remain executable with their four
legacy sections and without invented components.
Mutable group-protection and auxiliary distribution-board components use
`distribution-board-component.insert` and
`distribution-board-component.delete`; edits and footer reordering use
`distribution-board-component.update`. Their complete expected/target
snapshots include the optional one-to-one protection-device state and reject
stale data. Updates preserve id, ownership, role and placement. Fixed
main-switch and surge-protection header roles are excluded from these general
component commands.
Empty circuit-group creation, display-name updates and deletion use
`circuit-group.insert`, `circuit-group.update` and `circuit-group.delete`.
Their snapshots keep category, positive group number and derived prefix
consistent. General deletion rejects groups containing circuits or group
components; populated deletion remains a separate confirmed command.
`circuit-group.reorder` requires a complete expected/target sort assignment for
every group in one circuit list. It changes only sort positions and never
renumbers groups, prefixes or circuits.
Explicit group-number changes use `circuit-group.renumber`. The command carries
the complete expected/target group, circuit-BMK and optional group-component
BMK plan, applies swaps through collision-safe temporary values and preserves
circuit suffixes. Undo/Redo uses the exact inverse plan.
Cross-group circuit moves use `circuit.move-group` and are limited to distinct
groups of the same category. The stored target BMK is highest target suffix
plus one at planning time and is never recalculated for Redo. Only circuit
group, BMK and sort order change; device rows and circuit protection retain the
stable circuit id.
Confirmed populated-group deletion uses
`circuit-group.delete-subtree`/`circuit-group.restore-subtree`. The exact
snapshot contains the group, optional component protections, complete circuits,
circuit protections and all device-row link/override metadata. Delete
re-captures and compares the subtree before cascading; restore preserves every
UUID in foreign-key-safe order.
The circuit-tree read model exposes fixed `headerComponents`, group-owned
`components`, `footerComponents`, optional group category/number and optional
one-to-one protection DTOs for circuits and group components. These are
additive to the retained flat circuit protection fields during the transition.
Distribution-board floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one
persistent undo step.
+6 -1
View File
@@ -12,7 +12,11 @@ implementiert.
## Unterstützter Arbeitsablauf
- Projekte, Verteilungen, Etagen und Räume verwalten
- pro Verteilung eine Stromkreisliste mit festen Bereichen bearbeiten
- pro Verteilung geschützte Stromkreisgruppen für Beleuchtung, 1-phasige und
3-phasige Stromkreise verwalten
- Hauptschalter, Überspannungsableiter, Gruppenschutz und zusätzliche
Verteilergeräte in der Stromkreisliste abbilden
- Schutzgeräte je Stromkreis und optional je Gruppe konfigurieren
- leere, einzeilige und mehrzeilige Stromkreise abbilden
- Stromkreise und Gerätezeilen per Drag-and-drop umstrukturieren
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
@@ -90,6 +94,7 @@ Frontend und API laufen anschließend auf denselben Ports wie im Docker-Setup.
npm test
npm run build:api
npm run build:web
npm run typecheck:scripts
npx tsc --noEmit -p tsconfig.next.json
```
+1
View File
@@ -43,6 +43,7 @@ Limitierungen und Roadmap.
- [Datenmodellkonzept](spec/03-data-model-concept.md)
- [UI- und Interaktionsanforderungen](spec/04-ui-interaction-requirements.md)
- [Verknüpfte Projektgeräte und Synchronisierung](spec/05-linked-devices-and-sync.md)
- [Verteilerkomponenten und Schutzgruppen](spec/09-distribution-board-components-and-protection-groups.md)
## Archiv
+86 -14
View File
@@ -59,16 +59,23 @@ this value for optimistic concurrency checks.
- clients cannot submit arbitrary `project.restore-state` payloads through
the generic command endpoint
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit.delete`, `circuit-device-row.update`,
`circuit-device-row.insert`, `circuit-device-row.delete`,
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
`circuit.reorder-section`, `circuit.reorder-sections`,
`circuit.renumber-section` and
`project-device.update`, `project-device.insert`, `project-device.delete` and
`project-device.sync-rows` as well as `project.update-settings`, all with
schema version `1`. Other command types
are rejected. Insert commands contain the complete entity or circuit block
The public dispatcher supports the Circuit and CircuitDeviceRow commands
`circuit.update`, `circuit.insert`, `circuit.delete`,
`circuit-device-row.update`, `circuit-device-row.insert`,
`circuit-device-row.delete`, `circuit-device-row.move`,
`circuit-device-row.move-with-new-circuit`, `circuit.reorder-section`,
`circuit.reorder-sections` and `circuit.renumber-section`.
Protected-board structure uses
`distribution-board-component.insert|update|delete`,
`circuit-group.insert|update|delete|reorder|renumber`,
`circuit.move-group`, `circuit-group.delete-subtree` and
`circuit-protection.update`. A subtree restore is normally emitted only as the
persisted inverse of a delete. The dispatcher also supports the documented
project-device, project settings, distribution-board and project-location
commands. The generic editor commands listed here use schema version `1`;
older stored formats can remain supported by their owning command boundary.
Unsupported types are rejected. Insert commands contain the complete entity or circuit block
with stable ids; delete commands include the expected parent identity. Circuit
snapshots contain zero, one or multiple complete device rows. Move commands
contain each row's expected and target circuit plus its exact expected and
@@ -101,6 +108,24 @@ targets occupied by other sections, then applies swaps through collision-safe
temporary identifiers. Undo restores the exact prior identifiers; sort
positions and device rows remain unchanged.
`circuit-group.reorder` changes only complete group sort assignments.
`circuit-group.renumber` carries every affected group, child circuit and group
component with expected and target identifiers so swaps can be applied
collision-safely. `circuit.move-group` changes one complete circuit's group,
BMK and position and stores the deterministic target BMK. The target group must
have the same category.
`circuit-group.delete-subtree` carries the complete warned group state,
including both protection layers and every device-row link/override value. The
server captures the current subtree again inside the transaction and rejects a
stale or incomplete snapshot. Undo restores the same UUIDs through the stored
inverse command.
`circuit-protection.update` inserts or replaces the complete validated
one-to-one circuit protection state. Users cannot remove the required
protection row; removal is available only to a stored inverse when undoing its
initial creation.
`project-device.sync-rows` represents `synchronize`, `disconnect` and
`reconnect` operations. Every selected row carries complete expected and target
snapshots of all ProjectDevice-sync fields, the link and `overriddenFields`.
@@ -194,11 +219,14 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
- `supplyType` is one of `AV`, `SV`, `EV`, `USV`, `MSR` or `SiBe` and must
be enabled in the project settings
- executes `distribution-board.insert` with stable ids for the distribution
board, its circuit list and all four default circuit sections
board, its circuit list, the three default circuit groups, main switch
`-Q0` and surge protective device `-FA`
- response:
`{ "distributionBoard": { ... }, "revision": { ... }, "history": { ... } }`
- persistent Undo removes only the unchanged and still-empty generated
structure; Redo restores the same ids
- stored command schema versions 1 and 2 remain executable with their four
legacy sections and without generated components
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
- body:
@@ -227,7 +255,11 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
### Tree Endpoint
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
- Purpose: returns section/circuit/device-row tree with calculated row and circuit totals.
- Purpose: returns fixed header components, grouped sections with optional
protection components, complete circuit/device-row blocks and auxiliary
footer components. Circuit and component protection devices use their
separate one-to-one DTOs. Calculated row, circuit, section and board totals
remain included.
Response sketch:
@@ -235,16 +267,47 @@ Response sketch:
{
"circuitListId": "cl_1",
"currentRevision": 12,
"headerComponents": [
{
"id": "cmp_main",
"equipmentIdentifier": "-Q0",
"name": "Hauptschalter",
"role": "main_switch",
"placement": "header"
}
],
"sections": [
{
"id": "sec_1",
"key": "lighting",
"prefix": "-1F",
"prefix": "-1F1.",
"category": "lighting",
"groupNumber": 1,
"components": [
{
"id": "cmp_rcd",
"equipmentIdentifier": "-1Q1.0",
"name": "Gruppen-FI",
"role": "group_residual_current_protection",
"placement": "group",
"protectionDevice": {
"type": "FI",
"ratedCurrentA": 40,
"rcdType": "A",
"ratedResidualCurrentMa": 30
}
}
],
"circuits": [
{
"id": "cir_1",
"equipmentIdentifier": "-1F1",
"equipmentIdentifier": "-1F1.1",
"circuitTotalPower": 4.2,
"protectionDevice": {
"type": "LS",
"ratedCurrentA": 10,
"tripCharacteristic": "B"
},
"deviceRows": [
{
"id": "row_1",
@@ -255,6 +318,15 @@ Response sketch:
}
]
}
],
"footerComponents": [
{
"id": "cmp_actor",
"equipmentIdentifier": "-K1",
"name": "KNX Schaltaktor",
"role": "auxiliary",
"placement": "footer"
}
]
}
```
+18 -10
View File
@@ -17,13 +17,15 @@ The pure grid modules have no React state and are covered by focused unit tests.
## Domain Model Overview
- `CircuitSection`
- Groups circuits by planning section (for example lighting, single-phase, three-phase).
- Owns section metadata (`key`, `displayName`, `prefix`, ordering).
- Represents a protected circuit group in one of the categories lighting,
single-phase or three-phase.
- Owns category, positive group number, generated prefix, display name and
ordering.
- `Circuit`
- Core electrical unit in the list.
- Owns circuit-level identifiers and technical data:
- `equipmentIdentifier` (BMK)
- protection data
- one explicit one-to-one protection device
- cable data
- reserve state
- voltage and optional control requirement for future sizing
@@ -43,6 +45,10 @@ The pure grid modules have no React state and are covered by focused unit tests.
- `ProjectDevice`
- Reusable device template entity at project level.
- Can be linked to `CircuitDeviceRow` entries, with copied display values on insert.
- `DistributionBoardComponent`
- Represents fixed header components, optional group protection and manually
named auxiliary footer devices with their own unique BMK.
- Group protection may own a separate one-to-one protection configuration.
- Retained legacy migration source
- Old `consumers` rows, mappings and reports are available only to explicit
database upgrade tooling.
@@ -77,7 +83,9 @@ Section-level `-frei-` placeholder rows represent insertion targets for creating
`equipmentIdentifier` / BMK is circuit-owned (`Circuit.equipmentIdentifier`).
- Device rows do not have their own BMK.
- Existing identifiers must stay stable unless explicitly changed by user action.
- Existing identifiers stay stable on sort, insert and delete. Explicit
renumbering may change them, and a user-initiated complete-circuit move to
another same-category group assigns the next target-group identifier.
- Renumbering is explicit, not implicit on move/sort/delete.
## Data Ownership Split: Circuit vs Device Row
@@ -113,9 +121,9 @@ The sibling `/tree` route is a read-only structure preview. Old
## Future Persistence Direction
Persistent undo/redo, project revisions, logical snapshots, external-model exchange and PostgreSQL readiness are specified in [Project History and External Model Architecture](./project-history-and-external-model-architecture.md).
Critical multi-write commands already use explicit persistence transaction
adapters. The next step is to compose these command boundaries into server-side,
project-scoped revisions and change sets. Database backups remain separate from
user-visible project snapshots.
Persistent Undo/Redo, project revisions and logical snapshots are implemented
through project-scoped commands and the shared transaction boundary. Database
backups remain separate from user-visible project snapshots. External-model
exchange and PostgreSQL readiness are specified in
[Project History and External Model Architecture](./project-history-and-external-model-architecture.md)
and remain future work.
+37 -7
View File
@@ -28,6 +28,7 @@ The editor can have a selected cell without an active editor input. `editingCell
The UI works from a normalized `visibleRows` model built from filtered/sorted sections:
- section header rows
- fixed header, group-protection and auxiliary footer component rows
- circuit rows (`circuitCompact`, `circuitSummary`, `reserveCircuit`)
- device rows (`deviceRow`)
- section placeholder rows (`placeholder`)
@@ -82,13 +83,21 @@ Intent is separated by drag source type:
- device row drag:
- drop to existing circuit -> move row(s) into that circuit
- drop to placeholder -> create new target circuit and move row(s)
- drop on the upper or lower edge of a circuit row -> create a new target
circuit directly before or after that circuit and move the row(s)
- the center of a circuit row remains the explicit target for adding the
row(s) to that existing circuit
- crossing a section boundary requires explicit confirmation
- phase type, category, linked project device and local row values remain unchanged
- target creation, row assignments and reserve-state updates use one SQLite transaction
- circuit drag (BMK handle):
- reorder circuits inside same section only
- cross-section reorder is rejected
- the complete section order is validated and stored in one SQLite transaction
- reorder one or multiple circuits inside the same group without changing BMKs
- move exactly one circuit into another group of the same category
- a cross-group move assigns the highest existing target suffix plus one;
gaps are not filled
- cross-category and cross-group multi-circuit moves are rejected
- same-group reorder validates the complete group order; cross-group move
stores the one exact source/target transition
- bulk device row move:
- supported via multi-selection + drag
- multi-circuit move:
@@ -96,7 +105,25 @@ Intent is separated by drag source type:
The sidebar insertion controls use the same project-device placement rules as drag-and-drop. Invalid section and circuit options are disabled after selecting a project device.
Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Moving never renumbers existing circuits.
Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Device-row moves never renumber circuits; the explicitly initiated complete-circuit group move is the sole automatic BMK-changing move.
## Protected Group and Component Controls
- New groups are created manually in one of the three supported categories and
receive the highest existing category group number plus one.
- Group headings always show the stored editable display name; category labels
do not overwrite names of the default first groups.
- Group reorder buttons move only relative to category peers and never change a
group number or BMK.
- `Gruppen-BMK neu nummerieren` is an explicit confirmed category-wide action.
It follows the persisted group order and updates group prefixes, optional
`.0` protection BMKs and circuit BMKs together.
- Optional group upstream protection and group FI as well as auxiliary footer
components use labeled, type-dependent modals.
- Each circuit protection device uses its own modal; only fields valid for the
selected type are shown.
- Removing a populated group warns with circuit, device-row and group
protection counts. The complete deletion remains persistently undoable.
## Filtering and Sorting
@@ -118,7 +145,8 @@ Cross-section device moves show confirmation-required feedback before drop. The
- Column visibility and order are configurable.
- BMK column (`equipmentIdentifier`) is locked as first column.
- Layout is saved in local storage (`circuitTreeEditor.columnLayout.v1`).
- Layout is saved in local storage under a project-specific key so switching
distribution boards preserves the same project layout.
## Undo/Redo
@@ -138,8 +166,10 @@ Covered operations include:
- insert/delete circuit
- insert/delete row
- edit cell values
- moves (single/bulk rows, circuit reorder)
- renumber and identifier update flows
- moves (single/bulk rows, circuit reorder and same-category group move)
- circuit and group renumber/identifier flows
- component, group and protection editing
- warned populated-group deletion and exact restoration
- apply sorted order
The editor toolbar exposes availability through its Undo/Redo buttons. The
@@ -16,5 +16,11 @@
- Final electrical sizing logic is not implemented yet.
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
- Sorting is view-only until users explicitly apply sorted order.
- Cross-section circuit drag-reorder is intentionally blocked.
- Complete-circuit drag supports one circuit at a time between groups of the
same category. Cross-category moves and multi-circuit moves across group
boundaries are intentionally rejected; multi-circuit reorder inside one
group remains supported.
- Protection and cable values are planner-owned manual selections. The later
sizing/warning engine does not yet validate them against load, length or
voltage-drop rules.
- Legacy consumer UI and server endpoints are removed; retained source rows and migration mappings remain available only for upgrade traceability.
+153 -4
View File
@@ -261,8 +261,9 @@ mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu
zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt
die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen
innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
@@ -270,8 +271,10 @@ Projektübersicht verwendet dafür den separaten Collection-Endpunkt
deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das
Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den
projektgebundenen Endpunkt beschränkt.
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
`distribution-board.insert` versioniert die Anlage einer Verteilung mit
Command-Schema 3 als vollständigen Block aus Verteilung, Stromkreisliste, den
drei Gruppen `Beleuchtung 1`, `1-phasig 1`, `3-phasig 1`, Hauptschalter `-Q0`
und Überspannungsableiter `-FA`.
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
`distribution-board.delete` ist die persistierte Inverse und entfernt nur den
vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
@@ -280,6 +283,130 @@ Controller-Schreibweg ist entfernt. Verteilungen besitzen eine optionale
Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und
nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
Freigabe der Netzart in den Projekteinstellungen.
Gespeicherte Anlage-Commands der Schemas 1 und 2 bleiben mit ihren vier
Legacy-Abschnitten und ohne nachträglich erfundene Komponenten ausführbar.
`distribution-board-component.insert` und
`distribution-board-component.delete` sowie
`distribution-board-component.update` versionieren Anlage, Entfernung,
Bearbeitung und Sortierung veränderlicher Gruppen-Schutzgeräte und zusätzlicher
Verteilergeräte. Die vollständigen Snapshots enthalten stabile
Komponenten-UUIDs und, für Gruppen-Schutzgeräte, die getrennte
1:1-Schutzgerätekonfiguration. Updates tauschen einen exakt erwarteten gegen
einen vollständigen Zielzustand aus; ID, Eigentum, Rolle und Platzierungszone
bleiben dabei unverändert. Projekt-, Listen- und Gruppenzugehörigkeit sowie
veraltete Zustände werden innerhalb derselben Transaktion geprüft. Die festen
Kopfkomponenten Hauptschalter und Überspannungsableiter sind von diesen
allgemeinen Commands ausgeschlossen. Veränderliche Gruppen- und Fußkomponenten
werden im Editor über eigene Modale und diese Commands verwaltet.
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. Befüllte Gruppen verwenden getrennt davon den vollständigen
`circuit-group.delete-subtree`-Command mit ausdrücklicher Warnung.
`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.
`circuit-grid-projection.ts` normalisiert diese Struktur zusammen mit den
bisherigen kompakten und mehrzeiligen Stromkreisblöcken für den Editor. Kopf-,
Gruppen- und Fußkomponenten erscheinen als Zeilen über die volle Tabellenbreite
und nehmen nicht an Zellauswahl oder Tastatur-Einfügen teil. Hauptschalter und
Überspannungsableiter bleiben feste, schreibgeschützte Kopfkomponenten.
Optionale Gruppenvorsicherungen, Gruppen-FIs und zusätzliche Verteilergeräte
werden über beschriftete Modale angelegt, bearbeitet und nach Bestätigung
entfernt. Der Frontend-API-Adapter serialisiert dafür die vorhandenen
`distribution-board-component.*`-Commands; die verbindliche
geräteabhängige Validierung und atomare Revision bleiben serverseitig.
Neue Stromkreisgruppen erhalten im Editor die höchste vorhandene
Gruppennummer ihrer Kategorie plus eins und ein daraus abgeleitetes Präfix.
Das Bearbeiten ändert ausschließlich den Anzeigenamen. Nur vollständig leere
Gruppen können über `circuit-group.delete` entfernt werden; für befüllte
Gruppen bleibt die Aktion bis zum gesonderten Warn- und Unterbaumdialog
gesperrt.
`circuit-protection.update` bildet die eigene persistente Schreibgrenze für
die neue 1:1-Stromkreisschutztabelle. Der Command vergleicht den vollständigen
erwarteten Datensatz, validiert Typ und abhängige Felder, prüft die
Projektzugehörigkeit und schreibt Schutzgerät, Revision und Historienübergang
atomar. Benutzer können Schutzdaten anlegen oder ändern, aber nicht entfernen;
Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt
wiederherstellen.
Der vollständige `CircuitSnapshot` kann rückwärtskompatibel einen
`protectionDevice`-Datensatz enthalten. Neue gruppierte Stromkreise und durch
Geräteverschiebung erzeugte Zielstromkreise verwenden die vereinbarten
Kategorie-Standardwerte und schreiben Schutzgerät, Stromkreis sowie
Gerätezeilen atomar. Delete/Undo erfasst denselben Datensatz vollständig.
Im Editor sind die bisherigen Schutzspalten deshalb nur noch eine
schreibgeschützte Projektion der 1:1-Daten; Änderungen erfolgen über ein
geräteabhängiges Schutzgeräte-Modal und `circuit-protection.update`.
Gruppen lassen sich im Editor schrittweise nur gegenüber einer benachbarten
Gruppe derselben Kategorie verschieben. Der Client sendet dabei immer die
vollständige erwartete und gewünschte Sortierreihenfolge über
`circuit-group.reorder`; Gruppennummer, Präfix und Kind-BMK bleiben unverändert.
Eine separate, ausdrücklich bestätigte Editoraktion erzeugt dagegen einen
vollständigen `circuit-group.renumber`-Plan für genau eine Kategorie. Sie
nummeriert deren Gruppen gemäß aktueller Reihenfolge ab eins und ändert
Gruppenpräfixe, optionale Gruppen-Schutz-BMK und Stromkreis-BMK gemeinsam;
die Stromkreisnummer hinter dem letzten Punkt bleibt erhalten.
Das vorhandene Stromkreis-Drag-Handle kann außerdem genau einen vollständigen
Stromkreis in eine andere Gruppe derselben Kategorie verschieben. Der atomare
`circuit.move-group`-Befehl erhält Gerätezeilen und Schutzdaten, setzt die
Zielposition und vergibt dort die höchste vorhandene Stromkreisnummer plus eins;
Lücken werden nicht automatisch gefüllt.
Befüllte Gruppen werden im Editor erst nach einer Warnung mit Anzahl der
enthaltenen Stromkreise, Gerätezeilen und Gruppenschutzgeräte gelöscht. Der
Client sendet dafür den vollständigen aktuellen Unterbaum an
`circuit-group.delete-subtree`; der Server vergleicht ihn innerhalb derselben
Transaktion mit dem Datenbankstand. Undo stellt Gruppe, Schutzgeräte,
Stromkreise, Gerätereihen und Verknüpfungs-/Override-Metadaten vollständig
wieder her.
`distribution-board.update` versioniert Etage, Netzart und den
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
@@ -290,6 +417,13 @@ Snapshot-Schema 4 enthält bereits Etage und Netzart; Schema 1/2 sowie
gespeicherte Version-1-Strukturcommands werden ohne erfundene Zuordnung
hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als
Projektauswahl hochgestuft.
Snapshot-Schema 7 ergänzt Gruppenkategorie und -nummer,
Verteilerkomponenten sowie die getrennten 1:1-Schutzgerätedaten. Capture,
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
JSON-Importmodi verwenden denselben vollständigen Zustand. Schema 6 und älter
bleiben lesbar: Die drei bekannten Abschnittsschlüssel werden deterministisch
Gruppe 1 zugeordnet, neue Komponenten- und Schutzgerätesammlungen bleiben leer
und bestehende flache Schutzangaben unverändert erhalten.
`project-floor.insert` und `project-room.insert` versionieren die Anlage von
Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden
jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur
@@ -335,6 +469,21 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
- Die vollständige Verteilungs-Testfixture liegt unter
`tests/support/distribution-board-fixture.ts` und ist kein exportierter
Produktions-Schreibweg.
- Migration `0024` bildet die additive relationale Grundlage für geschützte
Stromkreisgruppen und Verteilerkomponenten. Die drei bestehenden
Stromkreisabschnitte erhalten Kategorie und Gruppennummer 1. Separate
1:1-Tabellen halten künftig Stromkreis- und Komponenten-Schutzgeräte; die
bisherigen flachen Schutzfelder bleiben in dieser Übergangsphase unverändert.
Ein triggergeführtes Register erzwingt bereits eine normalisierte,
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und
Fußkomponenten über dedizierte Command-Modale. Gruppenanlage, -umbenennung,
-sortierung, explizite Neunummerierung, Same-Category-Stromkreiswechsel,
geschütztes Unterbaumlöschen und Stromkreisschutz sind integriert.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
+8 -2
View File
@@ -45,10 +45,16 @@ Projekt- oder Kundendaten versehentlich ins Repository. Für einen lokalen
Testbestand:
1. In der UI ein Projekt `Demo` anlegen.
2. Im Projekt eine Verteilung `UV-01` erstellen.
2. Im Projekt eine Verteilung `UV-01` erstellen. Die Stromkreisliste enthält
danach Hauptschalter, Überspannungsableiter und je eine Gruppe für
Beleuchtung, 1-phasige und 3-phasige Stromkreise.
3. Ein Projektgerät mit unkritischen Fantasiewerten anlegen.
4. Die erzeugte Stromkreisliste öffnen und das Gerät in einen passenden Bereich
4. Die erzeugte Stromkreisliste öffnen und das Gerät in eine passende Gruppe
ziehen.
5. Optional eine zweite Gruppe derselben Kategorie, einen Gruppen-FI und ein
zusätzliches Verteilergerät anlegen. Damit lassen sich Gruppensortierung,
explizite Neunummerierung, Stromkreiswechsel und persistentes Undo/Redo ohne
reale Projekt- oder Kundendaten prüfen.
Dateien unter `data/` und `data/backups/` dürfen nicht committed werden.
+1 -1
View File
@@ -264,6 +264,6 @@ nur Architekturvorschläge des LLM sind.
- `docs/spec/03-data-model-concept.md` Datenmodellkonzept
- `docs/spec/05-linked-devices-and-sync.md` Regeln für Projektgeräte und
kontrollierte Synchronisierung
- `docs/spec/07-implementation-phases-todo.md` Roadmap, insbesondere Phase 13
- `docs/spec/07-implementation-phases-todo.md` Roadmap, insbesondere Phase 14
- `docs/circuit-list-editor-known-limitations.md` noch nicht implementierte
Funktionen
+36 -2
View File
@@ -496,7 +496,41 @@ Acceptance criteria:
- restoring an older state does not delete intervening history
- concurrent stale commands cannot silently overwrite newer project state
## Phase 13: External Model Round-Trip
## Phase 13: Distribution Board Components and Protection Groups
Goal:
Extend the circuit list into the structural basis of a tabular single-line
diagram before defining Revit parameter exchange.
Detailed specification:
- `docs/spec/09-distribution-board-components-and-protection-groups.md`
Delivery:
- add typed protection-device catalogs and defaults
- evolve sections into repeatable category-specific circuit groups
- add fixed, group and auxiliary distribution-board components
- give every new circuit one explicit protection device
- implement nested BMK generation, same-category group moves and explicit
collision-safe group renumbering
- preserve all new state through commands, snapshots, transfer and persistent
Undo/Redo
- project the header, groups and auxiliary footer in the spreadsheet editor
Acceptance criteria:
- new boards contain the main switch, surge protective device and three default
groups
- all circuits and components have list-wide unique BMKs
- circuit reordering keeps identifiers while cross-group moves assign the next
target-group identifier
- destructive group operations are confirmed, atomic and fully undoable
- existing supported project data migrates without silently changing BMKs or
protection selections
## Phase 14: External Model Round-Trip
Goal:
@@ -521,7 +555,7 @@ Acceptance criteria:
PostgreSQL should be introduced when shared multi-user operation, background jobs or operational scale justify it. External-object count alone does not require an immediate migration.
## Phase 14: Documentation and Collaboration Handoff
## Phase 15: Documentation and Collaboration Handoff
Goal:
+45 -2
View File
@@ -8,6 +8,48 @@ requirements and intended sequencing, not proof of implementation.
- Collect the remaining editor interaction issues before changing additional
behavior.
## Distribution Board Components and Protection Groups
- [x] 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.
- [x] Phase E2b: render the structure projection in the editor grid.
- [x] Phase E3a: create, edit and delete mutable group and footer components.
- [x] Phase E3b1: create, rename and safely delete empty circuit groups.
- [x] Phase E3b2a: persistent circuit-protection update command.
- [x] Phase E3b2b: circuit-protection defaults on insert and editor modal.
- [x] Phase E4a: persistent same-category group reorder controls.
- [x] Phase E4b: explicit group renumber, circuit moves and populated-delete warning.
- [x] Explicit same-category group renumbering updates prefixes and all child BMK atomically.
- [x] Single-circuit moves between same-category groups with automatic next-free BMK assignment.
- [x] Populated-group delete warning and explicit subtree deletion.
- [x] Phase E: editor projection and editing.
- [x] Phase F: documentation and full GUI verification.
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
the planner supplies the calculation requirements.
- Detailed specification:
`docs/spec/09-distribution-board-components-and-protection-groups.md`
## First Release Baseline
Complete this immediately before the first supported release, once the schema
@@ -90,5 +132,6 @@ and initial feature set are frozen:
3. [x] Snapshot-to-revision descriptions and history presentation.
4. [x] Distribution-board power summary and project column layout.
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
must not be inferred from these tasks.
The Revit/CSV/IFCGUID round-trip follows the distribution-board component
foundation as a separate jointly planned phase and must not be inferred from
these tasks.
@@ -0,0 +1,853 @@
# Distribution Board Components and Protection Groups
## Status
This document records the agreed and implemented behavior for
distribution-board components, protected circuit groups and circuit
protection devices. Delivery phases A through E are complete. Phase F retains
the final GUI checklist; the current runtime paths are documented in
`docs/current-architecture.md`.
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: Complete.
- 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/E2b/E3a:
- 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
- the editor renders fixed header, group protection and auxiliary footer
components as read-only full-width rows
- structural component rows are excluded from spreadsheet cell navigation,
insertion and deletion commands until their dedicated editing controls exist
- group headings offer at most one optional upstream protection device and one
optional group RCD; the agreed group-RCD default is applied only when it is
created
- mutable group protection and auxiliary footer components use labeled modals,
type-dependent protection fields and persistent project commands
- fixed main-switch and surge-protection header components remain read-only
- users can create a group in any supported category; its number is the
category's highest existing number plus one and its prefix is generated
- group editing changes only the display name; category, number, prefix and
child BMKs remain stable
- an exactly empty group can be removed through the compact persistent group
command
- `circuit-protection.update` inserts or updates one validated 1:1 protection
snapshot, rejects stale state and preserves exact persistent Undo/Redo
- user commands cannot remove circuit protection; a nullable target exists
only as the inverse of adding protection to a retained circuit without it
- newly inserted circuits and placeholder targets carry the category-specific
default protection in their complete atomic snapshot
- circuit deletion and restoration preserve the exact 1:1 protection row
together with the circuit and all device rows
- the editor modal exposes only fields valid for the selected protection type;
the old flat protection columns now project the new 1:1 data read-only
- group controls move a group only to the adjacent peer of the same category;
the complete list order is one persistent command and no number or BMK changes
- explicit category-wide group renumbering follows the visible persisted group
order, updates every affected prefix and child BMK atomically and preserves
outgoing-circuit suffixes
- dragging one circuit into another group of the same category moves the
complete circuit and assigns the highest target suffix plus one; same-group
multi-circuit drag remains a pure reorder
- populated-group deletion shows the affected circuit, device-row and group
protection counts before sending the complete subtree to the guarded
delete/restore command
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
Status: Complete. Documentation, automated verification and the agreed GUI
checklist are complete.
- update current architecture, API and interaction documentation
- update the safe local demo-data instructions; no versioned customer-like
seed data is introduced
- 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.
## Resolved Implementation Decisions
- a trigger-maintained circuit-list registry enforces shared BMK uniqueness
across circuits and distribution-board components
- group headings and full-width structural rows show ownership without adding
a permanent group column
- structural component rows remain outside editable-cell filtering and
spreadsheet navigation; circuit filtering continues to preserve complete
circuit blocks
- 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
View File
@@ -15,8 +15,8 @@
"build:web": "next build",
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
"start": "node dist/server/index.js",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test": "tsx --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/distribution-board-component-schema-migration.test.ts tests/circuit-group-numbering.test.ts tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/distribution-board-component-schema-migration.test.ts tests/circuit-group-numbering.test.ts tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts",
+72 -3
View File
@@ -8,12 +8,21 @@ const requiredTables = [
"circuit_sections",
"circuits",
"circuit_device_rows",
"circuit_list_equipment_identifiers",
"circuit_protection_devices",
"distribution_board_components",
"distribution_board_component_protection_devices",
"legacy_consumer_circuit_migrations",
"legacy_consumer_migration_reports",
];
const tablePlaceholders = requiredTables.map(() => "?").join(", ");
const rows = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?, ?, ?)")
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'table' AND name IN (${tablePlaceholders})`
)
.all(...requiredTables);
const existing = new Set(rows.map((row) => row.name));
@@ -48,6 +57,16 @@ const circuitColumns = new Set(
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
);
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
const requiredCircuitSectionColumns = ["category", "group_number"];
const circuitSectionColumns = new Set(
db
.prepare("PRAGMA table_info(circuit_sections)")
.all()
.map((column) => column.name)
);
const missingCircuitSectionColumns = requiredCircuitSectionColumns.filter(
(name) => !circuitSectionColumns.has(name)
);
const requiredProjectColumns = [
"internal_project_number",
"external_project_number",
@@ -61,7 +80,11 @@ const projectColumns = new Set(
const missingProjectColumns = requiredProjectColumns.filter(
(name) => !projectColumns.has(name)
);
const requiredDistributionBoardColumns = ["floor_id", "supply_type"];
const requiredDistributionBoardColumns = [
"floor_id",
"supply_type",
"simultaneity_factor",
];
const distributionBoardColumns = new Set(
db
.prepare("PRAGMA table_info(distribution_boards)")
@@ -72,6 +95,28 @@ const missingDistributionBoardColumns =
requiredDistributionBoardColumns.filter(
(name) => !distributionBoardColumns.has(name)
);
const requiredEquipmentIdentifierTriggers = [
"circuits_equipment_identifier_insert",
"circuits_equipment_identifier_update",
"circuits_equipment_identifier_delete",
"distribution_board_components_equipment_identifier_insert",
"distribution_board_components_equipment_identifier_update",
"distribution_board_components_equipment_identifier_delete",
];
const equipmentIdentifierTriggers = new Set(
db
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'trigger'`
)
.all()
.map((trigger) => trigger.name)
);
const missingEquipmentIdentifierTriggers =
requiredEquipmentIdentifierTriggers.filter(
(name) => !equipmentIdentifierTriggers.has(name)
);
const integrityCheck = db.pragma("integrity_check", { simple: true });
const foreignKeyViolations = db.pragma("foreign_key_check");
@@ -80,11 +125,19 @@ console.log("Required tables:", requiredTables.join(", "));
console.log("Existing tables:", [...existing].join(", ") || "(none)");
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
console.log(
"Required circuit-section columns:",
requiredCircuitSectionColumns.join(", ")
);
console.log("Required project columns:", requiredProjectColumns.join(", "));
console.log(
"Required distribution-board columns:",
requiredDistributionBoardColumns.join(", ")
);
console.log(
"Required BMK synchronization triggers:",
requiredEquipmentIdentifierTriggers.join(", ")
);
console.log("Integrity:", integrityCheck);
console.log("Foreign-key violations:", foreignKeyViolations.length);
@@ -111,6 +164,14 @@ if (missingCircuitColumns.length > 0) {
process.exit(1);
}
if (missingCircuitSectionColumns.length > 0) {
console.error(
"Missing circuit-section columns:",
missingCircuitSectionColumns.join(", ")
);
process.exit(1);
}
if (missingProjectColumns.length > 0) {
console.error("Missing project columns:", missingProjectColumns.join(", "));
process.exit(1);
@@ -124,9 +185,17 @@ if (missingDistributionBoardColumns.length > 0) {
process.exit(1);
}
if (missingEquipmentIdentifierTriggers.length > 0) {
console.error(
"Missing BMK synchronization triggers:",
missingEquipmentIdentifierTriggers.join(", ")
);
process.exit(1);
}
if (integrityCheck !== "ok" || foreignKeyViolations.length > 0) {
console.error("Database integrity or foreign-key verification failed.");
process.exit(1);
}
console.log("Circuit-first schema verification passed.");
console.log("Circuit-first and distribution-component schema verification passed.");
+51
View File
@@ -951,6 +951,57 @@ a.kpi:hover {
font-weight: 600;
}
.tree-grid .structure-component-row td {
padding: 0.38rem 0.6rem;
border-bottom-color: #d8dee9;
}
.tree-grid .structure-component-row.headerComponent td {
background: #e2e8f0;
}
.tree-grid .structure-component-row.groupComponent td {
background: #f8fafc;
}
.tree-grid .structure-component-row.footerComponent td {
background: #f1f5f9;
}
.tree-grid .structure-component-content {
display: flex;
align-items: baseline;
gap: 0.75rem;
min-width: 0;
}
.tree-grid .structure-component-content strong {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.tree-grid .structure-component-protection {
color: #475569;
font-size: 0.78rem;
}
.tree-grid .structure-component-actions {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-left: auto;
}
.tree-grid .structure-component-actions button {
font-size: 0.75rem;
padding: 0.18rem 0.45rem;
}
.tree-grid .structure-component-fixed {
color: #64748b;
font-size: 0.75rem;
}
.tree-grid .section-drop-cell {
padding: 0.45rem 0.6rem;
}
@@ -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
+7
View File
@@ -169,6 +169,13 @@
"when": 1785343645615,
"tag": "0023_cheerful_night_thrasher",
"breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1785431250141,
"tag": "0024_damp_mister_sinister",
"breakpoints": true
}
]
}
@@ -17,6 +17,7 @@ import type {
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
@@ -396,6 +397,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository
remark: snapshot.remark,
})
.run();
if (snapshot.protectionDevice) {
database
.insert(circuitProtectionDevices)
.values(snapshot.protectionDevice)
.run();
}
}
private assertTargetCircuitUnchanged(
@@ -438,6 +445,27 @@ export class CircuitDeviceRowMoveProjectCommandRepository
"Created target circuit changed before command execution."
);
}
const protectionDevice = database
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, snapshot.id))
.get();
if (
snapshot.protectionDevice === undefined
? protectionDevice !== undefined
: snapshot.protectionDevice === null
? protectionDevice !== undefined
: !protectionDevice ||
Object.entries(snapshot.protectionDevice).some(
([key, value]) =>
(protectionDevice as Record<string, unknown>)[key] !==
value
)
) {
throw new Error(
"Created target circuit protection changed before command execution."
);
}
const currentRowIds = database
.select({ id: circuitDeviceRows.id })
@@ -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));
}
}
@@ -0,0 +1,97 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitProtectionUpdateProjectCommand,
createCircuitProtectionUpdateProjectCommand,
type CircuitProtectionSnapshot,
} from "../../domain/models/circuit-protection-project-command.model.js";
import type {
CircuitProtectionProjectCommandStore,
ExecuteCircuitProtectionCommandInput,
} from "../../domain/ports/circuit-protection-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuits } from "../schema/circuits.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class CircuitProtectionProjectCommandRepository
implements CircuitProtectionProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitProtectionCommandInput) {
return executeProjectCommandTransaction(this.database, input, (tx) => {
assertCircuitProtectionUpdateProjectCommand(input.command);
const { circuitId, expected, target } = input.command.payload;
if (input.source === "user" && target === null) {
throw new Error("User commands cannot remove circuit protection.");
}
this.assertOwnership(tx, input.projectId, circuitId);
const actual = tx
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuitId))
.get();
if (!sameNullableSnapshot(expected, actual)) {
throw new Error("Circuit protection changed before update.");
}
if (target === null) {
tx.delete(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuitId))
.run();
} else if (actual) {
tx.update(circuitProtectionDevices)
.set(target)
.where(eq(circuitProtectionDevices.circuitId, circuitId))
.run();
} else {
tx.insert(circuitProtectionDevices).values(target).run();
}
return createCircuitProtectionUpdateProjectCommand(
circuitId,
target,
expected
);
});
}
private assertOwnership(
database: AppDatabase,
projectId: string,
circuitId: string
) {
const circuit = database
.select({ projectId: circuitLists.projectId })
.from(circuits)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(
and(
eq(circuits.id, circuitId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!circuit) {
throw new Error("Circuit protection does not belong to project.");
}
}
}
function sameNullableSnapshot(
expected: CircuitProtectionSnapshot | null,
actual: typeof circuitProtectionDevices.$inferSelect | undefined
) {
if (expected === null) {
return actual === undefined;
}
if (!actual) {
return false;
}
return Object.entries(expected).every(
([key, value]) =>
(actual as Record<string, unknown>)[key] === value
);
}
@@ -17,6 +17,7 @@ import { isElectricalPhaseType } from "../../domain/services/project-voltage.ser
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 {
@@ -175,6 +176,12 @@ export class CircuitStructureProjectCommandRepository
if (snapshot.deviceRows.length > 0) {
database.insert(circuitDeviceRows).values(snapshot.deviceRows).run();
}
if (snapshot.protectionDevice) {
database
.insert(circuitProtectionDevices)
.values(snapshot.protectionDevice)
.run();
}
return createCircuitDeleteProjectCommand(
snapshot.id,
@@ -219,6 +226,11 @@ export class CircuitStructureProjectCommandRepository
asc(circuitDeviceRows.id)
)
.all();
const protectionDevice = database
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
.get();
const inverse = createCircuitInsertProjectCommand({
id: circuit.id,
circuitListId: circuit.circuitListId,
@@ -240,6 +252,7 @@ export class CircuitStructureProjectCommandRepository
isReserve: Boolean(circuit.isReserve),
remark: circuit.remark,
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
protectionDevice: protectionDevice ?? null,
});
const result = database
@@ -0,0 +1,263 @@
import { and, eq } from "drizzle-orm";
import {
assertDistributionBoardComponentDeleteProjectCommand,
assertDistributionBoardComponentInsertProjectCommand,
assertDistributionBoardComponentUpdateProjectCommand,
createDistributionBoardComponentDeleteProjectCommand,
createDistributionBoardComponentInsertProjectCommand,
createDistributionBoardComponentUpdateProjectCommand,
distributionBoardComponentDeleteCommandType,
distributionBoardComponentInsertCommandType,
distributionBoardComponentUpdateCommandType,
type DistributionBoardComponentSnapshot,
type DistributionBoardComponentStructureProjectCommand,
} from "../../domain/models/distribution-board-component-structure-project-command.model.js";
import type {
DistributionBoardComponentStructureProjectCommandStore,
ExecuteDistributionBoardComponentStructureCommandInput,
} from "../../domain/ports/distribution-board-component-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class DistributionBoardComponentStructureProjectCommandRepository
implements DistributionBoardComponentStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(
input: ExecuteDistributionBoardComponentStructureCommandInput
) {
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(
tx,
input.projectId,
input.command
)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: DistributionBoardComponentStructureProjectCommand
): DistributionBoardComponentStructureProjectCommand {
if (command.type === distributionBoardComponentInsertCommandType) {
assertDistributionBoardComponentInsertProjectCommand(command);
this.insert(database, projectId, command.payload.snapshot);
return createDistributionBoardComponentDeleteProjectCommand(
command.payload.snapshot
);
}
if (command.type === distributionBoardComponentDeleteCommandType) {
assertDistributionBoardComponentDeleteProjectCommand(command);
this.delete(database, projectId, command.payload.snapshot);
return createDistributionBoardComponentInsertProjectCommand(
command.payload.snapshot
);
}
if (command.type === distributionBoardComponentUpdateCommandType) {
assertDistributionBoardComponentUpdateProjectCommand(command);
this.update(
database,
projectId,
command.payload.expected,
command.payload.target
);
return createDistributionBoardComponentUpdateProjectCommand(
command.payload.target,
command.payload.expected
);
}
throw new Error(
"Unsupported distribution-board component structure command."
);
}
private insert(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardComponentSnapshot
) {
this.assertOwnership(database, projectId, snapshot);
const existing = database
.select({ id: distributionBoardComponents.id })
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, snapshot.component.id))
.get();
if (existing) {
throw new Error("Distribution-board component id already exists.");
}
database
.insert(distributionBoardComponents)
.values(snapshot.component)
.run();
if (snapshot.protectionDevice !== null) {
database
.insert(distributionBoardComponentProtectionDevices)
.values(snapshot.protectionDevice)
.run();
}
}
private delete(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardComponentSnapshot
) {
this.assertOwnership(database, projectId, snapshot);
const component = database
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, snapshot.component.id))
.get();
const protection = database
.select()
.from(distributionBoardComponentProtectionDevices)
.where(
eq(
distributionBoardComponentProtectionDevices.componentId,
snapshot.component.id
)
)
.get();
if (
!component ||
!sameRecord(snapshot.component, component) ||
!sameNullableRecord(snapshot.protectionDevice, protection)
) {
throw new Error(
"Distribution-board component changed before deletion."
);
}
const deleted = database
.delete(distributionBoardComponents)
.where(
and(
eq(distributionBoardComponents.id, component.id),
eq(
distributionBoardComponents.circuitListId,
snapshot.component.circuitListId
)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error(
"Distribution-board component could not be deleted."
);
}
}
private update(
database: AppDatabase,
projectId: string,
expected: DistributionBoardComponentSnapshot,
target: DistributionBoardComponentSnapshot
) {
this.assertOwnership(database, projectId, expected);
this.assertOwnership(database, projectId, target);
const component = database
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, expected.component.id))
.get();
const protection = database
.select()
.from(distributionBoardComponentProtectionDevices)
.where(
eq(
distributionBoardComponentProtectionDevices.componentId,
expected.component.id
)
)
.get();
if (
!component ||
!sameRecord(expected.component, component) ||
!sameNullableRecord(expected.protectionDevice, protection)
) {
throw new Error(
"Distribution-board component changed before update."
);
}
database
.update(distributionBoardComponents)
.set(target.component)
.where(eq(distributionBoardComponents.id, expected.component.id))
.run();
if (
expected.protectionDevice !== null &&
target.protectionDevice !== null
) {
database
.update(distributionBoardComponentProtectionDevices)
.set(target.protectionDevice)
.where(
eq(
distributionBoardComponentProtectionDevices.componentId,
expected.component.id
)
)
.run();
}
}
private assertOwnership(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardComponentSnapshot
) {
const list = database
.select({ projectId: circuitLists.projectId })
.from(circuitLists)
.where(eq(circuitLists.id, snapshot.component.circuitListId))
.get();
if (!list || list.projectId !== projectId) {
throw new Error(
"Distribution-board component circuit list does not belong to project."
);
}
if (snapshot.component.sectionId !== null) {
const section = database
.select({ circuitListId: circuitSections.circuitListId })
.from(circuitSections)
.where(eq(circuitSections.id, snapshot.component.sectionId))
.get();
if (
!section ||
section.circuitListId !== snapshot.component.circuitListId
) {
throw new Error(
"Distribution-board component section does not belong to circuit list."
);
}
}
}
}
function sameNullableRecord(
expected: object | null,
actual: object | undefined
) {
return expected === null
? actual === undefined
: actual !== undefined && sameRecord(expected, actual);
}
function sameRecord(expected: object, actual: object) {
const expectedEntries = Object.entries(expected);
const actualRecord = actual as Record<string, unknown>;
return (
expectedEntries.length === Object.keys(actual).length &&
expectedEntries.every(
([key, value]) => actualRecord[key] === value
)
);
}
@@ -0,0 +1,36 @@
import { asc, eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
export class DistributionBoardComponentRepository {
constructor(private readonly database: AppDatabase) {}
async listByCircuitList(circuitListId: string) {
return this.database
.select()
.from(distributionBoardComponents)
.where(
eq(distributionBoardComponents.circuitListId, circuitListId)
)
.orderBy(
asc(distributionBoardComponents.sortOrder),
asc(distributionBoardComponents.id)
);
}
async listProtectionByComponentIds(componentIds: string[]) {
if (componentIds.length === 0) {
return [];
}
return this.database
.select()
.from(distributionBoardComponentProtectionDevices)
.where(
inArray(
distributionBoardComponentProtectionDevices.componentId,
componentIds
)
);
}
}
@@ -2,14 +2,12 @@ import { and, asc, eq, inArray } from "drizzle-orm";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
createDistributionBoardDeleteProjectCommand,
createDistributionBoardInsertProjectCommand,
distributionBoardDeleteCommandType,
distributionBoardInsertCommandType,
invertDistributionBoardStructureProjectCommand,
normalizeDistributionBoardStructureProjectCommand,
type DistributionBoardStructureProjectCommand,
type DistributionBoardStructureSnapshot,
legacyDistributionBoardStructureCommandSchemaVersion,
type NormalizedDistributionBoardStructureSnapshot,
} from "../../domain/models/distribution-board-structure-project-command.model.js";
import {
assertDistributionBoardUpdateProjectCommand,
@@ -28,6 +26,7 @@ import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js";
@@ -68,31 +67,15 @@ export class DistributionBoardStructureProjectCommandRepository
projectId: string,
command: DistributionBoardStructureProjectCommand
): DistributionBoardStructureProjectCommand {
const normalized =
const structure =
normalizeDistributionBoardStructureProjectCommand(command);
if (normalized.type === distributionBoardInsertCommandType) {
assertDistributionBoardInsertProjectCommand(normalized);
const inverse = this.insert(
database,
projectId,
normalized.payload.structure
);
return command.schemaVersion ===
legacyDistributionBoardStructureCommandSchemaVersion
? toLegacyStructureCommand(inverse)
: inverse;
if (command.type === distributionBoardInsertCommandType) {
this.insert(database, projectId, structure);
return invertDistributionBoardStructureProjectCommand(command);
}
if (normalized.type === distributionBoardDeleteCommandType) {
assertDistributionBoardDeleteProjectCommand(normalized);
const inverse = this.delete(
database,
projectId,
normalized.payload.structure
);
return command.schemaVersion ===
legacyDistributionBoardStructureCommandSchemaVersion
? toLegacyStructureCommand(inverse)
: inverse;
if (command.type === distributionBoardDeleteCommandType) {
this.delete(database, projectId, structure);
return invertDistributionBoardStructureProjectCommand(command);
}
throw new Error("Unsupported distribution-board structure command.");
}
@@ -100,7 +83,7 @@ export class DistributionBoardStructureProjectCommandRepository
private insert(
database: AppDatabase,
projectId: string,
structure: DistributionBoardStructureSnapshot
structure: NormalizedDistributionBoardStructureSnapshot
) {
if (
structure.distributionBoard.projectId !== projectId ||
@@ -159,7 +142,26 @@ export class DistributionBoardStructureProjectCommandRepository
)
.limit(1)
.get();
if (existingBoard || existingList || existingSection) {
const existingComponent =
structure.components.length === 0
? undefined
: database
.select({ id: distributionBoardComponents.id })
.from(distributionBoardComponents)
.where(
inArray(
distributionBoardComponents.id,
structure.components.map((component) => component.id)
)
)
.limit(1)
.get();
if (
existingBoard ||
existingList ||
existingSection ||
existingComponent
) {
throw new Error("Distribution-board structure id already exists.");
}
@@ -168,14 +170,22 @@ export class DistributionBoardStructureProjectCommandRepository
.values(structure.distributionBoard)
.run();
database.insert(circuitLists).values(structure.circuitList).run();
database.insert(circuitSections).values(structure.sections).run();
return createDistributionBoardDeleteProjectCommand(structure);
database
.insert(circuitSections)
.values(structure.sections)
.run();
if (structure.components.length > 0) {
database
.insert(distributionBoardComponents)
.values(structure.components)
.run();
}
}
private delete(
database: AppDatabase,
projectId: string,
structure: DistributionBoardStructureSnapshot
structure: NormalizedDistributionBoardStructureSnapshot
) {
const board = database
.select()
@@ -198,11 +208,30 @@ export class DistributionBoardStructureProjectCommandRepository
asc(circuitSections.id)
)
.all();
const components = database
.select()
.from(distributionBoardComponents)
.where(
eq(
distributionBoardComponents.circuitListId,
structure.circuitList.id
)
)
.orderBy(
asc(distributionBoardComponents.sortOrder),
asc(distributionBoardComponents.id)
)
.all();
if (
!board ||
!list ||
board.projectId !== projectId ||
!structureMatches(structure, { board, list, sections })
!structureMatches(structure, {
board,
list,
sections,
components,
})
) {
throw new Error(
"Distribution-board structure changed before deletion."
@@ -235,7 +264,6 @@ export class DistributionBoardStructureProjectCommandRepository
if (deleted.changes !== 1) {
throw new Error("Distribution board could not be deleted.");
}
return createDistributionBoardInsertProjectCommand(structure);
}
private update(
@@ -350,28 +378,6 @@ function assertSupplyTypeEnabled(
}
}
function toLegacyStructureCommand(
command:
| ReturnType<typeof createDistributionBoardInsertProjectCommand>
| ReturnType<typeof createDistributionBoardDeleteProjectCommand>
): DistributionBoardStructureProjectCommand {
const {
floorId: _floorId,
supplyType: _supplyType,
...distributionBoard
} = command.payload.structure.distributionBoard;
return {
schemaVersion: legacyDistributionBoardStructureCommandSchemaVersion,
type: command.type,
payload: {
structure: {
...command.payload.structure,
distributionBoard,
},
},
} as DistributionBoardStructureProjectCommand;
}
function getDistributionBoardFieldValue<
TField extends DistributionBoardUpdateField,
>(
@@ -382,11 +388,12 @@ function getDistributionBoardFieldValue<
}
function structureMatches(
expected: DistributionBoardStructureSnapshot,
expected: NormalizedDistributionBoardStructureSnapshot,
actual: {
board: typeof distributionBoards.$inferSelect;
list: typeof circuitLists.$inferSelect;
sections: Array<typeof circuitSections.$inferSelect>;
components: Array<typeof distributionBoardComponents.$inferSelect>;
}
) {
const {
@@ -400,7 +407,8 @@ function structureMatches(
actualDistributionBoard
) ||
!sameRecord(expected.circuitList, actual.list) ||
expected.sections.length !== actual.sections.length
expected.sections.length !== actual.sections.length ||
expected.components.length !== actual.components.length
) {
return false;
}
@@ -409,20 +417,32 @@ function structureMatches(
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
);
return expectedSections.every((section, index) =>
const sectionsMatch = expectedSections.every((section, index) =>
sameRecord(section, actual.sections[index])
);
const expectedComponents = [...expected.components].sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
);
return (
sectionsMatch &&
expectedComponents.every((component, index) =>
sameRecord(component, actual.components[index])
)
);
}
function sameRecord(
expected: Record<string, unknown>,
actual: Record<string, unknown>
expected: object,
actual: object
) {
const expectedEntries = Object.entries(expected);
const actualRecord = actual as Record<string, unknown>;
return (
expectedEntries.length === Object.keys(actual).length &&
expectedEntries.every(
([key, value]) => actual[key] === value
([key, value]) => actualRecord[key] === value
)
);
}
@@ -9,6 +9,7 @@ import {
legacyProjectStateSnapshotSchemaVersion,
previousProjectStateSnapshotSchemaVersion,
projectStateSnapshotSchemaVersion,
simultaneityFactorProjectStateSnapshotSchemaVersion,
supplyTypesProjectStateSnapshotSchemaVersion,
voltageProjectStateSnapshotSchemaVersion,
} from "../../domain/models/project-state-snapshot.model.js";
@@ -152,6 +153,8 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
supplyTypesProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !==
voltageProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !==
simultaneityFactorProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== projectStateSnapshotSchemaVersion
) {
throw new Error("Project snapshot schema version is not supported.");
@@ -15,9 +15,12 @@ import type {
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { consumers } from "../schema/consumers.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
@@ -141,6 +144,17 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
distributionBoards: byId(state.distributionBoards),
circuitLists: byId(state.circuitLists),
circuitSections: byId(state.circuitSections),
distributionBoardComponents: byId(
state.distributionBoardComponents
),
circuitProtectionDevices: byKey(
state.circuitProtectionDevices,
"circuitId"
),
distributionBoardComponentProtectionDevices: byKey(
state.distributionBoardComponentProtectionDevices,
"componentId"
),
circuits: byId(
state.circuits.map((circuit) => ({
...circuit,
@@ -159,6 +173,15 @@ function byId<T extends { id: string }>(entries: readonly T[]) {
);
}
function byKey<
TKey extends string,
TEntry extends Record<TKey, string>,
>(entries: readonly TEntry[], key: TKey) {
return [...entries].sort((left, right) =>
left[key].localeCompare(right[key])
);
}
function replaceProjectState(
database: AppDatabase,
state: ProjectStateSnapshot
@@ -214,6 +237,21 @@ function replaceProjectState(
isReserve: circuit.isReserve ? 1 : 0,
}))
);
insertMany(
database,
distributionBoardComponents,
state.distributionBoardComponents
);
insertMany(
database,
circuitProtectionDevices,
state.circuitProtectionDevices
);
insertMany(
database,
distributionBoardComponentProtectionDevices,
state.distributionBoardComponentProtectionDevices
);
insertMany(
database,
circuitDeviceRows,
@@ -9,8 +9,11 @@ import {
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
@@ -69,6 +72,8 @@ export function readProjectStateSnapshot(
displayName: circuitSections.displayName,
prefix: circuitSections.prefix,
sortOrder: circuitSections.sortOrder,
category: circuitSections.category,
groupNumber: circuitSections.groupNumber,
})
.from(circuitSections)
.innerJoin(
@@ -82,6 +87,56 @@ export function readProjectStateSnapshot(
asc(circuitSections.id)
)
.all();
const componentRows = database
.select({ component: distributionBoardComponents })
.from(distributionBoardComponents)
.innerJoin(
circuitLists,
eq(circuitLists.id, distributionBoardComponents.circuitListId)
)
.where(eq(circuitLists.projectId, projectId))
.orderBy(
asc(distributionBoardComponents.circuitListId),
asc(distributionBoardComponents.placement),
asc(distributionBoardComponents.sortOrder),
asc(distributionBoardComponents.id)
)
.all()
.map((row) => row.component);
const circuitProtectionRows = database
.select({ protection: circuitProtectionDevices })
.from(circuitProtectionDevices)
.innerJoin(
circuits,
eq(circuits.id, circuitProtectionDevices.circuitId)
)
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(eq(circuitLists.projectId, projectId))
.orderBy(asc(circuitProtectionDevices.circuitId))
.all()
.map((row) => row.protection);
const componentProtectionRows = database
.select({
protection: distributionBoardComponentProtectionDevices,
})
.from(distributionBoardComponentProtectionDevices)
.innerJoin(
distributionBoardComponents,
eq(
distributionBoardComponents.id,
distributionBoardComponentProtectionDevices.componentId
)
)
.innerJoin(
circuitLists,
eq(circuitLists.id, distributionBoardComponents.circuitListId)
)
.where(eq(circuitLists.projectId, projectId))
.orderBy(
asc(distributionBoardComponentProtectionDevices.componentId)
)
.all()
.map((row) => row.protection);
const circuitRows = database
.select({
id: circuits.id,
@@ -190,6 +245,10 @@ export function readProjectStateSnapshot(
distributionBoards: boardRows,
circuitLists: listRows,
circuitSections: sectionRows,
distributionBoardComponents: componentRows,
circuitProtectionDevices: circuitProtectionRows,
distributionBoardComponentProtectionDevices:
componentProtectionRows,
circuits: circuitRows.map((circuit) => ({
...circuit,
isReserve: Boolean(circuit.isReserve),
@@ -10,8 +10,11 @@ import type { ProjectTransferStore } from "../../domain/ports/project-transfer.s
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
@@ -127,6 +130,21 @@ function insertProjectState(
isReserve: circuit.isReserve ? 1 : 0,
}))
);
insertMany(
database,
distributionBoardComponents,
state.distributionBoardComponents
);
insertMany(
database,
circuitProtectionDevices,
state.circuitProtectionDevices
);
insertMany(
database,
distributionBoardComponentProtectionDevices,
state.distributionBoardComponentProtectionDevices
);
insertMany(
database,
circuitDeviceRows,
@@ -0,0 +1,26 @@
import { sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { circuitLists } from "./circuit-lists.js";
export const circuitListEquipmentIdentifiers = sqliteTable(
"circuit_list_equipment_identifiers",
{
ownerType: text("owner_type")
.$type<"circuit" | "distribution_board_component">()
.notNull(),
ownerId: text("owner_id").notNull(),
circuitListId: text("circuit_list_id")
.notNull()
.references(() => circuitLists.id, { onDelete: "cascade" }),
equipmentIdentifier: text("equipment_identifier").notNull(),
},
(table) => [
unique("circuit_list_equipment_identifiers_owner_unique").on(
table.ownerType,
table.ownerId
),
unique("circuit_list_equipment_identifiers_list_identifier_unique").on(
table.circuitListId,
table.equipmentIdentifier
),
]
);
@@ -0,0 +1,27 @@
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
import { circuits } from "./circuits.js";
export const circuitProtectionDevices = sqliteTable(
"circuit_protection_devices",
{
circuitId: text("circuit_id")
.primaryKey()
.references(() => circuits.id, { onDelete: "cascade" }),
type: text("type").$type<ProtectionDeviceType>().notNull(),
ratedCurrentA: real("rated_current_a").notNull(),
fuseUtilizationCategory: text(
"fuse_utilization_category"
).$type<FuseUtilizationCategory>(),
tripCharacteristic: text(
"trip_characteristic"
).$type<BreakerTripCharacteristic>(),
rcdType: text("rcd_type").$type<RcdType>(),
ratedResidualCurrentMa: real("rated_residual_current_ma"),
}
);
+8
View File
@@ -1,5 +1,6 @@
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { circuitLists } from "./circuit-lists.js";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
export const circuitSections = sqliteTable(
"circuit_sections",
@@ -12,10 +13,17 @@ export const circuitSections = sqliteTable(
displayName: text("display_name").notNull(),
prefix: text("prefix").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
category: text("category").$type<CircuitGroupCategory>(),
groupNumber: integer("group_number"),
},
(table) => [
unique("circuit_sections_list_key_unique").on(table.circuitListId, table.key),
unique("circuit_sections_list_prefix_unique").on(table.circuitListId, table.prefix),
unique("circuit_sections_list_category_group_unique").on(
table.circuitListId,
table.category,
table.groupNumber
),
]
);
@@ -0,0 +1,29 @@
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
import { distributionBoardComponents } from "./distribution-board-components.js";
export const distributionBoardComponentProtectionDevices = sqliteTable(
"distribution_board_component_protection_devices",
{
componentId: text("component_id")
.primaryKey()
.references(() => distributionBoardComponents.id, {
onDelete: "cascade",
}),
type: text("type").$type<ProtectionDeviceType>().notNull(),
ratedCurrentA: real("rated_current_a").notNull(),
fuseUtilizationCategory: text(
"fuse_utilization_category"
).$type<FuseUtilizationCategory>(),
tripCharacteristic: text(
"trip_characteristic"
).$type<BreakerTripCharacteristic>(),
rcdType: text("rcd_type").$type<RcdType>(),
ratedResidualCurrentMa: real("rated_residual_current_ma"),
}
);
@@ -0,0 +1,37 @@
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import type {
DistributionBoardComponentPlacement,
DistributionBoardComponentRole,
} from "../../shared/constants/distribution-board-component.js";
import { circuitLists } from "./circuit-lists.js";
import { circuitSections } from "./circuit-sections.js";
export const distributionBoardComponents = sqliteTable(
"distribution_board_components",
{
id: text("id").primaryKey(),
circuitListId: text("circuit_list_id")
.notNull()
.references(() => circuitLists.id, { onDelete: "cascade" }),
sectionId: text("section_id").references(() => circuitSections.id, {
onDelete: "cascade",
}),
equipmentIdentifier: text("equipment_identifier").notNull(),
name: text("name").notNull(),
role: text("role").$type<DistributionBoardComponentRole>().notNull(),
placement: text("placement")
.$type<DistributionBoardComponentPlacement>()
.notNull(),
sortOrder: integer("sort_order").notNull().default(0),
},
(table) => [
unique("distribution_board_components_list_identifier_unique").on(
table.circuitListId,
table.equipmentIdentifier
),
unique("distribution_board_components_section_role_unique").on(
table.sectionId,
table.role
),
]
);
@@ -0,0 +1,92 @@
import { parseGroupedEquipmentIdentifier } from "../services/circuit-group-numbering.js";
import type { CircuitGroupMovePlan } from "../services/circuit-group-move-planning.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitGroupMoveCommandType = "circuit.move-group" as const;
export const circuitGroupMoveCommandSchemaVersion = 1 as const;
export interface CircuitGroupMoveProjectCommand
extends SerializedProjectCommand<CircuitGroupMovePlan> {
schemaVersion: typeof circuitGroupMoveCommandSchemaVersion;
type: typeof circuitGroupMoveCommandType;
}
export function createCircuitGroupMoveProjectCommand(
plan: CircuitGroupMovePlan
): CircuitGroupMoveProjectCommand {
const command: CircuitGroupMoveProjectCommand = {
schemaVersion: circuitGroupMoveCommandSchemaVersion,
type: circuitGroupMoveCommandType,
payload: plan,
};
assertCircuitGroupMoveProjectCommand(command);
return command;
}
export function assertCircuitGroupMoveProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupMoveProjectCommand {
if (
command.schemaVersion !== circuitGroupMoveCommandSchemaVersion ||
command.type !== circuitGroupMoveCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 8
) {
throw new Error("Unsupported circuit group-move command.");
}
for (const field of [
"circuitId",
"circuitListId",
"expectedSectionId",
"targetSectionId",
"expectedEquipmentIdentifier",
"targetEquipmentIdentifier",
] as const) {
if (
typeof command.payload[field] !== "string" ||
!command.payload[field].trim()
) {
throw new Error(`Circuit group-move ${field} is invalid.`);
}
}
if (
command.payload.expectedSectionId === command.payload.targetSectionId ||
!Number.isFinite(command.payload.expectedSortOrder) ||
!Number.isFinite(command.payload.targetSortOrder)
) {
throw new Error("Circuit group-move positions are invalid.");
}
const expected = parseGroupedEquipmentIdentifier(
command.payload.expectedEquipmentIdentifier as string
);
const target = parseGroupedEquipmentIdentifier(
command.payload.targetEquipmentIdentifier as string
);
if (
expected?.kind !== "circuit" ||
target?.kind !== "circuit" ||
expected.category !== target.category ||
expected.groupNumber === target.groupNumber
) {
throw new Error("Circuit group-move BMKs are incompatible.");
}
}
export function invertCircuitGroupMovePlan(
plan: CircuitGroupMovePlan
): CircuitGroupMovePlan {
return {
circuitId: plan.circuitId,
circuitListId: plan.circuitListId,
expectedSectionId: plan.targetSectionId,
targetSectionId: plan.expectedSectionId,
expectedEquipmentIdentifier: plan.targetEquipmentIdentifier,
targetEquipmentIdentifier: plan.expectedEquipmentIdentifier,
expectedSortOrder: plan.targetSortOrder,
targetSortOrder: plan.expectedSortOrder,
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,183 @@
import {
circuitGroupCategories,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group.js";
import {
formatCircuitGroupPrefix,
parseGroupedEquipmentIdentifier,
} from "../services/circuit-group-numbering.js";
import type { CircuitGroupRenumberPlan } from "../services/circuit-group-renumbering.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitGroupRenumberCommandType =
"circuit-group.renumber" as const;
export const circuitGroupRenumberCommandSchemaVersion = 1 as const;
export interface CircuitGroupRenumberCommandPayload
extends CircuitGroupRenumberPlan {
circuitListId: string;
}
export interface CircuitGroupRenumberProjectCommand
extends SerializedProjectCommand<CircuitGroupRenumberCommandPayload> {
schemaVersion: typeof circuitGroupRenumberCommandSchemaVersion;
type: typeof circuitGroupRenumberCommandType;
}
export function createCircuitGroupRenumberProjectCommand(
circuitListId: string,
plan: CircuitGroupRenumberPlan
): CircuitGroupRenumberProjectCommand {
const command: CircuitGroupRenumberProjectCommand = {
schemaVersion: circuitGroupRenumberCommandSchemaVersion,
type: circuitGroupRenumberCommandType,
payload: { circuitListId, groups: plan.groups },
};
assertCircuitGroupRenumberProjectCommand(command);
return command;
}
export function assertCircuitGroupRenumberProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupRenumberProjectCommand {
if (
command.schemaVersion !== circuitGroupRenumberCommandSchemaVersion ||
command.type !== circuitGroupRenumberCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2 ||
typeof command.payload.circuitListId !== "string" ||
!command.payload.circuitListId.trim() ||
!Array.isArray(command.payload.groups) ||
command.payload.groups.length === 0
) {
throw new Error("Unsupported circuit-group renumber command.");
}
const groupIds = new Set<string>();
const entityIds = new Set<string>();
const targetGroupKeys = new Set<string>();
const targetIdentifiers = new Set<string>();
let hasChange = false;
for (const group of command.payload.groups) {
if (
!isPlainObject(group) ||
Object.keys(group).length !== 8 ||
typeof group.groupId !== "string" ||
!group.groupId.trim() ||
groupIds.has(group.groupId) ||
!circuitGroupCategories.includes(
group.category as CircuitGroupCategory
) ||
!positiveInteger(group.expectedGroupNumber) ||
!positiveInteger(group.targetGroupNumber) ||
group.expectedPrefix !==
formatCircuitGroupPrefix(
group.category as CircuitGroupCategory,
group.expectedGroupNumber as number
) ||
group.targetPrefix !==
formatCircuitGroupPrefix(
group.category as CircuitGroupCategory,
group.targetGroupNumber as number
) ||
!Array.isArray(group.circuits) ||
!Array.isArray(group.components)
) {
throw new Error("Circuit-group renumber assignment is invalid.");
}
const targetGroupKey = `${group.category}:${group.targetGroupNumber}`;
if (targetGroupKeys.has(targetGroupKey)) {
throw new Error(
"Circuit-group renumber contains duplicate target group numbers."
);
}
hasChange ||= group.expectedGroupNumber !== group.targetGroupNumber;
groupIds.add(group.groupId);
targetGroupKeys.add(targetGroupKey);
for (const circuit of group.circuits) {
assertEntityAssignment(
circuit,
"circuitId",
"circuit",
group.category as CircuitGroupCategory,
group.expectedGroupNumber as number,
group.targetGroupNumber as number,
entityIds,
targetIdentifiers
);
}
for (const component of group.components) {
assertEntityAssignment(
component,
"componentId",
"component",
group.category as CircuitGroupCategory,
group.expectedGroupNumber as number,
group.targetGroupNumber as number,
entityIds,
targetIdentifiers
);
}
}
if (!hasChange) {
throw new Error(
"Circuit-group renumber must change at least one group number."
);
}
}
function assertEntityAssignment(
value: unknown,
idField: "circuitId" | "componentId",
kind: "circuit" | "component",
category: CircuitGroupCategory,
expectedGroupNumber: number,
targetGroupNumber: number,
entityIds: Set<string>,
targetIdentifiers: Set<string>
) {
if (
!isPlainObject(value) ||
Object.keys(value).length !== 3 ||
typeof value[idField] !== "string" ||
!(value[idField] as string).trim() ||
typeof value.expectedEquipmentIdentifier !== "string" ||
typeof value.targetEquipmentIdentifier !== "string" ||
entityIds.has(value[idField] as string) ||
targetIdentifiers.has(value.targetEquipmentIdentifier)
) {
throw new Error(`Circuit-group renumber ${kind} assignment is invalid.`);
}
const expected = parseGroupedEquipmentIdentifier(
value.expectedEquipmentIdentifier
);
const target = parseGroupedEquipmentIdentifier(
value.targetEquipmentIdentifier
);
if (
!expected ||
!target ||
expected.kind !== target.kind ||
expected.category !== category ||
target.category !== category ||
expected.groupNumber !== expectedGroupNumber ||
target.groupNumber !== targetGroupNumber ||
(kind === "circuit" &&
(expected.kind !== "circuit" ||
expected.circuitNumber !== target.circuitNumber)) ||
(kind === "component" && expected.kind === "circuit")
) {
throw new Error(
`Circuit-group renumber ${kind} identifiers are incompatible.`
);
}
entityIds.add(value[idField] as string);
targetIdentifiers.add(value.targetEquipmentIdentifier);
}
function positiveInteger(value: unknown): value is number {
return Number.isInteger(value) && (value as number) > 0;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,262 @@
import {
circuitGroupCategories,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group.js";
import { formatCircuitGroupPrefix } from "../services/circuit-group-numbering.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitGroupInsertCommandType = "circuit-group.insert" as const;
export const circuitGroupDeleteCommandType = "circuit-group.delete" as const;
export const circuitGroupUpdateCommandType = "circuit-group.update" as const;
export const circuitGroupReorderCommandType = "circuit-group.reorder" as const;
export const circuitGroupStructureCommandSchemaVersion = 1 as const;
export interface CircuitGroupSnapshot {
id: string;
circuitListId: string;
key: string;
displayName: string;
prefix: string;
sortOrder: number;
category: CircuitGroupCategory;
groupNumber: number;
}
interface SnapshotPayload {
snapshot: CircuitGroupSnapshot;
}
interface UpdatePayload {
expected: CircuitGroupSnapshot;
target: CircuitGroupSnapshot;
}
export interface CircuitGroupReorderAssignment {
groupId: string;
expectedSortOrder: number;
targetSortOrder: number;
}
interface ReorderPayload {
circuitListId: string;
assignments: CircuitGroupReorderAssignment[];
}
export interface CircuitGroupInsertProjectCommand
extends SerializedProjectCommand<SnapshotPayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
type: typeof circuitGroupInsertCommandType;
}
export interface CircuitGroupDeleteProjectCommand
extends SerializedProjectCommand<SnapshotPayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
type: typeof circuitGroupDeleteCommandType;
}
export interface CircuitGroupUpdateProjectCommand
extends SerializedProjectCommand<UpdatePayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
type: typeof circuitGroupUpdateCommandType;
}
export interface CircuitGroupReorderProjectCommand
extends SerializedProjectCommand<ReorderPayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
type: typeof circuitGroupReorderCommandType;
}
export type CircuitGroupStructureProjectCommand =
| CircuitGroupInsertProjectCommand
| CircuitGroupDeleteProjectCommand
| CircuitGroupUpdateProjectCommand
| CircuitGroupReorderProjectCommand;
export function createCircuitGroupInsertProjectCommand(
snapshot: CircuitGroupSnapshot
): CircuitGroupInsertProjectCommand {
const command: CircuitGroupInsertProjectCommand = {
schemaVersion: circuitGroupStructureCommandSchemaVersion,
type: circuitGroupInsertCommandType,
payload: { snapshot },
};
assertCircuitGroupInsertProjectCommand(command);
return command;
}
export function createCircuitGroupDeleteProjectCommand(
snapshot: CircuitGroupSnapshot
): CircuitGroupDeleteProjectCommand {
const command: CircuitGroupDeleteProjectCommand = {
schemaVersion: circuitGroupStructureCommandSchemaVersion,
type: circuitGroupDeleteCommandType,
payload: { snapshot },
};
assertCircuitGroupDeleteProjectCommand(command);
return command;
}
export function createCircuitGroupUpdateProjectCommand(
expected: CircuitGroupSnapshot,
target: CircuitGroupSnapshot
): CircuitGroupUpdateProjectCommand {
const command: CircuitGroupUpdateProjectCommand = {
schemaVersion: circuitGroupStructureCommandSchemaVersion,
type: circuitGroupUpdateCommandType,
payload: { expected, target },
};
assertCircuitGroupUpdateProjectCommand(command);
return command;
}
export function createCircuitGroupReorderProjectCommand(
circuitListId: string,
assignments: CircuitGroupReorderAssignment[]
): CircuitGroupReorderProjectCommand {
const command: CircuitGroupReorderProjectCommand = {
schemaVersion: circuitGroupStructureCommandSchemaVersion,
type: circuitGroupReorderCommandType,
payload: { circuitListId, assignments },
};
assertCircuitGroupReorderProjectCommand(command);
return command;
}
export function assertCircuitGroupInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupInsertProjectCommand {
assertSnapshotCommand(command, circuitGroupInsertCommandType);
}
export function assertCircuitGroupDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupDeleteProjectCommand {
assertSnapshotCommand(command, circuitGroupDeleteCommandType);
}
export function assertCircuitGroupUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupUpdateProjectCommand {
if (
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
command.type !== circuitGroupUpdateCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Unsupported circuit-group update command.");
}
assertCircuitGroupSnapshot(command.payload.expected);
assertCircuitGroupSnapshot(command.payload.target);
for (const field of [
"id",
"circuitListId",
"key",
"prefix",
"sortOrder",
"category",
"groupNumber",
] as const) {
if (command.payload.expected[field] !== command.payload.target[field]) {
throw new Error(
"Circuit-group updates can only change the display name."
);
}
}
}
export function assertCircuitGroupReorderProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupReorderProjectCommand {
if (
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
command.type !== circuitGroupReorderCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2 ||
typeof command.payload.circuitListId !== "string" ||
!command.payload.circuitListId.trim() ||
!Array.isArray(command.payload.assignments) ||
command.payload.assignments.length === 0
) {
throw new Error("Unsupported circuit-group reorder command.");
}
const groupIds = new Set<string>();
let hasChange = false;
for (const assignment of command.payload.assignments) {
if (
!isPlainObject(assignment) ||
typeof assignment.groupId !== "string" ||
!assignment.groupId.trim() ||
!Number.isFinite(assignment.expectedSortOrder) ||
!Number.isFinite(assignment.targetSortOrder) ||
groupIds.has(assignment.groupId)
) {
throw new Error(
"Circuit-group reorder contains an invalid or duplicate assignment."
);
}
hasChange ||= assignment.expectedSortOrder !== assignment.targetSortOrder;
groupIds.add(assignment.groupId);
}
if (!hasChange) {
throw new Error("Circuit-group reorder must change at least one position.");
}
}
function assertSnapshotCommand(
command: SerializedProjectCommand<unknown>,
type:
| typeof circuitGroupInsertCommandType
| typeof circuitGroupDeleteCommandType
) {
if (
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
command.type !== type ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported circuit-group structure command.");
}
assertCircuitGroupSnapshot(command.payload.snapshot);
}
export function assertCircuitGroupSnapshot(
value: unknown
): asserts value is CircuitGroupSnapshot {
if (!isPlainObject(value) || Object.keys(value).length !== 8) {
throw new Error("Circuit-group snapshot is invalid.");
}
for (const field of [
"id",
"circuitListId",
"key",
"displayName",
"prefix",
] as const) {
if (typeof value[field] !== "string" || !value[field].trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
if (
!circuitGroupCategories.includes(
value.category as CircuitGroupCategory
) ||
!Number.isInteger(value.groupNumber) ||
(value.groupNumber as number) < 1 ||
!Number.isFinite(value.sortOrder)
) {
throw new Error("Circuit-group category, number or order is invalid.");
}
if (
value.prefix !==
formatCircuitGroupPrefix(
value.category as CircuitGroupCategory,
value.groupNumber as number
)
) {
throw new Error("Circuit-group prefix does not match its category and number.");
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,88 @@
import {
assertCircuitGroupSubtreeSnapshot,
type CircuitGroupSubtreeSnapshot,
} from "./circuit-group-subtree-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitGroupDeleteSubtreeCommandType =
"circuit-group.delete-subtree" as const;
export const circuitGroupRestoreSubtreeCommandType =
"circuit-group.restore-subtree" as const;
export const circuitGroupSubtreeCommandSchemaVersion = 1 as const;
interface Payload {
snapshot: CircuitGroupSubtreeSnapshot;
}
export interface CircuitGroupDeleteSubtreeProjectCommand
extends SerializedProjectCommand<Payload> {
schemaVersion: typeof circuitGroupSubtreeCommandSchemaVersion;
type: typeof circuitGroupDeleteSubtreeCommandType;
}
export interface CircuitGroupRestoreSubtreeProjectCommand
extends SerializedProjectCommand<Payload> {
schemaVersion: typeof circuitGroupSubtreeCommandSchemaVersion;
type: typeof circuitGroupRestoreSubtreeCommandType;
}
export type CircuitGroupSubtreeProjectCommand =
| CircuitGroupDeleteSubtreeProjectCommand
| CircuitGroupRestoreSubtreeProjectCommand;
export function createCircuitGroupDeleteSubtreeProjectCommand(
snapshot: CircuitGroupSubtreeSnapshot
): CircuitGroupDeleteSubtreeProjectCommand {
const command: CircuitGroupDeleteSubtreeProjectCommand = {
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
type: circuitGroupDeleteSubtreeCommandType,
payload: { snapshot },
};
assertCircuitGroupDeleteSubtreeProjectCommand(command);
return command;
}
export function createCircuitGroupRestoreSubtreeProjectCommand(
snapshot: CircuitGroupSubtreeSnapshot
): CircuitGroupRestoreSubtreeProjectCommand {
const command: CircuitGroupRestoreSubtreeProjectCommand = {
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
type: circuitGroupRestoreSubtreeCommandType,
payload: { snapshot },
};
assertCircuitGroupRestoreSubtreeProjectCommand(command);
return command;
}
export function assertCircuitGroupDeleteSubtreeProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupDeleteSubtreeProjectCommand {
assertCommand(command, circuitGroupDeleteSubtreeCommandType);
}
export function assertCircuitGroupRestoreSubtreeProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupRestoreSubtreeProjectCommand {
assertCommand(command, circuitGroupRestoreSubtreeCommandType);
}
function assertCommand(
command: SerializedProjectCommand<unknown>,
type:
| typeof circuitGroupDeleteSubtreeCommandType
| typeof circuitGroupRestoreSubtreeCommandType
) {
if (
command.schemaVersion !== circuitGroupSubtreeCommandSchemaVersion ||
command.type !== type ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported circuit-group subtree command.");
}
assertCircuitGroupSubtreeSnapshot(command.payload.snapshot);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,169 @@
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
import {
assertCircuitGroupSnapshot,
type CircuitGroupSnapshot,
} from "./circuit-group-structure-project-command.model.js";
import {
assertCircuitInsertProjectCommand,
circuitInsertCommandType,
circuitStructureCommandSchemaVersion,
type CircuitSnapshot,
} from "./circuit-structure-project-command.model.js";
import {
assertDistributionBoardComponentSnapshot,
type DistributionBoardComponentSnapshot,
} from "./distribution-board-component-structure-project-command.model.js";
export interface CircuitProtectionDeviceSnapshot {
circuitId: string;
type: ProtectionDeviceType;
ratedCurrentA: number;
fuseUtilizationCategory: FuseUtilizationCategory | null;
tripCharacteristic: BreakerTripCharacteristic | null;
rcdType: RcdType | null;
ratedResidualCurrentMa: number | null;
}
export interface CircuitGroupSubtreeSnapshot {
group: CircuitGroupSnapshot;
components: DistributionBoardComponentSnapshot[];
circuits: Array<{
circuit: CircuitSnapshot;
protectionDevice: CircuitProtectionDeviceSnapshot | null;
}>;
}
export interface CircuitGroupDeletionSummary {
hasUpstreamProtection: boolean;
hasResidualCurrentProtection: boolean;
circuitCount: number;
deviceRowCount: number;
}
export function assertCircuitGroupSubtreeSnapshot(
value: unknown
): asserts value is CircuitGroupSubtreeSnapshot {
if (
!isPlainObject(value) ||
Object.keys(value).length !== 3 ||
!Array.isArray(value.components) ||
!Array.isArray(value.circuits)
) {
throw new Error("Circuit-group subtree snapshot is invalid.");
}
assertCircuitGroupSnapshot(value.group);
const group = value.group;
const entityIds = new Set<string>();
const equipmentIdentifiers = new Set<string>();
for (const component of value.components) {
assertDistributionBoardComponentSnapshot(component);
if (
component.component.circuitListId !== group.circuitListId ||
component.component.sectionId !== group.id ||
entityIds.has(component.component.id) ||
equipmentIdentifiers.has(component.component.equipmentIdentifier)
) {
throw new Error(
"Circuit-group component snapshot has invalid ownership or duplicates."
);
}
entityIds.add(component.component.id);
equipmentIdentifiers.add(component.component.equipmentIdentifier);
}
for (const entry of value.circuits) {
if (
!isPlainObject(entry) ||
Object.keys(entry).length !== 2 ||
!isPlainObject(entry.circuit)
) {
throw new Error("Circuit-group circuit snapshot is invalid.");
}
assertCircuitInsertProjectCommand({
schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitInsertCommandType,
payload: { circuit: entry.circuit },
});
const circuit = entry.circuit as unknown as CircuitSnapshot;
if (
circuit.circuitListId !== group.circuitListId ||
circuit.sectionId !== group.id ||
entityIds.has(circuit.id) ||
equipmentIdentifiers.has(circuit.equipmentIdentifier)
) {
throw new Error(
"Circuit-group circuit snapshot has invalid ownership or duplicates."
);
}
entityIds.add(circuit.id);
equipmentIdentifiers.add(circuit.equipmentIdentifier);
if (entry.protectionDevice !== null) {
assertCircuitProtectionDeviceSnapshot(
entry.protectionDevice,
circuit.id
);
}
}
}
export function summarizeCircuitGroupDeletion(
snapshot: CircuitGroupSubtreeSnapshot
): CircuitGroupDeletionSummary {
assertCircuitGroupSubtreeSnapshot(snapshot);
return {
hasUpstreamProtection: snapshot.components.some(
({ component }) =>
component.role === "group_upstream_protection"
),
hasResidualCurrentProtection: snapshot.components.some(
({ component }) =>
component.role === "group_residual_current_protection"
),
circuitCount: snapshot.circuits.length,
deviceRowCount: snapshot.circuits.reduce(
(count, { circuit }) => count + circuit.deviceRows.length,
0
),
};
}
function assertCircuitProtectionDeviceSnapshot(
value: unknown,
circuitId: string
): asserts value is CircuitProtectionDeviceSnapshot {
if (
!isPlainObject(value) ||
Object.keys(value).length !== 7 ||
value.circuitId !== circuitId
) {
throw new Error("Circuit protection-device snapshot is invalid.");
}
const result = protectionDeviceConfigurationSchema.safeParse({
type: value.type,
ratedCurrentA: value.ratedCurrentA,
...(value.fuseUtilizationCategory === null
? {}
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
...(value.tripCharacteristic === null
? {}
: { tripCharacteristic: value.tripCharacteristic }),
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
...(value.ratedResidualCurrentMa === null
? {}
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
});
if (!result.success) {
throw new Error(
"Circuit protection-device configuration is invalid."
);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,111 @@
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 circuitProtectionUpdateCommandType =
"circuit-protection.update" as const;
export const circuitProtectionUpdateCommandSchemaVersion = 1 as const;
export interface CircuitProtectionSnapshot {
circuitId: string;
type: ProtectionDeviceType;
ratedCurrentA: number;
fuseUtilizationCategory: FuseUtilizationCategory | null;
tripCharacteristic: BreakerTripCharacteristic | null;
rcdType: RcdType | null;
ratedResidualCurrentMa: number | null;
}
interface CircuitProtectionUpdatePayload {
circuitId: string;
expected: CircuitProtectionSnapshot | null;
target: CircuitProtectionSnapshot | null;
}
export interface CircuitProtectionUpdateProjectCommand
extends SerializedProjectCommand<CircuitProtectionUpdatePayload> {
schemaVersion: typeof circuitProtectionUpdateCommandSchemaVersion;
type: typeof circuitProtectionUpdateCommandType;
}
export function createCircuitProtectionUpdateProjectCommand(
circuitId: string,
expected: CircuitProtectionSnapshot | null,
target: CircuitProtectionSnapshot | null
): CircuitProtectionUpdateProjectCommand {
const command: CircuitProtectionUpdateProjectCommand = {
schemaVersion: circuitProtectionUpdateCommandSchemaVersion,
type: circuitProtectionUpdateCommandType,
payload: { circuitId, expected, target },
};
assertCircuitProtectionUpdateProjectCommand(command);
return command;
}
export function assertCircuitProtectionUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitProtectionUpdateProjectCommand {
if (
command.schemaVersion !== circuitProtectionUpdateCommandSchemaVersion ||
command.type !== circuitProtectionUpdateCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 3
) {
throw new Error("Unsupported circuit-protection update command.");
}
const { circuitId, expected, target } = command.payload;
if (typeof circuitId !== "string" || !circuitId.trim()) {
throw new Error("Circuit protection requires a circuit id.");
}
if (expected === null && target === null) {
throw new Error("Circuit protection update must change state.");
}
if (expected !== null) {
assertCircuitProtectionSnapshot(expected, circuitId);
}
if (target !== null) {
assertCircuitProtectionSnapshot(target, circuitId);
}
if (JSON.stringify(expected) === JSON.stringify(target)) {
throw new Error("Circuit protection update must change state.");
}
}
export function assertCircuitProtectionSnapshot(
value: unknown,
circuitId: string
) {
if (
!isPlainObject(value) ||
Object.keys(value).length !== 7 ||
value.circuitId !== circuitId
) {
throw new Error("Circuit-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("Circuit-protection configuration is invalid.");
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -5,6 +5,10 @@ import {
type CircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import {
assertCircuitProtectionSnapshot,
type CircuitProtectionSnapshot,
} from "./circuit-protection-project-command.model.js";
export const circuitInsertCommandType = "circuit.insert" as const;
export const circuitDeleteCommandType = "circuit.delete" as const;
@@ -31,6 +35,7 @@ export interface CircuitSnapshot {
isReserve: boolean;
remark: string | null;
deviceRows: CircuitDeviceRowSnapshot[];
protectionDevice?: CircuitProtectionSnapshot | null;
}
export interface CircuitInsertCommandPayload {
@@ -142,6 +147,15 @@ export function assertCircuitInsertProjectCommand(
if (!Array.isArray(circuit.deviceRows)) {
throw new Error("circuit.deviceRows must be an array.");
}
if (
circuit.protectionDevice !== undefined &&
circuit.protectionDevice !== null
) {
assertCircuitProtectionSnapshot(
circuit.protectionDevice,
circuit.id as string
);
}
if (circuit.isReserve !== (circuit.deviceRows.length === 0)) {
throw new Error(
"Circuit reserve state must match whether device rows exist."
+38
View File
@@ -44,6 +44,7 @@ export interface CircuitTreeCircuit {
remark?: string;
circuitTotalPower: number;
deviceRows: CircuitTreeDeviceRow[];
protectionDevice?: CircuitTreeProtectionDevice;
}
export interface CircuitTreeSectionBlock {
@@ -52,7 +53,10 @@ export interface CircuitTreeSectionBlock {
displayName: string;
prefix: string;
sortOrder: number;
category?: CircuitGroupCategory;
groupNumber?: number;
sectionTotalPower: number;
components: CircuitTreeComponent[];
circuits: CircuitTreeCircuit[];
}
@@ -64,7 +68,9 @@ export interface CircuitTreeResponse {
distributionBoardSimultaneityFactor: number;
distributionBoardTotalPower: number;
distributionBoardTotalPowerWithSimultaneityFactor: number;
headerComponents: CircuitTreeComponent[];
sections: CircuitTreeSectionBlock[];
footerComponents: CircuitTreeComponent[];
}
export interface LegacyMigrationReport {
@@ -78,3 +84,35 @@ export interface LegacyMigrationReport {
warnings: string[];
}
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import type {
DistributionBoardComponentPlacement,
DistributionBoardComponentRole,
} from "../../shared/constants/distribution-board-component.js";
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
export interface CircuitTreeProtectionDevice {
type: ProtectionDeviceType;
ratedCurrentA: number;
fuseUtilizationCategory?: FuseUtilizationCategory;
tripCharacteristic?: BreakerTripCharacteristic;
rcdType?: RcdType;
ratedResidualCurrentMa?: number;
}
export interface CircuitTreeComponent {
id: string;
circuitListId: string;
sectionId?: string;
equipmentIdentifier: string;
name: string;
role: DistributionBoardComponentRole;
placement: DistributionBoardComponentPlacement;
sortOrder: number;
protectionDevice?: CircuitTreeProtectionDevice;
}
@@ -0,0 +1,323 @@
import { distributionBoardComponentPlacements } from "../../shared/constants/distribution-board-component.js";
import type { DistributionBoardComponentPlacement } from "../../shared/constants/distribution-board-component.js";
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardComponentInsertCommandType =
"distribution-board-component.insert" as const;
export const distributionBoardComponentDeleteCommandType =
"distribution-board-component.delete" as const;
export const distributionBoardComponentUpdateCommandType =
"distribution-board-component.update" as const;
export const distributionBoardComponentStructureCommandSchemaVersion =
1 as const;
type MutableDistributionBoardComponentRole =
| "group_upstream_protection"
| "group_residual_current_protection"
| "auxiliary";
export interface DistributionBoardComponentSnapshot {
component: {
id: string;
circuitListId: string;
sectionId: string | null;
equipmentIdentifier: string;
name: string;
role: MutableDistributionBoardComponentRole;
placement: DistributionBoardComponentPlacement;
sortOrder: number;
};
protectionDevice: {
componentId: string;
type: ProtectionDeviceType;
ratedCurrentA: number;
fuseUtilizationCategory: FuseUtilizationCategory | null;
tripCharacteristic: BreakerTripCharacteristic | null;
rcdType: RcdType | null;
ratedResidualCurrentMa: number | null;
} | null;
}
interface DistributionBoardComponentStructurePayload {
snapshot: DistributionBoardComponentSnapshot;
}
interface DistributionBoardComponentUpdatePayload {
expected: DistributionBoardComponentSnapshot;
target: DistributionBoardComponentSnapshot;
}
export interface DistributionBoardComponentInsertProjectCommand
extends SerializedProjectCommand<DistributionBoardComponentStructurePayload> {
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
type: typeof distributionBoardComponentInsertCommandType;
}
export interface DistributionBoardComponentDeleteProjectCommand
extends SerializedProjectCommand<DistributionBoardComponentStructurePayload> {
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
type: typeof distributionBoardComponentDeleteCommandType;
}
export interface DistributionBoardComponentUpdateProjectCommand
extends SerializedProjectCommand<DistributionBoardComponentUpdatePayload> {
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
type: typeof distributionBoardComponentUpdateCommandType;
}
export type DistributionBoardComponentStructureProjectCommand =
| DistributionBoardComponentInsertProjectCommand
| DistributionBoardComponentDeleteProjectCommand
| DistributionBoardComponentUpdateProjectCommand;
export function createDistributionBoardComponentInsertProjectCommand(
snapshot: DistributionBoardComponentSnapshot
): DistributionBoardComponentInsertProjectCommand {
const command: DistributionBoardComponentInsertProjectCommand = {
schemaVersion:
distributionBoardComponentStructureCommandSchemaVersion,
type: distributionBoardComponentInsertCommandType,
payload: { snapshot },
};
assertDistributionBoardComponentInsertProjectCommand(command);
return command;
}
export function createDistributionBoardComponentDeleteProjectCommand(
snapshot: DistributionBoardComponentSnapshot
): DistributionBoardComponentDeleteProjectCommand {
const command: DistributionBoardComponentDeleteProjectCommand = {
schemaVersion:
distributionBoardComponentStructureCommandSchemaVersion,
type: distributionBoardComponentDeleteCommandType,
payload: { snapshot },
};
assertDistributionBoardComponentDeleteProjectCommand(command);
return command;
}
export function createDistributionBoardComponentUpdateProjectCommand(
expected: DistributionBoardComponentSnapshot,
target: DistributionBoardComponentSnapshot
): DistributionBoardComponentUpdateProjectCommand {
const command: DistributionBoardComponentUpdateProjectCommand = {
schemaVersion:
distributionBoardComponentStructureCommandSchemaVersion,
type: distributionBoardComponentUpdateCommandType,
payload: { expected, target },
};
assertDistributionBoardComponentUpdateProjectCommand(command);
return command;
}
export function assertDistributionBoardComponentInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardComponentInsertProjectCommand {
assertStructureCommand(
command,
distributionBoardComponentInsertCommandType
);
}
export function assertDistributionBoardComponentDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardComponentDeleteProjectCommand {
assertStructureCommand(
command,
distributionBoardComponentDeleteCommandType
);
}
export function assertDistributionBoardComponentUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardComponentUpdateProjectCommand {
if (
command.schemaVersion !==
distributionBoardComponentStructureCommandSchemaVersion ||
command.type !== distributionBoardComponentUpdateCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error(
"Unsupported distribution-board component update command."
);
}
assertDistributionBoardComponentSnapshot(command.payload.expected);
assertDistributionBoardComponentSnapshot(command.payload.target);
assertSameComponentIdentity(
command.payload.expected,
command.payload.target
);
}
function assertStructureCommand(
command: SerializedProjectCommand<unknown>,
type:
| typeof distributionBoardComponentInsertCommandType
| typeof distributionBoardComponentDeleteCommandType
) {
if (
command.schemaVersion !==
distributionBoardComponentStructureCommandSchemaVersion ||
command.type !== type ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error(
"Unsupported distribution-board component structure command."
);
}
assertDistributionBoardComponentSnapshot(command.payload.snapshot);
}
function assertSameComponentIdentity(
expected: DistributionBoardComponentSnapshot,
target: DistributionBoardComponentSnapshot
) {
for (const field of [
"id",
"circuitListId",
"sectionId",
"role",
"placement",
] as const) {
if (expected.component[field] !== target.component[field]) {
throw new Error(
"Distribution-board component updates cannot change ownership or role."
);
}
}
}
export function assertDistributionBoardComponentSnapshot(
value: unknown
): asserts value is DistributionBoardComponentSnapshot {
if (
!isPlainObject(value) ||
Object.keys(value).length !== 2 ||
!isPlainObject(value.component)
) {
throw new Error(
"Distribution-board component snapshot is invalid."
);
}
const component = value.component;
if (Object.keys(component).length !== 8) {
throw new Error(
"Distribution-board component snapshot is incomplete."
);
}
for (const field of [
"id",
"circuitListId",
"equipmentIdentifier",
"name",
] as const) {
assertNonEmptyString(component[field], `component.${field}`);
}
if (
component.role !== "group_upstream_protection" &&
component.role !== "group_residual_current_protection" &&
component.role !== "auxiliary"
) {
throw new Error(
"Fixed distribution-board components cannot use component CRUD commands."
);
}
if (
!distributionBoardComponentPlacements.includes(
component.placement as DistributionBoardComponentPlacement
) ||
!Number.isFinite(component.sortOrder)
) {
throw new Error(
"Distribution-board component placement or sort order is invalid."
);
}
const groupComponent = component.role !== "auxiliary";
if (
groupComponent !==
(component.placement === "group" &&
typeof component.sectionId === "string" &&
Boolean(component.sectionId.trim()))
) {
throw new Error(
"Group protection components require group placement and a section."
);
}
if (
!groupComponent &&
(component.placement !== "footer" ||
component.sectionId !== null)
) {
throw new Error(
"Auxiliary components require footer placement without a section."
);
}
if (groupComponent) {
if (!isPlainObject(value.protectionDevice)) {
throw new Error(
"Group protection components require protection data."
);
}
assertProtectionDevice(
value.protectionDevice,
component.id as string
);
} else if (value.protectionDevice !== null) {
throw new Error(
"Auxiliary components cannot contain protection data."
);
}
}
function assertProtectionDevice(
value: Record<string, unknown>,
componentId: string
) {
if (
Object.keys(value).length !== 7 ||
value.componentId !== componentId
) {
throw new Error(
"Distribution-board component protection snapshot is invalid."
);
}
const result = protectionDeviceConfigurationSchema.safeParse({
type: value.type,
ratedCurrentA: value.ratedCurrentA,
...(value.fuseUtilizationCategory === null
? {}
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
...(value.tripCharacteristic === null
? {}
: { tripCharacteristic: value.tripCharacteristic }),
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
...(value.ratedResidualCurrentMa === null
? {}
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
});
if (!result.success) {
throw new Error(
"Distribution-board component protection configuration is invalid."
);
}
}
function assertNonEmptyString(value: unknown, field: string) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -3,6 +3,15 @@ import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import {
defaultMainSwitchComponent,
defaultSurgeProtectiveDeviceComponent,
} from "../../shared/constants/distribution-board-component.js";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import type {
DistributionBoardComponentPlacement,
DistributionBoardComponentRole,
} from "../../shared/constants/distribution-board-component.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardInsertCommandType =
@@ -10,9 +19,10 @@ export const distributionBoardInsertCommandType =
export const distributionBoardDeleteCommandType =
"distribution-board.delete" as const;
export const legacyDistributionBoardStructureCommandSchemaVersion = 1 as const;
export const distributionBoardStructureCommandSchemaVersion = 2 as const;
export const previousDistributionBoardStructureCommandSchemaVersion = 2 as const;
export const distributionBoardStructureCommandSchemaVersion = 3 as const;
export const defaultCircuitSectionDefinitions = [
const legacyDefaultCircuitSectionDefinitions = [
{
key: "lighting",
displayName: "Lighting",
@@ -39,6 +49,33 @@ export const defaultCircuitSectionDefinitions = [
},
] as const;
export const defaultCircuitSectionDefinitions = [
{
key: "lighting",
displayName: "Beleuchtung 1",
prefix: "-1F1.",
sortOrder: 10,
category: "lighting",
groupNumber: 1,
},
{
key: "single_phase",
displayName: "1-phasig 1",
prefix: "-2F1.",
sortOrder: 20,
category: "single_phase",
groupNumber: 1,
},
{
key: "three_phase",
displayName: "3-phasig 1",
prefix: "-3F1.",
sortOrder: 30,
category: "three_phase",
groupNumber: 1,
},
] as const;
interface LegacyDistributionBoardStructureSnapshot {
distributionBoard: {
id: string;
@@ -46,7 +83,22 @@ interface LegacyDistributionBoardStructureSnapshot {
name: string;
};
circuitList: DistributionBoardStructureSnapshot["circuitList"];
sections: DistributionBoardStructureSnapshot["sections"];
sections: LegacyCircuitSectionSnapshot[];
}
interface PreviousDistributionBoardStructureSnapshot {
distributionBoard: DistributionBoardStructureSnapshot["distributionBoard"];
circuitList: DistributionBoardStructureSnapshot["circuitList"];
sections: LegacyCircuitSectionSnapshot[];
}
interface LegacyCircuitSectionSnapshot {
id: string;
circuitListId: string;
key: string;
displayName: string;
prefix: string;
sortOrder: number;
}
export interface DistributionBoardStructureSnapshot {
@@ -70,7 +122,31 @@ export interface DistributionBoardStructureSnapshot {
displayName: string;
prefix: string;
sortOrder: number;
category: CircuitGroupCategory;
groupNumber: number;
}>;
components: Array<{
id: string;
circuitListId: string;
sectionId: null;
equipmentIdentifier: string;
name: string;
role: DistributionBoardComponentRole;
placement: DistributionBoardComponentPlacement;
sortOrder: number;
}>;
}
export interface NormalizedDistributionBoardStructureSnapshot {
distributionBoard: DistributionBoardStructureSnapshot["distributionBoard"];
circuitList: DistributionBoardStructureSnapshot["circuitList"];
sections: Array<
LegacyCircuitSectionSnapshot & {
category: CircuitGroupCategory | null;
groupNumber: number | null;
}
>;
components: DistributionBoardStructureSnapshot["components"];
}
interface DistributionBoardStructureCommandPayload<
@@ -97,6 +173,16 @@ interface LegacyDistributionBoardStructureProjectCommand
| typeof distributionBoardDeleteCommandType;
}
interface PreviousDistributionBoardStructureProjectCommand
extends SerializedProjectCommand<
DistributionBoardStructureCommandPayload<PreviousDistributionBoardStructureSnapshot>
> {
schemaVersion: typeof previousDistributionBoardStructureCommandSchemaVersion;
type:
| typeof distributionBoardInsertCommandType
| typeof distributionBoardDeleteCommandType;
}
export interface DistributionBoardInsertProjectCommand
extends CurrentDistributionBoardStructureProjectCommand {
type: typeof distributionBoardInsertCommandType;
@@ -110,6 +196,7 @@ export interface DistributionBoardDeleteProjectCommand
export type DistributionBoardStructureProjectCommand =
| DistributionBoardInsertProjectCommand
| DistributionBoardDeleteProjectCommand
| PreviousDistributionBoardStructureProjectCommand
| LegacyDistributionBoardStructureProjectCommand;
export function createDistributionBoardStructureSnapshot(
@@ -143,6 +230,22 @@ export function createDistributionBoardStructureSnapshot(
circuitListId: structureId,
...section,
})),
components: [
{
id: crypto.randomUUID(),
circuitListId: structureId,
sectionId: null,
...defaultMainSwitchComponent,
sortOrder: 10,
},
{
id: crypto.randomUUID(),
circuitListId: structureId,
sectionId: null,
...defaultSurgeProtectiveDeviceComponent,
sortOrder: 20,
},
],
};
assertDistributionBoardStructureSnapshot(structure);
return structure;
@@ -192,32 +295,71 @@ export function assertDistributionBoardDeleteProjectCommand(
export function normalizeDistributionBoardStructureProjectCommand(
command: DistributionBoardStructureProjectCommand
): DistributionBoardInsertProjectCommand | DistributionBoardDeleteProjectCommand {
): NormalizedDistributionBoardStructureSnapshot {
if (
command.schemaVersion ===
legacyDistributionBoardStructureCommandSchemaVersion
) {
const structure: DistributionBoardStructureSnapshot = {
...command.payload.structure,
return {
distributionBoard: {
...command.payload.structure.distributionBoard,
floorId: null,
supplyType: null,
},
circuitList: command.payload.structure.circuitList,
sections: command.payload.structure.sections.map(
normalizeLegacySection
),
components: [],
};
return command.type === distributionBoardInsertCommandType
? createDistributionBoardInsertProjectCommand(structure)
: createDistributionBoardDeleteProjectCommand(structure);
}
return command as
| DistributionBoardInsertProjectCommand
| DistributionBoardDeleteProjectCommand;
if (
command.schemaVersion ===
previousDistributionBoardStructureCommandSchemaVersion
) {
return {
...command.payload.structure,
sections: command.payload.structure.sections.map(
normalizeLegacySection
),
components: [],
};
}
return command.payload.structure;
}
export function invertDistributionBoardStructureProjectCommand(
command: DistributionBoardStructureProjectCommand
): DistributionBoardStructureProjectCommand {
return {
...command,
type:
command.type === distributionBoardInsertCommandType
? distributionBoardDeleteCommandType
: distributionBoardInsertCommandType,
} as DistributionBoardStructureProjectCommand;
}
function normalizeLegacySection(
section: LegacyCircuitSectionSnapshot
): NormalizedDistributionBoardStructureSnapshot["sections"][number] {
const category =
section.key === "lighting" ||
section.key === "single_phase" ||
section.key === "three_phase"
? section.key
: null;
return {
...section,
category,
groupNumber: category === null ? null : 1,
};
}
export function assertDistributionBoardStructureSnapshot(
structure: unknown
): asserts structure is DistributionBoardStructureSnapshot {
assertDistributionBoardStructureSnapshotVersion(structure, false);
assertDistributionBoardStructureSnapshotVersion(structure, "current");
}
function assertDistributionBoardStructureProjectCommand(
@@ -239,36 +381,57 @@ function assertDistributionBoardStructureProjectCommand(
) {
assertDistributionBoardStructureSnapshotVersion(
command.payload.structure,
true
"legacy"
);
return;
}
if (
command.schemaVersion !== distributionBoardStructureCommandSchemaVersion
command.schemaVersion ===
previousDistributionBoardStructureCommandSchemaVersion
) {
assertDistributionBoardStructureSnapshotVersion(
command.payload.structure,
"previous"
);
return;
}
if (
command.schemaVersion !==
distributionBoardStructureCommandSchemaVersion
) {
throw new Error("Unsupported distribution-board structure command.");
}
assertDistributionBoardStructureSnapshotVersion(
command.payload.structure,
false
"current"
);
}
function assertDistributionBoardStructureSnapshotVersion(
structure: unknown,
legacy: boolean
version: "legacy" | "previous" | "current"
) {
if (!isPlainObject(structure) || Object.keys(structure).length !== 3) {
const current = version === "current";
if (
!isPlainObject(structure) ||
Object.keys(structure).length !== (current ? 4 : 3)
) {
throw new Error("Distribution-board structure is invalid.");
}
const { distributionBoard, circuitList, sections } = structure;
const { distributionBoard, circuitList, sections, components } =
structure;
const expectedSections = current
? defaultCircuitSectionDefinitions
: legacyDefaultCircuitSectionDefinitions;
if (
!isPlainObject(distributionBoard) ||
Object.keys(distributionBoard).length !== (legacy ? 3 : 5) ||
Object.keys(distributionBoard).length !==
(version === "legacy" ? 3 : 5) ||
!isPlainObject(circuitList) ||
Object.keys(circuitList).length !== 4 ||
!Array.isArray(sections) ||
sections.length !== defaultCircuitSectionDefinitions.length
sections.length !== expectedSections.length ||
(current && (!Array.isArray(components) || components.length !== 2))
) {
throw new Error("Distribution-board structure is incomplete.");
}
@@ -278,7 +441,7 @@ function assertDistributionBoardStructureSnapshotVersion(
`distributionBoard.${field}`
);
}
if (!legacy) {
if (version !== "legacy") {
assertNullableId(distributionBoard.floorId, "distributionBoard.floorId");
assertNullableSupplyType(distributionBoard.supplyType);
}
@@ -296,8 +459,11 @@ function assertDistributionBoardStructureSnapshotVersion(
const sectionIds = new Set<string>();
for (let index = 0; index < sections.length; index += 1) {
const section = sections[index];
const expected = defaultCircuitSectionDefinitions[index];
if (!isPlainObject(section) || Object.keys(section).length !== 6) {
const expected = expectedSections[index];
if (
!isPlainObject(section) ||
Object.keys(section).length !== (current ? 8 : 6)
) {
throw new Error("Default circuit section is invalid.");
}
assertNonEmptyString(section.id, "section.id");
@@ -310,13 +476,64 @@ function assertDistributionBoardStructureSnapshotVersion(
section.key !== expected.key ||
section.displayName !== expected.displayName ||
section.prefix !== expected.prefix ||
section.sortOrder !== expected.sortOrder
section.sortOrder !== expected.sortOrder ||
(current &&
(!("category" in expected) ||
section.category !== expected.category ||
section.groupNumber !== expected.groupNumber))
) {
throw new Error(
"Distribution-board structure has invalid default sections."
);
}
}
if (current) {
assertDefaultComponents(components, circuitList.id);
}
}
function assertDefaultComponents(
components: unknown,
circuitListId: unknown
) {
if (!Array.isArray(components)) {
throw new Error("Default distribution-board components are invalid.");
}
const expectedComponents = [
{ ...defaultMainSwitchComponent, sortOrder: 10 },
{ ...defaultSurgeProtectiveDeviceComponent, sortOrder: 20 },
];
const componentIds = new Set<string>();
for (let index = 0; index < expectedComponents.length; index += 1) {
const component = components[index];
const expected = expectedComponents[index];
if (!isPlainObject(component) || Object.keys(component).length !== 8) {
throw new Error(
"Default distribution-board component is invalid."
);
}
assertNonEmptyString(component.id, "component.id");
if (componentIds.has(component.id)) {
throw new Error(
"Default distribution-board component ids must be unique."
);
}
componentIds.add(component.id);
if (
component.circuitListId !== circuitListId ||
component.sectionId !== null ||
component.equipmentIdentifier !== expected.equipmentIdentifier ||
component.name !== expected.name ||
component.role !== expected.role ||
component.placement !== expected.placement ||
component.sortOrder !== expected.sortOrder
) {
throw new Error(
"Distribution-board structure has invalid default components."
);
}
}
}
function assertNullableId(value: unknown, field: string) {
+383 -34
View File
@@ -3,6 +3,21 @@ import {
defaultDistributionBoardSupplyTypes,
distributionBoardSupplyTypes,
} from "../../shared/constants/distribution-board.js";
import {
circuitGroupCategories,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group.js";
import {
distributionBoardComponentPlacements,
distributionBoardComponentRoles,
} from "../../shared/constants/distribution-board-component.js";
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
import {
breakerTripCharacteristics,
fuseUtilizationCategories,
protectionDeviceTypes,
rcdTypes,
} from "../../shared/constants/protection-device.js";
import {
resolveCircuitPhaseType,
resolveProjectVoltage,
@@ -14,7 +29,8 @@ export const previousProjectStateSnapshotSchemaVersion = 2 as const;
export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const;
export const supplyTypesProjectStateSnapshotSchemaVersion = 4 as const;
export const voltageProjectStateSnapshotSchemaVersion = 5 as const;
export const projectStateSnapshotSchemaVersion = 6 as const;
export const simultaneityFactorProjectStateSnapshotSchemaVersion = 6 as const;
export const projectStateSnapshotSchemaVersion = 7 as const;
const idSchema = z.string().trim().min(1);
const nullableStringSchema = z.string().nullable();
@@ -73,7 +89,7 @@ const circuitListSchema = z
})
.strict();
const circuitSectionSchema = z
const legacyCircuitSectionSchema = z
.object({
id: idSchema,
circuitListId: idSchema,
@@ -84,6 +100,22 @@ const circuitSectionSchema = z
})
.strict();
const circuitSectionSchema = legacyCircuitSectionSchema
.extend({
category: z.enum(circuitGroupCategories).nullable(),
groupNumber: z.number().int().positive().nullable(),
})
.strict()
.superRefine((section, context) => {
if ((section.category === null) !== (section.groupNumber === null)) {
context.addIssue({
code: "custom",
message:
"Circuit-section category and group number must both be set or both be null.",
});
}
});
const circuitDeviceRowSchema = z
.object({
id: idSchema,
@@ -173,21 +205,106 @@ const roomSchema = z
})
.strict();
const commonProjectStateSnapshotContents = {
const distributionBoardComponentSchema = z
.object({
id: idSchema,
circuitListId: idSchema,
sectionId: idSchema.nullable(),
equipmentIdentifier: z.string().trim().min(1),
name: z.string().trim().min(1),
role: z.enum(distributionBoardComponentRoles),
placement: z.enum(distributionBoardComponentPlacements),
sortOrder: finiteNumberSchema,
})
.strict();
const persistedProtectionDeviceFields = {
type: z.enum(protectionDeviceTypes),
ratedCurrentA: finiteNumberSchema.positive(),
fuseUtilizationCategory: z.enum(fuseUtilizationCategories).nullable(),
tripCharacteristic: z.enum(breakerTripCharacteristics).nullable(),
rcdType: z.enum(rcdTypes).nullable(),
ratedResidualCurrentMa: finiteNumberSchema.positive().nullable(),
};
function validatePersistedProtectionDevice(
value: {
type: (typeof protectionDeviceTypes)[number];
ratedCurrentA: number;
fuseUtilizationCategory:
| (typeof fuseUtilizationCategories)[number]
| null;
tripCharacteristic:
| (typeof breakerTripCharacteristics)[number]
| null;
rcdType: (typeof rcdTypes)[number] | null;
ratedResidualCurrentMa: number | null;
},
context: z.RefinementCtx
) {
const result = protectionDeviceConfigurationSchema.safeParse({
type: value.type,
ratedCurrentA: value.ratedCurrentA,
...(value.fuseUtilizationCategory === null
? {}
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
...(value.tripCharacteristic === null
? {}
: { tripCharacteristic: value.tripCharacteristic }),
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
...(value.ratedResidualCurrentMa === null
? {}
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
});
if (!result.success) {
context.addIssue({
code: "custom",
message: "Snapshot protection-device configuration is invalid.",
});
}
}
const circuitProtectionDeviceSchema = z
.object({
circuitId: idSchema,
...persistedProtectionDeviceFields,
})
.strict()
.superRefine(validatePersistedProtectionDevice);
const componentProtectionDeviceSchema = z
.object({
componentId: idSchema,
...persistedProtectionDeviceFields,
})
.strict()
.superRefine(validatePersistedProtectionDevice);
const legacyProjectStateSnapshotContents = {
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
circuitSections: z.array(legacyCircuitSectionSchema),
circuits: z.array(circuitSchema),
projectDevices: z.array(projectDeviceSchema),
floors: z.array(floorSchema),
rooms: z.array(roomSchema),
};
const currentProjectStateSnapshotContents = {
...legacyProjectStateSnapshotContents,
circuitSections: z.array(circuitSectionSchema),
distributionBoardComponents: z.array(distributionBoardComponentSchema),
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
distributionBoardComponentProtectionDevices: z.array(
componentProtectionDeviceSchema
),
};
const legacyProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: legacyProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
...commonProjectStateSnapshotContents,
...legacyProjectStateSnapshotContents,
})
.strict();
@@ -196,7 +313,7 @@ const previousProjectStateSnapshotSchema = z
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
...commonProjectStateSnapshotContents,
...legacyProjectStateSnapshotContents,
})
.strict();
@@ -207,7 +324,7 @@ const distributionBoardProjectStateSnapshotSchema = z
),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
...commonProjectStateSnapshotContents,
...legacyProjectStateSnapshotContents,
})
.strict();
@@ -216,7 +333,7 @@ const supplyTypesProjectStateSnapshotSchema = z
schemaVersion: z.literal(supplyTypesProjectStateSnapshotSchemaVersion),
project: currentProjectSchema,
distributionBoards: z.array(distributionBoardSchema),
...commonProjectStateSnapshotContents,
...legacyProjectStateSnapshotContents,
})
.strict();
@@ -227,12 +344,20 @@ const voltageProjectStateSnapshotSchema =
),
});
export const projectStateSnapshotSchema =
const simultaneityFactorProjectStateSnapshotSchema =
voltageProjectStateSnapshotSchema.extend({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
schemaVersion: z.literal(
simultaneityFactorProjectStateSnapshotSchemaVersion
),
distributionBoards: z.array(currentDistributionBoardSchema),
});
export const projectStateSnapshotSchema =
simultaneityFactorProjectStateSnapshotSchema.extend({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
...currentProjectStateSnapshotContents,
});
export type ProjectStateSnapshot = z.infer<
typeof projectStateSnapshotSchema
>;
@@ -243,46 +368,65 @@ export function parseProjectStateSnapshot(
const version = isPlainObject(value) ? value.schemaVersion : undefined;
const parsedSnapshot =
version === legacyProjectStateSnapshotSchemaVersion
? upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot(
upgradeLegacyProjectStateSnapshot(
legacyProjectStateSnapshotSchema.parse(value)
? upgradeSimultaneityFactorProjectStateSnapshot(
upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot(
upgradeLegacyProjectStateSnapshot(
legacyProjectStateSnapshotSchema.parse(value)
)
)
)
)
)
)
: version === previousProjectStateSnapshotSchemaVersion
? upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot(
previousProjectStateSnapshotSchema.parse(value)
? upgradeSimultaneityFactorProjectStateSnapshot(
upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot(
previousProjectStateSnapshotSchema.parse(value)
)
)
)
)
)
: version === distributionBoardProjectStateSnapshotSchemaVersion
? upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
distributionBoardProjectStateSnapshotSchema.parse(value)
? upgradeSimultaneityFactorProjectStateSnapshot(
upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
distributionBoardProjectStateSnapshotSchema.parse(
value
)
)
)
)
)
: version === supplyTypesProjectStateSnapshotSchemaVersion
? upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
supplyTypesProjectStateSnapshotSchema.parse(value)
? upgradeSimultaneityFactorProjectStateSnapshot(
upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
supplyTypesProjectStateSnapshotSchema.parse(value)
)
)
)
: version === voltageProjectStateSnapshotSchemaVersion
? upgradeVoltageProjectStateSnapshot(
voltageProjectStateSnapshotSchema.parse(value)
? upgradeSimultaneityFactorProjectStateSnapshot(
upgradeVoltageProjectStateSnapshot(
voltageProjectStateSnapshotSchema.parse(value)
)
)
: projectStateSnapshotSchema.parse(value);
: version ===
simultaneityFactorProjectStateSnapshotSchemaVersion
? upgradeSimultaneityFactorProjectStateSnapshot(
simultaneityFactorProjectStateSnapshotSchema.parse(
value
)
)
: projectStateSnapshotSchema.parse(value);
const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot);
assertProjectStateSnapshotRelations(snapshot);
return snapshot;
@@ -412,10 +556,10 @@ function upgradeSupplyTypesProjectStateSnapshot(
function upgradeVoltageProjectStateSnapshot(
snapshot: z.infer<typeof voltageProjectStateSnapshotSchema>
): ProjectStateSnapshot {
): z.infer<typeof simultaneityFactorProjectStateSnapshotSchema> {
return {
...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion,
schemaVersion: simultaneityFactorProjectStateSnapshotSchemaVersion,
distributionBoards: snapshot.distributionBoards.map((board) => ({
...board,
simultaneityFactor: 1,
@@ -423,6 +567,39 @@ function upgradeVoltageProjectStateSnapshot(
};
}
function upgradeSimultaneityFactorProjectStateSnapshot(
snapshot: z.infer<typeof simultaneityFactorProjectStateSnapshotSchema>
): ProjectStateSnapshot {
return {
...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion,
circuitSections: snapshot.circuitSections.map((section) => {
const category = initialCategoryBySectionKey(section.key);
return {
...section,
category,
groupNumber: category === null ? null : 1,
};
}),
distributionBoardComponents: [],
circuitProtectionDevices: [],
distributionBoardComponentProtectionDevices: [],
};
}
function initialCategoryBySectionKey(
key: string
): CircuitGroupCategory | null {
if (
key === "lighting" ||
key === "single_phase" ||
key === "three_phase"
) {
return key;
}
return null;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -461,10 +638,22 @@ function assertProjectStateSnapshotRelations(
);
const floorIds = uniqueIds(snapshot.floors, "floor");
const roomIds = uniqueIds(snapshot.rooms, "room");
uniqueIds(snapshot.circuits, "circuit");
const circuitIds = uniqueIds(snapshot.circuits, "circuit");
const componentIds = uniqueIds(
snapshot.distributionBoardComponents,
"distribution board component"
);
const sectionById = new Map(
snapshot.circuitSections.map((entry) => [entry.id, entry])
);
const componentById = new Map(
snapshot.distributionBoardComponents.map((entry) => [
entry.id,
entry,
])
);
const groupKeys = new Set<string>();
const equipmentIdentifiersByList = new Map<string, Set<string>>();
for (const board of snapshot.distributionBoards) {
assertProjectOwnership(board.projectId, projectId, "distribution board");
@@ -500,6 +689,19 @@ function assertProjectStateSnapshotRelations(
section.circuitListId,
"circuit section list"
);
if (section.category !== null && section.groupNumber !== null) {
const groupKey = [
section.circuitListId,
section.category,
section.groupNumber,
].join(":");
if (groupKeys.has(groupKey)) {
throw new Error(
"Snapshot contains a duplicate circuit group number."
);
}
groupKeys.add(groupKey);
}
}
for (const device of snapshot.projectDevices) {
assertProjectOwnership(device.projectId, projectId, "project device");
@@ -553,6 +755,11 @@ function assertProjectStateSnapshotRelations(
"Snapshot circuit voltage must match project settings."
);
}
registerEquipmentIdentifier(
equipmentIdentifiersByList,
circuit.circuitListId,
circuit.equipmentIdentifier
);
for (const row of circuit.deviceRows) {
if (row.circuitId !== circuit.id) {
throw new Error(
@@ -575,6 +782,148 @@ function assertProjectStateSnapshotRelations(
}
}
}
const fixedComponentRolesByList = new Set<string>();
const groupComponentRolesBySection = new Set<string>();
for (const component of snapshot.distributionBoardComponents) {
assertReference(
circuitListIds,
component.circuitListId,
"distribution board component circuit list"
);
if (component.sectionId !== null) {
assertReference(
sectionIds,
component.sectionId,
"distribution board component section"
);
if (
sectionById.get(component.sectionId)?.circuitListId !==
component.circuitListId
) {
throw new Error(
"Snapshot distribution board component section belongs to a different circuit list."
);
}
}
assertComponentPlacement(component);
registerEquipmentIdentifier(
equipmentIdentifiersByList,
component.circuitListId,
component.equipmentIdentifier
);
if (
component.role === "main_switch" ||
component.role === "surge_protective_device"
) {
assertUniqueKey(
fixedComponentRolesByList,
`${component.circuitListId}:${component.role}`,
"Snapshot contains duplicate fixed distribution board components."
);
}
if (
component.role === "group_upstream_protection" ||
component.role === "group_residual_current_protection"
) {
assertUniqueKey(
groupComponentRolesBySection,
`${component.sectionId}:${component.role}`,
"Snapshot contains duplicate group protection components."
);
}
}
const protectedCircuitIds = new Set<string>();
for (const protection of snapshot.circuitProtectionDevices) {
assertReference(
circuitIds,
protection.circuitId,
"circuit protection device circuit"
);
assertUniqueKey(
protectedCircuitIds,
protection.circuitId,
"Snapshot contains duplicate circuit protection devices."
);
}
const protectedComponentIds = new Set<string>();
for (const protection of snapshot.distributionBoardComponentProtectionDevices) {
assertReference(
componentIds,
protection.componentId,
"component protection device component"
);
assertUniqueKey(
protectedComponentIds,
protection.componentId,
"Snapshot contains duplicate component protection devices."
);
const component = componentById.get(protection.componentId)!;
if (
component.role !== "group_upstream_protection" &&
component.role !== "group_residual_current_protection"
) {
throw new Error(
"Snapshot protection data belongs to a non-protection component."
);
}
}
}
function assertComponentPlacement(
component: ProjectStateSnapshot["distributionBoardComponents"][number]
) {
const isFixedHeader =
component.role === "main_switch" ||
component.role === "surge_protective_device";
const isGroupProtection =
component.role === "group_upstream_protection" ||
component.role === "group_residual_current_protection";
if (
(isFixedHeader &&
(component.placement !== "header" ||
component.sectionId !== null)) ||
(isGroupProtection &&
(component.placement !== "group" ||
component.sectionId === null)) ||
(component.role === "auxiliary" &&
(component.placement !== "footer" ||
component.sectionId !== null))
) {
throw new Error(
"Snapshot distribution board component placement is invalid."
);
}
}
function registerEquipmentIdentifier(
identifiersByList: Map<string, Set<string>>,
circuitListId: string,
equipmentIdentifier: string
) {
const identifiers =
identifiersByList.get(circuitListId) ?? new Set<string>();
const normalized = equipmentIdentifier.trim().toLocaleLowerCase("de");
if (identifiers.has(normalized)) {
throw new Error(
"Snapshot contains duplicate equipment identifiers in one circuit list."
);
}
identifiers.add(normalized);
identifiersByList.set(circuitListId, identifiers);
}
function assertUniqueKey(
keys: Set<string>,
key: string,
message: string
) {
if (keys.has(key)) {
throw new Error(message);
}
keys.add(key);
}
function uniqueIds(
@@ -79,6 +79,10 @@ export function remapProjectState(
const listIds = idMap(source.circuitLists, createId);
const sectionIds = idMap(source.circuitSections, createId);
const circuitIds = idMap(source.circuits, createId);
const componentIds = idMap(
source.distributionBoardComponents,
createId
);
const deviceIds = idMap(source.projectDevices, createId);
const floorIds = idMap(source.floors, createId);
const roomIds = idMap(source.rooms, createId);
@@ -110,6 +114,35 @@ export function remapProjectState(
id: requiredId(sectionIds, section.id),
circuitListId: requiredId(listIds, section.circuitListId),
})),
distributionBoardComponents:
source.distributionBoardComponents.map((component) => ({
...component,
id: requiredId(componentIds, component.id),
circuitListId: requiredId(
listIds,
component.circuitListId
),
sectionId:
component.sectionId === null
? null
: requiredId(sectionIds, component.sectionId),
})),
circuitProtectionDevices: source.circuitProtectionDevices.map(
(protection) => ({
...protection,
circuitId: requiredId(circuitIds, protection.circuitId),
})
),
distributionBoardComponentProtectionDevices:
source.distributionBoardComponentProtectionDevices.map(
(protection) => ({
...protection,
componentId: requiredId(
componentIds,
protection.componentId
),
})
),
circuits: source.circuits.map((circuit) => ({
...circuit,
id: requiredId(circuitIds, circuit.id),
@@ -0,0 +1,40 @@
import type {
BreakerTripCharacteristic,
FuseProtectionDeviceType,
FuseUtilizationCategory,
RatedResidualCurrentMa,
RcdType,
} from "../../shared/constants/protection-device.js";
export interface FuseProtectionDeviceConfiguration {
type: FuseProtectionDeviceType;
ratedCurrentA: number;
fuseUtilizationCategory: FuseUtilizationCategory;
}
export interface BreakerProtectionDeviceConfiguration {
type: "LS" | "AFDD";
ratedCurrentA: number;
tripCharacteristic: BreakerTripCharacteristic;
}
export interface ResidualCurrentDeviceConfiguration {
type: "FI";
ratedCurrentA: number;
rcdType: RcdType;
ratedResidualCurrentMa: RatedResidualCurrentMa;
}
export interface CombinedResidualCurrentBreakerConfiguration {
type: "FI_LS";
ratedCurrentA: number;
tripCharacteristic: BreakerTripCharacteristic;
rcdType: RcdType;
ratedResidualCurrentMa: RatedResidualCurrentMa;
}
export type ProtectionDeviceConfiguration =
| FuseProtectionDeviceConfiguration
| BreakerProtectionDeviceConfiguration
| ResidualCurrentDeviceConfiguration
| CombinedResidualCurrentBreakerConfiguration;
@@ -0,0 +1,22 @@
import type { CircuitGroupMoveProjectCommand } from "../models/circuit-group-move-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitGroupMoveCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitGroupMoveProjectCommand;
}
export interface CircuitGroupMoveProjectCommandStore {
execute(input: ExecuteCircuitGroupMoveCommandInput): {
revision: AppendedProjectRevision;
inverse: CircuitGroupMoveProjectCommand;
};
}
@@ -0,0 +1,22 @@
import type { CircuitGroupRenumberProjectCommand } from "../models/circuit-group-renumber-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitGroupRenumberCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitGroupRenumberProjectCommand;
}
export interface CircuitGroupRenumberProjectCommandStore {
execute(input: ExecuteCircuitGroupRenumberCommandInput): {
revision: AppendedProjectRevision;
inverse: CircuitGroupRenumberProjectCommand;
};
}
@@ -0,0 +1,22 @@
import type { CircuitGroupStructureProjectCommand } from "../models/circuit-group-structure-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitGroupStructureCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitGroupStructureProjectCommand;
}
export interface CircuitGroupStructureProjectCommandStore {
execute(input: ExecuteCircuitGroupStructureCommandInput): {
revision: AppendedProjectRevision;
inverse: CircuitGroupStructureProjectCommand;
};
}
@@ -0,0 +1,24 @@
import type { CircuitGroupSubtreeSnapshot } from "../models/circuit-group-subtree-snapshot.model.js";
import type { CircuitGroupSubtreeProjectCommand } from "../models/circuit-group-subtree-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitGroupSubtreeCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitGroupSubtreeProjectCommand;
}
export interface CircuitGroupSubtreeProjectCommandStore {
capture(projectId: string, groupId: string): CircuitGroupSubtreeSnapshot;
execute(input: ExecuteCircuitGroupSubtreeCommandInput): {
revision: AppendedProjectRevision;
inverse: CircuitGroupSubtreeProjectCommand;
};
}
@@ -0,0 +1,24 @@
import type {
CircuitProtectionUpdateProjectCommand,
} from "../models/circuit-protection-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitProtectionCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitProtectionUpdateProjectCommand;
}
export interface CircuitProtectionProjectCommandStore {
execute(input: ExecuteCircuitProtectionCommandInput): {
revision: AppendedProjectRevision;
inverse: CircuitProtectionUpdateProjectCommand;
};
}
@@ -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.");
}
}
@@ -19,6 +19,10 @@ import {
assertCircuitUpdateProjectCommand,
circuitUpdateCommandType,
} from "../models/circuit-project-command.model.js";
import {
assertCircuitProtectionUpdateProjectCommand,
circuitProtectionUpdateCommandType,
} from "../models/circuit-protection-project-command.model.js";
import {
assertCircuitSectionReorderProjectCommand,
circuitSectionReorderCommandType,
@@ -41,6 +45,14 @@ import {
assertDistributionBoardUpdateProjectCommand,
distributionBoardUpdateCommandType,
} from "../models/distribution-board-project-command.model.js";
import {
assertDistributionBoardComponentDeleteProjectCommand,
assertDistributionBoardComponentInsertProjectCommand,
assertDistributionBoardComponentUpdateProjectCommand,
distributionBoardComponentDeleteCommandType,
distributionBoardComponentInsertCommandType,
distributionBoardComponentUpdateCommandType,
} from "../models/distribution-board-component-structure-project-command.model.js";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
@@ -87,10 +99,16 @@ import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-devic
import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js";
import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
import type { CircuitProtectionProjectCommandStore } from "../ports/circuit-protection-project-command.store.js";
import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
import type { CircuitGroupStructureProjectCommandStore } from "../ports/circuit-group-structure-project-command.store.js";
import type { CircuitGroupRenumberProjectCommandStore } from "../ports/circuit-group-renumber-project-command.store.js";
import type { CircuitGroupMoveProjectCommandStore } from "../ports/circuit-group-move-project-command.store.js";
import type { CircuitGroupSubtreeProjectCommandStore } from "../ports/circuit-group-subtree-project-command.store.js";
import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js";
import type { DistributionBoardComponentStructureProjectCommandStore } from "../ports/distribution-board-component-structure-project-command.store.js";
import type {
ExecuteProjectHistoryCommandInput,
ExecutedProjectCommand,
@@ -101,6 +119,30 @@ import type {
ProjectHistoryDirection,
ProjectHistoryStore,
} from "../ports/project-history.store.js";
import {
assertCircuitGroupDeleteProjectCommand,
assertCircuitGroupInsertProjectCommand,
assertCircuitGroupReorderProjectCommand,
assertCircuitGroupUpdateProjectCommand,
circuitGroupDeleteCommandType,
circuitGroupInsertCommandType,
circuitGroupReorderCommandType,
circuitGroupUpdateCommandType,
} from "../models/circuit-group-structure-project-command.model.js";
import {
assertCircuitGroupRenumberProjectCommand,
circuitGroupRenumberCommandType,
} from "../models/circuit-group-renumber-project-command.model.js";
import {
assertCircuitGroupMoveProjectCommand,
circuitGroupMoveCommandType,
} from "../models/circuit-group-move-project-command.model.js";
import {
assertCircuitGroupDeleteSubtreeProjectCommand,
assertCircuitGroupRestoreSubtreeProjectCommand,
circuitGroupDeleteSubtreeCommandType,
circuitGroupRestoreSubtreeCommandType,
} from "../models/circuit-group-subtree-project-command.model.js";
import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js";
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
@@ -135,6 +177,12 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore,
private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore,
private readonly circuitGroupRenumberStore: CircuitGroupRenumberProjectCommandStore,
private readonly circuitGroupMoveStore: CircuitGroupMoveProjectCommandStore,
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
@@ -296,6 +344,96 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case distributionBoardComponentInsertCommandType: {
assertDistributionBoardComponentInsertProjectCommand(
input.command
);
return this.distributionBoardComponentStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case distributionBoardComponentDeleteCommandType: {
assertDistributionBoardComponentDeleteProjectCommand(
input.command
);
return this.distributionBoardComponentStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case distributionBoardComponentUpdateCommandType: {
assertDistributionBoardComponentUpdateProjectCommand(
input.command
);
return this.distributionBoardComponentStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitGroupInsertCommandType: {
assertCircuitGroupInsertProjectCommand(input.command);
return this.circuitGroupStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitGroupDeleteCommandType: {
assertCircuitGroupDeleteProjectCommand(input.command);
return this.circuitGroupStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitGroupUpdateCommandType: {
assertCircuitGroupUpdateProjectCommand(input.command);
return this.circuitGroupStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitGroupReorderCommandType: {
assertCircuitGroupReorderProjectCommand(input.command);
return this.circuitGroupStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitGroupRenumberCommandType: {
assertCircuitGroupRenumberProjectCommand(input.command);
return this.circuitGroupRenumberStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitGroupMoveCommandType: {
assertCircuitGroupMoveProjectCommand(input.command);
return this.circuitGroupMoveStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitGroupDeleteSubtreeCommandType: {
assertCircuitGroupDeleteSubtreeProjectCommand(input.command);
return this.circuitGroupSubtreeStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitGroupRestoreSubtreeCommandType: {
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
return this.circuitGroupSubtreeStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitProtectionUpdateCommandType: {
assertCircuitProtectionUpdateProjectCommand(input.command);
return this.circuitProtectionStore.execute({
...input,
command: input.command,
}).revision;
}
case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
@@ -0,0 +1,41 @@
import type { ProtectionDeviceConfiguration } from "../models/protection-device.model.js";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
const defaultCircuitProtectionByCategory = {
lighting: {
type: "LS",
ratedCurrentA: 10,
tripCharacteristic: "B",
},
single_phase: {
type: "FI_LS",
ratedCurrentA: 16,
tripCharacteristic: "B",
rcdType: "A",
ratedResidualCurrentMa: 30,
},
three_phase: {
type: "FI_LS",
ratedCurrentA: 16,
tripCharacteristic: "B",
rcdType: "A",
ratedResidualCurrentMa: 30,
},
} as const satisfies Record<CircuitGroupCategory, ProtectionDeviceConfiguration>;
const defaultGroupRcd = {
type: "FI",
ratedCurrentA: 40,
rcdType: "A",
ratedResidualCurrentMa: 30,
} as const satisfies ProtectionDeviceConfiguration;
export function createDefaultCircuitProtection(
category: CircuitGroupCategory
): ProtectionDeviceConfiguration {
return { ...defaultCircuitProtectionByCategory[category] };
}
export function createDefaultGroupRcd(): ProtectionDeviceConfiguration {
return { ...defaultGroupRcd };
}
@@ -0,0 +1,97 @@
"use client";
import { type FormEvent, useState } from "react";
import {
circuitGroupCategories,
circuitGroupCategoryLabels,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group";
import type { CircuitTreeSectionDto } from "../types";
import { FormModal } from "./form-modal";
interface CircuitGroupModalProps {
initialSection?: CircuitTreeSectionDto;
isSaving: boolean;
onClose: () => void;
onSave: (values: {
category: CircuitGroupCategory;
displayName: string;
}) => Promise<void>;
}
export function CircuitGroupModal({
initialSection,
isSaving,
onClose,
onSave,
}: CircuitGroupModalProps) {
const [category, setCategory] = useState<CircuitGroupCategory>(
initialSection?.category ?? "lighting"
);
const [displayName, setDisplayName] = useState(
initialSection?.displayName ?? ""
);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSave({ category, displayName });
}
return (
<FormModal
description={
initialSection
? "Nur der Anzeigename wird geändert. Kategorie, Nummer, Präfix und vorhandene BMK bleiben stabil."
: "Die nächste freie Gruppennummer der gewählten Kategorie wird automatisch vergeben."
}
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={Boolean(initialSection && !displayName.trim())}
submitLabel={initialSection ? "Änderungen speichern" : "Gruppe anlegen"}
title={initialSection ? "Stromkreisgruppe bearbeiten" : "Stromkreisgruppe hinzufügen"}
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-group-category">
Kategorie
</label>
<select
autoFocus={!initialSection}
className="form-select"
disabled={Boolean(initialSection)}
id="circuit-group-category"
onChange={(event) =>
setCategory(event.target.value as CircuitGroupCategory)
}
value={category}
>
{circuitGroupCategories.map((entry) => (
<option key={entry} value={entry}>
{circuitGroupCategoryLabels[entry]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-group-name">
Anzeigename
</label>
<input
autoFocus={Boolean(initialSection)}
className="form-control"
id="circuit-group-name"
onChange={(event) => setDisplayName(event.target.value)}
placeholder={
initialSection
? undefined
: "Leer lassen für den automatisch erzeugten Namen"
}
required={Boolean(initialSection)}
value={displayName}
/>
</div>
</div>
</FormModal>
);
}
@@ -0,0 +1,233 @@
"use client";
import { type FormEvent, useState } from "react";
import {
allowedRatedCurrentsAByProtectionDeviceType,
breakerTripCharacteristics,
fuseProtectionDeviceTypes,
fuseUtilizationCategories,
protectionDeviceTypeLabels,
protectionDeviceTypes,
ratedResidualCurrentsMa,
rcdTypes,
type ProtectionDeviceType,
} from "../../shared/constants/protection-device";
import type { CircuitTreeProtectionDeviceDto } from "../types";
import { FormModal } from "./form-modal";
interface CircuitProtectionModalProps {
equipmentIdentifier: string;
initialProtection: CircuitTreeProtectionDeviceDto;
isSaving: boolean;
onClose: () => void;
onSave: (protection: CircuitTreeProtectionDeviceDto) => Promise<void>;
}
export function CircuitProtectionModal({
equipmentIdentifier,
initialProtection,
isSaving,
onClose,
onSave,
}: CircuitProtectionModalProps) {
const [type, setType] = useState(initialProtection.type);
const [ratedCurrentA, setRatedCurrentA] = useState(
initialProtection.ratedCurrentA
);
const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState(
initialProtection.fuseUtilizationCategory ?? "gG"
);
const [tripCharacteristic, setTripCharacteristic] = useState(
initialProtection.tripCharacteristic ?? "B"
);
const [rcdType, setRcdType] = useState(
initialProtection.rcdType ?? "A"
);
const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState(
initialProtection.ratedResidualCurrentMa ?? 30
);
const usesFuseCategory = (
fuseProtectionDeviceTypes as readonly string[]
).includes(type);
const usesTripCharacteristic =
type === "LS" || type === "FI_LS" || type === "AFDD";
const usesResidualCurrent = type === "FI" || type === "FI_LS";
const allowedRatedCurrents =
allowedRatedCurrentsAByProtectionDeviceType[type];
function handleTypeChange(nextType: ProtectionDeviceType) {
setType(nextType);
setRatedCurrentA(
allowedRatedCurrentsAByProtectionDeviceType[nextType][0]
);
setFuseUtilizationCategory("gG");
setTripCharacteristic("B");
setRcdType("A");
setRatedResidualCurrentMa(30);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSave({
type,
ratedCurrentA,
...(usesFuseCategory ? { fuseUtilizationCategory } : {}),
...(usesTripCharacteristic ? { tripCharacteristic } : {}),
...(usesResidualCurrent
? { rcdType, ratedResidualCurrentMa }
: {}),
});
}
return (
<FormModal
description="Die Auswahl ist planerisch gesetzt. Eine spätere Dimensionierung wird nur Empfehlungen und Warnungen ergänzen."
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={
!(allowedRatedCurrents as readonly number[]).includes(
ratedCurrentA
)
}
submitLabel="Schutzgerät speichern"
title={`Schutzgerät bearbeiten ${equipmentIdentifier}`}
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-protection-type">
Schutzgerät
</label>
<select
autoFocus
className="form-select"
id="circuit-protection-type"
onChange={(event) =>
handleTypeChange(
event.target.value as ProtectionDeviceType
)
}
value={type}
>
{protectionDeviceTypes.map((entry) => (
<option key={entry} value={entry}>
{protectionDeviceTypeLabels[entry]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-protection-current">
Bemessungsstrom
</label>
<select
className="form-select"
id="circuit-protection-current"
onChange={(event) =>
setRatedCurrentA(Number(event.target.value))
}
value={ratedCurrentA}
>
{allowedRatedCurrents.map((current) => (
<option key={current} value={current}>
{current} A
</option>
))}
</select>
</div>
{usesFuseCategory ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-fuse-category">
Sicherungscharakteristik
</label>
<select
className="form-select"
id="circuit-fuse-category"
onChange={(event) =>
setFuseUtilizationCategory(
event.target
.value as (typeof fuseUtilizationCategories)[number]
)
}
value={fuseUtilizationCategory}
>
{fuseUtilizationCategories.map((entry) => (
<option key={entry} value={entry}>
{entry}
</option>
))}
</select>
</div>
) : null}
{usesTripCharacteristic ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-trip-characteristic">
Auslösecharakteristik
</label>
<select
className="form-select"
id="circuit-trip-characteristic"
onChange={(event) =>
setTripCharacteristic(
event.target
.value as (typeof breakerTripCharacteristics)[number]
)
}
value={tripCharacteristic}
>
{breakerTripCharacteristics.map((entry) => (
<option key={entry} value={entry}>
{entry}
</option>
))}
</select>
</div>
) : null}
{usesResidualCurrent ? (
<>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-rcd-type">
FI-Typ
</label>
<select
className="form-select"
id="circuit-rcd-type"
onChange={(event) =>
setRcdType(
event.target.value as (typeof rcdTypes)[number]
)
}
value={rcdType}
>
{rcdTypes.map((entry) => (
<option key={entry} value={entry}>
Typ {entry}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-residual-current">
Bemessungsdifferenzstrom
</label>
<select
className="form-select"
id="circuit-residual-current"
onChange={(event) =>
setRatedResidualCurrentMa(Number(event.target.value))
}
value={ratedResidualCurrentMa}
>
{ratedResidualCurrentsMa.map((entry) => (
<option key={entry} value={entry}>
{entry} mA
</option>
))}
</select>
</div>
</>
) : null}
</div>
</FormModal>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,316 @@
"use client";
import { type FormEvent, useState } from "react";
import {
allowedRatedCurrentsAByProtectionDeviceType,
breakerTripCharacteristics,
fuseProtectionDeviceTypes,
fuseUtilizationCategories,
protectionDeviceTypeLabels,
ratedResidualCurrentsMa,
rcdTypes,
type ProtectionDeviceType,
} from "../../shared/constants/protection-device";
import type { CircuitTreeComponentDto } from "../types";
import type {
DistributionBoardComponentEditorValues,
MutableDistributionBoardComponentRole,
} from "../utils/distribution-board-component-editing";
import { FormModal } from "./form-modal";
interface DistributionBoardComponentModalProps {
initialComponent?: CircuitTreeComponentDto;
initialEquipmentIdentifier?: string;
isSaving: boolean;
onClose: () => void;
onSave: (values: DistributionBoardComponentEditorValues) => Promise<void>;
role: MutableDistributionBoardComponentRole;
}
const upstreamProtectionTypes: readonly ProtectionDeviceType[] = [
...fuseProtectionDeviceTypes,
"LS",
];
export function DistributionBoardComponentModal({
initialComponent,
initialEquipmentIdentifier = "",
isSaving,
onClose,
onSave,
role,
}: DistributionBoardComponentModalProps) {
const [equipmentIdentifier, setEquipmentIdentifier] = useState(
initialComponent?.equipmentIdentifier ?? initialEquipmentIdentifier
);
const [name, setName] = useState(
initialComponent?.name ?? defaultName(role)
);
const initialProtection = initialComponent?.protectionDevice;
const [protectionType, setProtectionType] = useState<
ProtectionDeviceType | ""
>(
initialProtection?.type ??
(role === "group_residual_current_protection" ? "FI" : "")
);
const [ratedCurrentA, setRatedCurrentA] = useState(
initialProtection?.ratedCurrentA ??
(role === "group_residual_current_protection" ? 40 : 0)
);
const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState(
initialProtection?.fuseUtilizationCategory ?? "gG"
);
const [tripCharacteristic, setTripCharacteristic] = useState(
initialProtection?.tripCharacteristic ?? "B"
);
const [rcdType, setRcdType] = useState(
initialProtection?.rcdType ?? "A"
);
const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState(
initialProtection?.ratedResidualCurrentMa ?? 30
);
const isAuxiliary = role === "auxiliary";
const availableProtectionTypes =
role === "group_residual_current_protection"
? (["FI"] as const)
: upstreamProtectionTypes;
const usesFuseCategory =
protectionType !== "" &&
(fuseProtectionDeviceTypes as readonly string[]).includes(protectionType);
const usesTripCharacteristic = protectionType === "LS";
const usesResidualCurrent = protectionType === "FI";
const allowedRatedCurrents =
protectionType === ""
? []
: allowedRatedCurrentsAByProtectionDeviceType[protectionType];
function handleProtectionTypeChange(type: ProtectionDeviceType) {
setProtectionType(type);
setRatedCurrentA(allowedRatedCurrentsAByProtectionDeviceType[type][0]);
setFuseUtilizationCategory("gG");
setTripCharacteristic("B");
setRcdType("A");
setRatedResidualCurrentMa(30);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSave({
equipmentIdentifier,
name,
...(!isAuxiliary && protectionType
? {
protectionDevice: {
type: protectionType,
ratedCurrentA,
...(usesFuseCategory
? { fuseUtilizationCategory }
: {}),
...(usesTripCharacteristic
? { tripCharacteristic }
: {}),
...(usesResidualCurrent
? { rcdType, ratedResidualCurrentMa }
: {}),
},
}
: {}),
});
}
const isValid =
equipmentIdentifier.trim().length > 0 &&
name.trim().length > 0 &&
(isAuxiliary ||
(protectionType !== "" &&
(allowedRatedCurrents as readonly number[]).includes(ratedCurrentA)));
return (
<FormModal
description={
isAuxiliary
? "Zusätzliches Gerät im Verteiler. BMK und Name werden manuell vergeben."
: "Schutzgerät dieser Stromkreisgruppe konfigurieren."
}
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!isValid}
submitLabel={initialComponent ? "Änderungen speichern" : "Komponente anlegen"}
title={`${initialComponent ? "Komponente bearbeiten" : "Komponente hinzufügen"} ${defaultName(role)}`}
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-equipment-identifier">
Betriebsmittelkennzeichen
</label>
<input
autoFocus
className="form-control"
id="component-equipment-identifier"
onChange={(event) => setEquipmentIdentifier(event.target.value)}
required
value={equipmentIdentifier}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-name">
Name
</label>
<input
className="form-control"
id="component-name"
onChange={(event) => setName(event.target.value)}
required
value={name}
/>
</div>
{!isAuxiliary ? (
<>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-protection-type">
Schutzgerät
</label>
<select
className="form-select"
disabled={role === "group_residual_current_protection"}
id="component-protection-type"
onChange={(event) =>
handleProtectionTypeChange(
event.target.value as ProtectionDeviceType
)
}
required
value={protectionType}
>
<option value="">Schutzgerät auswählen</option>
{availableProtectionTypes.map((type) => (
<option key={type} value={type}>
{protectionDeviceTypeLabels[type]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-rated-current">
Bemessungsstrom
</label>
<select
className="form-select"
disabled={!protectionType}
id="component-rated-current"
onChange={(event) => setRatedCurrentA(Number(event.target.value))}
value={ratedCurrentA}
>
{allowedRatedCurrents.map((ratedCurrent) => (
<option key={ratedCurrent} value={ratedCurrent}>
{ratedCurrent} A
</option>
))}
</select>
</div>
{usesFuseCategory ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-fuse-category">
Sicherungscharakteristik
</label>
<select
className="form-select"
id="component-fuse-category"
onChange={(event) =>
setFuseUtilizationCategory(
event.target.value as (typeof fuseUtilizationCategories)[number]
)
}
value={fuseUtilizationCategory}
>
{fuseUtilizationCategories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
) : null}
{usesTripCharacteristic ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-trip-characteristic">
Auslösecharakteristik
</label>
<select
className="form-select"
id="component-trip-characteristic"
onChange={(event) =>
setTripCharacteristic(
event.target.value as (typeof breakerTripCharacteristics)[number]
)
}
value={tripCharacteristic}
>
{breakerTripCharacteristics.map((characteristic) => (
<option key={characteristic} value={characteristic}>
{characteristic}
</option>
))}
</select>
</div>
) : null}
{usesResidualCurrent ? (
<>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-rcd-type">
FI-Typ
</label>
<select
className="form-select"
id="component-rcd-type"
onChange={(event) =>
setRcdType(event.target.value as (typeof rcdTypes)[number])
}
value={rcdType}
>
{rcdTypes.map((type) => (
<option key={type} value={type}>
Typ {type}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-residual-current">
Bemessungsdifferenzstrom
</label>
<select
className="form-select"
id="component-residual-current"
onChange={(event) =>
setRatedResidualCurrentMa(Number(event.target.value))
}
value={ratedResidualCurrentMa}
>
{ratedResidualCurrentsMa.map((ratedResidualCurrent) => (
<option key={ratedResidualCurrent} value={ratedResidualCurrent}>
{ratedResidualCurrent} mA
</option>
))}
</select>
</div>
</>
) : null}
</>
) : null}
</div>
</FormModal>
);
}
function defaultName(role: MutableDistributionBoardComponentRole): string {
if (role === "group_upstream_protection") {
return "Gruppenvorsicherung";
}
if (role === "group_residual_current_protection") {
return "Gruppen-FI";
}
return "Verteilergerät";
}
+38
View File
@@ -1,4 +1,15 @@
import type { DistributionBoardSupplyType } from "../shared/constants/distribution-board";
import type { CircuitGroupCategory } from "../shared/constants/circuit-group";
import type {
DistributionBoardComponentPlacement,
DistributionBoardComponentRole,
} from "../shared/constants/distribution-board-component";
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../shared/constants/protection-device";
export interface ProjectDto {
id: string;
@@ -267,6 +278,27 @@ export interface CircuitTreeDeviceRowDto {
rowTotalPower: number;
}
export interface CircuitTreeProtectionDeviceDto {
type: ProtectionDeviceType;
ratedCurrentA: number;
fuseUtilizationCategory?: FuseUtilizationCategory;
tripCharacteristic?: BreakerTripCharacteristic;
rcdType?: RcdType;
ratedResidualCurrentMa?: number;
}
export interface CircuitTreeComponentDto {
id: string;
circuitListId: string;
sectionId?: string;
equipmentIdentifier: string;
name: string;
role: DistributionBoardComponentRole;
placement: DistributionBoardComponentPlacement;
sortOrder: number;
protectionDevice?: CircuitTreeProtectionDeviceDto;
}
export interface CircuitTreeCircuitDto {
id: string;
circuitListId: string;
@@ -289,6 +321,7 @@ export interface CircuitTreeCircuitDto {
remark?: string;
circuitTotalPower: number;
deviceRows: CircuitTreeDeviceRowDto[];
protectionDevice?: CircuitTreeProtectionDeviceDto;
}
export interface CircuitTreeSectionDto {
@@ -297,7 +330,10 @@ export interface CircuitTreeSectionDto {
displayName: string;
prefix: string;
sortOrder: number;
category?: CircuitGroupCategory;
groupNumber?: number;
sectionTotalPower: number;
components: CircuitTreeComponentDto[];
circuits: CircuitTreeCircuitDto[];
}
@@ -320,7 +356,9 @@ export interface CircuitTreeResponseDto {
distributionBoardSimultaneityFactor: number;
distributionBoardTotalPower: number;
distributionBoardTotalPowerWithSimultaneityFactor: number;
headerComponents: CircuitTreeComponentDto[];
sections: CircuitTreeSectionDto[];
footerComponents: CircuitTreeComponentDto[];
migrationReport?: CircuitTreeMigrationReportDto;
}
+212
View File
@@ -52,6 +52,25 @@ import type {
import type {
CircuitSectionRenumberAssignment,
} from "../../domain/models/circuit-section-renumber-project-command.model";
import type {
DistributionBoardComponentSnapshot,
} from "../../domain/models/distribution-board-component-structure-project-command.model";
import type {
CircuitGroupSnapshot,
CircuitGroupReorderAssignment,
} from "../../domain/models/circuit-group-structure-project-command.model";
import type {
CircuitProtectionSnapshot,
} from "../../domain/models/circuit-protection-project-command.model";
import type {
CircuitGroupRenumberPlan,
} from "../../domain/services/circuit-group-renumbering";
import type {
CircuitGroupMovePlan,
} from "../../domain/services/circuit-group-move-planning";
import type {
CircuitGroupSubtreeSnapshot,
} from "../../domain/models/circuit-group-subtree-snapshot.model";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -320,6 +339,199 @@ export function getCircuitTree(projectId: string, circuitListId: string) {
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
}
export function insertDistributionBoardComponentCommand(
projectId: string,
expectedRevision: number,
snapshot: DistributionBoardComponentSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "distribution-board-component.insert",
payload: { snapshot },
},
"Verteilerkomponente hinzufügen"
);
}
export function updateDistributionBoardComponentCommand(
projectId: string,
expectedRevision: number,
expected: DistributionBoardComponentSnapshot,
target: DistributionBoardComponentSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "distribution-board-component.update",
payload: { expected, target },
},
"Verteilerkomponente bearbeiten"
);
}
export function deleteDistributionBoardComponentCommand(
projectId: string,
expectedRevision: number,
snapshot: DistributionBoardComponentSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "distribution-board-component.delete",
payload: { snapshot },
},
"Verteilerkomponente entfernen"
);
}
export function insertCircuitGroupCommand(
projectId: string,
expectedRevision: number,
snapshot: CircuitGroupSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-group.insert",
payload: { snapshot },
},
"Stromkreisgruppe hinzufügen"
);
}
export function updateCircuitGroupCommand(
projectId: string,
expectedRevision: number,
expected: CircuitGroupSnapshot,
target: CircuitGroupSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-group.update",
payload: { expected, target },
},
"Stromkreisgruppe bearbeiten"
);
}
export function deleteCircuitGroupCommand(
projectId: string,
expectedRevision: number,
snapshot: CircuitGroupSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-group.delete",
payload: { snapshot },
},
"Leere Stromkreisgruppe entfernen"
);
}
export function deleteCircuitGroupSubtreeCommand(
projectId: string,
expectedRevision: number,
snapshot: CircuitGroupSubtreeSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-group.delete-subtree",
payload: { snapshot },
},
"Befüllte Stromkreisgruppe entfernen"
);
}
export function reorderCircuitGroupsCommand(
projectId: string,
expectedRevision: number,
circuitListId: string,
assignments: CircuitGroupReorderAssignment[]
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-group.reorder",
payload: { circuitListId, assignments },
},
"Stromkreisgruppen sortieren"
);
}
export function renumberCircuitGroupsCommand(
projectId: string,
expectedRevision: number,
circuitListId: string,
plan: CircuitGroupRenumberPlan
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-group.renumber",
payload: { circuitListId, groups: plan.groups },
},
"Stromkreisgruppen neu nummerieren"
);
}
export function moveCircuitToGroupCommand(
projectId: string,
expectedRevision: number,
plan: CircuitGroupMovePlan
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit.move-group",
payload: plan,
},
"Stromkreis in andere Gruppe verschieben"
);
}
export function updateCircuitProtectionCommand(
projectId: string,
expectedRevision: number,
circuitId: string,
expected: CircuitProtectionSnapshot | null,
target: CircuitProtectionSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-protection.update",
payload: { circuitId, expected, target },
},
"Stromkreisschutz bearbeiten"
);
}
export function updateCircuitById(
projectId: string,
expectedRevision: number,
@@ -7,6 +7,16 @@ export type GridInsertionRowType =
export type GridInsertionCellKind = "circuitField" | "deviceField" | "readonly" | "computed";
export function isGridInsertionRowType(rowType: string): rowType is GridInsertionRowType {
return (
rowType === "circuitCompact" ||
rowType === "circuitSummary" ||
rowType === "deviceRow" ||
rowType === "reserveCircuit" ||
rowType === "placeholder"
);
}
export interface GridInsertionSelection {
rowType: GridInsertionRowType;
cellKind: GridInsertionCellKind;
@@ -77,3 +87,26 @@ export function getInsertionSortOrder(
const next = entries[index + 1]?.sortOrder;
return next === undefined ? current + 10 : current + (next - current) / 2;
}
export function getAdjacentInsertionSortOrder(
orderedEntries: Array<{ id: string; sortOrder: number }>,
targetId: string,
placement: "before" | "after"
): number {
const entries = [...orderedEntries].sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
);
const index = entries.findIndex((entry) => entry.id === targetId);
if (index < 0) {
throw new Error("Der Zielstromkreis wurde nicht gefunden.");
}
if (placement === "after") {
return getInsertionSortOrder(entries, targetId);
}
const previous = entries[index - 1];
return previous
? getInsertionSortOrder(entries, previous.id)
: entries[index].sortOrder - 10;
}
+63 -9
View File
@@ -1,6 +1,7 @@
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js";
import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js";
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
export type CellKey =
| "equipmentIdentifier"
@@ -37,12 +38,15 @@ export type CellKey =
| "isReserve";
export type RowType =
| "headerComponent"
| "section"
| "groupComponent"
| "circuitCompact"
| "circuitSummary"
| "deviceRow"
| "reserveCircuit"
| "placeholder";
| "placeholder"
| "footerComponent";
export type CellKind = "circuitField" | "deviceField" | "computed" | "readonly";
export type GridValue = string | number | boolean | undefined;
@@ -140,8 +144,15 @@ const circuitSectionLabels: Record<string, string> = {
};
export function getCircuitSectionLabel(
section: { key: string; displayName: string }
section: {
key: string;
displayName: string;
category?: string;
}
): string {
if (section.category) {
return section.displayName;
}
return circuitSectionLabels[section.key] ?? section.displayName;
}
@@ -469,18 +480,54 @@ export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): G
}
export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
const protection = circuit.protectionDevice;
const protectionCharacteristic = protection
? [
protection.fuseUtilizationCategory,
protection.tripCharacteristic,
protection.rcdType ? `Typ ${protection.rcdType}` : undefined,
protection.ratedResidualCurrentMa !== undefined
? `${protection.ratedResidualCurrentMa} mA`
: undefined,
]
.filter(Boolean)
.join(" · ")
: undefined;
switch (key) {
case "equipmentIdentifier": return circuit.equipmentIdentifier;
case "displayName": return circuit.displayName;
case "circuitTotalPower": return circuit.circuitTotalPower;
case "protectionType": return circuit.protectionType;
case "protectionRatedCurrent": return circuit.protectionRatedCurrent;
case "protectionCharacteristic": return circuit.protectionCharacteristic;
case "protectionType":
return protection
? protectionDeviceTypeLabels[protection.type]
: circuit.protectionType;
case "protectionRatedCurrent":
return protection?.ratedCurrentA ?? circuit.protectionRatedCurrent;
case "protectionCharacteristic":
return protectionCharacteristic || circuit.protectionCharacteristic;
case "protectionSummary": {
const current = circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null
? `${circuit.protectionRatedCurrent}A`
if (!protection) {
const legacyCurrent =
circuit.protectionRatedCurrent !== undefined &&
circuit.protectionRatedCurrent !== null
? `${circuit.protectionRatedCurrent}A`
: "";
return [
circuit.protectionType,
legacyCurrent,
circuit.protectionCharacteristic,
].filter(Boolean).join(" ").trim() || undefined;
}
const currentValue =
protection.ratedCurrentA;
const current = currentValue !== undefined && currentValue !== null
? `${currentValue} A`
: "";
return [circuit.protectionType, current, circuit.protectionCharacteristic].filter(Boolean).join(" ").trim() || undefined;
return [
protectionDeviceTypeLabels[protection.type],
current,
protectionCharacteristic,
].filter(Boolean).join(" · ").trim() || undefined;
}
case "cableSummary": {
const length = circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : "";
@@ -536,7 +583,14 @@ export function compareSortValues(left: GridValue, right: GridValue): number {
}
export function getCellKind(rowType: RowType, key: CellKey): CellKind {
if (key === "rowTotalPower" || key === "circuitTotalPower") return "computed";
if (
key === "rowTotalPower" ||
key === "circuitTotalPower" ||
key === "protectionType" ||
key === "protectionRatedCurrent" ||
key === "protectionCharacteristic" ||
key === "protectionSummary"
) return "computed";
if (key === "voltage") return "readonly";
if (rowType === "section") return "readonly";
if (rowType === "placeholder") {
+61 -15
View File
@@ -1,8 +1,10 @@
import type {
CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeDeviceRowDto,
CircuitTreeSectionDto,
} from "../types.js";
import { buildCircuitStructureProjection } from "./circuit-structure-projection";
import {
allColumns,
circuitOnlyColumns,
@@ -42,6 +44,7 @@ export interface VisibleGridRow {
sectionId: string;
circuit?: CircuitTreeCircuitDto;
device?: CircuitTreeDeviceRowDto;
component?: CircuitTreeComponentDto;
cells: VisibleGridCell[];
}
@@ -160,21 +163,64 @@ function makeVisibleGridRow(
}
export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] {
const rows: VisibleGridRow[] = [];
for (const section of sections) {
rows.push({
rowKey: `section:${section.id}`,
rowType: "section",
sectionId: section.id,
cells: allColumns.map((column) => ({
cellKey: column.key,
editable: false,
kind: "readonly",
value: undefined,
})),
});
return buildVisibleGridRowsWithStructure(sections, {
headerComponents: [],
footerComponents: [],
});
}
for (const circuit of section.circuits) {
export function buildVisibleGridRowsWithStructure(
sections: readonly CircuitTreeSectionDto[],
structure: {
headerComponents: readonly CircuitTreeComponentDto[];
footerComponents: readonly CircuitTreeComponentDto[];
}
): VisibleGridRow[] {
const rows: VisibleGridRow[] = [];
const projected = buildCircuitStructureProjection({
headerComponents: [...structure.headerComponents],
sections: [...sections],
footerComponents: [...structure.footerComponents],
});
for (const row of projected) {
if ("component" in row) {
const component = row.component;
rows.push({
rowKey: row.rowKey,
rowType: row.rowType,
sectionId: component.sectionId ?? `__${row.zone}__`,
component,
cells: allColumns.map((column) => ({
cellKey: column.key,
editable: false,
kind: "readonly",
value:
column.key === "equipmentIdentifier"
? component.equipmentIdentifier
: column.key === "displayName"
? component.name
: undefined,
})),
});
continue;
}
const section = row.section;
if (row.rowType === "groupHeader") {
rows.push({
rowKey: `section:${section.id}`,
rowType: "section",
sectionId: section.id,
cells: allColumns.map((column) => ({
cellKey: column.key,
editable: false,
kind: "readonly",
value: undefined,
})),
});
continue;
}
if (row.rowType === "circuitBlock") {
const circuit = row.circuit;
if (circuit.deviceRows.length === 0) {
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit));
} else if (circuit.deviceRows.length === 1) {
@@ -185,8 +231,8 @@ export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[])
rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device));
}
}
continue;
}
rows.push(makeVisibleGridRow("placeholder", section.id));
}
return rows;
+532
View File
@@ -0,0 +1,532 @@
import type {
CircuitGroupSnapshot,
} from "../../domain/models/circuit-group-structure-project-command.model";
import {
circuitGroupCategoryNumbers,
circuitGroupCategoryLabels,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group";
import type { CircuitTreeSectionDto } from "../types";
import type {
CircuitGroupReorderAssignment,
} from "../../domain/models/circuit-group-structure-project-command.model";
import type {
CircuitGroupRenumberPlan,
} from "../../domain/services/circuit-group-renumbering";
import type {
CircuitGroupMovePlan,
} from "../../domain/services/circuit-group-move-planning";
import type {
CircuitGroupSubtreeSnapshot,
} from "../../domain/models/circuit-group-subtree-snapshot.model";
import {
toDistributionBoardComponentSnapshot,
} from "./distribution-board-component-editing";
export function toCircuitGroupSnapshot(
section: CircuitTreeSectionDto,
circuitListId: string
): CircuitGroupSnapshot {
if (!section.category || !section.groupNumber) {
throw new Error("Der Bereich ist keine verwaltbare Stromkreisgruppe.");
}
return {
id: section.id,
circuitListId,
key: section.key,
displayName: section.displayName,
prefix: section.prefix,
sortOrder: section.sortOrder,
category: section.category,
groupNumber: section.groupNumber,
};
}
export function buildNewCircuitGroupSnapshot(input: {
id: string;
circuitListId: string;
category: CircuitGroupCategory;
displayName?: string;
sections: readonly CircuitTreeSectionDto[];
}): CircuitGroupSnapshot {
const groupedSections = input.sections.filter(
(section): section is CircuitTreeSectionDto & {
category: CircuitGroupCategory;
groupNumber: number;
} => Boolean(section.category && section.groupNumber)
);
const groupNumber =
Math.max(
0,
...groupedSections
.filter((section) => section.category === input.category)
.map((section) => section.groupNumber)
) + 1;
return {
id: input.id,
circuitListId: input.circuitListId,
key: `${input.category}_${groupNumber}`,
displayName:
input.displayName?.trim() ||
`${circuitGroupCategoryLabels[input.category]} ${groupNumber}`,
prefix: `-${circuitGroupCategoryNumbers[input.category]}F${groupNumber}.`,
sortOrder: getNewGroupSortOrder(
input.category,
groupedSections
),
category: input.category,
groupNumber,
};
}
export function renameCircuitGroupSnapshot(
expected: CircuitGroupSnapshot,
displayName: string
): CircuitGroupSnapshot {
return { ...expected, displayName: displayName.trim() };
}
export function canDeleteCircuitGroup(
section: CircuitTreeSectionDto
): boolean {
return section.circuits.length === 0 && section.components.length === 0;
}
export function buildCircuitGroupSubtreeSnapshot(
section: CircuitTreeSectionDto,
circuitListId: string
): CircuitGroupSubtreeSnapshot {
const components = [...section.components]
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
)
.map(toDistributionBoardComponentSnapshot);
const circuits = [...section.circuits]
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
)
.map((circuit) => ({
circuit: {
id: circuit.id,
circuitListId: circuit.circuitListId,
sectionId: circuit.sectionId,
equipmentIdentifier: circuit.equipmentIdentifier,
displayName: circuit.displayName ?? null,
sortOrder: circuit.sortOrder,
protectionType: circuit.protectionType ?? null,
protectionRatedCurrent:
circuit.protectionRatedCurrent ?? null,
protectionCharacteristic:
circuit.protectionCharacteristic ?? null,
cableType: circuit.cableType ?? null,
cableCrossSection: circuit.cableCrossSection ?? null,
cableLength: circuit.cableLength ?? null,
rcdAssignment: circuit.rcdAssignment ?? null,
terminalDesignation: circuit.terminalDesignation ?? null,
voltage: circuit.voltage ?? null,
controlRequirement: circuit.controlRequirement ?? null,
status: circuit.status ?? null,
isReserve: circuit.isReserve,
remark: circuit.remark ?? null,
deviceRows: [...circuit.deviceRows]
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
)
.map((row) => ({
id: row.id,
circuitId: circuit.id,
linkedProjectDeviceId:
row.linkedProjectDeviceId ?? null,
legacyConsumerId: row.legacyConsumerId ?? null,
sortOrder: row.sortOrder,
name: row.name,
displayName: row.displayName,
phaseType: row.phaseType ?? null,
connectionKind: row.connectionKind ?? null,
costGroup: row.costGroup ?? null,
category: row.category ?? null,
level: row.level ?? null,
roomId: row.roomId ?? null,
roomNumberSnapshot: row.roomNumberSnapshot ?? null,
roomNameSnapshot: row.roomNameSnapshot ?? null,
quantity: row.quantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi ?? null,
remark: row.remark ?? null,
overriddenFields: row.overriddenFields ?? null,
})),
},
protectionDevice: circuit.protectionDevice
? {
circuitId: circuit.id,
type: circuit.protectionDevice.type,
ratedCurrentA:
circuit.protectionDevice.ratedCurrentA,
fuseUtilizationCategory:
circuit.protectionDevice.fuseUtilizationCategory ??
null,
tripCharacteristic:
circuit.protectionDevice.tripCharacteristic ?? null,
rcdType: circuit.protectionDevice.rcdType ?? null,
ratedResidualCurrentMa:
circuit.protectionDevice.ratedResidualCurrentMa ??
null,
}
: null,
}));
return {
group: toCircuitGroupSnapshot(section, circuitListId),
components,
circuits,
};
}
export function summarizeCircuitGroupSubtree(
snapshot: CircuitGroupSubtreeSnapshot
) {
return {
circuitCount: snapshot.circuits.length,
deviceRowCount: snapshot.circuits.reduce(
(count, entry) =>
count + entry.circuit.deviceRows.length,
0
),
protectionComponentCount: snapshot.components.length,
};
}
export function buildCircuitGroupReorderAssignments(
sections: readonly CircuitTreeSectionDto[],
groupId: string,
direction: -1 | 1
): CircuitGroupReorderAssignment[] | null {
const current = sections.find((section) => section.id === groupId);
if (!current?.category) {
return null;
}
const categoryGroups = sections
.filter((section) => section.category === current.category)
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
);
const currentIndex = categoryGroups.findIndex(
(section) => section.id === groupId
);
const target = categoryGroups[currentIndex + direction];
if (!target) {
return null;
}
return sections.map((section) => ({
groupId: section.id,
expectedSortOrder: section.sortOrder,
targetSortOrder:
section.id === current.id
? target.sortOrder
: section.id === target.id
? current.sortOrder
: section.sortOrder,
}));
}
export function buildCircuitGroupRenumberPlan(
sections: readonly CircuitTreeSectionDto[],
category: CircuitGroupCategory
): CircuitGroupRenumberPlan | null {
const groups = getOrderedCategoryGroups(sections, category);
if (!canRenumberCircuitGroups(sections, category)) {
return null;
}
return {
groups: groups.map((group, index) => {
const targetGroupNumber = index + 1;
const expectedPrefix = formatGroupPrefix(
category,
group.groupNumber
);
if (group.prefix !== expectedPrefix) {
throw new Error(
`Das Präfix der Gruppe „${group.displayName}“ passt nicht zu ihrer Gruppennummer.`
);
}
return {
groupId: group.id,
category,
expectedGroupNumber: group.groupNumber,
targetGroupNumber,
expectedPrefix,
targetPrefix: formatGroupPrefix(category, targetGroupNumber),
circuits: group.circuits.map((circuit) => {
const circuitNumber = parseCircuitNumber(
circuit.equipmentIdentifier,
category,
group.groupNumber
);
return {
circuitId: circuit.id,
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
targetEquipmentIdentifier: formatCircuitIdentifier(
category,
targetGroupNumber,
circuitNumber
),
};
}),
components: group.components.map((component) => ({
componentId: component.id,
expectedEquipmentIdentifier: component.equipmentIdentifier,
targetEquipmentIdentifier: formatGroupComponentIdentifier(
category,
targetGroupNumber,
component.role
),
})),
};
}),
};
}
export function canRenumberCircuitGroups(
sections: readonly CircuitTreeSectionDto[],
category: CircuitGroupCategory
) {
const groups = getOrderedCategoryGroups(sections, category);
return (
groups.length > 0 &&
groups.some((group, index) => group.groupNumber !== index + 1)
);
}
export function canMoveCircuitToGroup(
sections: readonly CircuitTreeSectionDto[],
circuitId: string,
targetSectionId: string
) {
const source = sections.find((section) =>
section.circuits.some((circuit) => circuit.id === circuitId)
);
const target = sections.find(
(section) => section.id === targetSectionId
);
return Boolean(
source?.category &&
target?.category &&
source.id !== target.id &&
source.category === target.category
);
}
export function buildCircuitGroupMovePlan(input: {
sections: readonly CircuitTreeSectionDto[];
circuitId: string;
targetSectionId: string;
placement:
| { kind: "end" }
| {
kind: "before" | "after";
targetCircuitId: string;
};
}): CircuitGroupMovePlan {
const sourceGroup = input.sections.find((section) =>
section.circuits.some(
(circuit) => circuit.id === input.circuitId
)
);
const targetGroup = input.sections.find(
(section) => section.id === input.targetSectionId
);
const circuit = sourceGroup?.circuits.find(
(entry) => entry.id === input.circuitId
);
if (
!sourceGroup?.category ||
!sourceGroup.groupNumber ||
!targetGroup?.category ||
!targetGroup.groupNumber ||
!circuit ||
sourceGroup.id === targetGroup.id ||
sourceGroup.category !== targetGroup.category
) {
throw new Error(
"Stromkreise können nur zwischen verschiedenen Gruppen derselben Kategorie verschoben werden."
);
}
parseCircuitNumber(
circuit.equipmentIdentifier,
sourceGroup.category,
sourceGroup.groupNumber
);
const targetCircuitNumbers = targetGroup.circuits.map((entry) =>
parseCircuitNumber(
entry.equipmentIdentifier,
targetGroup.category!,
targetGroup.groupNumber!
)
);
return {
circuitId: circuit.id,
circuitListId: circuit.circuitListId,
expectedSectionId: sourceGroup.id,
targetSectionId: targetGroup.id,
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
targetEquipmentIdentifier: formatCircuitIdentifier(
targetGroup.category,
targetGroup.groupNumber,
Math.max(0, ...targetCircuitNumbers) + 1
),
expectedSortOrder: circuit.sortOrder,
targetSortOrder: getCircuitMoveSortOrder(
targetGroup.circuits,
input.placement
),
};
}
function getNewGroupSortOrder(
category: CircuitGroupCategory,
sections: readonly (CircuitTreeSectionDto & {
category: CircuitGroupCategory;
groupNumber: number;
})[]
): number {
const ordered = [...sections].sort(
(left, right) => left.sortOrder - right.sortOrder
);
const sameCategory = ordered.filter(
(section) => section.category === category
);
const previous = sameCategory[sameCategory.length - 1];
if (!previous) {
return ordered.length === 0
? 10
: Math.max(...ordered.map((section) => section.sortOrder)) + 10;
}
const next = ordered.find(
(section) => section.sortOrder > previous.sortOrder
);
return next
? previous.sortOrder + (next.sortOrder - previous.sortOrder) / 2
: previous.sortOrder + 10;
}
function formatGroupPrefix(
category: CircuitGroupCategory,
groupNumber: number
) {
return `-${circuitGroupCategoryNumbers[category]}F${groupNumber}.`;
}
function getOrderedCategoryGroups(
sections: readonly CircuitTreeSectionDto[],
category: CircuitGroupCategory
) {
return sections
.filter(
(section): section is CircuitTreeSectionDto & {
category: CircuitGroupCategory;
groupNumber: number;
} =>
section.category === category &&
Number.isInteger(section.groupNumber) &&
(section.groupNumber ?? 0) > 0
)
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
);
}
function formatCircuitIdentifier(
category: CircuitGroupCategory,
groupNumber: number,
circuitNumber: number
) {
return `${formatGroupPrefix(category, groupNumber)}${circuitNumber}`;
}
function parseCircuitNumber(
equipmentIdentifier: string,
category: CircuitGroupCategory,
groupNumber: number
) {
const prefix = formatGroupPrefix(category, groupNumber);
const suffix = equipmentIdentifier.startsWith(prefix)
? equipmentIdentifier.slice(prefix.length)
: "";
const circuitNumber = Number(suffix);
if (
!/^[1-9]\d*$/.test(suffix) ||
!Number.isSafeInteger(circuitNumber)
) {
throw new Error(
`Das Stromkreis-BMK „${equipmentIdentifier}“ passt nicht zu seiner Gruppe.`
);
}
return circuitNumber;
}
function formatGroupComponentIdentifier(
category: CircuitGroupCategory,
groupNumber: number,
role: CircuitTreeSectionDto["components"][number]["role"]
) {
const categoryNumber = circuitGroupCategoryNumbers[category];
if (role === "group_upstream_protection") {
return `-${categoryNumber}F${groupNumber}.0`;
}
if (role === "group_residual_current_protection") {
return `-${categoryNumber}Q${groupNumber}.0`;
}
throw new Error(
"Die Stromkreisgruppe enthält eine nicht unterstützte Verteilerkomponente."
);
}
function getCircuitMoveSortOrder(
circuits: CircuitTreeSectionDto["circuits"],
placement:
| { kind: "end" }
| {
kind: "before" | "after";
targetCircuitId: string;
}
) {
const ordered = [...circuits].sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
);
if (placement.kind === "end") {
return ordered.length === 0
? 10
: ordered[ordered.length - 1].sortOrder + 10;
}
const index = ordered.findIndex(
(circuit) => circuit.id === placement.targetCircuitId
);
if (index < 0) {
throw new Error("Der Zielstromkreis wurde nicht gefunden.");
}
const current = ordered[index].sortOrder;
if (placement.kind === "before") {
const previous = ordered[index - 1]?.sortOrder;
return previous === undefined
? current - 10
: previous + (current - previous) / 2;
}
const next = ordered[index + 1]?.sortOrder;
return next === undefined
? current + 10
: current + (next - current) / 2;
}
@@ -0,0 +1,36 @@
import type {
CircuitProtectionSnapshot,
} from "../../domain/models/circuit-protection-project-command.model";
import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
import type {
CircuitTreeProtectionDeviceDto,
} from "../types";
export function toCircuitProtectionSnapshot(
circuitId: string,
protection: CircuitTreeProtectionDeviceDto
): CircuitProtectionSnapshot {
return {
circuitId,
type: protection.type,
ratedCurrentA: protection.ratedCurrentA,
fuseUtilizationCategory:
protection.fuseUtilizationCategory ?? null,
tripCharacteristic: protection.tripCharacteristic ?? null,
rcdType: protection.rcdType ?? null,
ratedResidualCurrentMa:
protection.ratedResidualCurrentMa ?? null,
};
}
export function getCircuitProtectionEditorInitialValue(
category: CircuitGroupCategory | undefined,
protection: CircuitTreeProtectionDeviceDto | undefined
): CircuitTreeProtectionDeviceDto {
if (protection) {
return protection;
}
const fallback = createDefaultCircuitProtection(category ?? "lighting");
return { ...fallback };
}
@@ -8,6 +8,8 @@ import type {
CreateCircuitDeviceRowInputDto,
CreateCircuitInputDto,
} from "../types.js";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults";
export function buildCircuitDeviceRowInsertSnapshot(input: {
id: string;
@@ -46,8 +48,12 @@ export function buildCircuitInsertSnapshot(input: {
circuitListId: string;
values: CreateCircuitInputDto;
deviceRows: CircuitDeviceRowSnapshot[];
category?: CircuitGroupCategory;
}): CircuitSnapshot {
const { id, circuitListId, values, deviceRows } = input;
const { id, circuitListId, values, deviceRows, category } = input;
const defaultProtection = category
? createDefaultCircuitProtection(category)
: null;
return {
id,
circuitListId,
@@ -69,5 +75,30 @@ export function buildCircuitInsertSnapshot(input: {
isReserve: deviceRows.length === 0,
remark: values.remark ?? null,
deviceRows,
...(defaultProtection
? {
protectionDevice: {
circuitId: id,
type: defaultProtection.type,
ratedCurrentA: defaultProtection.ratedCurrentA,
fuseUtilizationCategory:
"fuseUtilizationCategory" in defaultProtection
? defaultProtection.fuseUtilizationCategory
: null,
tripCharacteristic:
"tripCharacteristic" in defaultProtection
? defaultProtection.tripCharacteristic
: null,
rcdType:
"rcdType" in defaultProtection
? defaultProtection.rcdType
: null,
ratedResidualCurrentMa:
"ratedResidualCurrentMa" in defaultProtection
? defaultProtection.ratedResidualCurrentMa
: null,
},
}
: {}),
};
}
@@ -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)
);
}
@@ -0,0 +1,139 @@
import type {
DistributionBoardComponentSnapshot,
} from "../../domain/models/distribution-board-component-structure-project-command.model";
import type {
CircuitTreeComponentDto,
CircuitTreeProtectionDeviceDto,
CircuitTreeSectionDto,
} from "../types";
import { circuitGroupCategoryNumbers } from "../../shared/constants/circuit-group";
export type MutableDistributionBoardComponentRole =
| "group_upstream_protection"
| "group_residual_current_protection"
| "auxiliary";
export interface DistributionBoardComponentEditorValues {
equipmentIdentifier: string;
name: string;
protectionDevice?: CircuitTreeProtectionDeviceDto;
}
export function toDistributionBoardComponentSnapshot(
component: CircuitTreeComponentDto
): DistributionBoardComponentSnapshot {
if (
component.role !== "group_upstream_protection" &&
component.role !== "group_residual_current_protection" &&
component.role !== "auxiliary"
) {
throw new Error("Feste Verteilerkomponenten können hier nicht bearbeitet werden.");
}
return {
component: {
id: component.id,
circuitListId: component.circuitListId,
sectionId: component.sectionId ?? null,
equipmentIdentifier: component.equipmentIdentifier,
name: component.name,
role: component.role,
placement: component.placement,
sortOrder: component.sortOrder,
},
protectionDevice: component.protectionDevice
? {
componentId: component.id,
type: component.protectionDevice.type,
ratedCurrentA: component.protectionDevice.ratedCurrentA,
fuseUtilizationCategory:
component.protectionDevice.fuseUtilizationCategory ?? null,
tripCharacteristic:
component.protectionDevice.tripCharacteristic ?? null,
rcdType: component.protectionDevice.rcdType ?? null,
ratedResidualCurrentMa:
component.protectionDevice.ratedResidualCurrentMa ?? null,
}
: null,
};
}
export function buildDistributionBoardComponentSnapshot(input: {
id: string;
circuitListId: string;
role: MutableDistributionBoardComponentRole;
sectionId?: string;
sortOrder: number;
values: DistributionBoardComponentEditorValues;
}): DistributionBoardComponentSnapshot {
const groupComponent = input.role !== "auxiliary";
if (groupComponent && !input.sectionId) {
throw new Error("Für den Gruppenschutz fehlt die Stromkreisgruppe.");
}
if (groupComponent && !input.values.protectionDevice) {
throw new Error("Für den Gruppenschutz fehlt die Schutzgerätekonfiguration.");
}
return {
component: {
id: input.id,
circuitListId: input.circuitListId,
sectionId: groupComponent ? input.sectionId! : null,
equipmentIdentifier: input.values.equipmentIdentifier.trim(),
name: input.values.name.trim(),
role: input.role,
placement: groupComponent ? "group" : "footer",
sortOrder: input.sortOrder,
},
protectionDevice:
groupComponent && input.values.protectionDevice
? {
componentId: input.id,
type: input.values.protectionDevice.type,
ratedCurrentA: input.values.protectionDevice.ratedCurrentA,
fuseUtilizationCategory:
input.values.protectionDevice.fuseUtilizationCategory ?? null,
tripCharacteristic:
input.values.protectionDevice.tripCharacteristic ?? null,
rcdType: input.values.protectionDevice.rcdType ?? null,
ratedResidualCurrentMa:
input.values.protectionDevice.ratedResidualCurrentMa ?? null,
}
: null,
};
}
export function updateDistributionBoardComponentSnapshot(
expected: DistributionBoardComponentSnapshot,
values: DistributionBoardComponentEditorValues
): DistributionBoardComponentSnapshot {
return buildDistributionBoardComponentSnapshot({
id: expected.component.id,
circuitListId: expected.component.circuitListId,
role: expected.component.role,
sectionId: expected.component.sectionId ?? undefined,
sortOrder: expected.component.sortOrder,
values,
});
}
export function getSuggestedGroupComponentIdentifier(
section: CircuitTreeSectionDto,
role:
| "group_upstream_protection"
| "group_residual_current_protection"
): string {
if (!section.category || !section.groupNumber) {
return "";
}
const categoryNumber = circuitGroupCategoryNumbers[section.category];
const deviceLetter =
role === "group_upstream_protection" ? "F" : "Q";
return `-${categoryNumber}${deviceLetter}${section.groupNumber}.0`;
}
export function getNextComponentSortOrder(
components: readonly { sortOrder: number }[]
): number {
return components.length === 0
? 10
: Math.max(...components.map((component) => component.sortOrder)) + 10;
}
@@ -33,6 +33,17 @@ const commandTypeLabels: Record<string, string> = {
"distribution-board.insert": "Verteilung angelegt",
"distribution-board.update": "Verteilung bearbeitet",
"distribution-board.delete": "Verteilung entfernt",
"distribution-board-component.insert": "Verteilergerät angelegt",
"distribution-board-component.delete": "Verteilergerät entfernt",
"distribution-board-component.update": "Verteilergerät bearbeitet",
"circuit-group.insert": "Stromkreisgruppe angelegt",
"circuit-group.delete": "Stromkreisgruppe entfernt",
"circuit-group.update": "Stromkreisgruppe bearbeitet",
"circuit-group.reorder": "Stromkreisgruppen sortiert",
"circuit-group.renumber": "Stromkreisgruppen neu nummeriert",
"circuit.move-group": "Stromkreis in andere Gruppe verschoben",
"circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt",
"circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt",
"project-floor.insert": "Geschoss angelegt",
"project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt",
@@ -3,6 +3,8 @@ import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit-protection-device.repository.js";
import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js";
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
import { FloorRepository } from "../../db/repositories/floor.repository.js";
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
@@ -15,6 +17,10 @@ export const circuitDeviceRowRepository =
export const circuitListRepository = new CircuitListRepository(db);
export const circuitSectionRepository = new CircuitSectionRepository(db);
export const circuitRepository = new CircuitRepository(db);
export const circuitProtectionDeviceRepository =
new CircuitProtectionDeviceRepository(db);
export const distributionBoardComponentRepository =
new DistributionBoardComponentRepository(db);
export const distributionBoardRepository =
new DistributionBoardRepository(db);
export const floorRepository = new FloorRepository(db);
@@ -6,7 +6,13 @@ import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-p
import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { CircuitProtectionProjectCommandRepository } from "../../db/repositories/circuit-protection-project-command.repository.js";
import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-structure-project-command.repository.js";
import { CircuitGroupRenumberProjectCommandRepository } from "../../db/repositories/circuit-group-renumber-project-command.repository.js";
import { CircuitGroupMoveProjectCommandRepository } from "../../db/repositories/circuit-group-move-project-command.repository.js";
import { CircuitGroupSubtreeProjectCommandRepository } from "../../db/repositories/circuit-group-subtree-project-command.repository.js";
import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js";
import { DistributionBoardComponentStructureProjectCommandRepository } from "../../db/repositories/distribution-board-component-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectLocationStructureProjectCommandRepository } from "../../db/repositories/project-location-structure-project-command.repository.js";
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
@@ -25,8 +31,20 @@ export const circuitDeviceRowMoveProjectCommandStore =
new CircuitDeviceRowMoveProjectCommandRepository(db);
export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db);
export const circuitProtectionProjectCommandStore =
new CircuitProtectionProjectCommandRepository(db);
export const circuitGroupStructureProjectCommandStore =
new CircuitGroupStructureProjectCommandRepository(db);
export const circuitGroupRenumberProjectCommandStore =
new CircuitGroupRenumberProjectCommandRepository(db);
export const circuitGroupMoveProjectCommandStore =
new CircuitGroupMoveProjectCommandRepository(db);
export const circuitGroupSubtreeProjectCommandStore =
new CircuitGroupSubtreeProjectCommandRepository(db);
export const distributionBoardStructureProjectCommandStore =
new DistributionBoardStructureProjectCommandRepository(db);
export const distributionBoardComponentStructureProjectCommandStore =
new DistributionBoardComponentStructureProjectCommandRepository(db);
export const projectLocationStructureProjectCommandStore =
new ProjectLocationStructureProjectCommandRepository(db);
export const circuitSectionReorderProjectCommandStore =
@@ -59,5 +77,11 @@ export const projectCommandService = new ProjectCommandService(
projectDeviceRowSyncProjectCommandStore,
projectSettingsProjectCommandStore,
projectStateRestoreCommandStore,
distributionBoardComponentStructureProjectCommandStore,
circuitGroupStructureProjectCommandStore,
circuitGroupRenumberProjectCommandStore,
circuitGroupMoveProjectCommandStore,
circuitGroupSubtreeProjectCommandStore,
circuitProtectionProjectCommandStore,
projectHistoryStore
);
@@ -7,11 +7,14 @@ import {
calculateSectionTotalPower,
} from "../../domain/calculations/circuit-power-calculation.js";
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
import type { CircuitTreeProtectionDevice } from "../../domain/models/circuit-tree.model.js";
import {
circuitDeviceRowRepository,
circuitListRepository,
circuitProtectionDeviceRepository,
circuitRepository,
circuitSectionRepository,
distributionBoardComponentRepository,
distributionBoardRepository,
projectRepository,
} from "../composition/application-repositories.js";
@@ -23,7 +26,9 @@ export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
return (
error.message.includes("no such table: circuit_sections") ||
error.message.includes("no such table: circuits") ||
error.message.includes("no such table: circuit_device_rows")
error.message.includes("no such table: circuit_device_rows") ||
error.message.includes("no such table: distribution_board_components") ||
error.message.includes("no such table: circuit_protection_devices")
);
}
@@ -56,9 +61,28 @@ export async function getCircuitTree(req: Request, res: Response) {
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
const circuits = await circuitRepository.listByCircuitList(circuitListId);
const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id));
const components =
await distributionBoardComponentRepository.listByCircuitList(
circuitListId
);
const [circuitProtections, componentProtections] =
await Promise.all([
circuitProtectionDeviceRepository.listByCircuitIds(
circuits.map((entry) => entry.id)
),
distributionBoardComponentRepository.listProtectionByComponentIds(
components.map((entry) => entry.id)
),
]);
const sectionById = new Map(sections.map((section) => [section.id, section]));
const rowsByCircuitId = new Map<string, typeof rows>();
const circuitProtectionById = new Map(
circuitProtections.map((entry) => [entry.circuitId, entry])
);
const componentProtectionById = new Map(
componentProtections.map((entry) => [entry.componentId, entry])
);
for (const row of rows) {
if (!rowsByCircuitId.has(row.circuitId)) {
rowsByCircuitId.set(row.circuitId, []);
@@ -75,17 +99,46 @@ export async function getCircuitTree(req: Request, res: Response) {
distributionBoard.simultaneityFactor,
distributionBoardTotalPower: 0,
distributionBoardTotalPowerWithSimultaneityFactor: 0,
headerComponents: [],
sections: sections.map((section) => ({
id: section.id,
key: section.key,
displayName: section.displayName,
prefix: section.prefix,
sortOrder: section.sortOrder,
category: section.category ?? undefined,
groupNumber: section.groupNumber ?? undefined,
sectionTotalPower: 0,
components: [],
circuits: [],
})),
footerComponents: [],
};
const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section]));
for (const component of components) {
const protection =
componentProtectionById.get(component.id);
const dto = {
id: component.id,
circuitListId: component.circuitListId,
sectionId: component.sectionId ?? undefined,
equipmentIdentifier: component.equipmentIdentifier,
name: component.name,
role: component.role,
placement: component.placement,
sortOrder: component.sortOrder,
protectionDevice: protection
? toProtectionDeviceDto(protection)
: undefined,
};
if (component.placement === "header") {
tree.headerComponents.push(dto);
} else if (component.placement === "footer") {
tree.footerComponents.push(dto);
} else if (component.sectionId !== null) {
sectionBlocks.get(component.sectionId)?.components.push(dto);
}
}
for (const circuit of circuits) {
const section = sectionById.get(circuit.sectionId);
@@ -116,6 +169,7 @@ export async function getCircuitTree(req: Request, res: Response) {
rowTotalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
}));
const circuitTotalPower = calculateCircuitTotalPower(deviceRows);
const protection = circuitProtectionById.get(circuit.id);
sectionBlocks.get(section.id)?.circuits.push({
id: circuit.id,
@@ -139,6 +193,9 @@ export async function getCircuitTree(req: Request, res: Response) {
remark: circuit.remark ?? undefined,
circuitTotalPower,
deviceRows,
protectionDevice: protection
? toProtectionDeviceDto(protection)
: undefined,
});
}
@@ -167,7 +224,9 @@ export async function getCircuitTree(req: Request, res: Response) {
distributionBoard.simultaneityFactor,
distributionBoardTotalPower: 0,
distributionBoardTotalPowerWithSimultaneityFactor: 0,
headerComponents: [],
sections: [],
footerComponents: [],
warning:
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
});
@@ -175,3 +234,23 @@ export async function getCircuitTree(req: Request, res: Response) {
throw error;
}
}
function toProtectionDeviceDto(value: {
type: CircuitTreeProtectionDevice["type"];
ratedCurrentA: number;
fuseUtilizationCategory: CircuitTreeProtectionDevice["fuseUtilizationCategory"] | null;
tripCharacteristic: CircuitTreeProtectionDevice["tripCharacteristic"] | null;
rcdType: CircuitTreeProtectionDevice["rcdType"] | null;
ratedResidualCurrentMa: number | null;
}): CircuitTreeProtectionDevice {
return {
type: value.type,
ratedCurrentA: value.ratedCurrentA,
fuseUtilizationCategory:
value.fuseUtilizationCategory ?? undefined,
tripCharacteristic: value.tripCharacteristic ?? undefined,
rcdType: value.rcdType ?? undefined,
ratedResidualCurrentMa:
value.ratedResidualCurrentMa ?? undefined,
};
}
+20
View File
@@ -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;
};
+108
View File
@@ -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
>;
@@ -11,6 +11,7 @@ import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/reposito
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.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";
@@ -193,6 +194,15 @@ function createMoveTargetSnapshot(
isReserve: true,
remark: null,
deviceRows: [],
protectionDevice: {
circuitId: "circuit-new",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
},
...overrides,
};
}
@@ -520,6 +530,14 @@ describe("circuit device-row move project-command repository", () => {
"-1F4"
);
assert.equal(isReserve(fixture.context, "circuit-new"), false);
assert.equal(
fixture.context.db
.select({ ratedCurrentA: circuitProtectionDevices.ratedCurrentA })
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
.get()?.ratedCurrentA,
10
);
assert.equal(isReserve(fixture.context, "circuit-1"), false);
assert.equal(isReserve(fixture.context, "circuit-2"), true);
@@ -538,6 +556,14 @@ describe("circuit device-row move project-command repository", () => {
.get(),
undefined
);
assert.equal(
fixture.context.db
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
.get(),
undefined
);
assert.deepEqual(
[
getRow(fixture.context, "row-1"),
@@ -562,6 +588,14 @@ describe("circuit device-row move project-command repository", () => {
getRow(fixture.context, "row-2")?.circuitId,
"circuit-new"
);
assert.equal(
fixture.context.db
.select({ type: circuitProtectionDevices.type })
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
.get()?.type,
"LS"
);
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
+30
View File
@@ -1,11 +1,20 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
getAdjacentInsertionSortOrder,
getInsertionSortOrder,
isGridInsertionRowType,
resolveGridInsertionIntent,
} from "../src/frontend/utils/circuit-grid-insertion.js";
describe("circuit grid insertion", () => {
it("excludes structural rows from insertion and deletion commands", () => {
assert.equal(isGridInsertionRowType("headerComponent"), false);
assert.equal(isGridInsertionRowType("groupComponent"), false);
assert.equal(isGridInsertionRowType("footerComponent"), false);
assert.equal(isGridInsertionRowType("circuitCompact"), true);
});
it("inserts a circuit after circuit-level selections", () => {
assert.deepEqual(
resolveGridInsertionIntent(
@@ -81,5 +90,26 @@ describe("circuit grid insertion", () => {
assert.equal(getInsertionSortOrder(entries, "c"), 40);
assert.equal(getInsertionSortOrder(entries), 40);
assert.equal(getInsertionSortOrder([], "missing"), 10);
assert.equal(
getAdjacentInsertionSortOrder(entries, "a", "before"),
0
);
assert.equal(
getAdjacentInsertionSortOrder(entries, "b", "before"),
15
);
assert.equal(
getAdjacentInsertionSortOrder(entries, "b", "after"),
25
);
assert.throws(
() =>
getAdjacentInsertionSortOrder(
entries,
"missing",
"before"
),
/nicht gefunden/
);
});
});
+25
View File
@@ -101,6 +101,14 @@ describe("circuit grid model", () => {
});
it("shows stable circuit sections with German labels", () => {
assert.equal(
getCircuitSectionLabel({
key: "lighting",
displayName: "Lighting",
category: "lighting",
}),
"Lighting"
);
assert.equal(
getCircuitSectionLabel({
key: "lighting",
@@ -128,12 +136,29 @@ describe("circuit grid model", () => {
assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField");
assert.equal(getCellKind("deviceRow", "controlRequirement"), "readonly");
assert.equal(getCellKind("circuitCompact", "rowTotalPower"), "computed");
assert.equal(getCellKind("circuitCompact", "protectionType"), "computed");
});
it("projects circuit and device values without mixing ownership", () => {
assert.equal(getDeviceValue(device, "roomSummary"), "1.01 Office");
assert.equal(getDeviceValue(device, "rowTotalPower"), 0.08);
assert.equal(getCircuitValue(circuit, "protectionSummary"), "MCB 16A B");
assert.equal(
getCircuitValue(
{
...circuit,
protectionDevice: {
type: "FI_LS",
ratedCurrentA: 16,
tripCharacteristic: "B",
rcdType: "A",
ratedResidualCurrentMa: 30,
},
},
"protectionSummary"
),
"FI/LS · 16 A · B · Typ A · 30 mA"
);
assert.equal(getCircuitValue(circuit, "voltage"), 230);
assert.equal(getCircuitValue(circuit, "controlRequirement"), "DALI");
assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m");
+62
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildVisibleGridRows,
buildVisibleGridRowsWithStructure,
filterAndSortCircuitSections,
getDistinctFilterValues,
} from "../src/frontend/utils/circuit-grid-projection.js";
@@ -10,6 +11,7 @@ import type {
CircuitTreeDeviceRowDto,
CircuitTreeSectionDto,
} from "../src/frontend/types.js";
import "./circuit-structure-projection.test.js";
function makeDevice(
id: string,
@@ -175,6 +177,66 @@ describe("circuit grid projection", () => {
);
});
it("normalizes structural component rows around complete circuit groups", () => {
const component = {
circuitListId: "list-1",
equipmentIdentifier: "-Q0",
name: "Hauptschalter",
role: "main_switch" as const,
placement: "header" as const,
sortOrder: 10,
};
const groupedSections = sections.map((section) => ({
...section,
components: [
{
...component,
id: "group-rcd",
sectionId: section.id,
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection" as const,
placement: "group" as const,
},
],
}));
const rows = buildVisibleGridRowsWithStructure(groupedSections, {
headerComponents: [{ ...component, id: "main" }],
footerComponents: [
{
...component,
id: "actor",
equipmentIdentifier: "-K1",
name: "KNX Aktor",
role: "auxiliary",
placement: "footer",
},
],
});
assert.deepEqual(
rows.map((row) => row.rowType),
[
"headerComponent",
"section",
"groupComponent",
"circuitCompact",
"circuitSummary",
"deviceRow",
"deviceRow",
"reserveCircuit",
"placeholder",
"footerComponent",
]
);
assert.equal(
rows.find((row) => row.rowType === "groupComponent")?.cells.some(
(cell) => cell.editable
),
false
);
});
it("collects filter options from both circuit and device values", () => {
const values = getDistinctFilterValues(sections, [
{ key: "displayName", label: "Display name" },
@@ -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();
}
});
});
+746
View File
@@ -0,0 +1,746 @@
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";
import {
buildCircuitGroupMovePlan,
buildCircuitGroupRenumberPlan,
buildCircuitGroupReorderAssignments,
buildCircuitGroupSubtreeSnapshot,
buildNewCircuitGroupSnapshot,
canMoveCircuitToGroup,
canRenumberCircuitGroups,
canDeleteCircuitGroup,
renameCircuitGroupSnapshot,
summarizeCircuitGroupSubtree,
} from "../src/frontend/utils/circuit-group-editing.js";
describe("circuit group numbering", () => {
it("builds the next group without filling gaps or renumbering existing groups", () => {
const sections = [
groupSection("lighting-1", "lighting", 1, 10),
groupSection("single-1", "single_phase", 1, 20),
groupSection("lighting-3", "lighting", 3, 15),
];
const snapshot = buildNewCircuitGroupSnapshot({
id: "lighting-4",
circuitListId: "list-1",
category: "lighting",
sections,
});
assert.deepEqual(snapshot, {
id: "lighting-4",
circuitListId: "list-1",
key: "lighting_4",
displayName: "Beleuchtung 4",
prefix: "-1F4.",
sortOrder: 17.5,
category: "lighting",
groupNumber: 4,
});
assert.equal(
renameCircuitGroupSnapshot(snapshot, " Beleuchtung Nord ").displayName,
"Beleuchtung Nord"
);
assert.equal(canDeleteCircuitGroup(sections[0]), true);
assert.equal(
canDeleteCircuitGroup({
...sections[0],
components: [
{
id: "component-1",
circuitListId: "list-1",
sectionId: sections[0].id,
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 10,
},
],
}),
false
);
});
it("reorders complete group lists only between category peers", () => {
const sections = [
groupSection("lighting-1", "lighting", 1, 10),
groupSection("lighting-2", "lighting", 2, 15),
groupSection("single-1", "single_phase", 1, 20),
];
assert.deepEqual(
buildCircuitGroupReorderAssignments(
sections,
"lighting-1",
1
),
[
{
groupId: "lighting-1",
expectedSortOrder: 10,
targetSortOrder: 15,
},
{
groupId: "lighting-2",
expectedSortOrder: 15,
targetSortOrder: 10,
},
{
groupId: "single-1",
expectedSortOrder: 20,
targetSortOrder: 20,
},
]
);
assert.equal(
buildCircuitGroupReorderAssignments(
sections,
"lighting-1",
-1
),
null
);
assert.equal(
buildCircuitGroupReorderAssignments(
sections,
"single-1",
1
),
null
);
});
it("renumbers a complete category by group order and preserves circuit suffixes", () => {
const sections = [
{
...groupSection("lighting-3", "lighting", 3, 10),
prefix: "-1F3.",
circuits: [
{
id: "circuit-7",
circuitListId: "list-1",
sectionId: "lighting-3",
equipmentIdentifier: "-1F3.7",
sortOrder: 10,
isReserve: false,
circuitTotalPower: 0,
deviceRows: [],
},
],
components: [
{
id: "rcd-3",
circuitListId: "list-1",
sectionId: "lighting-3",
equipmentIdentifier: "-1Q3.0",
name: "Gruppen-FI",
role: "group_residual_current_protection" as const,
placement: "group" as const,
sortOrder: 10,
},
],
},
{
...groupSection("lighting-1", "lighting", 1, 20),
prefix: "-1F1.",
},
groupSection("single-1", "single_phase", 1, 30),
];
assert.deepEqual(buildCircuitGroupRenumberPlan(sections, "lighting"), {
groups: [
{
groupId: "lighting-3",
category: "lighting",
expectedGroupNumber: 3,
targetGroupNumber: 1,
expectedPrefix: "-1F3.",
targetPrefix: "-1F1.",
circuits: [
{
circuitId: "circuit-7",
expectedEquipmentIdentifier: "-1F3.7",
targetEquipmentIdentifier: "-1F1.7",
},
],
components: [
{
componentId: "rcd-3",
expectedEquipmentIdentifier: "-1Q3.0",
targetEquipmentIdentifier: "-1Q1.0",
},
],
},
{
groupId: "lighting-1",
category: "lighting",
expectedGroupNumber: 1,
targetGroupNumber: 2,
expectedPrefix: "-1F1.",
targetPrefix: "-1F2.",
circuits: [],
components: [],
},
],
});
assert.equal(
buildCircuitGroupRenumberPlan(
[
groupSection("lighting-1", "lighting", 1, 10),
groupSection("lighting-2", "lighting", 2, 20),
],
"lighting"
),
null
);
assert.equal(
canRenumberCircuitGroups(sections, "lighting"),
true
);
assert.equal(
canRenumberCircuitGroups(
[
groupSection("lighting-1", "lighting", 1, 10),
groupSection("lighting-2", "lighting", 2, 20),
],
"lighting"
),
false
);
});
it("plans a same-category circuit drop with the next target BMK", () => {
const source = {
...groupSection("lighting-1", "lighting", 1, 10),
circuits: [
{
id: "circuit-source",
circuitListId: "list-1",
sectionId: "lighting-1",
equipmentIdentifier: "-1F1.5",
sortOrder: 10,
isReserve: false,
circuitTotalPower: 0,
deviceRows: [],
},
],
};
const target = {
...groupSection("lighting-2", "lighting", 2, 20),
circuits: [
{
id: "circuit-target",
circuitListId: "list-1",
sectionId: "lighting-2",
equipmentIdentifier: "-1F2.3",
sortOrder: 20,
isReserve: false,
circuitTotalPower: 0,
deviceRows: [],
},
],
};
assert.equal(
canMoveCircuitToGroup(
[source, target],
"circuit-source",
"lighting-2"
),
true
);
assert.deepEqual(
buildCircuitGroupMovePlan({
sections: [source, target],
circuitId: "circuit-source",
targetSectionId: "lighting-2",
placement: {
kind: "before",
targetCircuitId: "circuit-target",
},
}),
{
circuitId: "circuit-source",
circuitListId: "list-1",
expectedSectionId: "lighting-1",
targetSectionId: "lighting-2",
expectedEquipmentIdentifier: "-1F1.5",
targetEquipmentIdentifier: "-1F2.4",
expectedSortOrder: 10,
targetSortOrder: 10,
}
);
assert.equal(
canMoveCircuitToGroup(
[
source,
groupSection("single-1", "single_phase", 1, 20),
],
"circuit-source",
"single-1"
),
false
);
});
it("captures a complete populated group for warned deletion", () => {
const section = {
...groupSection("lighting-1", "lighting", 1, 10),
components: [
{
id: "rcd-1",
circuitListId: "list-1",
sectionId: "lighting-1",
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection" as const,
placement: "group" as const,
sortOrder: 10,
protectionDevice: {
type: "FI" as const,
ratedCurrentA: 40,
rcdType: "A" as const,
ratedResidualCurrentMa: 30,
},
},
],
circuits: [
{
id: "circuit-1",
circuitListId: "list-1",
sectionId: "lighting-1",
equipmentIdentifier: "-1F1.1",
displayName: "Flurlicht",
sortOrder: 10,
voltage: 230,
isReserve: false,
circuitTotalPower: 0.1,
protectionDevice: {
type: "LS" as const,
ratedCurrentA: 10,
tripCharacteristic: "B" as const,
},
deviceRows: [
{
id: "row-1",
linkedProjectDeviceId: "device-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte Flur",
phaseType: "single_phase",
quantity: 2,
powerPerUnit: 0.05,
simultaneityFactor: 1,
cosPhi: 0.95,
overriddenFields: "[\"displayName\"]",
rowTotalPower: 0.1,
},
],
},
],
};
const snapshot = buildCircuitGroupSubtreeSnapshot(
section,
"list-1"
);
assert.deepEqual(summarizeCircuitGroupSubtree(snapshot), {
circuitCount: 1,
deviceRowCount: 1,
protectionComponentCount: 1,
});
assert.equal(
snapshot.circuits[0].circuit.deviceRows[0]
.linkedProjectDeviceId,
"device-1"
);
assert.equal(
snapshot.circuits[0].circuit.deviceRows[0]
.overriddenFields,
"[\"displayName\"]"
);
assert.deepEqual(snapshot.circuits[0].protectionDevice, {
circuitId: "circuit-1",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
});
});
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
);
});
});
function groupSection(
id: string,
category: "lighting" | "single_phase" | "three_phase",
groupNumber: number,
sortOrder: number
) {
return {
id,
key: id,
displayName: id,
prefix: `-${category === "lighting" ? 1 : category === "single_phase" ? 2 : 3}F${groupNumber}.`,
sortOrder,
category,
groupNumber,
sectionTotalPower: 0,
components: [],
circuits: [],
};
}
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,191 @@
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 { CircuitProtectionProjectCommandRepository } from "../src/db/repositories/circuit-protection-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.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 { projects } from "../src/db/schema/projects.js";
import {
createCircuitProtectionUpdateProjectCommand,
type CircuitProtectionSnapshot,
} from "../src/domain/models/circuit-protection-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");
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);
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: list.id,
sectionId: section.id,
equipmentIdentifier: "-1F1.1",
sortOrder: 10,
})
.run();
return context;
}
const protection: CircuitProtectionSnapshot = {
circuitId: "circuit-1",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
};
describe("circuit-protection project command", () => {
it("inserts, updates and restores protection through exact inverse commands", () => {
const context = createTestDatabase();
try {
const repository = new CircuitProtectionProjectCommandRepository(
context.db
);
const history = new ProjectHistoryRepository(context.db);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitProtectionUpdateProjectCommand(
"circuit-1",
null,
protection
),
});
assert.deepEqual(
context.db.select().from(circuitProtectionDevices).get(),
protection
);
const target = { ...protection, ratedCurrentA: 16 };
const updated = repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitProtectionUpdateProjectCommand(
"circuit-1",
protection,
target
),
});
assert.equal(
context.db.select().from(circuitProtectionDevices).get()
?.ratedCurrentA,
16
);
const updateUndo = history.getNextCommand("project-1", "undo");
assert.ok(updateUndo);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "undo",
historyTargetChangeSetId: updateUndo.changeSetId,
command: updated.inverse,
});
assert.deepEqual(
context.db.select().from(circuitProtectionDevices).get(),
protection
);
const insertUndo = history.getNextCommand("project-1", "undo");
assert.ok(insertUndo);
repository.execute({
projectId: "project-1",
expectedRevision: 3,
source: "undo",
historyTargetChangeSetId: insertUndo.changeSetId,
command: inserted.inverse,
});
assert.equal(
context.db.select().from(circuitProtectionDevices).get(),
undefined
);
} finally {
context.close();
}
});
it("rejects stale state, foreign ownership and user removal", () => {
const context = createTestDatabase();
try {
const repository = new CircuitProtectionProjectCommandRepository(
context.db
);
assert.throws(
() =>
repository.execute({
projectId: "project-2",
expectedRevision: 0,
source: "user",
command: createCircuitProtectionUpdateProjectCommand(
"circuit-1",
null,
protection
),
}),
/does not belong/
);
context.db.insert(circuitProtectionDevices).values(protection).run();
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitProtectionUpdateProjectCommand(
"circuit-1",
null,
protection
),
}),
/changed before/
);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitProtectionUpdateProjectCommand(
"circuit-1",
protection,
null
),
}),
/cannot remove/
);
} finally {
context.close();
}
});
});
+10
View File
@@ -65,12 +65,22 @@ describe("circuit structure command adapters", () => {
controlRequirement: "DALI",
},
deviceRows: [],
category: "single_phase",
});
assert.equal(reserve.isReserve, true);
assert.equal(reserve.displayName, null);
assert.equal(reserve.voltage, 230);
assert.equal(reserve.controlRequirement, "DALI");
assert.deepEqual(reserve.protectionDevice, {
circuitId: "circuit-1",
type: "FI_LS",
ratedCurrentA: 16,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: "A",
ratedResidualCurrentMa: 30,
});
const row = buildCircuitDeviceRowInsertSnapshot({
id: "row-1",
@@ -11,6 +11,7 @@ import { CircuitStructureProjectCommandRepository } from "../src/db/repositories
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.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 { projectDevices } from "../src/db/schema/project-devices.js";
@@ -165,6 +166,18 @@ function createTestDatabase(): TestFixture {
},
])
.run();
context.db
.insert(circuitProtectionDevices)
.values({
circuitId: "circuit-existing",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
})
.run();
return {
context,
@@ -377,6 +390,11 @@ describe("circuit structure project-command repository", () => {
"circuit-existing"
);
const beforeRows = getRows(fixture.context, "circuit-existing");
const beforeProtection = fixture.context.db
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
.get();
assert.ok(beforeCircuit);
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
@@ -395,6 +413,14 @@ describe("circuit structure project-command repository", () => {
undefined
);
assert.equal(getRows(fixture.context, "circuit-existing").length, 0);
assert.equal(
fixture.context.db
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
.get(),
undefined
);
store.execute({
projectId: "project-1",
@@ -411,6 +437,14 @@ describe("circuit structure project-command repository", () => {
getRows(fixture.context, "circuit-existing"),
beforeRows
);
assert.deepEqual(
fixture.context.db
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
.get(),
beforeProtection
);
} finally {
fixture.context.close();
}
+144
View File
@@ -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"
);
});
});
+199 -1
View File
@@ -1,6 +1,19 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { isMissingCircuitTreeSchemaError } from "../src/server/controllers/circuit-tree.controller.js";
import {
getCircuitTree,
isMissingCircuitTreeSchemaError,
} from "../src/server/controllers/circuit-tree.controller.js";
import {
circuitDeviceRowRepository,
circuitListRepository,
circuitProtectionDeviceRepository,
circuitRepository,
circuitSectionRepository,
distributionBoardComponentRepository,
distributionBoardRepository,
projectRepository,
} from "../src/server/composition/application-repositories.js";
describe("circuit tree controller", () => {
it("detects missing circuit-first schema errors", () => {
@@ -13,7 +26,192 @@ describe("circuit tree controller", () => {
isMissingCircuitTreeSchemaError(new Error("SqliteError: no such table: circuit_device_rows")),
true
);
assert.equal(
isMissingCircuitTreeSchemaError(
new Error("SqliteError: no such table: distribution_board_components")
),
true
);
assert.equal(isMissingCircuitTreeSchemaError(new Error("Some other error")), false);
});
it("returns header, grouped and footer components with both protection layers", async () => {
const originals = {
findList: circuitListRepository.findById,
findProject: projectRepository.findById,
findBoard: distributionBoardRepository.findById,
listSections: circuitSectionRepository.listByCircuitList,
listCircuits: circuitRepository.listByCircuitList,
listRows: circuitDeviceRowRepository.listByCircuitList,
listComponents:
distributionBoardComponentRepository.listByCircuitList,
listComponentProtection:
distributionBoardComponentRepository.listProtectionByComponentIds,
listCircuitProtection:
circuitProtectionDeviceRepository.listByCircuitIds,
};
circuitListRepository.findById = async () =>
({
id: "list-1",
projectId: "project-1",
distributionBoardId: "board-1",
name: "UV-01",
}) as never;
projectRepository.findById = async () =>
({
id: "project-1",
name: "Projekt",
currentRevision: 4,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
}) as never;
distributionBoardRepository.findById = async () =>
({ id: "board-1", simultaneityFactor: 0.8 }) as never;
circuitSectionRepository.listByCircuitList = async () =>
[
{
id: "group-1",
circuitListId: "list-1",
key: "lighting",
displayName: "Beleuchtung 1",
prefix: "-1F1.",
sortOrder: 10,
category: "lighting",
groupNumber: 1,
},
] as never;
circuitRepository.listByCircuitList = async () =>
[
{
id: "circuit-1",
circuitListId: "list-1",
sectionId: "group-1",
equipmentIdentifier: "-1F1.1",
displayName: "Licht",
sortOrder: 10,
isReserve: 0,
},
] as never;
circuitDeviceRowRepository.listByCircuitList = async () => [];
distributionBoardComponentRepository.listByCircuitList =
async () =>
[
{
id: "main",
circuitListId: "list-1",
sectionId: null,
equipmentIdentifier: "-Q0",
name: "Hauptschalter",
role: "main_switch",
placement: "header",
sortOrder: 10,
},
{
id: "rcd",
circuitListId: "list-1",
sectionId: "group-1",
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 10,
},
{
id: "actor",
circuitListId: "list-1",
sectionId: null,
equipmentIdentifier: "-K1",
name: "KNX Aktor",
role: "auxiliary",
placement: "footer",
sortOrder: 10,
},
] as never;
distributionBoardComponentRepository.listProtectionByComponentIds =
async () =>
[
{
componentId: "rcd",
type: "FI",
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A",
ratedResidualCurrentMa: 30,
},
] as never;
circuitProtectionDeviceRepository.listByCircuitIds = async () =>
[
{
circuitId: "circuit-1",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
},
] as never;
let responseBody: unknown;
const response = {
status() {
return this;
},
json(value: unknown) {
responseBody = value;
return this;
},
};
try {
await getCircuitTree(
{ params: { projectId: "project-1", circuitListId: "list-1" } } as never,
response as never
);
} finally {
circuitListRepository.findById = originals.findList;
projectRepository.findById = originals.findProject;
distributionBoardRepository.findById = originals.findBoard;
circuitSectionRepository.listByCircuitList =
originals.listSections;
circuitRepository.listByCircuitList = originals.listCircuits;
circuitDeviceRowRepository.listByCircuitList = originals.listRows;
distributionBoardComponentRepository.listByCircuitList =
originals.listComponents;
distributionBoardComponentRepository.listProtectionByComponentIds =
originals.listComponentProtection;
circuitProtectionDeviceRepository.listByCircuitIds =
originals.listCircuitProtection;
}
const tree = responseBody as {
headerComponents: Array<{ equipmentIdentifier: string }>;
footerComponents: Array<{ equipmentIdentifier: string }>;
sections: Array<{
category: string;
groupNumber: number;
components: Array<{
protectionDevice: { ratedResidualCurrentMa: number };
}>;
circuits: Array<{
protectionDevice: { tripCharacteristic: string };
}>;
}>;
};
assert.equal(tree.headerComponents[0].equipmentIdentifier, "-Q0");
assert.equal(tree.footerComponents[0].equipmentIdentifier, "-K1");
assert.equal(tree.sections[0].category, "lighting");
assert.equal(tree.sections[0].groupNumber, 1);
assert.equal(
tree.sections[0].components[0].protectionDevice
.ratedResidualCurrentMa,
30
);
assert.equal(
tree.sections[0].circuits[0].protectionDevice
.tripCharacteristic,
"B"
);
});
});

Some files were not shown because too many files have changed in this diff Show More