Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac21d6eb5d | |||
| 7a9530a914 | |||
| 99bdf6491b | |||
| db4c757018 | |||
| 3399fcd88b | |||
| 9ad91887b2 | |||
| 07925b2fd9 | |||
| 63650a623c | |||
| e702712bd8 | |||
| 1250f9a6af |
@@ -309,6 +309,33 @@ snapshots include the optional one-to-one protection-device state and reject
|
|||||||
stale data. Updates preserve id, ownership, role and placement. Fixed
|
stale data. Updates preserve id, ownership, role and placement. Fixed
|
||||||
main-switch and surge-protection header roles are excluded from these general
|
main-switch and surge-protection header roles are excluded from these general
|
||||||
component commands.
|
component commands.
|
||||||
|
Empty circuit-group creation, display-name updates and deletion use
|
||||||
|
`circuit-group.insert`, `circuit-group.update` and `circuit-group.delete`.
|
||||||
|
Their snapshots keep category, positive group number and derived prefix
|
||||||
|
consistent. General deletion rejects groups containing circuits or group
|
||||||
|
components; populated deletion remains a separate confirmed command.
|
||||||
|
`circuit-group.reorder` requires a complete expected/target sort assignment for
|
||||||
|
every group in one circuit list. It changes only sort positions and never
|
||||||
|
renumbers groups, prefixes or circuits.
|
||||||
|
Explicit group-number changes use `circuit-group.renumber`. The command carries
|
||||||
|
the complete expected/target group, circuit-BMK and optional group-component
|
||||||
|
BMK plan, applies swaps through collision-safe temporary values and preserves
|
||||||
|
circuit suffixes. Undo/Redo uses the exact inverse plan.
|
||||||
|
Cross-group circuit moves use `circuit.move-group` and are limited to distinct
|
||||||
|
groups of the same category. The stored target BMK is highest target suffix
|
||||||
|
plus one at planning time and is never recalculated for Redo. Only circuit
|
||||||
|
group, BMK and sort order change; device rows and circuit protection retain the
|
||||||
|
stable circuit id.
|
||||||
|
Confirmed populated-group deletion uses
|
||||||
|
`circuit-group.delete-subtree`/`circuit-group.restore-subtree`. The exact
|
||||||
|
snapshot contains the group, optional component protections, complete circuits,
|
||||||
|
circuit protections and all device-row link/override metadata. Delete
|
||||||
|
re-captures and compares the subtree before cascading; restore preserves every
|
||||||
|
UUID in foreign-key-safe order.
|
||||||
|
The circuit-tree read model exposes fixed `headerComponents`, group-owned
|
||||||
|
`components`, `footerComponents`, optional group category/number and optional
|
||||||
|
one-to-one protection DTOs for circuits and group components. These are
|
||||||
|
additive to the retained flat circuit protection fields during the transition.
|
||||||
Distribution-board floor assignment and a project-enabled supply type use
|
Distribution-board floor assignment and a project-enabled supply type use
|
||||||
`distribution-board.update`; both values are snapshot/export fields and one
|
`distribution-board.update`; both values are snapshot/export fields and one
|
||||||
persistent undo step.
|
persistent undo step.
|
||||||
|
|||||||
@@ -230,7 +230,11 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
|||||||
### Tree Endpoint
|
### Tree Endpoint
|
||||||
|
|
||||||
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
||||||
- Purpose: returns section/circuit/device-row tree with calculated row and circuit totals.
|
- Purpose: returns fixed header components, grouped sections with optional
|
||||||
|
protection components, complete circuit/device-row blocks and auxiliary
|
||||||
|
footer components. Circuit and component protection devices use their
|
||||||
|
separate one-to-one DTOs. Calculated row, circuit, section and board totals
|
||||||
|
remain included.
|
||||||
|
|
||||||
Response sketch:
|
Response sketch:
|
||||||
|
|
||||||
@@ -238,16 +242,47 @@ Response sketch:
|
|||||||
{
|
{
|
||||||
"circuitListId": "cl_1",
|
"circuitListId": "cl_1",
|
||||||
"currentRevision": 12,
|
"currentRevision": 12,
|
||||||
|
"headerComponents": [
|
||||||
|
{
|
||||||
|
"id": "cmp_main",
|
||||||
|
"equipmentIdentifier": "-Q0",
|
||||||
|
"name": "Hauptschalter",
|
||||||
|
"role": "main_switch",
|
||||||
|
"placement": "header"
|
||||||
|
}
|
||||||
|
],
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
"id": "sec_1",
|
"id": "sec_1",
|
||||||
"key": "lighting",
|
"key": "lighting",
|
||||||
"prefix": "-1F",
|
"prefix": "-1F1.",
|
||||||
|
"category": "lighting",
|
||||||
|
"groupNumber": 1,
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"id": "cmp_rcd",
|
||||||
|
"equipmentIdentifier": "-1Q1.0",
|
||||||
|
"name": "Gruppen-FI",
|
||||||
|
"role": "group_residual_current_protection",
|
||||||
|
"placement": "group",
|
||||||
|
"protectionDevice": {
|
||||||
|
"type": "FI",
|
||||||
|
"ratedCurrentA": 40,
|
||||||
|
"rcdType": "A",
|
||||||
|
"ratedResidualCurrentMa": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"circuits": [
|
"circuits": [
|
||||||
{
|
{
|
||||||
"id": "cir_1",
|
"id": "cir_1",
|
||||||
"equipmentIdentifier": "-1F1",
|
"equipmentIdentifier": "-1F1.1",
|
||||||
"circuitTotalPower": 4.2,
|
"circuitTotalPower": 4.2,
|
||||||
|
"protectionDevice": {
|
||||||
|
"type": "LS",
|
||||||
|
"ratedCurrentA": 10,
|
||||||
|
"tripCharacteristic": "B"
|
||||||
|
},
|
||||||
"deviceRows": [
|
"deviceRows": [
|
||||||
{
|
{
|
||||||
"id": "row_1",
|
"id": "row_1",
|
||||||
@@ -258,6 +293,15 @@ Response sketch:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"footerComponents": [
|
||||||
|
{
|
||||||
|
"id": "cmp_actor",
|
||||||
|
"equipmentIdentifier": "-K1",
|
||||||
|
"name": "KNX Schaltaktor",
|
||||||
|
"role": "auxiliary",
|
||||||
|
"placement": "footer"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -298,6 +298,63 @@ veraltete Zustände werden innerhalb derselben Transaktion geprüft. Die festen
|
|||||||
Kopfkomponenten Hauptschalter und Überspannungsableiter sind von diesen
|
Kopfkomponenten Hauptschalter und Überspannungsableiter sind von diesen
|
||||||
allgemeinen Commands ausgeschlossen. API- und Editorintegration folgen in den
|
allgemeinen Commands ausgeschlossen. API- und Editorintegration folgen in den
|
||||||
nächsten Arbeitspaketen.
|
nächsten Arbeitspaketen.
|
||||||
|
Leere Stromkreisgruppen werden über `circuit-group.insert`,
|
||||||
|
`circuit-group.update` und `circuit-group.delete` verwaltet. Ihr vollständiger
|
||||||
|
Snapshot koppelt Kategorie, positive Gruppennummer und daraus abgeleiteten
|
||||||
|
BMK-Präfix; allgemeine Updates ändern ausschließlich den Anzeigenamen.
|
||||||
|
Löschen verlangt einen exakt unveränderten Zustand ohne Stromkreise und ohne
|
||||||
|
Gruppenkomponenten. Das bestätigte Löschen befüllter Unterbäume bleibt ein
|
||||||
|
eigener späterer Command.
|
||||||
|
`circuit-group.reorder` sortiert Gruppen als vollständige Stromkreislisten-
|
||||||
|
Zuordnung. Jede vorhandene Gruppe muss mit erwarteter und neuer Position
|
||||||
|
enthalten sein. Der Command verändert ausschließlich `sortOrder`; Nummern,
|
||||||
|
Präfixe und Stromkreis-BMKs bleiben stabil. Die gesamte Sortierung bildet eine
|
||||||
|
Revision und einen Undo/Redo-Schritt.
|
||||||
|
`src/domain/services/circuit-group-renumbering.ts` plant explizite
|
||||||
|
Gruppennummerierungen deterministisch. Der reine Plan erhält
|
||||||
|
Stromkreis-Endnummern, leitet Gruppenpräfixe und optionale `.0`-Komponenten-BMKs
|
||||||
|
neu ab und prüft Nummerntausch sowie Kollisionen mit unveränderten Gruppen.
|
||||||
|
`circuit-group.renumber` führt diesen Plan über die gemeinsame
|
||||||
|
Projekt-Command-Transaktion aus. Vor dem Schreiben müssen alle betroffenen
|
||||||
|
Gruppen, Stromkreise und Gruppenkomponenten exakt dem erwarteten Zustand
|
||||||
|
entsprechen. Nummerntausch verwendet kollisionsfreie temporäre Präfixe und
|
||||||
|
BMKs über beide BMK-Tabellen; anschließend werden alle Zielwerte finalisiert.
|
||||||
|
Der vollständige inverse Plan ermöglicht dauerhaftes Undo/Redo.
|
||||||
|
`src/domain/services/circuit-group-move-planning.ts` plant den Wechsel eines
|
||||||
|
vollständigen Stromkreises zwischen zwei verschiedenen Gruppen derselben
|
||||||
|
Kategorie. Die Ziel-BMK verwendet die höchste vorhandene Stromkreis-Endnummer
|
||||||
|
der Zielgruppe plus eins; Lücken werden nicht gefüllt. Quell-/Zielgruppe,
|
||||||
|
Quell-/Zielposition und beide BMKs werden festgeschrieben, damit eine spätere
|
||||||
|
Wiederholung nichts neu berechnet.
|
||||||
|
`circuit.move-group` führt den geplanten Wechsel als eine Projekt-Revision aus.
|
||||||
|
Nur `sectionId`, `equipmentIdentifier` und `sortOrder` des Stromkreises ändern
|
||||||
|
sich. Gerätezeilen und das getrennte 1:1-Schutzgerät bleiben über die stabile
|
||||||
|
Stromkreis-UUID verbunden. Der inverse Wechsel speichert Quellgruppe, BMK und
|
||||||
|
Position vollständig für dauerhaftes Undo/Redo.
|
||||||
|
`CircuitGroupSubtreeSnapshot` ist der kanonische Vertrag für destruktive
|
||||||
|
Gruppenoperationen. Er umfasst Gruppe, optionale Gruppenkomponenten samt
|
||||||
|
1:1-Schutzdaten, vollständige Stromkreise samt 1:1-Schutzdaten und sämtliche
|
||||||
|
Gerätezeilen einschließlich Link- und Override-Metadaten. Seine
|
||||||
|
Warnzusammenfassung liefert Vorsicherung/FI sowie Stromkreis- und
|
||||||
|
Gerätezeilenanzahl.
|
||||||
|
`circuit-group.delete-subtree` nimmt den aktuellen Unterbaum unmittelbar vor
|
||||||
|
dem Löschen erneut auf und verlangt exakte Übereinstimmung mit dem bestätigten
|
||||||
|
Snapshot. `circuit-group.restore-subtree` schreibt ihn in FK-sicherer Reihenfolge
|
||||||
|
mit denselben UUIDs zurück. Beide Richtungen laufen als vollständige inverse
|
||||||
|
Projekt-Commands; späte Fehler hinterlassen weder Teillöschungen noch
|
||||||
|
Teilwiederherstellungen.
|
||||||
|
Der Circuit-Tree liefert zusätzlich `headerComponents`,
|
||||||
|
gruppenbezogene `components` und `footerComponents`. Abschnitte enthalten
|
||||||
|
optionale Gruppenkategorie und -nummer; Stromkreise und Gruppenkomponenten
|
||||||
|
enthalten ihre optionale getrennte Schutzgerätekonfiguration. Die bisherigen
|
||||||
|
flachen Stromkreis-Schutzfelder bleiben während der Übergangsphase additiv
|
||||||
|
erhalten. Separate aktive Read-Repositories lesen Komponenten- und beide
|
||||||
|
Schutzgerätetabellen; Schreibzugriffe bleiben ausschließlich in Commands.
|
||||||
|
`src/frontend/utils/circuit-structure-projection.ts` projiziert diesen Tree
|
||||||
|
rein und deterministisch in die drei Zonen Kopf, vollständige Gruppen und Fuß.
|
||||||
|
Innerhalb einer Gruppe folgen auf die Überschrift optionale Schutzkomponenten,
|
||||||
|
vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleiben
|
||||||
|
sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert.
|
||||||
`distribution-board.update` versioniert Etage, Netzart und den
|
`distribution-board.update` versioniert Etage, Netzart und den
|
||||||
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
||||||
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
||||||
@@ -369,8 +426,9 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
|||||||
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
|
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
|
||||||
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden
|
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden
|
||||||
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
|
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
|
||||||
veränderliche Verteilerkomponenten sind integriert; Gruppen- und UI-Schritte
|
veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen
|
||||||
folgen in abgegrenzten Arbeitspaketen.
|
sowie vollständige Gruppensortierung sind integriert; befüllte Unterbäume
|
||||||
|
und UI folgen in abgegrenzten Arbeitspaketen.
|
||||||
|
|
||||||
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
||||||
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
||||||
|
|||||||
@@ -23,8 +23,17 @@ requirements and intended sequencing, not proof of implementation.
|
|||||||
- [x] Phase C2a1: persistent insert/delete commands for mutable group
|
- [x] Phase C2a1: persistent insert/delete commands for mutable group
|
||||||
protection and auxiliary components.
|
protection and auxiliary components.
|
||||||
- [x] Phase C2a2: component update and reorder commands.
|
- [x] Phase C2a2: component update and reorder commands.
|
||||||
- [ ] Phase C2b: group CRUD and reorder commands.
|
- [x] Phase C2b1: empty-group insert/update/delete commands.
|
||||||
- [ ] Phase D: group numbering, moves and destructive operations.
|
- [x] Phase C2b2: complete group reorder command.
|
||||||
|
- [x] Phase D1a: deterministic collision-aware group-renumber plan.
|
||||||
|
- [x] Phase D1b: persistent collision-safe group-renumber command.
|
||||||
|
- [x] Phase D2a: deterministic same-category circuit-move plan.
|
||||||
|
- [x] Phase D2b: persistent same-category circuit-move command.
|
||||||
|
- [x] Phase D3a: complete validated group-subtree snapshot and warning summary.
|
||||||
|
- [x] Phase D3b: persistent populated-group delete/restore command.
|
||||||
|
- [x] Phase E1: tree read model for components, groups and protection devices.
|
||||||
|
- [x] Phase E2a: pure header/group/footer structure projection.
|
||||||
|
- [ ] Phase E2b: render the structure projection in the editor grid.
|
||||||
- [ ] Phase E: editor projection and editing.
|
- [ ] Phase E: editor projection and editing.
|
||||||
- [ ] Phase F: documentation and full GUI verification.
|
- [ ] Phase F: documentation and full GUI verification.
|
||||||
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
|
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
|
||||||
|
|||||||
@@ -593,8 +593,7 @@ Acceptance:
|
|||||||
|
|
||||||
### C. Persistent Commands and Board Defaults
|
### C. Persistent Commands and Board Defaults
|
||||||
|
|
||||||
Status: In progress. New-board defaults C1 and component management C2a are
|
Status: Complete.
|
||||||
complete. Group management C2b remains pending.
|
|
||||||
|
|
||||||
- extend `distribution-board.insert` with fixed components and three groups
|
- extend `distribution-board.insert` with fixed components and three groups
|
||||||
- implement component and group CRUD commands
|
- implement component and group CRUD commands
|
||||||
@@ -635,6 +634,26 @@ Implemented in C2a2:
|
|||||||
- stale expected state and cross-entity BMK collisions are rejected; Undo/Redo
|
- stale expected state and cross-entity BMK collisions are rejected; Undo/Redo
|
||||||
restores the exact previous snapshot
|
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:
|
Acceptance:
|
||||||
|
|
||||||
- new boards contain the agreed fixed structure
|
- new boards contain the agreed fixed structure
|
||||||
@@ -643,11 +662,73 @@ Acceptance:
|
|||||||
|
|
||||||
### D. Group Numbering and Circuit Moves
|
### D. Group Numbering and Circuit Moves
|
||||||
|
|
||||||
|
Status: Complete.
|
||||||
|
|
||||||
- implement nested identifier generation
|
- implement nested identifier generation
|
||||||
- support same-category cross-group circuit moves
|
- support same-category cross-group circuit moves
|
||||||
- implement explicit collision-safe group renumbering
|
- implement explicit collision-safe group renumbering
|
||||||
- implement confirmed populated-group deletion
|
- 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:
|
Acceptance:
|
||||||
|
|
||||||
- target identifiers use highest suffix plus one
|
- target identifiers use highest suffix plus one
|
||||||
@@ -658,6 +739,9 @@ Acceptance:
|
|||||||
|
|
||||||
### E. Editor Projection and Editing
|
### E. Editor Projection and Editing
|
||||||
|
|
||||||
|
Status: In progress. The complete tree read model E1 and pure structural
|
||||||
|
projection E2a are complete. Grid rendering and editing remain pending.
|
||||||
|
|
||||||
- render fixed header components
|
- render fixed header components
|
||||||
- render group components and circuit blocks
|
- render group components and circuit blocks
|
||||||
- render sortable auxiliary footer components
|
- render sortable auxiliary footer components
|
||||||
@@ -665,6 +749,17 @@ Acceptance:
|
|||||||
- add type-dependent protection editing
|
- add type-dependent protection editing
|
||||||
- keep spreadsheet selection, keyboard and drag behavior coherent
|
- keep spreadsheet selection, keyboard and drag behavior coherent
|
||||||
|
|
||||||
|
Implemented in E1/E2a:
|
||||||
|
|
||||||
|
- the tree response exposes fixed header, group and auxiliary footer
|
||||||
|
components plus both one-to-one protection layers
|
||||||
|
- a pure projection produces the stable order header, complete groups and
|
||||||
|
footer without mixing zones
|
||||||
|
- each group keeps its heading, optional protection components, complete
|
||||||
|
circuit blocks and free placeholder together
|
||||||
|
- empty groups remain visible and circuit device rows remain owned by their
|
||||||
|
circuit block
|
||||||
|
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
- the visible order is header, groups, footer
|
- the visible order is header, groups, footer
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { parseGroupedEquipmentIdentifier } from "../services/circuit-group-numbering.js";
|
||||||
|
import type { CircuitGroupMovePlan } from "../services/circuit-group-move-planning.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const circuitGroupMoveCommandType = "circuit.move-group" as const;
|
||||||
|
export const circuitGroupMoveCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface CircuitGroupMoveProjectCommand
|
||||||
|
extends SerializedProjectCommand<CircuitGroupMovePlan> {
|
||||||
|
schemaVersion: typeof circuitGroupMoveCommandSchemaVersion;
|
||||||
|
type: typeof circuitGroupMoveCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitGroupMoveProjectCommand(
|
||||||
|
plan: CircuitGroupMovePlan
|
||||||
|
): CircuitGroupMoveProjectCommand {
|
||||||
|
const command: CircuitGroupMoveProjectCommand = {
|
||||||
|
schemaVersion: circuitGroupMoveCommandSchemaVersion,
|
||||||
|
type: circuitGroupMoveCommandType,
|
||||||
|
payload: plan,
|
||||||
|
};
|
||||||
|
assertCircuitGroupMoveProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitGroupMoveProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitGroupMoveProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== circuitGroupMoveCommandSchemaVersion ||
|
||||||
|
command.type !== circuitGroupMoveCommandType ||
|
||||||
|
!isPlainObject(command.payload) ||
|
||||||
|
Object.keys(command.payload).length !== 8
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit group-move command.");
|
||||||
|
}
|
||||||
|
for (const field of [
|
||||||
|
"circuitId",
|
||||||
|
"circuitListId",
|
||||||
|
"expectedSectionId",
|
||||||
|
"targetSectionId",
|
||||||
|
"expectedEquipmentIdentifier",
|
||||||
|
"targetEquipmentIdentifier",
|
||||||
|
] as const) {
|
||||||
|
if (
|
||||||
|
typeof command.payload[field] !== "string" ||
|
||||||
|
!command.payload[field].trim()
|
||||||
|
) {
|
||||||
|
throw new Error(`Circuit group-move ${field} is invalid.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
command.payload.expectedSectionId === command.payload.targetSectionId ||
|
||||||
|
!Number.isFinite(command.payload.expectedSortOrder) ||
|
||||||
|
!Number.isFinite(command.payload.targetSortOrder)
|
||||||
|
) {
|
||||||
|
throw new Error("Circuit group-move positions are invalid.");
|
||||||
|
}
|
||||||
|
const expected = parseGroupedEquipmentIdentifier(
|
||||||
|
command.payload.expectedEquipmentIdentifier as string
|
||||||
|
);
|
||||||
|
const target = parseGroupedEquipmentIdentifier(
|
||||||
|
command.payload.targetEquipmentIdentifier as string
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
expected?.kind !== "circuit" ||
|
||||||
|
target?.kind !== "circuit" ||
|
||||||
|
expected.category !== target.category ||
|
||||||
|
expected.groupNumber === target.groupNumber
|
||||||
|
) {
|
||||||
|
throw new Error("Circuit group-move BMKs are incompatible.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invertCircuitGroupMovePlan(
|
||||||
|
plan: CircuitGroupMovePlan
|
||||||
|
): CircuitGroupMovePlan {
|
||||||
|
return {
|
||||||
|
circuitId: plan.circuitId,
|
||||||
|
circuitListId: plan.circuitListId,
|
||||||
|
expectedSectionId: plan.targetSectionId,
|
||||||
|
targetSectionId: plan.expectedSectionId,
|
||||||
|
expectedEquipmentIdentifier: plan.targetEquipmentIdentifier,
|
||||||
|
targetEquipmentIdentifier: plan.expectedEquipmentIdentifier,
|
||||||
|
expectedSortOrder: plan.targetSortOrder,
|
||||||
|
targetSortOrder: plan.expectedSortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import {
|
||||||
|
circuitGroupCategories,
|
||||||
|
type CircuitGroupCategory,
|
||||||
|
} from "../../shared/constants/circuit-group.js";
|
||||||
|
import {
|
||||||
|
formatCircuitGroupPrefix,
|
||||||
|
parseGroupedEquipmentIdentifier,
|
||||||
|
} from "../services/circuit-group-numbering.js";
|
||||||
|
import type { CircuitGroupRenumberPlan } from "../services/circuit-group-renumbering.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const circuitGroupRenumberCommandType =
|
||||||
|
"circuit-group.renumber" as const;
|
||||||
|
export const circuitGroupRenumberCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface CircuitGroupRenumberCommandPayload
|
||||||
|
extends CircuitGroupRenumberPlan {
|
||||||
|
circuitListId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitGroupRenumberProjectCommand
|
||||||
|
extends SerializedProjectCommand<CircuitGroupRenumberCommandPayload> {
|
||||||
|
schemaVersion: typeof circuitGroupRenumberCommandSchemaVersion;
|
||||||
|
type: typeof circuitGroupRenumberCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitGroupRenumberProjectCommand(
|
||||||
|
circuitListId: string,
|
||||||
|
plan: CircuitGroupRenumberPlan
|
||||||
|
): CircuitGroupRenumberProjectCommand {
|
||||||
|
const command: CircuitGroupRenumberProjectCommand = {
|
||||||
|
schemaVersion: circuitGroupRenumberCommandSchemaVersion,
|
||||||
|
type: circuitGroupRenumberCommandType,
|
||||||
|
payload: { circuitListId, groups: plan.groups },
|
||||||
|
};
|
||||||
|
assertCircuitGroupRenumberProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitGroupRenumberProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitGroupRenumberProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== circuitGroupRenumberCommandSchemaVersion ||
|
||||||
|
command.type !== circuitGroupRenumberCommandType ||
|
||||||
|
!isPlainObject(command.payload) ||
|
||||||
|
Object.keys(command.payload).length !== 2 ||
|
||||||
|
typeof command.payload.circuitListId !== "string" ||
|
||||||
|
!command.payload.circuitListId.trim() ||
|
||||||
|
!Array.isArray(command.payload.groups) ||
|
||||||
|
command.payload.groups.length === 0
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit-group renumber command.");
|
||||||
|
}
|
||||||
|
const groupIds = new Set<string>();
|
||||||
|
const entityIds = new Set<string>();
|
||||||
|
const targetGroupKeys = new Set<string>();
|
||||||
|
const targetIdentifiers = new Set<string>();
|
||||||
|
let hasChange = false;
|
||||||
|
for (const group of command.payload.groups) {
|
||||||
|
if (
|
||||||
|
!isPlainObject(group) ||
|
||||||
|
Object.keys(group).length !== 8 ||
|
||||||
|
typeof group.groupId !== "string" ||
|
||||||
|
!group.groupId.trim() ||
|
||||||
|
groupIds.has(group.groupId) ||
|
||||||
|
!circuitGroupCategories.includes(
|
||||||
|
group.category as CircuitGroupCategory
|
||||||
|
) ||
|
||||||
|
!positiveInteger(group.expectedGroupNumber) ||
|
||||||
|
!positiveInteger(group.targetGroupNumber) ||
|
||||||
|
group.expectedPrefix !==
|
||||||
|
formatCircuitGroupPrefix(
|
||||||
|
group.category as CircuitGroupCategory,
|
||||||
|
group.expectedGroupNumber as number
|
||||||
|
) ||
|
||||||
|
group.targetPrefix !==
|
||||||
|
formatCircuitGroupPrefix(
|
||||||
|
group.category as CircuitGroupCategory,
|
||||||
|
group.targetGroupNumber as number
|
||||||
|
) ||
|
||||||
|
!Array.isArray(group.circuits) ||
|
||||||
|
!Array.isArray(group.components)
|
||||||
|
) {
|
||||||
|
throw new Error("Circuit-group renumber assignment is invalid.");
|
||||||
|
}
|
||||||
|
const targetGroupKey = `${group.category}:${group.targetGroupNumber}`;
|
||||||
|
if (targetGroupKeys.has(targetGroupKey)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit-group renumber contains duplicate target group numbers."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
hasChange ||= group.expectedGroupNumber !== group.targetGroupNumber;
|
||||||
|
groupIds.add(group.groupId);
|
||||||
|
targetGroupKeys.add(targetGroupKey);
|
||||||
|
for (const circuit of group.circuits) {
|
||||||
|
assertEntityAssignment(
|
||||||
|
circuit,
|
||||||
|
"circuitId",
|
||||||
|
"circuit",
|
||||||
|
group.category as CircuitGroupCategory,
|
||||||
|
group.expectedGroupNumber as number,
|
||||||
|
group.targetGroupNumber as number,
|
||||||
|
entityIds,
|
||||||
|
targetIdentifiers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const component of group.components) {
|
||||||
|
assertEntityAssignment(
|
||||||
|
component,
|
||||||
|
"componentId",
|
||||||
|
"component",
|
||||||
|
group.category as CircuitGroupCategory,
|
||||||
|
group.expectedGroupNumber as number,
|
||||||
|
group.targetGroupNumber as number,
|
||||||
|
entityIds,
|
||||||
|
targetIdentifiers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasChange) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit-group renumber must change at least one group number."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEntityAssignment(
|
||||||
|
value: unknown,
|
||||||
|
idField: "circuitId" | "componentId",
|
||||||
|
kind: "circuit" | "component",
|
||||||
|
category: CircuitGroupCategory,
|
||||||
|
expectedGroupNumber: number,
|
||||||
|
targetGroupNumber: number,
|
||||||
|
entityIds: Set<string>,
|
||||||
|
targetIdentifiers: Set<string>
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!isPlainObject(value) ||
|
||||||
|
Object.keys(value).length !== 3 ||
|
||||||
|
typeof value[idField] !== "string" ||
|
||||||
|
!(value[idField] as string).trim() ||
|
||||||
|
typeof value.expectedEquipmentIdentifier !== "string" ||
|
||||||
|
typeof value.targetEquipmentIdentifier !== "string" ||
|
||||||
|
entityIds.has(value[idField] as string) ||
|
||||||
|
targetIdentifiers.has(value.targetEquipmentIdentifier)
|
||||||
|
) {
|
||||||
|
throw new Error(`Circuit-group renumber ${kind} assignment is invalid.`);
|
||||||
|
}
|
||||||
|
const expected = parseGroupedEquipmentIdentifier(
|
||||||
|
value.expectedEquipmentIdentifier
|
||||||
|
);
|
||||||
|
const target = parseGroupedEquipmentIdentifier(
|
||||||
|
value.targetEquipmentIdentifier
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!expected ||
|
||||||
|
!target ||
|
||||||
|
expected.kind !== target.kind ||
|
||||||
|
expected.category !== category ||
|
||||||
|
target.category !== category ||
|
||||||
|
expected.groupNumber !== expectedGroupNumber ||
|
||||||
|
target.groupNumber !== targetGroupNumber ||
|
||||||
|
(kind === "circuit" &&
|
||||||
|
(expected.kind !== "circuit" ||
|
||||||
|
expected.circuitNumber !== target.circuitNumber)) ||
|
||||||
|
(kind === "component" && expected.kind === "circuit")
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Circuit-group renumber ${kind} identifiers are incompatible.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
entityIds.add(value[idField] as string);
|
||||||
|
targetIdentifiers.add(value.targetEquipmentIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value: unknown): value is number {
|
||||||
|
return Number.isInteger(value) && (value as number) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import {
|
||||||
|
circuitGroupCategories,
|
||||||
|
type CircuitGroupCategory,
|
||||||
|
} from "../../shared/constants/circuit-group.js";
|
||||||
|
import { formatCircuitGroupPrefix } from "../services/circuit-group-numbering.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const circuitGroupInsertCommandType = "circuit-group.insert" as const;
|
||||||
|
export const circuitGroupDeleteCommandType = "circuit-group.delete" as const;
|
||||||
|
export const circuitGroupUpdateCommandType = "circuit-group.update" as const;
|
||||||
|
export const circuitGroupReorderCommandType = "circuit-group.reorder" as const;
|
||||||
|
export const circuitGroupStructureCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface CircuitGroupSnapshot {
|
||||||
|
id: string;
|
||||||
|
circuitListId: string;
|
||||||
|
key: string;
|
||||||
|
displayName: string;
|
||||||
|
prefix: string;
|
||||||
|
sortOrder: number;
|
||||||
|
category: CircuitGroupCategory;
|
||||||
|
groupNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnapshotPayload {
|
||||||
|
snapshot: CircuitGroupSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdatePayload {
|
||||||
|
expected: CircuitGroupSnapshot;
|
||||||
|
target: CircuitGroupSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitGroupReorderAssignment {
|
||||||
|
groupId: string;
|
||||||
|
expectedSortOrder: number;
|
||||||
|
targetSortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReorderPayload {
|
||||||
|
circuitListId: string;
|
||||||
|
assignments: CircuitGroupReorderAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitGroupInsertProjectCommand
|
||||||
|
extends SerializedProjectCommand<SnapshotPayload> {
|
||||||
|
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||||
|
type: typeof circuitGroupInsertCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitGroupDeleteProjectCommand
|
||||||
|
extends SerializedProjectCommand<SnapshotPayload> {
|
||||||
|
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||||
|
type: typeof circuitGroupDeleteCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitGroupUpdateProjectCommand
|
||||||
|
extends SerializedProjectCommand<UpdatePayload> {
|
||||||
|
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||||
|
type: typeof circuitGroupUpdateCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitGroupReorderProjectCommand
|
||||||
|
extends SerializedProjectCommand<ReorderPayload> {
|
||||||
|
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||||
|
type: typeof circuitGroupReorderCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CircuitGroupStructureProjectCommand =
|
||||||
|
| CircuitGroupInsertProjectCommand
|
||||||
|
| CircuitGroupDeleteProjectCommand
|
||||||
|
| CircuitGroupUpdateProjectCommand
|
||||||
|
| CircuitGroupReorderProjectCommand;
|
||||||
|
|
||||||
|
export function createCircuitGroupInsertProjectCommand(
|
||||||
|
snapshot: CircuitGroupSnapshot
|
||||||
|
): CircuitGroupInsertProjectCommand {
|
||||||
|
const command: CircuitGroupInsertProjectCommand = {
|
||||||
|
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||||
|
type: circuitGroupInsertCommandType,
|
||||||
|
payload: { snapshot },
|
||||||
|
};
|
||||||
|
assertCircuitGroupInsertProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitGroupDeleteProjectCommand(
|
||||||
|
snapshot: CircuitGroupSnapshot
|
||||||
|
): CircuitGroupDeleteProjectCommand {
|
||||||
|
const command: CircuitGroupDeleteProjectCommand = {
|
||||||
|
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||||
|
type: circuitGroupDeleteCommandType,
|
||||||
|
payload: { snapshot },
|
||||||
|
};
|
||||||
|
assertCircuitGroupDeleteProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitGroupUpdateProjectCommand(
|
||||||
|
expected: CircuitGroupSnapshot,
|
||||||
|
target: CircuitGroupSnapshot
|
||||||
|
): CircuitGroupUpdateProjectCommand {
|
||||||
|
const command: CircuitGroupUpdateProjectCommand = {
|
||||||
|
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||||
|
type: circuitGroupUpdateCommandType,
|
||||||
|
payload: { expected, target },
|
||||||
|
};
|
||||||
|
assertCircuitGroupUpdateProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitGroupReorderProjectCommand(
|
||||||
|
circuitListId: string,
|
||||||
|
assignments: CircuitGroupReorderAssignment[]
|
||||||
|
): CircuitGroupReorderProjectCommand {
|
||||||
|
const command: CircuitGroupReorderProjectCommand = {
|
||||||
|
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||||
|
type: circuitGroupReorderCommandType,
|
||||||
|
payload: { circuitListId, assignments },
|
||||||
|
};
|
||||||
|
assertCircuitGroupReorderProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitGroupInsertProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitGroupInsertProjectCommand {
|
||||||
|
assertSnapshotCommand(command, circuitGroupInsertCommandType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitGroupDeleteProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitGroupDeleteProjectCommand {
|
||||||
|
assertSnapshotCommand(command, circuitGroupDeleteCommandType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitGroupUpdateProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitGroupUpdateProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
|
||||||
|
command.type !== circuitGroupUpdateCommandType ||
|
||||||
|
!isPlainObject(command.payload) ||
|
||||||
|
Object.keys(command.payload).length !== 2
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit-group update command.");
|
||||||
|
}
|
||||||
|
assertCircuitGroupSnapshot(command.payload.expected);
|
||||||
|
assertCircuitGroupSnapshot(command.payload.target);
|
||||||
|
for (const field of [
|
||||||
|
"id",
|
||||||
|
"circuitListId",
|
||||||
|
"key",
|
||||||
|
"prefix",
|
||||||
|
"sortOrder",
|
||||||
|
"category",
|
||||||
|
"groupNumber",
|
||||||
|
] as const) {
|
||||||
|
if (command.payload.expected[field] !== command.payload.target[field]) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit-group updates can only change the display name."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitGroupReorderProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitGroupReorderProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
|
||||||
|
command.type !== circuitGroupReorderCommandType ||
|
||||||
|
!isPlainObject(command.payload) ||
|
||||||
|
Object.keys(command.payload).length !== 2 ||
|
||||||
|
typeof command.payload.circuitListId !== "string" ||
|
||||||
|
!command.payload.circuitListId.trim() ||
|
||||||
|
!Array.isArray(command.payload.assignments) ||
|
||||||
|
command.payload.assignments.length === 0
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit-group reorder command.");
|
||||||
|
}
|
||||||
|
const groupIds = new Set<string>();
|
||||||
|
let hasChange = false;
|
||||||
|
for (const assignment of command.payload.assignments) {
|
||||||
|
if (
|
||||||
|
!isPlainObject(assignment) ||
|
||||||
|
typeof assignment.groupId !== "string" ||
|
||||||
|
!assignment.groupId.trim() ||
|
||||||
|
!Number.isFinite(assignment.expectedSortOrder) ||
|
||||||
|
!Number.isFinite(assignment.targetSortOrder) ||
|
||||||
|
groupIds.has(assignment.groupId)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit-group reorder contains an invalid or duplicate assignment."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
hasChange ||= assignment.expectedSortOrder !== assignment.targetSortOrder;
|
||||||
|
groupIds.add(assignment.groupId);
|
||||||
|
}
|
||||||
|
if (!hasChange) {
|
||||||
|
throw new Error("Circuit-group reorder must change at least one position.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSnapshotCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>,
|
||||||
|
type:
|
||||||
|
| typeof circuitGroupInsertCommandType
|
||||||
|
| typeof circuitGroupDeleteCommandType
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
|
||||||
|
command.type !== type ||
|
||||||
|
!isPlainObject(command.payload) ||
|
||||||
|
Object.keys(command.payload).length !== 1
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit-group structure command.");
|
||||||
|
}
|
||||||
|
assertCircuitGroupSnapshot(command.payload.snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitGroupSnapshot(
|
||||||
|
value: unknown
|
||||||
|
): asserts value is CircuitGroupSnapshot {
|
||||||
|
if (!isPlainObject(value) || Object.keys(value).length !== 8) {
|
||||||
|
throw new Error("Circuit-group snapshot is invalid.");
|
||||||
|
}
|
||||||
|
for (const field of [
|
||||||
|
"id",
|
||||||
|
"circuitListId",
|
||||||
|
"key",
|
||||||
|
"displayName",
|
||||||
|
"prefix",
|
||||||
|
] as const) {
|
||||||
|
if (typeof value[field] !== "string" || !value[field].trim()) {
|
||||||
|
throw new Error(`${field} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!circuitGroupCategories.includes(
|
||||||
|
value.category as CircuitGroupCategory
|
||||||
|
) ||
|
||||||
|
!Number.isInteger(value.groupNumber) ||
|
||||||
|
(value.groupNumber as number) < 1 ||
|
||||||
|
!Number.isFinite(value.sortOrder)
|
||||||
|
) {
|
||||||
|
throw new Error("Circuit-group category, number or order is invalid.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value.prefix !==
|
||||||
|
formatCircuitGroupPrefix(
|
||||||
|
value.category as CircuitGroupCategory,
|
||||||
|
value.groupNumber as number
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("Circuit-group prefix does not match its category and number.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
assertCircuitGroupSubtreeSnapshot,
|
||||||
|
type CircuitGroupSubtreeSnapshot,
|
||||||
|
} from "./circuit-group-subtree-snapshot.model.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const circuitGroupDeleteSubtreeCommandType =
|
||||||
|
"circuit-group.delete-subtree" as const;
|
||||||
|
export const circuitGroupRestoreSubtreeCommandType =
|
||||||
|
"circuit-group.restore-subtree" as const;
|
||||||
|
export const circuitGroupSubtreeCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
interface Payload {
|
||||||
|
snapshot: CircuitGroupSubtreeSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitGroupDeleteSubtreeProjectCommand
|
||||||
|
extends SerializedProjectCommand<Payload> {
|
||||||
|
schemaVersion: typeof circuitGroupSubtreeCommandSchemaVersion;
|
||||||
|
type: typeof circuitGroupDeleteSubtreeCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitGroupRestoreSubtreeProjectCommand
|
||||||
|
extends SerializedProjectCommand<Payload> {
|
||||||
|
schemaVersion: typeof circuitGroupSubtreeCommandSchemaVersion;
|
||||||
|
type: typeof circuitGroupRestoreSubtreeCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CircuitGroupSubtreeProjectCommand =
|
||||||
|
| CircuitGroupDeleteSubtreeProjectCommand
|
||||||
|
| CircuitGroupRestoreSubtreeProjectCommand;
|
||||||
|
|
||||||
|
export function createCircuitGroupDeleteSubtreeProjectCommand(
|
||||||
|
snapshot: CircuitGroupSubtreeSnapshot
|
||||||
|
): CircuitGroupDeleteSubtreeProjectCommand {
|
||||||
|
const command: CircuitGroupDeleteSubtreeProjectCommand = {
|
||||||
|
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
|
||||||
|
type: circuitGroupDeleteSubtreeCommandType,
|
||||||
|
payload: { snapshot },
|
||||||
|
};
|
||||||
|
assertCircuitGroupDeleteSubtreeProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitGroupRestoreSubtreeProjectCommand(
|
||||||
|
snapshot: CircuitGroupSubtreeSnapshot
|
||||||
|
): CircuitGroupRestoreSubtreeProjectCommand {
|
||||||
|
const command: CircuitGroupRestoreSubtreeProjectCommand = {
|
||||||
|
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
|
||||||
|
type: circuitGroupRestoreSubtreeCommandType,
|
||||||
|
payload: { snapshot },
|
||||||
|
};
|
||||||
|
assertCircuitGroupRestoreSubtreeProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitGroupDeleteSubtreeProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitGroupDeleteSubtreeProjectCommand {
|
||||||
|
assertCommand(command, circuitGroupDeleteSubtreeCommandType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitGroupRestoreSubtreeProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is CircuitGroupRestoreSubtreeProjectCommand {
|
||||||
|
assertCommand(command, circuitGroupRestoreSubtreeCommandType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>,
|
||||||
|
type:
|
||||||
|
| typeof circuitGroupDeleteSubtreeCommandType
|
||||||
|
| typeof circuitGroupRestoreSubtreeCommandType
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== circuitGroupSubtreeCommandSchemaVersion ||
|
||||||
|
command.type !== type ||
|
||||||
|
!isPlainObject(command.payload) ||
|
||||||
|
Object.keys(command.payload).length !== 1
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported circuit-group subtree command.");
|
||||||
|
}
|
||||||
|
assertCircuitGroupSubtreeSnapshot(command.payload.snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import type {
|
||||||
|
BreakerTripCharacteristic,
|
||||||
|
FuseUtilizationCategory,
|
||||||
|
ProtectionDeviceType,
|
||||||
|
RcdType,
|
||||||
|
} from "../../shared/constants/protection-device.js";
|
||||||
|
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
|
||||||
|
import {
|
||||||
|
assertCircuitGroupSnapshot,
|
||||||
|
type CircuitGroupSnapshot,
|
||||||
|
} from "./circuit-group-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitInsertProjectCommand,
|
||||||
|
circuitInsertCommandType,
|
||||||
|
circuitStructureCommandSchemaVersion,
|
||||||
|
type CircuitSnapshot,
|
||||||
|
} from "./circuit-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertDistributionBoardComponentSnapshot,
|
||||||
|
type DistributionBoardComponentSnapshot,
|
||||||
|
} from "./distribution-board-component-structure-project-command.model.js";
|
||||||
|
|
||||||
|
export interface CircuitProtectionDeviceSnapshot {
|
||||||
|
circuitId: string;
|
||||||
|
type: ProtectionDeviceType;
|
||||||
|
ratedCurrentA: number;
|
||||||
|
fuseUtilizationCategory: FuseUtilizationCategory | null;
|
||||||
|
tripCharacteristic: BreakerTripCharacteristic | null;
|
||||||
|
rcdType: RcdType | null;
|
||||||
|
ratedResidualCurrentMa: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitGroupSubtreeSnapshot {
|
||||||
|
group: CircuitGroupSnapshot;
|
||||||
|
components: DistributionBoardComponentSnapshot[];
|
||||||
|
circuits: Array<{
|
||||||
|
circuit: CircuitSnapshot;
|
||||||
|
protectionDevice: CircuitProtectionDeviceSnapshot | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitGroupDeletionSummary {
|
||||||
|
hasUpstreamProtection: boolean;
|
||||||
|
hasResidualCurrentProtection: boolean;
|
||||||
|
circuitCount: number;
|
||||||
|
deviceRowCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitGroupSubtreeSnapshot(
|
||||||
|
value: unknown
|
||||||
|
): asserts value is CircuitGroupSubtreeSnapshot {
|
||||||
|
if (
|
||||||
|
!isPlainObject(value) ||
|
||||||
|
Object.keys(value).length !== 3 ||
|
||||||
|
!Array.isArray(value.components) ||
|
||||||
|
!Array.isArray(value.circuits)
|
||||||
|
) {
|
||||||
|
throw new Error("Circuit-group subtree snapshot is invalid.");
|
||||||
|
}
|
||||||
|
assertCircuitGroupSnapshot(value.group);
|
||||||
|
const group = value.group;
|
||||||
|
const entityIds = new Set<string>();
|
||||||
|
const equipmentIdentifiers = new Set<string>();
|
||||||
|
for (const component of value.components) {
|
||||||
|
assertDistributionBoardComponentSnapshot(component);
|
||||||
|
if (
|
||||||
|
component.component.circuitListId !== group.circuitListId ||
|
||||||
|
component.component.sectionId !== group.id ||
|
||||||
|
entityIds.has(component.component.id) ||
|
||||||
|
equipmentIdentifiers.has(component.component.equipmentIdentifier)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit-group component snapshot has invalid ownership or duplicates."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
entityIds.add(component.component.id);
|
||||||
|
equipmentIdentifiers.add(component.component.equipmentIdentifier);
|
||||||
|
}
|
||||||
|
for (const entry of value.circuits) {
|
||||||
|
if (
|
||||||
|
!isPlainObject(entry) ||
|
||||||
|
Object.keys(entry).length !== 2 ||
|
||||||
|
!isPlainObject(entry.circuit)
|
||||||
|
) {
|
||||||
|
throw new Error("Circuit-group circuit snapshot is invalid.");
|
||||||
|
}
|
||||||
|
assertCircuitInsertProjectCommand({
|
||||||
|
schemaVersion: circuitStructureCommandSchemaVersion,
|
||||||
|
type: circuitInsertCommandType,
|
||||||
|
payload: { circuit: entry.circuit },
|
||||||
|
});
|
||||||
|
const circuit = entry.circuit as unknown as CircuitSnapshot;
|
||||||
|
if (
|
||||||
|
circuit.circuitListId !== group.circuitListId ||
|
||||||
|
circuit.sectionId !== group.id ||
|
||||||
|
entityIds.has(circuit.id) ||
|
||||||
|
equipmentIdentifiers.has(circuit.equipmentIdentifier)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit-group circuit snapshot has invalid ownership or duplicates."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
entityIds.add(circuit.id);
|
||||||
|
equipmentIdentifiers.add(circuit.equipmentIdentifier);
|
||||||
|
if (entry.protectionDevice !== null) {
|
||||||
|
assertCircuitProtectionDeviceSnapshot(
|
||||||
|
entry.protectionDevice,
|
||||||
|
circuit.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeCircuitGroupDeletion(
|
||||||
|
snapshot: CircuitGroupSubtreeSnapshot
|
||||||
|
): CircuitGroupDeletionSummary {
|
||||||
|
assertCircuitGroupSubtreeSnapshot(snapshot);
|
||||||
|
return {
|
||||||
|
hasUpstreamProtection: snapshot.components.some(
|
||||||
|
({ component }) =>
|
||||||
|
component.role === "group_upstream_protection"
|
||||||
|
),
|
||||||
|
hasResidualCurrentProtection: snapshot.components.some(
|
||||||
|
({ component }) =>
|
||||||
|
component.role === "group_residual_current_protection"
|
||||||
|
),
|
||||||
|
circuitCount: snapshot.circuits.length,
|
||||||
|
deviceRowCount: snapshot.circuits.reduce(
|
||||||
|
(count, { circuit }) => count + circuit.deviceRows.length,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCircuitProtectionDeviceSnapshot(
|
||||||
|
value: unknown,
|
||||||
|
circuitId: string
|
||||||
|
): asserts value is CircuitProtectionDeviceSnapshot {
|
||||||
|
if (
|
||||||
|
!isPlainObject(value) ||
|
||||||
|
Object.keys(value).length !== 7 ||
|
||||||
|
value.circuitId !== circuitId
|
||||||
|
) {
|
||||||
|
throw new Error("Circuit protection-device snapshot is invalid.");
|
||||||
|
}
|
||||||
|
const result = protectionDeviceConfigurationSchema.safeParse({
|
||||||
|
type: value.type,
|
||||||
|
ratedCurrentA: value.ratedCurrentA,
|
||||||
|
...(value.fuseUtilizationCategory === null
|
||||||
|
? {}
|
||||||
|
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
|
||||||
|
...(value.tripCharacteristic === null
|
||||||
|
? {}
|
||||||
|
: { tripCharacteristic: value.tripCharacteristic }),
|
||||||
|
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
|
||||||
|
...(value.ratedResidualCurrentMa === null
|
||||||
|
? {}
|
||||||
|
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit protection-device configuration is invalid."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ export interface CircuitTreeCircuit {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
circuitTotalPower: number;
|
circuitTotalPower: number;
|
||||||
deviceRows: CircuitTreeDeviceRow[];
|
deviceRows: CircuitTreeDeviceRow[];
|
||||||
|
protectionDevice?: CircuitTreeProtectionDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CircuitTreeSectionBlock {
|
export interface CircuitTreeSectionBlock {
|
||||||
@@ -52,7 +53,10 @@ export interface CircuitTreeSectionBlock {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
category?: CircuitGroupCategory;
|
||||||
|
groupNumber?: number;
|
||||||
sectionTotalPower: number;
|
sectionTotalPower: number;
|
||||||
|
components: CircuitTreeComponent[];
|
||||||
circuits: CircuitTreeCircuit[];
|
circuits: CircuitTreeCircuit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +68,9 @@ export interface CircuitTreeResponse {
|
|||||||
distributionBoardSimultaneityFactor: number;
|
distributionBoardSimultaneityFactor: number;
|
||||||
distributionBoardTotalPower: number;
|
distributionBoardTotalPower: number;
|
||||||
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||||
|
headerComponents: CircuitTreeComponent[];
|
||||||
sections: CircuitTreeSectionBlock[];
|
sections: CircuitTreeSectionBlock[];
|
||||||
|
footerComponents: CircuitTreeComponent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LegacyMigrationReport {
|
export interface LegacyMigrationReport {
|
||||||
@@ -78,3 +84,35 @@ export interface LegacyMigrationReport {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||||
|
import type {
|
||||||
|
DistributionBoardComponentPlacement,
|
||||||
|
DistributionBoardComponentRole,
|
||||||
|
} from "../../shared/constants/distribution-board-component.js";
|
||||||
|
import type {
|
||||||
|
BreakerTripCharacteristic,
|
||||||
|
FuseUtilizationCategory,
|
||||||
|
ProtectionDeviceType,
|
||||||
|
RcdType,
|
||||||
|
} from "../../shared/constants/protection-device.js";
|
||||||
|
|
||||||
|
export interface CircuitTreeProtectionDevice {
|
||||||
|
type: ProtectionDeviceType;
|
||||||
|
ratedCurrentA: number;
|
||||||
|
fuseUtilizationCategory?: FuseUtilizationCategory;
|
||||||
|
tripCharacteristic?: BreakerTripCharacteristic;
|
||||||
|
rcdType?: RcdType;
|
||||||
|
ratedResidualCurrentMa?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitTreeComponent {
|
||||||
|
id: string;
|
||||||
|
circuitListId: string;
|
||||||
|
sectionId?: string;
|
||||||
|
equipmentIdentifier: string;
|
||||||
|
name: string;
|
||||||
|
role: DistributionBoardComponentRole;
|
||||||
|
placement: DistributionBoardComponentPlacement;
|
||||||
|
sortOrder: number;
|
||||||
|
protectionDevice?: CircuitTreeProtectionDevice;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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,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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@ function getGroupStem(
|
|||||||
return `-${circuitGroupCategoryNumbers[category]}${functionLetter}${groupNumber}`;
|
return `-${circuitGroupCategoryNumbers[category]}${functionLetter}${groupNumber}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatCircuitGroupPrefix(
|
||||||
|
category: CircuitGroupCategory,
|
||||||
|
groupNumber: number
|
||||||
|
) {
|
||||||
|
return `${getGroupStem(category, "F", groupNumber)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatGroupUpstreamProtectionIdentifier(
|
export function formatGroupUpstreamProtectionIdentifier(
|
||||||
category: CircuitGroupCategory,
|
category: CircuitGroupCategory,
|
||||||
groupNumber: number
|
groupNumber: number
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,10 @@ import type { CircuitProjectCommandStore } from "../ports/circuit-project-comman
|
|||||||
import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
|
import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
|
||||||
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
|
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
|
||||||
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
|
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
|
||||||
|
import type { CircuitGroupStructureProjectCommandStore } from "../ports/circuit-group-structure-project-command.store.js";
|
||||||
|
import type { CircuitGroupRenumberProjectCommandStore } from "../ports/circuit-group-renumber-project-command.store.js";
|
||||||
|
import type { CircuitGroupMoveProjectCommandStore } from "../ports/circuit-group-move-project-command.store.js";
|
||||||
|
import type { CircuitGroupSubtreeProjectCommandStore } from "../ports/circuit-group-subtree-project-command.store.js";
|
||||||
import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js";
|
import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js";
|
||||||
import type { DistributionBoardComponentStructureProjectCommandStore } from "../ports/distribution-board-component-structure-project-command.store.js";
|
import type { DistributionBoardComponentStructureProjectCommandStore } from "../ports/distribution-board-component-structure-project-command.store.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -110,6 +114,30 @@ import type {
|
|||||||
ProjectHistoryDirection,
|
ProjectHistoryDirection,
|
||||||
ProjectHistoryStore,
|
ProjectHistoryStore,
|
||||||
} from "../ports/project-history.store.js";
|
} from "../ports/project-history.store.js";
|
||||||
|
import {
|
||||||
|
assertCircuitGroupDeleteProjectCommand,
|
||||||
|
assertCircuitGroupInsertProjectCommand,
|
||||||
|
assertCircuitGroupReorderProjectCommand,
|
||||||
|
assertCircuitGroupUpdateProjectCommand,
|
||||||
|
circuitGroupDeleteCommandType,
|
||||||
|
circuitGroupInsertCommandType,
|
||||||
|
circuitGroupReorderCommandType,
|
||||||
|
circuitGroupUpdateCommandType,
|
||||||
|
} from "../models/circuit-group-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitGroupRenumberProjectCommand,
|
||||||
|
circuitGroupRenumberCommandType,
|
||||||
|
} from "../models/circuit-group-renumber-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitGroupMoveProjectCommand,
|
||||||
|
circuitGroupMoveCommandType,
|
||||||
|
} from "../models/circuit-group-move-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertCircuitGroupDeleteSubtreeProjectCommand,
|
||||||
|
assertCircuitGroupRestoreSubtreeProjectCommand,
|
||||||
|
circuitGroupDeleteSubtreeCommandType,
|
||||||
|
circuitGroupRestoreSubtreeCommandType,
|
||||||
|
} from "../models/circuit-group-subtree-project-command.model.js";
|
||||||
import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js";
|
import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js";
|
||||||
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
|
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
|
||||||
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
|
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
|
||||||
@@ -145,6 +173,10 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
|
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
|
||||||
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
|
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
|
||||||
private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore,
|
private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore,
|
||||||
|
private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore,
|
||||||
|
private readonly circuitGroupRenumberStore: CircuitGroupRenumberProjectCommandStore,
|
||||||
|
private readonly circuitGroupMoveStore: CircuitGroupMoveProjectCommandStore,
|
||||||
|
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
|
||||||
private readonly historyStore: ProjectHistoryStore
|
private readonly historyStore: ProjectHistoryStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -333,6 +365,62 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
|
case circuitGroupInsertCommandType: {
|
||||||
|
assertCircuitGroupInsertProjectCommand(input.command);
|
||||||
|
return this.circuitGroupStructureStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitGroupDeleteCommandType: {
|
||||||
|
assertCircuitGroupDeleteProjectCommand(input.command);
|
||||||
|
return this.circuitGroupStructureStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitGroupUpdateCommandType: {
|
||||||
|
assertCircuitGroupUpdateProjectCommand(input.command);
|
||||||
|
return this.circuitGroupStructureStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitGroupReorderCommandType: {
|
||||||
|
assertCircuitGroupReorderProjectCommand(input.command);
|
||||||
|
return this.circuitGroupStructureStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitGroupRenumberCommandType: {
|
||||||
|
assertCircuitGroupRenumberProjectCommand(input.command);
|
||||||
|
return this.circuitGroupRenumberStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitGroupMoveCommandType: {
|
||||||
|
assertCircuitGroupMoveProjectCommand(input.command);
|
||||||
|
return this.circuitGroupMoveStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitGroupDeleteSubtreeCommandType: {
|
||||||
|
assertCircuitGroupDeleteSubtreeProjectCommand(input.command);
|
||||||
|
return this.circuitGroupSubtreeStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case circuitGroupRestoreSubtreeCommandType: {
|
||||||
|
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
|
||||||
|
return this.circuitGroupSubtreeStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
case projectFloorInsertCommandType: {
|
case projectFloorInsertCommandType: {
|
||||||
assertProjectFloorInsertProjectCommand(input.command);
|
assertProjectFloorInsertProjectCommand(input.command);
|
||||||
return this.projectLocationStructureStore.execute({
|
return this.projectLocationStructureStore.execute({
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import type { DistributionBoardSupplyType } from "../shared/constants/distribution-board";
|
import type { DistributionBoardSupplyType } from "../shared/constants/distribution-board";
|
||||||
|
import type { CircuitGroupCategory } from "../shared/constants/circuit-group";
|
||||||
|
import type {
|
||||||
|
DistributionBoardComponentPlacement,
|
||||||
|
DistributionBoardComponentRole,
|
||||||
|
} from "../shared/constants/distribution-board-component";
|
||||||
|
import type {
|
||||||
|
BreakerTripCharacteristic,
|
||||||
|
FuseUtilizationCategory,
|
||||||
|
ProtectionDeviceType,
|
||||||
|
RcdType,
|
||||||
|
} from "../shared/constants/protection-device";
|
||||||
|
|
||||||
export interface ProjectDto {
|
export interface ProjectDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -267,6 +278,27 @@ export interface CircuitTreeDeviceRowDto {
|
|||||||
rowTotalPower: number;
|
rowTotalPower: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CircuitTreeProtectionDeviceDto {
|
||||||
|
type: ProtectionDeviceType;
|
||||||
|
ratedCurrentA: number;
|
||||||
|
fuseUtilizationCategory?: FuseUtilizationCategory;
|
||||||
|
tripCharacteristic?: BreakerTripCharacteristic;
|
||||||
|
rcdType?: RcdType;
|
||||||
|
ratedResidualCurrentMa?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitTreeComponentDto {
|
||||||
|
id: string;
|
||||||
|
circuitListId: string;
|
||||||
|
sectionId?: string;
|
||||||
|
equipmentIdentifier: string;
|
||||||
|
name: string;
|
||||||
|
role: DistributionBoardComponentRole;
|
||||||
|
placement: DistributionBoardComponentPlacement;
|
||||||
|
sortOrder: number;
|
||||||
|
protectionDevice?: CircuitTreeProtectionDeviceDto;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CircuitTreeCircuitDto {
|
export interface CircuitTreeCircuitDto {
|
||||||
id: string;
|
id: string;
|
||||||
circuitListId: string;
|
circuitListId: string;
|
||||||
@@ -289,6 +321,7 @@ export interface CircuitTreeCircuitDto {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
circuitTotalPower: number;
|
circuitTotalPower: number;
|
||||||
deviceRows: CircuitTreeDeviceRowDto[];
|
deviceRows: CircuitTreeDeviceRowDto[];
|
||||||
|
protectionDevice?: CircuitTreeProtectionDeviceDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CircuitTreeSectionDto {
|
export interface CircuitTreeSectionDto {
|
||||||
@@ -297,7 +330,10 @@ export interface CircuitTreeSectionDto {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
category?: CircuitGroupCategory;
|
||||||
|
groupNumber?: number;
|
||||||
sectionTotalPower: number;
|
sectionTotalPower: number;
|
||||||
|
components: CircuitTreeComponentDto[];
|
||||||
circuits: CircuitTreeCircuitDto[];
|
circuits: CircuitTreeCircuitDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +356,9 @@ export interface CircuitTreeResponseDto {
|
|||||||
distributionBoardSimultaneityFactor: number;
|
distributionBoardSimultaneityFactor: number;
|
||||||
distributionBoardTotalPower: number;
|
distributionBoardTotalPower: number;
|
||||||
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||||
|
headerComponents: CircuitTreeComponentDto[];
|
||||||
sections: CircuitTreeSectionDto[];
|
sections: CircuitTreeSectionDto[];
|
||||||
|
footerComponents: CircuitTreeComponentDto[];
|
||||||
migrationReport?: CircuitTreeMigrationReportDto;
|
migrationReport?: CircuitTreeMigrationReportDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type {
|
||||||
|
CircuitTreeCircuitDto,
|
||||||
|
CircuitTreeComponentDto,
|
||||||
|
CircuitTreeResponseDto,
|
||||||
|
CircuitTreeSectionDto,
|
||||||
|
} from "../types.js";
|
||||||
|
|
||||||
|
export type CircuitStructureProjectionRow =
|
||||||
|
| {
|
||||||
|
rowKey: string;
|
||||||
|
rowType: "headerComponent" | "footerComponent";
|
||||||
|
zone: "header" | "footer";
|
||||||
|
component: CircuitTreeComponentDto;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
rowKey: string;
|
||||||
|
rowType: "groupHeader" | "groupPlaceholder";
|
||||||
|
zone: "group";
|
||||||
|
section: CircuitTreeSectionDto;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
rowKey: string;
|
||||||
|
rowType: "groupComponent";
|
||||||
|
zone: "group";
|
||||||
|
section: CircuitTreeSectionDto;
|
||||||
|
component: CircuitTreeComponentDto;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
rowKey: string;
|
||||||
|
rowType: "circuitBlock";
|
||||||
|
zone: "group";
|
||||||
|
section: CircuitTreeSectionDto;
|
||||||
|
circuit: CircuitTreeCircuitDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildCircuitStructureProjection(
|
||||||
|
tree: Pick<
|
||||||
|
CircuitTreeResponseDto,
|
||||||
|
"headerComponents" | "sections" | "footerComponents"
|
||||||
|
>
|
||||||
|
): CircuitStructureProjectionRow[] {
|
||||||
|
const rows: CircuitStructureProjectionRow[] = [];
|
||||||
|
for (const component of ordered(tree.headerComponents)) {
|
||||||
|
rows.push({
|
||||||
|
rowKey: `header-component:${component.id}`,
|
||||||
|
rowType: "headerComponent",
|
||||||
|
zone: "header",
|
||||||
|
component,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const section of ordered(tree.sections)) {
|
||||||
|
rows.push({
|
||||||
|
rowKey: `group:${section.id}`,
|
||||||
|
rowType: "groupHeader",
|
||||||
|
zone: "group",
|
||||||
|
section,
|
||||||
|
});
|
||||||
|
for (const component of ordered(section.components)) {
|
||||||
|
rows.push({
|
||||||
|
rowKey: `group-component:${component.id}`,
|
||||||
|
rowType: "groupComponent",
|
||||||
|
zone: "group",
|
||||||
|
section,
|
||||||
|
component,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const circuit of section.circuits) {
|
||||||
|
rows.push({
|
||||||
|
rowKey: `circuit-block:${circuit.id}`,
|
||||||
|
rowType: "circuitBlock",
|
||||||
|
zone: "group",
|
||||||
|
section,
|
||||||
|
circuit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
rowKey: `group-placeholder:${section.id}`,
|
||||||
|
rowType: "groupPlaceholder",
|
||||||
|
zone: "group",
|
||||||
|
section,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const component of ordered(tree.footerComponents)) {
|
||||||
|
rows.push({
|
||||||
|
rowKey: `footer-component:${component.id}`,
|
||||||
|
rowType: "footerComponent",
|
||||||
|
zone: "footer",
|
||||||
|
component,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordered<T extends { id: string; sortOrder: number }>(
|
||||||
|
values: readonly T[]
|
||||||
|
) {
|
||||||
|
return [...values].sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.sortOrder - right.sortOrder ||
|
||||||
|
left.id.localeCompare(right.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,14 @@ const commandTypeLabels: Record<string, string> = {
|
|||||||
"distribution-board-component.insert": "Verteilergerät angelegt",
|
"distribution-board-component.insert": "Verteilergerät angelegt",
|
||||||
"distribution-board-component.delete": "Verteilergerät entfernt",
|
"distribution-board-component.delete": "Verteilergerät entfernt",
|
||||||
"distribution-board-component.update": "Verteilergerät bearbeitet",
|
"distribution-board-component.update": "Verteilergerät bearbeitet",
|
||||||
|
"circuit-group.insert": "Stromkreisgruppe angelegt",
|
||||||
|
"circuit-group.delete": "Stromkreisgruppe entfernt",
|
||||||
|
"circuit-group.update": "Stromkreisgruppe bearbeitet",
|
||||||
|
"circuit-group.reorder": "Stromkreisgruppen sortiert",
|
||||||
|
"circuit-group.renumber": "Stromkreisgruppen neu nummeriert",
|
||||||
|
"circuit.move-group": "Stromkreis in andere Gruppe verschoben",
|
||||||
|
"circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt",
|
||||||
|
"circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt",
|
||||||
"project-floor.insert": "Geschoss angelegt",
|
"project-floor.insert": "Geschoss angelegt",
|
||||||
"project-floor.delete": "Geschoss entfernt",
|
"project-floor.delete": "Geschoss entfernt",
|
||||||
"project-room.insert": "Raum angelegt",
|
"project-room.insert": "Raum angelegt",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device
|
|||||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||||
|
import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit-protection-device.repository.js";
|
||||||
|
import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js";
|
||||||
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||||
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
||||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
||||||
@@ -15,6 +17,10 @@ export const circuitDeviceRowRepository =
|
|||||||
export const circuitListRepository = new CircuitListRepository(db);
|
export const circuitListRepository = new CircuitListRepository(db);
|
||||||
export const circuitSectionRepository = new CircuitSectionRepository(db);
|
export const circuitSectionRepository = new CircuitSectionRepository(db);
|
||||||
export const circuitRepository = new CircuitRepository(db);
|
export const circuitRepository = new CircuitRepository(db);
|
||||||
|
export const circuitProtectionDeviceRepository =
|
||||||
|
new CircuitProtectionDeviceRepository(db);
|
||||||
|
export const distributionBoardComponentRepository =
|
||||||
|
new DistributionBoardComponentRepository(db);
|
||||||
export const distributionBoardRepository =
|
export const distributionBoardRepository =
|
||||||
new DistributionBoardRepository(db);
|
new DistributionBoardRepository(db);
|
||||||
export const floorRepository = new FloorRepository(db);
|
export const floorRepository = new FloorRepository(db);
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-p
|
|||||||
import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
|
import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||||
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
|
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||||
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
|
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
|
||||||
|
import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-structure-project-command.repository.js";
|
||||||
|
import { CircuitGroupRenumberProjectCommandRepository } from "../../db/repositories/circuit-group-renumber-project-command.repository.js";
|
||||||
|
import { CircuitGroupMoveProjectCommandRepository } from "../../db/repositories/circuit-group-move-project-command.repository.js";
|
||||||
|
import { CircuitGroupSubtreeProjectCommandRepository } from "../../db/repositories/circuit-group-subtree-project-command.repository.js";
|
||||||
import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js";
|
import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js";
|
||||||
import { DistributionBoardComponentStructureProjectCommandRepository } from "../../db/repositories/distribution-board-component-structure-project-command.repository.js";
|
import { DistributionBoardComponentStructureProjectCommandRepository } from "../../db/repositories/distribution-board-component-structure-project-command.repository.js";
|
||||||
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
|
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
|
||||||
@@ -26,6 +30,14 @@ export const circuitDeviceRowMoveProjectCommandStore =
|
|||||||
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
||||||
export const circuitStructureProjectCommandStore =
|
export const circuitStructureProjectCommandStore =
|
||||||
new CircuitStructureProjectCommandRepository(db);
|
new CircuitStructureProjectCommandRepository(db);
|
||||||
|
export const circuitGroupStructureProjectCommandStore =
|
||||||
|
new CircuitGroupStructureProjectCommandRepository(db);
|
||||||
|
export const circuitGroupRenumberProjectCommandStore =
|
||||||
|
new CircuitGroupRenumberProjectCommandRepository(db);
|
||||||
|
export const circuitGroupMoveProjectCommandStore =
|
||||||
|
new CircuitGroupMoveProjectCommandRepository(db);
|
||||||
|
export const circuitGroupSubtreeProjectCommandStore =
|
||||||
|
new CircuitGroupSubtreeProjectCommandRepository(db);
|
||||||
export const distributionBoardStructureProjectCommandStore =
|
export const distributionBoardStructureProjectCommandStore =
|
||||||
new DistributionBoardStructureProjectCommandRepository(db);
|
new DistributionBoardStructureProjectCommandRepository(db);
|
||||||
export const distributionBoardComponentStructureProjectCommandStore =
|
export const distributionBoardComponentStructureProjectCommandStore =
|
||||||
@@ -63,5 +75,9 @@ export const projectCommandService = new ProjectCommandService(
|
|||||||
projectSettingsProjectCommandStore,
|
projectSettingsProjectCommandStore,
|
||||||
projectStateRestoreCommandStore,
|
projectStateRestoreCommandStore,
|
||||||
distributionBoardComponentStructureProjectCommandStore,
|
distributionBoardComponentStructureProjectCommandStore,
|
||||||
|
circuitGroupStructureProjectCommandStore,
|
||||||
|
circuitGroupRenumberProjectCommandStore,
|
||||||
|
circuitGroupMoveProjectCommandStore,
|
||||||
|
circuitGroupSubtreeProjectCommandStore,
|
||||||
projectHistoryStore
|
projectHistoryStore
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import {
|
|||||||
calculateSectionTotalPower,
|
calculateSectionTotalPower,
|
||||||
} from "../../domain/calculations/circuit-power-calculation.js";
|
} from "../../domain/calculations/circuit-power-calculation.js";
|
||||||
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
|
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
|
||||||
|
import type { CircuitTreeProtectionDevice } from "../../domain/models/circuit-tree.model.js";
|
||||||
import {
|
import {
|
||||||
circuitDeviceRowRepository,
|
circuitDeviceRowRepository,
|
||||||
circuitListRepository,
|
circuitListRepository,
|
||||||
|
circuitProtectionDeviceRepository,
|
||||||
circuitRepository,
|
circuitRepository,
|
||||||
circuitSectionRepository,
|
circuitSectionRepository,
|
||||||
|
distributionBoardComponentRepository,
|
||||||
distributionBoardRepository,
|
distributionBoardRepository,
|
||||||
projectRepository,
|
projectRepository,
|
||||||
} from "../composition/application-repositories.js";
|
} from "../composition/application-repositories.js";
|
||||||
@@ -23,7 +26,9 @@ export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
|
|||||||
return (
|
return (
|
||||||
error.message.includes("no such table: circuit_sections") ||
|
error.message.includes("no such table: circuit_sections") ||
|
||||||
error.message.includes("no such table: circuits") ||
|
error.message.includes("no such table: circuits") ||
|
||||||
error.message.includes("no such table: circuit_device_rows")
|
error.message.includes("no such table: circuit_device_rows") ||
|
||||||
|
error.message.includes("no such table: distribution_board_components") ||
|
||||||
|
error.message.includes("no such table: circuit_protection_devices")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,9 +61,28 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
||||||
const circuits = await circuitRepository.listByCircuitList(circuitListId);
|
const circuits = await circuitRepository.listByCircuitList(circuitListId);
|
||||||
const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id));
|
const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id));
|
||||||
|
const components =
|
||||||
|
await distributionBoardComponentRepository.listByCircuitList(
|
||||||
|
circuitListId
|
||||||
|
);
|
||||||
|
const [circuitProtections, componentProtections] =
|
||||||
|
await Promise.all([
|
||||||
|
circuitProtectionDeviceRepository.listByCircuitIds(
|
||||||
|
circuits.map((entry) => entry.id)
|
||||||
|
),
|
||||||
|
distributionBoardComponentRepository.listProtectionByComponentIds(
|
||||||
|
components.map((entry) => entry.id)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
const sectionById = new Map(sections.map((section) => [section.id, section]));
|
const sectionById = new Map(sections.map((section) => [section.id, section]));
|
||||||
const rowsByCircuitId = new Map<string, typeof rows>();
|
const rowsByCircuitId = new Map<string, typeof rows>();
|
||||||
|
const circuitProtectionById = new Map(
|
||||||
|
circuitProtections.map((entry) => [entry.circuitId, entry])
|
||||||
|
);
|
||||||
|
const componentProtectionById = new Map(
|
||||||
|
componentProtections.map((entry) => [entry.componentId, entry])
|
||||||
|
);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!rowsByCircuitId.has(row.circuitId)) {
|
if (!rowsByCircuitId.has(row.circuitId)) {
|
||||||
rowsByCircuitId.set(row.circuitId, []);
|
rowsByCircuitId.set(row.circuitId, []);
|
||||||
@@ -75,17 +99,46 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
distributionBoard.simultaneityFactor,
|
distributionBoard.simultaneityFactor,
|
||||||
distributionBoardTotalPower: 0,
|
distributionBoardTotalPower: 0,
|
||||||
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
||||||
|
headerComponents: [],
|
||||||
sections: sections.map((section) => ({
|
sections: sections.map((section) => ({
|
||||||
id: section.id,
|
id: section.id,
|
||||||
key: section.key,
|
key: section.key,
|
||||||
displayName: section.displayName,
|
displayName: section.displayName,
|
||||||
prefix: section.prefix,
|
prefix: section.prefix,
|
||||||
sortOrder: section.sortOrder,
|
sortOrder: section.sortOrder,
|
||||||
|
category: section.category ?? undefined,
|
||||||
|
groupNumber: section.groupNumber ?? undefined,
|
||||||
sectionTotalPower: 0,
|
sectionTotalPower: 0,
|
||||||
|
components: [],
|
||||||
circuits: [],
|
circuits: [],
|
||||||
})),
|
})),
|
||||||
|
footerComponents: [],
|
||||||
};
|
};
|
||||||
const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section]));
|
const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section]));
|
||||||
|
for (const component of components) {
|
||||||
|
const protection =
|
||||||
|
componentProtectionById.get(component.id);
|
||||||
|
const dto = {
|
||||||
|
id: component.id,
|
||||||
|
circuitListId: component.circuitListId,
|
||||||
|
sectionId: component.sectionId ?? undefined,
|
||||||
|
equipmentIdentifier: component.equipmentIdentifier,
|
||||||
|
name: component.name,
|
||||||
|
role: component.role,
|
||||||
|
placement: component.placement,
|
||||||
|
sortOrder: component.sortOrder,
|
||||||
|
protectionDevice: protection
|
||||||
|
? toProtectionDeviceDto(protection)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
if (component.placement === "header") {
|
||||||
|
tree.headerComponents.push(dto);
|
||||||
|
} else if (component.placement === "footer") {
|
||||||
|
tree.footerComponents.push(dto);
|
||||||
|
} else if (component.sectionId !== null) {
|
||||||
|
sectionBlocks.get(component.sectionId)?.components.push(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const circuit of circuits) {
|
for (const circuit of circuits) {
|
||||||
const section = sectionById.get(circuit.sectionId);
|
const section = sectionById.get(circuit.sectionId);
|
||||||
@@ -116,6 +169,7 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
rowTotalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
|
rowTotalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
|
||||||
}));
|
}));
|
||||||
const circuitTotalPower = calculateCircuitTotalPower(deviceRows);
|
const circuitTotalPower = calculateCircuitTotalPower(deviceRows);
|
||||||
|
const protection = circuitProtectionById.get(circuit.id);
|
||||||
|
|
||||||
sectionBlocks.get(section.id)?.circuits.push({
|
sectionBlocks.get(section.id)?.circuits.push({
|
||||||
id: circuit.id,
|
id: circuit.id,
|
||||||
@@ -139,6 +193,9 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
remark: circuit.remark ?? undefined,
|
remark: circuit.remark ?? undefined,
|
||||||
circuitTotalPower,
|
circuitTotalPower,
|
||||||
deviceRows,
|
deviceRows,
|
||||||
|
protectionDevice: protection
|
||||||
|
? toProtectionDeviceDto(protection)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +224,9 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
distributionBoard.simultaneityFactor,
|
distributionBoard.simultaneityFactor,
|
||||||
distributionBoardTotalPower: 0,
|
distributionBoardTotalPower: 0,
|
||||||
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
||||||
|
headerComponents: [],
|
||||||
sections: [],
|
sections: [],
|
||||||
|
footerComponents: [],
|
||||||
warning:
|
warning:
|
||||||
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
||||||
});
|
});
|
||||||
@@ -175,3 +234,23 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toProtectionDeviceDto(value: {
|
||||||
|
type: CircuitTreeProtectionDevice["type"];
|
||||||
|
ratedCurrentA: number;
|
||||||
|
fuseUtilizationCategory: CircuitTreeProtectionDevice["fuseUtilizationCategory"] | null;
|
||||||
|
tripCharacteristic: CircuitTreeProtectionDevice["tripCharacteristic"] | null;
|
||||||
|
rcdType: CircuitTreeProtectionDevice["rcdType"] | null;
|
||||||
|
ratedResidualCurrentMa: number | null;
|
||||||
|
}): CircuitTreeProtectionDevice {
|
||||||
|
return {
|
||||||
|
type: value.type,
|
||||||
|
ratedCurrentA: value.ratedCurrentA,
|
||||||
|
fuseUtilizationCategory:
|
||||||
|
value.fuseUtilizationCategory ?? undefined,
|
||||||
|
tripCharacteristic: value.tripCharacteristic ?? undefined,
|
||||||
|
rcdType: value.rcdType ?? undefined,
|
||||||
|
ratedResidualCurrentMa:
|
||||||
|
value.ratedResidualCurrentMa ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
CircuitTreeDeviceRowDto,
|
CircuitTreeDeviceRowDto,
|
||||||
CircuitTreeSectionDto,
|
CircuitTreeSectionDto,
|
||||||
} from "../src/frontend/types.js";
|
} from "../src/frontend/types.js";
|
||||||
|
import "./circuit-structure-projection.test.js";
|
||||||
|
|
||||||
function makeDevice(
|
function makeDevice(
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
|
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { createCircuitGroupMoveProjectCommand } from "../src/domain/models/circuit-group-move-project-command.model.js";
|
||||||
|
import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js";
|
||||||
|
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values({ id: "project-1", name: "Projekt" })
|
||||||
|
.run();
|
||||||
|
new DistributionBoardFixtureRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||||
|
const list = context.db.select().from(circuitLists).get()!;
|
||||||
|
const source = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.category, "lighting"))
|
||||||
|
.get()!;
|
||||||
|
context.db
|
||||||
|
.insert(circuitSections)
|
||||||
|
.values({
|
||||||
|
id: "lighting-move-target",
|
||||||
|
circuitListId: list.id,
|
||||||
|
key: "lighting_2",
|
||||||
|
displayName: "Beleuchtung 2",
|
||||||
|
prefix: "-1F2.",
|
||||||
|
sortOrder: 40,
|
||||||
|
category: "lighting",
|
||||||
|
groupNumber: 2,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "circuit-move",
|
||||||
|
circuitListId: list.id,
|
||||||
|
sectionId: source.id,
|
||||||
|
equipmentIdentifier: "-1F1.7",
|
||||||
|
displayName: "Zu verschieben",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-target-existing",
|
||||||
|
circuitListId: list.id,
|
||||||
|
sectionId: "lighting-move-target",
|
||||||
|
equipmentIdentifier: "-1F2.4",
|
||||||
|
displayName: "Bestand",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values({
|
||||||
|
id: "row-move",
|
||||||
|
circuitId: "circuit-move",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitProtectionDevices)
|
||||||
|
.values({
|
||||||
|
circuitId: "circuit-move",
|
||||||
|
type: "LS",
|
||||||
|
ratedCurrentA: 10,
|
||||||
|
tripCharacteristic: "B",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMoveCommand(context: DatabaseContext) {
|
||||||
|
const circuit = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-move"))
|
||||||
|
.get()!;
|
||||||
|
const source = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, circuit.sectionId))
|
||||||
|
.get()!;
|
||||||
|
const target = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, "lighting-move-target"))
|
||||||
|
.get()!;
|
||||||
|
return createCircuitGroupMoveProjectCommand(
|
||||||
|
createCircuitGroupMovePlan({
|
||||||
|
circuit,
|
||||||
|
sourceGroup: {
|
||||||
|
id: source.id,
|
||||||
|
category: source.category!,
|
||||||
|
groupNumber: source.groupNumber!,
|
||||||
|
prefix: source.prefix,
|
||||||
|
},
|
||||||
|
targetGroup: {
|
||||||
|
id: target.id,
|
||||||
|
category: target.category!,
|
||||||
|
groupNumber: target.groupNumber!,
|
||||||
|
prefix: target.prefix,
|
||||||
|
},
|
||||||
|
targetCircuitEquipmentIdentifiers: context.db
|
||||||
|
.select({ value: circuits.equipmentIdentifier })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.sectionId, target.id))
|
||||||
|
.all()
|
||||||
|
.map(({ value }) => value),
|
||||||
|
targetSortOrder: 30,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit group-move project command", () => {
|
||||||
|
it("moves a complete circuit while preserving rows and protection through undo/redo", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const command = createMoveCommand(context);
|
||||||
|
const repository =
|
||||||
|
new CircuitGroupMoveProjectCommandRepository(context.db);
|
||||||
|
const moved = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({
|
||||||
|
sectionId: circuits.sectionId,
|
||||||
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||||
|
sortOrder: circuits.sortOrder,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-move"))
|
||||||
|
.get(),
|
||||||
|
{
|
||||||
|
sectionId: "lighting-move-target",
|
||||||
|
equipmentIdentifier: "-1F2.5",
|
||||||
|
sortOrder: 30,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-move"))
|
||||||
|
.get()?.circuitId,
|
||||||
|
"circuit-move"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitProtectionDevices)
|
||||||
|
.where(eq(circuitProtectionDevices.circuitId, "circuit-move"))
|
||||||
|
.get()?.ratedCurrentA,
|
||||||
|
10
|
||||||
|
);
|
||||||
|
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||||
|
context.db
|
||||||
|
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||||
|
command: moved.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-move"))
|
||||||
|
.get()?.equipmentIdentifier,
|
||||||
|
"-1F1.7"
|
||||||
|
);
|
||||||
|
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||||
|
context.db
|
||||||
|
).getNextCommand("project-1", "redo")?.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-move"))
|
||||||
|
.get()?.equipmentIdentifier,
|
||||||
|
"-1F2.5"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale state without moving the circuit", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const command = createMoveCommand(context);
|
||||||
|
context.db
|
||||||
|
.update(circuits)
|
||||||
|
.set({ sortOrder: 99 })
|
||||||
|
.where(eq(circuits.id, "circuit-move"))
|
||||||
|
.run();
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
new CircuitGroupMoveProjectCommandRepository(context.db).execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
}),
|
||||||
|
/changed before group move/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-move"))
|
||||||
|
.get()?.sectionId,
|
||||||
|
command.payload.expectedSectionId
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the circuit move when history persistence fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const command = createMoveCommand(context);
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_circuit_group_move_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced circuit group move history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
new CircuitGroupMoveProjectCommandRepository(context.db).execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
}),
|
||||||
|
/forced circuit group move history failure/
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({
|
||||||
|
sectionId: circuits.sectionId,
|
||||||
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||||
|
sortOrder: circuits.sortOrder,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-move"))
|
||||||
|
.get(),
|
||||||
|
{
|
||||||
|
sectionId: command.payload.expectedSectionId,
|
||||||
|
equipmentIdentifier: "-1F1.7",
|
||||||
|
sortOrder: 20,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,13 @@ import {
|
|||||||
getNextGroupedCircuitIdentifier,
|
getNextGroupedCircuitIdentifier,
|
||||||
parseGroupedEquipmentIdentifier,
|
parseGroupedEquipmentIdentifier,
|
||||||
} from "../src/domain/services/circuit-group-numbering.js";
|
} from "../src/domain/services/circuit-group-numbering.js";
|
||||||
|
import "./circuit-group-structure-project-command.repository.test.js";
|
||||||
|
import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js";
|
||||||
|
import "./circuit-group-renumber-project-command.repository.test.js";
|
||||||
|
import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js";
|
||||||
|
import "./circuit-group-move-project-command.repository.test.js";
|
||||||
|
import "./circuit-group-subtree-snapshot.test.js";
|
||||||
|
import "./circuit-group-subtree-project-command.repository.test.js";
|
||||||
|
|
||||||
describe("circuit group numbering", () => {
|
describe("circuit group numbering", () => {
|
||||||
it("formats the agreed identifiers including the leading hyphen", () => {
|
it("formats the agreed identifiers including the leading hyphen", () => {
|
||||||
@@ -102,3 +109,252 @@ describe("circuit group numbering", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("circuit group renumber planning", () => {
|
||||||
|
const groups = [
|
||||||
|
{
|
||||||
|
id: "lighting-1",
|
||||||
|
category: "lighting" as const,
|
||||||
|
groupNumber: 1,
|
||||||
|
prefix: "-1F1.",
|
||||||
|
circuits: [
|
||||||
|
{ id: "circuit-1", equipmentIdentifier: "-1F1.1" },
|
||||||
|
{ id: "circuit-2", equipmentIdentifier: "-1F1.7" },
|
||||||
|
],
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
id: "fuse-1",
|
||||||
|
role: "group_upstream_protection" as const,
|
||||||
|
equipmentIdentifier: "-1F1.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rcd-1",
|
||||||
|
role: "group_residual_current_protection" as const,
|
||||||
|
equipmentIdentifier: "-1Q1.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lighting-2",
|
||||||
|
category: "lighting" as const,
|
||||||
|
groupNumber: 2,
|
||||||
|
prefix: "-1F2.",
|
||||||
|
circuits: [
|
||||||
|
{ id: "circuit-3", equipmentIdentifier: "-1F2.4" },
|
||||||
|
],
|
||||||
|
components: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "single-1",
|
||||||
|
category: "single_phase" as const,
|
||||||
|
groupNumber: 1,
|
||||||
|
prefix: "-2F1.",
|
||||||
|
circuits: [],
|
||||||
|
components: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("preserves circuit suffixes and maps optional group components", () => {
|
||||||
|
const plan = createCircuitGroupRenumberPlan(groups, [
|
||||||
|
{ groupId: "lighting-1", targetGroupNumber: 3 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(plan.groups[0], {
|
||||||
|
groupId: "lighting-1",
|
||||||
|
category: "lighting",
|
||||||
|
expectedGroupNumber: 1,
|
||||||
|
targetGroupNumber: 3,
|
||||||
|
expectedPrefix: "-1F1.",
|
||||||
|
targetPrefix: "-1F3.",
|
||||||
|
circuits: [
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedEquipmentIdentifier: "-1F1.1",
|
||||||
|
targetEquipmentIdentifier: "-1F3.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedEquipmentIdentifier: "-1F1.7",
|
||||||
|
targetEquipmentIdentifier: "-1F3.7",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
componentId: "fuse-1",
|
||||||
|
expectedEquipmentIdentifier: "-1F1.0",
|
||||||
|
targetEquipmentIdentifier: "-1F3.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
componentId: "rcd-1",
|
||||||
|
expectedEquipmentIdentifier: "-1Q1.0",
|
||||||
|
targetEquipmentIdentifier: "-1Q3.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a number swap when every affected group participates", () => {
|
||||||
|
const plan = createCircuitGroupRenumberPlan(groups, [
|
||||||
|
{ groupId: "lighting-1", targetGroupNumber: 2 },
|
||||||
|
{ groupId: "lighting-2", targetGroupNumber: 1 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
plan.groups.map((group) => [
|
||||||
|
group.expectedGroupNumber,
|
||||||
|
group.targetGroupNumber,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
[1, 2],
|
||||||
|
[2, 1],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects collisions, no-op requests and mismatched current BMKs", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitGroupRenumberPlan(groups, [
|
||||||
|
{ groupId: "lighting-1", targetGroupNumber: 2 },
|
||||||
|
]),
|
||||||
|
/unchanged group/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitGroupRenumberPlan(groups, [
|
||||||
|
{ groupId: "lighting-1", targetGroupNumber: 1 },
|
||||||
|
]),
|
||||||
|
/must change/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitGroupRenumberPlan(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...groups[0],
|
||||||
|
circuits: [
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
equipmentIdentifier: "-1F9.1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[{ groupId: "lighting-1", targetGroupNumber: 3 }]
|
||||||
|
),
|
||||||
|
/Circuit BMK does not match/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("circuit group move planning", () => {
|
||||||
|
const sourceGroup = {
|
||||||
|
id: "lighting-1",
|
||||||
|
category: "lighting" as const,
|
||||||
|
groupNumber: 1,
|
||||||
|
prefix: "-1F1.",
|
||||||
|
};
|
||||||
|
const targetGroup = {
|
||||||
|
id: "lighting-2",
|
||||||
|
category: "lighting" as const,
|
||||||
|
groupNumber: 2,
|
||||||
|
prefix: "-1F2.",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("uses the highest target suffix plus one and stores both positions", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
createCircuitGroupMovePlan({
|
||||||
|
circuit: {
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "lighting-1",
|
||||||
|
equipmentIdentifier: "-1F1.7",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
sourceGroup,
|
||||||
|
targetGroup,
|
||||||
|
targetCircuitEquipmentIdentifiers: [
|
||||||
|
"-1F2.1",
|
||||||
|
"-1F2.4",
|
||||||
|
"-1F2.9",
|
||||||
|
],
|
||||||
|
targetSortOrder: 40,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
expectedSectionId: "lighting-1",
|
||||||
|
targetSectionId: "lighting-2",
|
||||||
|
expectedEquipmentIdentifier: "-1F1.7",
|
||||||
|
targetEquipmentIdentifier: "-1F2.10",
|
||||||
|
expectedSortOrder: 20,
|
||||||
|
targetSortOrder: 40,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fill target gaps", () => {
|
||||||
|
assert.equal(
|
||||||
|
createCircuitGroupMovePlan({
|
||||||
|
circuit: {
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "lighting-1",
|
||||||
|
equipmentIdentifier: "-1F1.2",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
sourceGroup,
|
||||||
|
targetGroup,
|
||||||
|
targetCircuitEquipmentIdentifiers: ["-1F2.1", "-1F2.3"],
|
||||||
|
targetSortOrder: 20,
|
||||||
|
}).targetEquipmentIdentifier,
|
||||||
|
"-1F2.4"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects cross-category, same-group and mismatched source moves", () => {
|
||||||
|
const base = {
|
||||||
|
circuit: {
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "lighting-1",
|
||||||
|
equipmentIdentifier: "-1F1.1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
sourceGroup,
|
||||||
|
targetGroup,
|
||||||
|
targetCircuitEquipmentIdentifiers: [] as string[],
|
||||||
|
targetSortOrder: 10,
|
||||||
|
};
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitGroupMovePlan({
|
||||||
|
...base,
|
||||||
|
targetGroup: {
|
||||||
|
id: "single-1",
|
||||||
|
category: "single_phase",
|
||||||
|
groupNumber: 1,
|
||||||
|
prefix: "-2F1.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/same category/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitGroupMovePlan({
|
||||||
|
...base,
|
||||||
|
targetGroup: sourceGroup,
|
||||||
|
}),
|
||||||
|
/different groups/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitGroupMovePlan({
|
||||||
|
...base,
|
||||||
|
circuit: {
|
||||||
|
...base.circuit,
|
||||||
|
equipmentIdentifier: "-1F9.1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/does not match/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { createCircuitGroupRenumberProjectCommand } from "../src/domain/models/circuit-group-renumber-project-command.model.js";
|
||||||
|
import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js";
|
||||||
|
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values({ id: "project-1", name: "Projekt" })
|
||||||
|
.run();
|
||||||
|
new DistributionBoardFixtureRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||||
|
const list = context.db.select().from(circuitLists).get()!;
|
||||||
|
const first = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.category, "lighting"))
|
||||||
|
.get()!;
|
||||||
|
context.db
|
||||||
|
.insert(circuitSections)
|
||||||
|
.values({
|
||||||
|
id: "lighting-2",
|
||||||
|
circuitListId: list.id,
|
||||||
|
key: "lighting_2",
|
||||||
|
displayName: "Beleuchtung 2",
|
||||||
|
prefix: "-1F2.",
|
||||||
|
sortOrder: 40,
|
||||||
|
category: "lighting",
|
||||||
|
groupNumber: 2,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "circuit-lighting-1",
|
||||||
|
circuitListId: list.id,
|
||||||
|
sectionId: first.id,
|
||||||
|
equipmentIdentifier: "-1F1.7",
|
||||||
|
displayName: "Licht 1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-lighting-2",
|
||||||
|
circuitListId: list.id,
|
||||||
|
sectionId: "lighting-2",
|
||||||
|
equipmentIdentifier: "-1F2.4",
|
||||||
|
displayName: "Licht 2",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(distributionBoardComponents)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "lighting-1-fuse",
|
||||||
|
circuitListId: list.id,
|
||||||
|
sectionId: first.id,
|
||||||
|
equipmentIdentifier: "-1F1.0",
|
||||||
|
name: "Vorsicherung",
|
||||||
|
role: "group_upstream_protection",
|
||||||
|
placement: "group",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lighting-1-rcd",
|
||||||
|
circuitListId: list.id,
|
||||||
|
sectionId: first.id,
|
||||||
|
equipmentIdentifier: "-1Q1.0",
|
||||||
|
name: "Gruppen-FI",
|
||||||
|
role: "group_residual_current_protection",
|
||||||
|
placement: "group",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSwapCommand(context: DatabaseContext) {
|
||||||
|
const list = context.db.select().from(circuitLists).get()!;
|
||||||
|
const groups = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.category, "lighting"))
|
||||||
|
.all()
|
||||||
|
.sort((left, right) => left.groupNumber! - right.groupNumber!)
|
||||||
|
.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
category: group.category!,
|
||||||
|
groupNumber: group.groupNumber!,
|
||||||
|
prefix: group.prefix,
|
||||||
|
circuits: context.db
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.sectionId, group.id))
|
||||||
|
.all(),
|
||||||
|
components: context.db
|
||||||
|
.select({
|
||||||
|
id: distributionBoardComponents.id,
|
||||||
|
role: distributionBoardComponents.role,
|
||||||
|
equipmentIdentifier:
|
||||||
|
distributionBoardComponents.equipmentIdentifier,
|
||||||
|
})
|
||||||
|
.from(distributionBoardComponents)
|
||||||
|
.where(eq(distributionBoardComponents.sectionId, group.id))
|
||||||
|
.all()
|
||||||
|
.map((component) => ({
|
||||||
|
...component,
|
||||||
|
role: component.role as
|
||||||
|
| "group_upstream_protection"
|
||||||
|
| "group_residual_current_protection",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
return createCircuitGroupRenumberProjectCommand(
|
||||||
|
list.id,
|
||||||
|
createCircuitGroupRenumberPlan(groups, [
|
||||||
|
{ groupId: groups[0].id, targetGroupNumber: 2 },
|
||||||
|
{ groupId: groups[1].id, targetGroupNumber: 1 },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit-group renumber project command", () => {
|
||||||
|
it("swaps group numbers and every derived BMK through undo and redo", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const command = createSwapCommand(context);
|
||||||
|
const repository =
|
||||||
|
new CircuitGroupRenumberProjectCommandRepository(context.db);
|
||||||
|
const executed = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, command.payload.groups[0].groupId))
|
||||||
|
.get()?.groupNumber,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-lighting-1"))
|
||||||
|
.get()?.equipmentIdentifier,
|
||||||
|
"-1F2.7"
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({ value: distributionBoardComponents.equipmentIdentifier })
|
||||||
|
.from(distributionBoardComponents)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
distributionBoardComponents.sectionId,
|
||||||
|
command.payload.groups[0].groupId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
.map(({ value }) => value)
|
||||||
|
.sort(),
|
||||||
|
["-1F2.0", "-1Q2.0"]
|
||||||
|
);
|
||||||
|
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||||
|
context.db
|
||||||
|
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||||
|
command: executed.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-lighting-1"))
|
||||||
|
.get()?.equipmentIdentifier,
|
||||||
|
"-1F1.7"
|
||||||
|
);
|
||||||
|
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||||
|
context.db
|
||||||
|
).getNextCommand("project-1", "redo")?.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-lighting-2"))
|
||||||
|
.get()?.equipmentIdentifier,
|
||||||
|
"-1F1.4"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects incomplete child snapshots without partial changes", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const command = createSwapCommand(context);
|
||||||
|
command.payload.groups[0].components.pop();
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
new CircuitGroupRenumberProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
).execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
}),
|
||||||
|
/include every unchanged child/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-lighting-1"))
|
||||||
|
.get()?.equipmentIdentifier,
|
||||||
|
"-1F1.7"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back every group and BMK when history persistence fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const command = createSwapCommand(context);
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_group_renumber_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced group renumber history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
new CircuitGroupRenumberProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
).execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
}),
|
||||||
|
/forced group renumber history failure/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-lighting-1"))
|
||||||
|
.get()?.equipmentIdentifier,
|
||||||
|
"-1F1.7"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(distributionBoardComponents)
|
||||||
|
.where(eq(distributionBoardComponents.id, "lighting-1-rcd"))
|
||||||
|
.get()?.equipmentIdentifier,
|
||||||
|
"-1Q1.0"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import {
|
||||||
|
createCircuitGroupDeleteProjectCommand,
|
||||||
|
createCircuitGroupInsertProjectCommand,
|
||||||
|
createCircuitGroupReorderProjectCommand,
|
||||||
|
createCircuitGroupUpdateProjectCommand,
|
||||||
|
type CircuitGroupSnapshot,
|
||||||
|
} from "../src/domain/models/circuit-group-structure-project-command.model.js";
|
||||||
|
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values([
|
||||||
|
{ id: "project-1", name: "Projekt" },
|
||||||
|
{ id: "project-2", name: "Fremdprojekt" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
new DistributionBoardFixtureRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||||
|
new DistributionBoardFixtureRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-2", "UV-02");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectList(context: DatabaseContext, projectId: string) {
|
||||||
|
const list = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.get();
|
||||||
|
assert.ok(list);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupSnapshot(circuitListId: string): CircuitGroupSnapshot {
|
||||||
|
return {
|
||||||
|
id: "group-lighting-2",
|
||||||
|
circuitListId,
|
||||||
|
key: "lighting_2",
|
||||||
|
displayName: "Beleuchtung 2",
|
||||||
|
prefix: "-1F2.",
|
||||||
|
sortOrder: 40,
|
||||||
|
category: "lighting",
|
||||||
|
groupNumber: 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit-group structure project command", () => {
|
||||||
|
it("inserts, renames and restores a group through persistent history", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const expected = groupSnapshot(
|
||||||
|
getProjectList(context, "project-1").id
|
||||||
|
);
|
||||||
|
const target = {
|
||||||
|
...expected,
|
||||||
|
displayName: "Beleuchtung Besprechungsräume",
|
||||||
|
};
|
||||||
|
const repository =
|
||||||
|
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitGroupInsertProjectCommand(expected),
|
||||||
|
});
|
||||||
|
const updated = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitGroupUpdateProjectCommand(
|
||||||
|
expected,
|
||||||
|
target
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, expected.id))
|
||||||
|
.get()?.displayName,
|
||||||
|
target.displayName
|
||||||
|
);
|
||||||
|
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||||
|
context.db
|
||||||
|
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||||
|
command: updated.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, expected.id))
|
||||||
|
.get(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes only an exact empty group and restores the same UUID", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const snapshot = groupSnapshot(
|
||||||
|
getProjectList(context, "project-1").id
|
||||||
|
);
|
||||||
|
const repository =
|
||||||
|
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||||
|
});
|
||||||
|
const deleted = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitGroupDeleteProjectCommand(snapshot),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, snapshot.id))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||||
|
context.db
|
||||||
|
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||||
|
command: deleted.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, snapshot.id))
|
||||||
|
.get(),
|
||||||
|
snapshot
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects foreign lists, invalid prefixes, stale updates and populated deletes", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const snapshot = groupSnapshot(
|
||||||
|
getProjectList(context, "project-1").id
|
||||||
|
);
|
||||||
|
const repository =
|
||||||
|
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitGroupInsertProjectCommand({
|
||||||
|
...snapshot,
|
||||||
|
prefix: "-1F9.",
|
||||||
|
}),
|
||||||
|
/prefix does not match/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-2",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||||
|
}),
|
||||||
|
/does not belong/
|
||||||
|
);
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "group-circuit",
|
||||||
|
circuitListId: snapshot.circuitListId,
|
||||||
|
sectionId: snapshot.id,
|
||||||
|
equipmentIdentifier: "-1F2.1",
|
||||||
|
displayName: "Beleuchtung",
|
||||||
|
sortOrder: 10,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitGroupDeleteProjectCommand(snapshot),
|
||||||
|
}),
|
||||||
|
/Only empty circuit groups/
|
||||||
|
);
|
||||||
|
context.db
|
||||||
|
.update(circuitSections)
|
||||||
|
.set({ displayName: "Direkt geändert" })
|
||||||
|
.where(eq(circuitSections.id, snapshot.id))
|
||||||
|
.run();
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitGroupUpdateProjectCommand(snapshot, {
|
||||||
|
...snapshot,
|
||||||
|
displayName: "Ziel",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/changed before update/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back group insertion when history persistence fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const snapshot = groupSnapshot(
|
||||||
|
getProjectList(context, "project-1").id
|
||||||
|
);
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_group_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced group history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
new CircuitGroupStructureProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
).execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||||
|
}),
|
||||||
|
/forced group history failure/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, snapshot.id))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders every group atomically without changing group identity", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const circuitListId = getProjectList(context, "project-1").id;
|
||||||
|
const groups = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||||
|
.all();
|
||||||
|
const expectedPrefixes = new Map(
|
||||||
|
groups.map((group) => [group.id, group.prefix])
|
||||||
|
);
|
||||||
|
const assignments = groups.map((group, index) => ({
|
||||||
|
groupId: group.id,
|
||||||
|
expectedSortOrder: group.sortOrder,
|
||||||
|
targetSortOrder: (groups.length - index) * 10,
|
||||||
|
}));
|
||||||
|
const repository =
|
||||||
|
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||||
|
const reordered = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitGroupReorderProjectCommand(
|
||||||
|
circuitListId,
|
||||||
|
assignments
|
||||||
|
),
|
||||||
|
});
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
const group = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, assignment.groupId))
|
||||||
|
.get();
|
||||||
|
assert.equal(group?.sortOrder, assignment.targetSortOrder);
|
||||||
|
assert.equal(
|
||||||
|
group?.prefix,
|
||||||
|
expectedPrefixes.get(assignment.groupId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||||
|
context.db
|
||||||
|
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||||
|
command: reordered.inverse,
|
||||||
|
});
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, assignment.groupId))
|
||||||
|
.get()?.sortOrder,
|
||||||
|
assignment.expectedSortOrder
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects incomplete reorders and rolls back late failures", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const circuitListId = getProjectList(context, "project-1").id;
|
||||||
|
const groups = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||||
|
.all();
|
||||||
|
const assignments = groups.map((group, index) => ({
|
||||||
|
groupId: group.id,
|
||||||
|
expectedSortOrder: group.sortOrder,
|
||||||
|
targetSortOrder: (groups.length - index) * 10,
|
||||||
|
}));
|
||||||
|
const repository =
|
||||||
|
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitGroupReorderProjectCommand(
|
||||||
|
circuitListId,
|
||||||
|
assignments.slice(1)
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/include every unchanged group/
|
||||||
|
);
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_group_reorder_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced group reorder history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitGroupReorderProjectCommand(
|
||||||
|
circuitListId,
|
||||||
|
assignments
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/forced group reorder history failure/
|
||||||
|
);
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, assignment.groupId))
|
||||||
|
.get()?.sortOrder,
|
||||||
|
assignment.expectedSortOrder
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
|
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
|
||||||
|
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
|
||||||
|
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(projects)
|
||||||
|
.values({ id: "project-1", name: "Projekt" })
|
||||||
|
.run();
|
||||||
|
new DistributionBoardFixtureRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||||
|
const list = context.db.select().from(circuitLists).get()!;
|
||||||
|
const group = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.category, "lighting"))
|
||||||
|
.get()!;
|
||||||
|
context.db
|
||||||
|
.insert(distributionBoardComponents)
|
||||||
|
.values({
|
||||||
|
id: "subtree-rcd",
|
||||||
|
circuitListId: list.id,
|
||||||
|
sectionId: group.id,
|
||||||
|
equipmentIdentifier: "-1Q1.0",
|
||||||
|
name: "Gruppen-FI",
|
||||||
|
role: "group_residual_current_protection",
|
||||||
|
placement: "group",
|
||||||
|
sortOrder: 10,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(distributionBoardComponentProtectionDevices)
|
||||||
|
.values({
|
||||||
|
componentId: "subtree-rcd",
|
||||||
|
type: "FI",
|
||||||
|
ratedCurrentA: 40,
|
||||||
|
rcdType: "A",
|
||||||
|
ratedResidualCurrentMa: 30,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "subtree-circuit",
|
||||||
|
circuitListId: list.id,
|
||||||
|
sectionId: group.id,
|
||||||
|
equipmentIdentifier: "-1F1.1",
|
||||||
|
displayName: "Beleuchtung",
|
||||||
|
sortOrder: 10,
|
||||||
|
voltage: 230,
|
||||||
|
isReserve: 0,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitProtectionDevices)
|
||||||
|
.values({
|
||||||
|
circuitId: "subtree-circuit",
|
||||||
|
type: "LS",
|
||||||
|
ratedCurrentA: 10,
|
||||||
|
tripCharacteristic: "B",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values({
|
||||||
|
id: "subtree-row",
|
||||||
|
circuitId: "subtree-circuit",
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
legacyConsumerId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: null,
|
||||||
|
costGroup: null,
|
||||||
|
category: null,
|
||||||
|
level: null,
|
||||||
|
roomId: null,
|
||||||
|
roomNumberSnapshot: null,
|
||||||
|
roomNameSnapshot: null,
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.03,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
remark: null,
|
||||||
|
overriddenFields: "[\"displayName\"]",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit-group subtree project command", () => {
|
||||||
|
it("deletes and restores the exact populated subtree through undo/redo", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository =
|
||||||
|
new CircuitGroupSubtreeProjectCommandRepository(context.db);
|
||||||
|
const groupId = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "subtree-circuit"))
|
||||||
|
.get()!.sectionId;
|
||||||
|
const snapshot = repository.capture("project-1", groupId);
|
||||||
|
const command =
|
||||||
|
createCircuitGroupDeleteSubtreeProjectCommand(snapshot);
|
||||||
|
const deleted = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, groupId))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "subtree-row"))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||||
|
context.db
|
||||||
|
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||||
|
command: deleted.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
repository.capture("project-1", groupId),
|
||||||
|
snapshot
|
||||||
|
);
|
||||||
|
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||||
|
context.db
|
||||||
|
).getNextCommand("project-1", "redo")?.changeSetId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "subtree-circuit"))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a subtree changed after confirmation", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository =
|
||||||
|
new CircuitGroupSubtreeProjectCommandRepository(context.db);
|
||||||
|
const groupId = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "subtree-circuit"))
|
||||||
|
.get()!.sectionId;
|
||||||
|
const snapshot = repository.capture("project-1", groupId);
|
||||||
|
context.db
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({ displayName: "Geändert" })
|
||||||
|
.where(eq(circuitDeviceRows.id, "subtree-row"))
|
||||||
|
.run();
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command:
|
||||||
|
createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
|
||||||
|
}),
|
||||||
|
/changed before deletion/
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, groupId))
|
||||||
|
.get()
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back a cascaded deletion when history persistence fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository =
|
||||||
|
new CircuitGroupSubtreeProjectCommandRepository(context.db);
|
||||||
|
const groupId = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "subtree-circuit"))
|
||||||
|
.get()!.sectionId;
|
||||||
|
const snapshot = repository.capture("project-1", groupId);
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_group_subtree_history
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced group subtree history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command:
|
||||||
|
createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
|
||||||
|
}),
|
||||||
|
/forced group subtree history failure/
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
repository.capture("project-1", groupId),
|
||||||
|
snapshot
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
assertCircuitGroupSubtreeSnapshot,
|
||||||
|
summarizeCircuitGroupDeletion,
|
||||||
|
type CircuitGroupSubtreeSnapshot,
|
||||||
|
} from "../src/domain/models/circuit-group-subtree-snapshot.model.js";
|
||||||
|
|
||||||
|
function createSnapshot(): CircuitGroupSubtreeSnapshot {
|
||||||
|
return {
|
||||||
|
group: {
|
||||||
|
id: "group-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
key: "lighting",
|
||||||
|
displayName: "Beleuchtung 1",
|
||||||
|
prefix: "-1F1.",
|
||||||
|
sortOrder: 10,
|
||||||
|
category: "lighting",
|
||||||
|
groupNumber: 1,
|
||||||
|
},
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
component: {
|
||||||
|
id: "fuse-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "group-1",
|
||||||
|
equipmentIdentifier: "-1F1.0",
|
||||||
|
name: "Vorsicherung",
|
||||||
|
role: "group_upstream_protection",
|
||||||
|
placement: "group",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
protectionDevice: {
|
||||||
|
componentId: "fuse-1",
|
||||||
|
type: "D02",
|
||||||
|
ratedCurrentA: 35,
|
||||||
|
fuseUtilizationCategory: "gG",
|
||||||
|
tripCharacteristic: null,
|
||||||
|
rcdType: null,
|
||||||
|
ratedResidualCurrentMa: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: {
|
||||||
|
id: "rcd-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "group-1",
|
||||||
|
equipmentIdentifier: "-1Q1.0",
|
||||||
|
name: "Gruppen-FI",
|
||||||
|
role: "group_residual_current_protection",
|
||||||
|
placement: "group",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
protectionDevice: {
|
||||||
|
componentId: "rcd-1",
|
||||||
|
type: "FI",
|
||||||
|
ratedCurrentA: 40,
|
||||||
|
fuseUtilizationCategory: null,
|
||||||
|
tripCharacteristic: null,
|
||||||
|
rcdType: "A",
|
||||||
|
ratedResidualCurrentMa: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
circuits: [
|
||||||
|
{
|
||||||
|
circuit: {
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "group-1",
|
||||||
|
equipmentIdentifier: "-1F1.1",
|
||||||
|
displayName: "Beleuchtung",
|
||||||
|
sortOrder: 10,
|
||||||
|
protectionType: null,
|
||||||
|
protectionRatedCurrent: null,
|
||||||
|
protectionCharacteristic: null,
|
||||||
|
cableType: null,
|
||||||
|
cableCrossSection: null,
|
||||||
|
cableLength: null,
|
||||||
|
rcdAssignment: null,
|
||||||
|
terminalDesignation: null,
|
||||||
|
voltage: 230,
|
||||||
|
controlRequirement: null,
|
||||||
|
status: null,
|
||||||
|
isReserve: false,
|
||||||
|
remark: null,
|
||||||
|
deviceRows: [
|
||||||
|
{
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: "device-1",
|
||||||
|
legacyConsumerId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte Flur",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: null,
|
||||||
|
costGroup: "440",
|
||||||
|
category: "Licht",
|
||||||
|
level: "EG",
|
||||||
|
roomId: "room-1",
|
||||||
|
roomNumberSnapshot: "001",
|
||||||
|
roomNameSnapshot: "Flur",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.03,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
remark: null,
|
||||||
|
overriddenFields: "[\"displayName\"]",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
protectionDevice: {
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
type: "LS",
|
||||||
|
ratedCurrentA: 10,
|
||||||
|
fuseUtilizationCategory: null,
|
||||||
|
tripCharacteristic: "B",
|
||||||
|
rcdType: null,
|
||||||
|
ratedResidualCurrentMa: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit-group subtree snapshot", () => {
|
||||||
|
it("validates complete relations and derives the deletion warning summary", () => {
|
||||||
|
const snapshot = createSnapshot();
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertCircuitGroupSubtreeSnapshot(snapshot)
|
||||||
|
);
|
||||||
|
assert.deepEqual(summarizeCircuitGroupDeletion(snapshot), {
|
||||||
|
hasUpstreamProtection: true,
|
||||||
|
hasResidualCurrentProtection: true,
|
||||||
|
circuitCount: 1,
|
||||||
|
deviceRowCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects foreign children, duplicate BMKs and invalid protection", () => {
|
||||||
|
const foreign = createSnapshot();
|
||||||
|
foreign.circuits[0].circuit.sectionId = "group-2";
|
||||||
|
assert.throws(
|
||||||
|
() => assertCircuitGroupSubtreeSnapshot(foreign),
|
||||||
|
/ownership/
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicate = createSnapshot();
|
||||||
|
duplicate.circuits[0].circuit.equipmentIdentifier = "-1F1.0";
|
||||||
|
assert.throws(
|
||||||
|
() => assertCircuitGroupSubtreeSnapshot(duplicate),
|
||||||
|
/duplicates/
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidProtection = createSnapshot();
|
||||||
|
invalidProtection.circuits[0].protectionDevice = {
|
||||||
|
...invalidProtection.circuits[0].protectionDevice!,
|
||||||
|
type: "AFDD",
|
||||||
|
ratedResidualCurrentMa: 30,
|
||||||
|
};
|
||||||
|
assert.throws(
|
||||||
|
() => assertCircuitGroupSubtreeSnapshot(invalidProtection),
|
||||||
|
/configuration/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves device link and override metadata", () => {
|
||||||
|
const row = createSnapshot().circuits[0].circuit.deviceRows[0];
|
||||||
|
assert.equal(row.linkedProjectDeviceId, "device-1");
|
||||||
|
assert.equal(row.overriddenFields, "[\"displayName\"]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { buildCircuitStructureProjection } from "../src/frontend/utils/circuit-structure-projection.js";
|
||||||
|
import type {
|
||||||
|
CircuitTreeCircuitDto,
|
||||||
|
CircuitTreeComponentDto,
|
||||||
|
CircuitTreeSectionDto,
|
||||||
|
} from "../src/frontend/types.js";
|
||||||
|
|
||||||
|
function component(
|
||||||
|
id: string,
|
||||||
|
placement: CircuitTreeComponentDto["placement"],
|
||||||
|
sortOrder: number,
|
||||||
|
sectionId?: string
|
||||||
|
): CircuitTreeComponentDto {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId,
|
||||||
|
equipmentIdentifier: `-${id}`,
|
||||||
|
name: id,
|
||||||
|
role:
|
||||||
|
placement === "header"
|
||||||
|
? "main_switch"
|
||||||
|
: placement === "footer"
|
||||||
|
? "auxiliary"
|
||||||
|
: "group_residual_current_protection",
|
||||||
|
placement,
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function circuit(
|
||||||
|
id: string,
|
||||||
|
sectionId: string
|
||||||
|
): CircuitTreeCircuitDto {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId,
|
||||||
|
equipmentIdentifier: `-${id}`,
|
||||||
|
sortOrder: 10,
|
||||||
|
isReserve: true,
|
||||||
|
circuitTotalPower: 0,
|
||||||
|
deviceRows: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(
|
||||||
|
id: string,
|
||||||
|
sortOrder: number
|
||||||
|
): CircuitTreeSectionDto {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
key: id,
|
||||||
|
displayName: id,
|
||||||
|
prefix: "-1F1.",
|
||||||
|
sortOrder,
|
||||||
|
category: "lighting",
|
||||||
|
groupNumber: sortOrder / 10,
|
||||||
|
sectionTotalPower: 0,
|
||||||
|
components: [
|
||||||
|
component(`${id}-rcd-late`, "group", 20, id),
|
||||||
|
component(`${id}-rcd-first`, "group", 10, id),
|
||||||
|
],
|
||||||
|
circuits: [circuit(`${id}-circuit`, id)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit structure projection", () => {
|
||||||
|
it("projects fixed header, complete groups and auxiliary footer in stable order", () => {
|
||||||
|
const rows = buildCircuitStructureProjection({
|
||||||
|
headerComponents: [
|
||||||
|
component("surge", "header", 20),
|
||||||
|
component("main", "header", 10),
|
||||||
|
],
|
||||||
|
sections: [section("group-2", 20), section("group-1", 10)],
|
||||||
|
footerComponents: [
|
||||||
|
component("actor-2", "footer", 20),
|
||||||
|
component("actor-1", "footer", 10),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((row) => row.rowKey),
|
||||||
|
[
|
||||||
|
"header-component:main",
|
||||||
|
"header-component:surge",
|
||||||
|
"group:group-1",
|
||||||
|
"group-component:group-1-rcd-first",
|
||||||
|
"group-component:group-1-rcd-late",
|
||||||
|
"circuit-block:group-1-circuit",
|
||||||
|
"group-placeholder:group-1",
|
||||||
|
"group:group-2",
|
||||||
|
"group-component:group-2-rcd-first",
|
||||||
|
"group-component:group-2-rcd-late",
|
||||||
|
"circuit-block:group-2-circuit",
|
||||||
|
"group-placeholder:group-2",
|
||||||
|
"footer-component:actor-1",
|
||||||
|
"footer-component:actor-2",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps structural rows around an empty group", () => {
|
||||||
|
const empty = section("group-empty", 10);
|
||||||
|
empty.components = [];
|
||||||
|
empty.circuits = [];
|
||||||
|
assert.deepEqual(
|
||||||
|
buildCircuitStructureProjection({
|
||||||
|
headerComponents: [],
|
||||||
|
sections: [empty],
|
||||||
|
footerComponents: [],
|
||||||
|
}).map((row) => row.rowType),
|
||||||
|
["groupHeader", "groupPlaceholder"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps circuit device rows inside their circuit block", () => {
|
||||||
|
const grouped = section("group-1", 10);
|
||||||
|
grouped.circuits[0].deviceRows = [
|
||||||
|
{
|
||||||
|
id: "device-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
rowTotalPower: 0.1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const block = buildCircuitStructureProjection({
|
||||||
|
headerComponents: [],
|
||||||
|
sections: [grouped],
|
||||||
|
footerComponents: [],
|
||||||
|
}).find((row) => row.rowType === "circuitBlock");
|
||||||
|
assert.equal(
|
||||||
|
block?.rowType === "circuitBlock"
|
||||||
|
? block.circuit.deviceRows[0].id
|
||||||
|
: null,
|
||||||
|
"device-1"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { isMissingCircuitTreeSchemaError } from "../src/server/controllers/circuit-tree.controller.js";
|
import {
|
||||||
|
getCircuitTree,
|
||||||
|
isMissingCircuitTreeSchemaError,
|
||||||
|
} from "../src/server/controllers/circuit-tree.controller.js";
|
||||||
|
import {
|
||||||
|
circuitDeviceRowRepository,
|
||||||
|
circuitListRepository,
|
||||||
|
circuitProtectionDeviceRepository,
|
||||||
|
circuitRepository,
|
||||||
|
circuitSectionRepository,
|
||||||
|
distributionBoardComponentRepository,
|
||||||
|
distributionBoardRepository,
|
||||||
|
projectRepository,
|
||||||
|
} from "../src/server/composition/application-repositories.js";
|
||||||
|
|
||||||
describe("circuit tree controller", () => {
|
describe("circuit tree controller", () => {
|
||||||
it("detects missing circuit-first schema errors", () => {
|
it("detects missing circuit-first schema errors", () => {
|
||||||
@@ -13,7 +26,192 @@ describe("circuit tree controller", () => {
|
|||||||
isMissingCircuitTreeSchemaError(new Error("SqliteError: no such table: circuit_device_rows")),
|
isMissingCircuitTreeSchemaError(new Error("SqliteError: no such table: circuit_device_rows")),
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
isMissingCircuitTreeSchemaError(
|
||||||
|
new Error("SqliteError: no such table: distribution_board_components")
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
assert.equal(isMissingCircuitTreeSchemaError(new Error("Some other error")), false);
|
assert.equal(isMissingCircuitTreeSchemaError(new Error("Some other error")), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns header, grouped and footer components with both protection layers", async () => {
|
||||||
|
const originals = {
|
||||||
|
findList: circuitListRepository.findById,
|
||||||
|
findProject: projectRepository.findById,
|
||||||
|
findBoard: distributionBoardRepository.findById,
|
||||||
|
listSections: circuitSectionRepository.listByCircuitList,
|
||||||
|
listCircuits: circuitRepository.listByCircuitList,
|
||||||
|
listRows: circuitDeviceRowRepository.listByCircuitList,
|
||||||
|
listComponents:
|
||||||
|
distributionBoardComponentRepository.listByCircuitList,
|
||||||
|
listComponentProtection:
|
||||||
|
distributionBoardComponentRepository.listProtectionByComponentIds,
|
||||||
|
listCircuitProtection:
|
||||||
|
circuitProtectionDeviceRepository.listByCircuitIds,
|
||||||
|
};
|
||||||
|
circuitListRepository.findById = async () =>
|
||||||
|
({
|
||||||
|
id: "list-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
distributionBoardId: "board-1",
|
||||||
|
name: "UV-01",
|
||||||
|
}) as never;
|
||||||
|
projectRepository.findById = async () =>
|
||||||
|
({
|
||||||
|
id: "project-1",
|
||||||
|
name: "Projekt",
|
||||||
|
currentRevision: 4,
|
||||||
|
singlePhaseVoltageV: 230,
|
||||||
|
threePhaseVoltageV: 400,
|
||||||
|
}) as never;
|
||||||
|
distributionBoardRepository.findById = async () =>
|
||||||
|
({ id: "board-1", simultaneityFactor: 0.8 }) as never;
|
||||||
|
circuitSectionRepository.listByCircuitList = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "group-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
key: "lighting",
|
||||||
|
displayName: "Beleuchtung 1",
|
||||||
|
prefix: "-1F1.",
|
||||||
|
sortOrder: 10,
|
||||||
|
category: "lighting",
|
||||||
|
groupNumber: 1,
|
||||||
|
},
|
||||||
|
] as never;
|
||||||
|
circuitRepository.listByCircuitList = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "group-1",
|
||||||
|
equipmentIdentifier: "-1F1.1",
|
||||||
|
displayName: "Licht",
|
||||||
|
sortOrder: 10,
|
||||||
|
isReserve: 0,
|
||||||
|
},
|
||||||
|
] as never;
|
||||||
|
circuitDeviceRowRepository.listByCircuitList = async () => [];
|
||||||
|
distributionBoardComponentRepository.listByCircuitList =
|
||||||
|
async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: null,
|
||||||
|
equipmentIdentifier: "-Q0",
|
||||||
|
name: "Hauptschalter",
|
||||||
|
role: "main_switch",
|
||||||
|
placement: "header",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rcd",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "group-1",
|
||||||
|
equipmentIdentifier: "-1Q1.0",
|
||||||
|
name: "Gruppen-FI",
|
||||||
|
role: "group_residual_current_protection",
|
||||||
|
placement: "group",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actor",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: null,
|
||||||
|
equipmentIdentifier: "-K1",
|
||||||
|
name: "KNX Aktor",
|
||||||
|
role: "auxiliary",
|
||||||
|
placement: "footer",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
] as never;
|
||||||
|
distributionBoardComponentRepository.listProtectionByComponentIds =
|
||||||
|
async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
componentId: "rcd",
|
||||||
|
type: "FI",
|
||||||
|
ratedCurrentA: 40,
|
||||||
|
fuseUtilizationCategory: null,
|
||||||
|
tripCharacteristic: null,
|
||||||
|
rcdType: "A",
|
||||||
|
ratedResidualCurrentMa: 30,
|
||||||
|
},
|
||||||
|
] as never;
|
||||||
|
circuitProtectionDeviceRepository.listByCircuitIds = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
type: "LS",
|
||||||
|
ratedCurrentA: 10,
|
||||||
|
fuseUtilizationCategory: null,
|
||||||
|
tripCharacteristic: "B",
|
||||||
|
rcdType: null,
|
||||||
|
ratedResidualCurrentMa: null,
|
||||||
|
},
|
||||||
|
] as never;
|
||||||
|
|
||||||
|
let responseBody: unknown;
|
||||||
|
const response = {
|
||||||
|
status() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(value: unknown) {
|
||||||
|
responseBody = value;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await getCircuitTree(
|
||||||
|
{ params: { projectId: "project-1", circuitListId: "list-1" } } as never,
|
||||||
|
response as never
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
circuitListRepository.findById = originals.findList;
|
||||||
|
projectRepository.findById = originals.findProject;
|
||||||
|
distributionBoardRepository.findById = originals.findBoard;
|
||||||
|
circuitSectionRepository.listByCircuitList =
|
||||||
|
originals.listSections;
|
||||||
|
circuitRepository.listByCircuitList = originals.listCircuits;
|
||||||
|
circuitDeviceRowRepository.listByCircuitList = originals.listRows;
|
||||||
|
distributionBoardComponentRepository.listByCircuitList =
|
||||||
|
originals.listComponents;
|
||||||
|
distributionBoardComponentRepository.listProtectionByComponentIds =
|
||||||
|
originals.listComponentProtection;
|
||||||
|
circuitProtectionDeviceRepository.listByCircuitIds =
|
||||||
|
originals.listCircuitProtection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = responseBody as {
|
||||||
|
headerComponents: Array<{ equipmentIdentifier: string }>;
|
||||||
|
footerComponents: Array<{ equipmentIdentifier: string }>;
|
||||||
|
sections: Array<{
|
||||||
|
category: string;
|
||||||
|
groupNumber: number;
|
||||||
|
components: Array<{
|
||||||
|
protectionDevice: { ratedResidualCurrentMa: number };
|
||||||
|
}>;
|
||||||
|
circuits: Array<{
|
||||||
|
protectionDevice: { tripCharacteristic: string };
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
assert.equal(tree.headerComponents[0].equipmentIdentifier, "-Q0");
|
||||||
|
assert.equal(tree.footerComponents[0].equipmentIdentifier, "-K1");
|
||||||
|
assert.equal(tree.sections[0].category, "lighting");
|
||||||
|
assert.equal(tree.sections[0].groupNumber, 1);
|
||||||
|
assert.equal(
|
||||||
|
tree.sections[0].components[0].protectionDevice
|
||||||
|
.ratedResidualCurrentMa,
|
||||||
|
30
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
tree.sections[0].circuits[0].protectionDevice
|
||||||
|
.tripCharacteristic,
|
||||||
|
"B"
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-
|
|||||||
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||||
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||||
|
import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
|
||||||
|
import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js";
|
||||||
|
import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js";
|
||||||
|
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
|
||||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||||
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
|
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
|
||||||
import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
|
import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
|
||||||
@@ -47,6 +51,16 @@ import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/
|
|||||||
import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
|
import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
|
||||||
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
|
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
|
||||||
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
|
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
createCircuitGroupInsertProjectCommand,
|
||||||
|
createCircuitGroupReorderProjectCommand,
|
||||||
|
createCircuitGroupUpdateProjectCommand,
|
||||||
|
} from "../src/domain/models/circuit-group-structure-project-command.model.js";
|
||||||
|
import { createCircuitGroupRenumberProjectCommand } from "../src/domain/models/circuit-group-renumber-project-command.model.js";
|
||||||
|
import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js";
|
||||||
|
import { createCircuitGroupMoveProjectCommand } from "../src/domain/models/circuit-group-move-project-command.model.js";
|
||||||
|
import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js";
|
||||||
|
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
createDistributionBoardInsertProjectCommand,
|
createDistributionBoardInsertProjectCommand,
|
||||||
createDistributionBoardStructureSnapshot,
|
createDistributionBoardStructureSnapshot,
|
||||||
@@ -131,6 +145,10 @@ function createService(context: DatabaseContext) {
|
|||||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||||
context.db
|
context.db
|
||||||
),
|
),
|
||||||
|
new CircuitGroupStructureProjectCommandRepository(context.db),
|
||||||
|
new CircuitGroupRenumberProjectCommandRepository(context.db),
|
||||||
|
new CircuitGroupMoveProjectCommandRepository(context.db),
|
||||||
|
new CircuitGroupSubtreeProjectCommandRepository(context.db),
|
||||||
new ProjectHistoryRepository(context.db)
|
new ProjectHistoryRepository(context.db)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1207,6 +1225,272 @@ describe("project command service", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dispatches circuit-group structure commands", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const service = createService(context);
|
||||||
|
const circuitListId = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.get()!.circuitListId;
|
||||||
|
const expected = {
|
||||||
|
id: "group-service",
|
||||||
|
circuitListId,
|
||||||
|
key: "lighting_2",
|
||||||
|
displayName: "Beleuchtung 2",
|
||||||
|
prefix: "-1F2.",
|
||||||
|
sortOrder: 40,
|
||||||
|
category: "lighting" as const,
|
||||||
|
groupNumber: 2,
|
||||||
|
};
|
||||||
|
const target = {
|
||||||
|
...expected,
|
||||||
|
displayName: "Beleuchtung Nebenräume",
|
||||||
|
};
|
||||||
|
service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createCircuitGroupInsertProjectCommand(expected),
|
||||||
|
});
|
||||||
|
const updated = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
command: createCircuitGroupUpdateProjectCommand(
|
||||||
|
expected,
|
||||||
|
target
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(updated.history.currentRevision, 2);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, expected.id))
|
||||||
|
.get()?.displayName,
|
||||||
|
target.displayName
|
||||||
|
);
|
||||||
|
|
||||||
|
createService(context).undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, expected.id))
|
||||||
|
.get()?.displayName,
|
||||||
|
expected.displayName
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||||
|
.all();
|
||||||
|
const reorderAssignments = groups.map((group, index) => ({
|
||||||
|
groupId: group.id,
|
||||||
|
expectedSortOrder: group.sortOrder,
|
||||||
|
targetSortOrder: (groups.length - index) * 10,
|
||||||
|
}));
|
||||||
|
const reordered = createService(context).executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 3,
|
||||||
|
command: createCircuitGroupReorderProjectCommand(
|
||||||
|
circuitListId,
|
||||||
|
reorderAssignments
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(reordered.history.currentRevision, 4);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches explicit circuit-group renumbering", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const service = createService(context);
|
||||||
|
const existing = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.category, "three_phase"))
|
||||||
|
.get()!;
|
||||||
|
const added = {
|
||||||
|
id: "group-three-phase-2",
|
||||||
|
circuitListId: existing.circuitListId,
|
||||||
|
key: "three_phase_2",
|
||||||
|
displayName: "3-phasig 2",
|
||||||
|
prefix: "-3F2.",
|
||||||
|
sortOrder: 40,
|
||||||
|
category: "three_phase" as const,
|
||||||
|
groupNumber: 2,
|
||||||
|
};
|
||||||
|
service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createCircuitGroupInsertProjectCommand(added),
|
||||||
|
});
|
||||||
|
const plan = createCircuitGroupRenumberPlan(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: existing.id,
|
||||||
|
category: "three_phase",
|
||||||
|
groupNumber: 1,
|
||||||
|
prefix: "-3F1.",
|
||||||
|
circuits: [],
|
||||||
|
components: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: added.id,
|
||||||
|
category: "three_phase",
|
||||||
|
groupNumber: 2,
|
||||||
|
prefix: "-3F2.",
|
||||||
|
circuits: [],
|
||||||
|
components: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ groupId: existing.id, targetGroupNumber: 2 },
|
||||||
|
{ groupId: added.id, targetGroupNumber: 1 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const result = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
command: createCircuitGroupRenumberProjectCommand(
|
||||||
|
existing.circuitListId,
|
||||||
|
plan
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(result.history.currentRevision, 2);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, existing.id))
|
||||||
|
.get()?.prefix,
|
||||||
|
"-3F2."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches same-category circuit group moves", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const service = createService(context);
|
||||||
|
const source = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.category, "three_phase"))
|
||||||
|
.get()!;
|
||||||
|
const target = {
|
||||||
|
id: "group-move-service-target",
|
||||||
|
circuitListId: source.circuitListId,
|
||||||
|
key: "three_phase_2",
|
||||||
|
displayName: "3-phasig 2",
|
||||||
|
prefix: "-3F2.",
|
||||||
|
sortOrder: 40,
|
||||||
|
category: "three_phase" as const,
|
||||||
|
groupNumber: 2,
|
||||||
|
};
|
||||||
|
service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createCircuitGroupInsertProjectCommand(target),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "circuit-group-move-service",
|
||||||
|
circuitListId: source.circuitListId,
|
||||||
|
sectionId: source.id,
|
||||||
|
equipmentIdentifier: "-3F1.1",
|
||||||
|
displayName: "Drehstrom",
|
||||||
|
sortOrder: 10,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
const result = service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
command: createCircuitGroupMoveProjectCommand(
|
||||||
|
createCircuitGroupMovePlan({
|
||||||
|
circuit: {
|
||||||
|
id: "circuit-group-move-service",
|
||||||
|
circuitListId: source.circuitListId,
|
||||||
|
sectionId: source.id,
|
||||||
|
equipmentIdentifier: "-3F1.1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
sourceGroup: {
|
||||||
|
id: source.id,
|
||||||
|
category: "three_phase",
|
||||||
|
groupNumber: 1,
|
||||||
|
prefix: "-3F1.",
|
||||||
|
},
|
||||||
|
targetGroup: target,
|
||||||
|
targetCircuitEquipmentIdentifiers: [],
|
||||||
|
targetSortOrder: 20,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(result.history.currentRevision, 2);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-group-move-service"))
|
||||||
|
.get()?.equipmentIdentifier,
|
||||||
|
"-3F2.1"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches populated circuit-group deletion and restoration", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository =
|
||||||
|
new CircuitGroupSubtreeProjectCommandRepository(context.db);
|
||||||
|
const groupId = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.get()!.sectionId;
|
||||||
|
const snapshot = repository.capture("project-1", groupId);
|
||||||
|
const deleted = createService(context).executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command:
|
||||||
|
createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
|
||||||
|
});
|
||||||
|
assert.equal(deleted.history.currentRevision, 1);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.id, groupId))
|
||||||
|
.get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const restored = createService(context).undo({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.equal(restored.history.currentRevision, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
repository.capture("project-1", groupId),
|
||||||
|
snapshot
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("dispatches floor and room setup with their persisted inverses", () => {
|
it("dispatches floor and room setup with their persisted inverses", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-
|
|||||||
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||||
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||||
|
import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
|
||||||
|
import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js";
|
||||||
|
import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js";
|
||||||
|
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
|
||||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||||
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
|
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
|
||||||
import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
|
import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
|
||||||
@@ -195,6 +199,10 @@ function createService(context: DatabaseContext) {
|
|||||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||||
context.db
|
context.db
|
||||||
),
|
),
|
||||||
|
new CircuitGroupStructureProjectCommandRepository(context.db),
|
||||||
|
new CircuitGroupRenumberProjectCommandRepository(context.db),
|
||||||
|
new CircuitGroupMoveProjectCommandRepository(context.db),
|
||||||
|
new CircuitGroupSubtreeProjectCommandRepository(context.db),
|
||||||
new ProjectHistoryRepository(context.db)
|
new ProjectHistoryRepository(context.db)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,58 @@ describe("project version history presentation", () => {
|
|||||||
),
|
),
|
||||||
"Verteilergerät bearbeitet"
|
"Verteilergerät bearbeitet"
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(
|
||||||
|
revision(8, { commandType: "circuit-group.insert" })
|
||||||
|
),
|
||||||
|
"Stromkreisgruppe angelegt"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(
|
||||||
|
revision(9, { commandType: "circuit-group.delete" })
|
||||||
|
),
|
||||||
|
"Stromkreisgruppe entfernt"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(
|
||||||
|
revision(10, { commandType: "circuit-group.update" })
|
||||||
|
),
|
||||||
|
"Stromkreisgruppe bearbeitet"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(
|
||||||
|
revision(11, { commandType: "circuit-group.reorder" })
|
||||||
|
),
|
||||||
|
"Stromkreisgruppen sortiert"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(
|
||||||
|
revision(12, { commandType: "circuit-group.renumber" })
|
||||||
|
),
|
||||||
|
"Stromkreisgruppen neu nummeriert"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(
|
||||||
|
revision(13, { commandType: "circuit.move-group" })
|
||||||
|
),
|
||||||
|
"Stromkreis in andere Gruppe verschoben"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(
|
||||||
|
revision(14, {
|
||||||
|
commandType: "circuit-group.delete-subtree",
|
||||||
|
})
|
||||||
|
),
|
||||||
|
"Stromkreisgruppe vollständig entfernt"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(
|
||||||
|
revision(15, {
|
||||||
|
commandType: "circuit-group.restore-subtree",
|
||||||
|
})
|
||||||
|
),
|
||||||
|
"Stromkreisgruppe vollständig wiederhergestellt"
|
||||||
|
);
|
||||||
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
|
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getProjectSnapshotKindLabel("automatic"),
|
getProjectSnapshotKindLabel("automatic"),
|
||||||
|
|||||||
Reference in New Issue
Block a user