Compare commits
30 Commits
704f4df2a8
...
f4bfef9071
| Author | SHA1 | Date | |
|---|---|---|---|
| f4bfef9071 | |||
| 4a859f31b8 | |||
| 2d97fafce9 | |||
| 7c8d4a7ddf | |||
| ab989cc637 | |||
| 93ec294759 | |||
| be9c6f0d52 | |||
| 0cb280ddb2 | |||
| 8898117ed4 | |||
| 18ca5eb3d4 | |||
| 0c92fbd09e | |||
| 756e307bd8 | |||
| 31589875b5 | |||
| ac21d6eb5d | |||
| 7a9530a914 | |||
| 99bdf6491b | |||
| db4c757018 | |||
| 3399fcd88b | |||
| 9ad91887b2 | |||
| 07925b2fd9 | |||
| 63650a623c | |||
| e702712bd8 | |||
| 1250f9a6af | |||
| 69b020339e | |||
| 604604f056 | |||
| 9d4d4082fe | |||
| 012308984d | |||
| e7607c90c4 | |||
| a88c4b2086 | |||
| a72d2c76e0 |
@@ -255,6 +255,11 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||
project data atomically and records a new `restore` revision with a complete
|
||||
inverse command. Restore can therefore be undone and redone after a restart.
|
||||
Snapshot schema version 7 also contains circuit-group identity,
|
||||
distribution-board components and the separate circuit/component protection
|
||||
records. Versions 1 through 6 remain readable. Portable duplicate imports
|
||||
remap component ids and protection-owner references together with the existing
|
||||
project graph.
|
||||
The central revision boundary creates an automatic logical snapshot after each
|
||||
25 new revisions and retains only the newest 12 automatic snapshots per
|
||||
project. Named snapshots are never removed by this retention policy.
|
||||
@@ -289,10 +294,48 @@ change in one revision and Undo/Redo restores them together; the project PUT
|
||||
route requires `expectedRevision`. The system catalog is `AV`, `SV`, `EV`,
|
||||
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
|
||||
cannot be disabled.
|
||||
Distribution-board setup uses `distribution-board.insert` with a complete
|
||||
stable snapshot of the board, circuit list and four default sections. Its
|
||||
Distribution-board setup uses `distribution-board.insert` schema version 3
|
||||
with a complete stable snapshot of the board, circuit list, three default
|
||||
groups, main switch `-Q0` and surge protective device `-FA`. Its
|
||||
inverse removes only the same unchanged and still-empty structure; the POST
|
||||
route requires `expectedRevision` and returns the updated history state.
|
||||
Stored schema-version 1 and 2 setup commands remain executable with their four
|
||||
legacy sections and without invented components.
|
||||
Mutable group-protection and auxiliary distribution-board components use
|
||||
`distribution-board-component.insert` and
|
||||
`distribution-board-component.delete`; edits and footer reordering use
|
||||
`distribution-board-component.update`. Their complete expected/target
|
||||
snapshots include the optional one-to-one protection-device state and reject
|
||||
stale data. Updates preserve id, ownership, role and placement. Fixed
|
||||
main-switch and surge-protection header roles are excluded from these general
|
||||
component commands.
|
||||
Empty circuit-group creation, display-name updates and deletion use
|
||||
`circuit-group.insert`, `circuit-group.update` and `circuit-group.delete`.
|
||||
Their snapshots keep category, positive group number and derived prefix
|
||||
consistent. General deletion rejects groups containing circuits or group
|
||||
components; populated deletion remains a separate confirmed command.
|
||||
`circuit-group.reorder` requires a complete expected/target sort assignment for
|
||||
every group in one circuit list. It changes only sort positions and never
|
||||
renumbers groups, prefixes or circuits.
|
||||
Explicit group-number changes use `circuit-group.renumber`. The command carries
|
||||
the complete expected/target group, circuit-BMK and optional group-component
|
||||
BMK plan, applies swaps through collision-safe temporary values and preserves
|
||||
circuit suffixes. Undo/Redo uses the exact inverse plan.
|
||||
Cross-group circuit moves use `circuit.move-group` and are limited to distinct
|
||||
groups of the same category. The stored target BMK is highest target suffix
|
||||
plus one at planning time and is never recalculated for Redo. Only circuit
|
||||
group, BMK and sort order change; device rows and circuit protection retain the
|
||||
stable circuit id.
|
||||
Confirmed populated-group deletion uses
|
||||
`circuit-group.delete-subtree`/`circuit-group.restore-subtree`. The exact
|
||||
snapshot contains the group, optional component protections, complete circuits,
|
||||
circuit protections and all device-row link/override metadata. Delete
|
||||
re-captures and compares the subtree before cascading; restore preserves every
|
||||
UUID in foreign-key-safe order.
|
||||
The circuit-tree read model exposes fixed `headerComponents`, group-owned
|
||||
`components`, `footerComponents`, optional group category/number and optional
|
||||
one-to-one protection DTOs for circuits and group components. These are
|
||||
additive to the retained flat circuit protection fields during the transition.
|
||||
Distribution-board floor assignment and a project-enabled supply type use
|
||||
`distribution-board.update`; both values are snapshot/export fields and one
|
||||
persistent undo step.
|
||||
|
||||
@@ -12,7 +12,11 @@ implementiert.
|
||||
## Unterstützter Arbeitsablauf
|
||||
|
||||
- Projekte, Verteilungen, Etagen und Räume verwalten
|
||||
- pro Verteilung eine Stromkreisliste mit festen Bereichen bearbeiten
|
||||
- pro Verteilung geschützte Stromkreisgruppen für Beleuchtung, 1-phasige und
|
||||
3-phasige Stromkreise verwalten
|
||||
- Hauptschalter, Überspannungsableiter, Gruppenschutz und zusätzliche
|
||||
Verteilergeräte in der Stromkreisliste abbilden
|
||||
- Schutzgeräte je Stromkreis und optional je Gruppe konfigurieren
|
||||
- leere, einzeilige und mehrzeilige Stromkreise abbilden
|
||||
- Stromkreise und Gerätezeilen per Drag-and-drop umstrukturieren
|
||||
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
|
||||
@@ -90,6 +94,7 @@ Frontend und API laufen anschließend auf denselben Ports wie im Docker-Setup.
|
||||
npm test
|
||||
npm run build:api
|
||||
npm run build:web
|
||||
npm run typecheck:scripts
|
||||
npx tsc --noEmit -p tsconfig.next.json
|
||||
```
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ Limitierungen und Roadmap.
|
||||
- [Datenmodellkonzept](spec/03-data-model-concept.md)
|
||||
- [UI- und Interaktionsanforderungen](spec/04-ui-interaction-requirements.md)
|
||||
- [Verknüpfte Projektgeräte und Synchronisierung](spec/05-linked-devices-and-sync.md)
|
||||
- [Verteilerkomponenten und Schutzgruppen](spec/09-distribution-board-components-and-protection-groups.md)
|
||||
|
||||
## Archiv
|
||||
|
||||
|
||||
@@ -59,16 +59,23 @@ this value for optimistic concurrency checks.
|
||||
- clients cannot submit arbitrary `project.restore-state` payloads through
|
||||
the generic command endpoint
|
||||
|
||||
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
|
||||
`circuit.delete`, `circuit-device-row.update`,
|
||||
`circuit-device-row.insert`, `circuit-device-row.delete`,
|
||||
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
|
||||
`circuit.reorder-section`, `circuit.reorder-sections`,
|
||||
`circuit.renumber-section` and
|
||||
`project-device.update`, `project-device.insert`, `project-device.delete` and
|
||||
`project-device.sync-rows` as well as `project.update-settings`, all with
|
||||
schema version `1`. Other command types
|
||||
are rejected. Insert commands contain the complete entity or circuit block
|
||||
The public dispatcher supports the Circuit and CircuitDeviceRow commands
|
||||
`circuit.update`, `circuit.insert`, `circuit.delete`,
|
||||
`circuit-device-row.update`, `circuit-device-row.insert`,
|
||||
`circuit-device-row.delete`, `circuit-device-row.move`,
|
||||
`circuit-device-row.move-with-new-circuit`, `circuit.reorder-section`,
|
||||
`circuit.reorder-sections` and `circuit.renumber-section`.
|
||||
|
||||
Protected-board structure uses
|
||||
`distribution-board-component.insert|update|delete`,
|
||||
`circuit-group.insert|update|delete|reorder|renumber`,
|
||||
`circuit.move-group`, `circuit-group.delete-subtree` and
|
||||
`circuit-protection.update`. A subtree restore is normally emitted only as the
|
||||
persisted inverse of a delete. The dispatcher also supports the documented
|
||||
project-device, project settings, distribution-board and project-location
|
||||
commands. The generic editor commands listed here use schema version `1`;
|
||||
older stored formats can remain supported by their owning command boundary.
|
||||
Unsupported types are rejected. Insert commands contain the complete entity or circuit block
|
||||
with stable ids; delete commands include the expected parent identity. Circuit
|
||||
snapshots contain zero, one or multiple complete device rows. Move commands
|
||||
contain each row's expected and target circuit plus its exact expected and
|
||||
@@ -101,6 +108,24 @@ targets occupied by other sections, then applies swaps through collision-safe
|
||||
temporary identifiers. Undo restores the exact prior identifiers; sort
|
||||
positions and device rows remain unchanged.
|
||||
|
||||
`circuit-group.reorder` changes only complete group sort assignments.
|
||||
`circuit-group.renumber` carries every affected group, child circuit and group
|
||||
component with expected and target identifiers so swaps can be applied
|
||||
collision-safely. `circuit.move-group` changes one complete circuit's group,
|
||||
BMK and position and stores the deterministic target BMK. The target group must
|
||||
have the same category.
|
||||
|
||||
`circuit-group.delete-subtree` carries the complete warned group state,
|
||||
including both protection layers and every device-row link/override value. The
|
||||
server captures the current subtree again inside the transaction and rejects a
|
||||
stale or incomplete snapshot. Undo restores the same UUIDs through the stored
|
||||
inverse command.
|
||||
|
||||
`circuit-protection.update` inserts or replaces the complete validated
|
||||
one-to-one circuit protection state. Users cannot remove the required
|
||||
protection row; removal is available only to a stored inverse when undoing its
|
||||
initial creation.
|
||||
|
||||
`project-device.sync-rows` represents `synchronize`, `disconnect` and
|
||||
`reconnect` operations. Every selected row carries complete expected and target
|
||||
snapshots of all ProjectDevice-sync fields, the link and `overriddenFields`.
|
||||
@@ -194,11 +219,14 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||
- `supplyType` is one of `AV`, `SV`, `EV`, `USV`, `MSR` or `SiBe` and must
|
||||
be enabled in the project settings
|
||||
- executes `distribution-board.insert` with stable ids for the distribution
|
||||
board, its circuit list and all four default circuit sections
|
||||
board, its circuit list, the three default circuit groups, main switch
|
||||
`-Q0` and surge protective device `-FA`
|
||||
- response:
|
||||
`{ "distributionBoard": { ... }, "revision": { ... }, "history": { ... } }`
|
||||
- persistent Undo removes only the unchanged and still-empty generated
|
||||
structure; Redo restores the same ids
|
||||
- stored command schema versions 1 and 2 remain executable with their four
|
||||
legacy sections and without generated components
|
||||
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
||||
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
||||
- body:
|
||||
@@ -227,7 +255,11 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||
### Tree Endpoint
|
||||
|
||||
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
||||
- Purpose: returns section/circuit/device-row tree with calculated row and circuit totals.
|
||||
- Purpose: returns fixed header components, grouped sections with optional
|
||||
protection components, complete circuit/device-row blocks and auxiliary
|
||||
footer components. Circuit and component protection devices use their
|
||||
separate one-to-one DTOs. Calculated row, circuit, section and board totals
|
||||
remain included.
|
||||
|
||||
Response sketch:
|
||||
|
||||
@@ -235,16 +267,47 @@ Response sketch:
|
||||
{
|
||||
"circuitListId": "cl_1",
|
||||
"currentRevision": 12,
|
||||
"headerComponents": [
|
||||
{
|
||||
"id": "cmp_main",
|
||||
"equipmentIdentifier": "-Q0",
|
||||
"name": "Hauptschalter",
|
||||
"role": "main_switch",
|
||||
"placement": "header"
|
||||
}
|
||||
],
|
||||
"sections": [
|
||||
{
|
||||
"id": "sec_1",
|
||||
"key": "lighting",
|
||||
"prefix": "-1F",
|
||||
"prefix": "-1F1.",
|
||||
"category": "lighting",
|
||||
"groupNumber": 1,
|
||||
"components": [
|
||||
{
|
||||
"id": "cmp_rcd",
|
||||
"equipmentIdentifier": "-1Q1.0",
|
||||
"name": "Gruppen-FI",
|
||||
"role": "group_residual_current_protection",
|
||||
"placement": "group",
|
||||
"protectionDevice": {
|
||||
"type": "FI",
|
||||
"ratedCurrentA": 40,
|
||||
"rcdType": "A",
|
||||
"ratedResidualCurrentMa": 30
|
||||
}
|
||||
}
|
||||
],
|
||||
"circuits": [
|
||||
{
|
||||
"id": "cir_1",
|
||||
"equipmentIdentifier": "-1F1",
|
||||
"equipmentIdentifier": "-1F1.1",
|
||||
"circuitTotalPower": 4.2,
|
||||
"protectionDevice": {
|
||||
"type": "LS",
|
||||
"ratedCurrentA": 10,
|
||||
"tripCharacteristic": "B"
|
||||
},
|
||||
"deviceRows": [
|
||||
{
|
||||
"id": "row_1",
|
||||
@@ -255,6 +318,15 @@ Response sketch:
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"footerComponents": [
|
||||
{
|
||||
"id": "cmp_actor",
|
||||
"equipmentIdentifier": "-K1",
|
||||
"name": "KNX Schaltaktor",
|
||||
"role": "auxiliary",
|
||||
"placement": "footer"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -17,13 +17,15 @@ The pure grid modules have no React state and are covered by focused unit tests.
|
||||
## Domain Model Overview
|
||||
|
||||
- `CircuitSection`
|
||||
- Groups circuits by planning section (for example lighting, single-phase, three-phase).
|
||||
- Owns section metadata (`key`, `displayName`, `prefix`, ordering).
|
||||
- Represents a protected circuit group in one of the categories lighting,
|
||||
single-phase or three-phase.
|
||||
- Owns category, positive group number, generated prefix, display name and
|
||||
ordering.
|
||||
- `Circuit`
|
||||
- Core electrical unit in the list.
|
||||
- Owns circuit-level identifiers and technical data:
|
||||
- `equipmentIdentifier` (BMK)
|
||||
- protection data
|
||||
- one explicit one-to-one protection device
|
||||
- cable data
|
||||
- reserve state
|
||||
- voltage and optional control requirement for future sizing
|
||||
@@ -43,6 +45,10 @@ The pure grid modules have no React state and are covered by focused unit tests.
|
||||
- `ProjectDevice`
|
||||
- Reusable device template entity at project level.
|
||||
- Can be linked to `CircuitDeviceRow` entries, with copied display values on insert.
|
||||
- `DistributionBoardComponent`
|
||||
- Represents fixed header components, optional group protection and manually
|
||||
named auxiliary footer devices with their own unique BMK.
|
||||
- Group protection may own a separate one-to-one protection configuration.
|
||||
- Retained legacy migration source
|
||||
- Old `consumers` rows, mappings and reports are available only to explicit
|
||||
database upgrade tooling.
|
||||
@@ -77,7 +83,9 @@ Section-level `-frei-` placeholder rows represent insertion targets for creating
|
||||
`equipmentIdentifier` / BMK is circuit-owned (`Circuit.equipmentIdentifier`).
|
||||
|
||||
- Device rows do not have their own BMK.
|
||||
- Existing identifiers must stay stable unless explicitly changed by user action.
|
||||
- Existing identifiers stay stable on sort, insert and delete. Explicit
|
||||
renumbering may change them, and a user-initiated complete-circuit move to
|
||||
another same-category group assigns the next target-group identifier.
|
||||
- Renumbering is explicit, not implicit on move/sort/delete.
|
||||
|
||||
## Data Ownership Split: Circuit vs Device Row
|
||||
@@ -113,9 +121,9 @@ The sibling `/tree` route is a read-only structure preview. Old
|
||||
|
||||
## Future Persistence Direction
|
||||
|
||||
Persistent undo/redo, project revisions, logical snapshots, external-model exchange and PostgreSQL readiness are specified in [Project History and External Model Architecture](./project-history-and-external-model-architecture.md).
|
||||
|
||||
Critical multi-write commands already use explicit persistence transaction
|
||||
adapters. The next step is to compose these command boundaries into server-side,
|
||||
project-scoped revisions and change sets. Database backups remain separate from
|
||||
user-visible project snapshots.
|
||||
Persistent Undo/Redo, project revisions and logical snapshots are implemented
|
||||
through project-scoped commands and the shared transaction boundary. Database
|
||||
backups remain separate from user-visible project snapshots. External-model
|
||||
exchange and PostgreSQL readiness are specified in
|
||||
[Project History and External Model Architecture](./project-history-and-external-model-architecture.md)
|
||||
and remain future work.
|
||||
|
||||
@@ -28,6 +28,7 @@ The editor can have a selected cell without an active editor input. `editingCell
|
||||
The UI works from a normalized `visibleRows` model built from filtered/sorted sections:
|
||||
|
||||
- section header rows
|
||||
- fixed header, group-protection and auxiliary footer component rows
|
||||
- circuit rows (`circuitCompact`, `circuitSummary`, `reserveCircuit`)
|
||||
- device rows (`deviceRow`)
|
||||
- section placeholder rows (`placeholder`)
|
||||
@@ -82,13 +83,21 @@ Intent is separated by drag source type:
|
||||
- device row drag:
|
||||
- drop to existing circuit -> move row(s) into that circuit
|
||||
- drop to placeholder -> create new target circuit and move row(s)
|
||||
- drop on the upper or lower edge of a circuit row -> create a new target
|
||||
circuit directly before or after that circuit and move the row(s)
|
||||
- the center of a circuit row remains the explicit target for adding the
|
||||
row(s) to that existing circuit
|
||||
- crossing a section boundary requires explicit confirmation
|
||||
- phase type, category, linked project device and local row values remain unchanged
|
||||
- target creation, row assignments and reserve-state updates use one SQLite transaction
|
||||
- circuit drag (BMK handle):
|
||||
- reorder circuits inside same section only
|
||||
- cross-section reorder is rejected
|
||||
- the complete section order is validated and stored in one SQLite transaction
|
||||
- reorder one or multiple circuits inside the same group without changing BMKs
|
||||
- move exactly one circuit into another group of the same category
|
||||
- a cross-group move assigns the highest existing target suffix plus one;
|
||||
gaps are not filled
|
||||
- cross-category and cross-group multi-circuit moves are rejected
|
||||
- same-group reorder validates the complete group order; cross-group move
|
||||
stores the one exact source/target transition
|
||||
- bulk device row move:
|
||||
- supported via multi-selection + drag
|
||||
- multi-circuit move:
|
||||
@@ -96,7 +105,25 @@ Intent is separated by drag source type:
|
||||
|
||||
The sidebar insertion controls use the same project-device placement rules as drag-and-drop. Invalid section and circuit options are disabled after selecting a project device.
|
||||
|
||||
Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Moving never renumbers existing circuits.
|
||||
Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Device-row moves never renumber circuits; the explicitly initiated complete-circuit group move is the sole automatic BMK-changing move.
|
||||
|
||||
## Protected Group and Component Controls
|
||||
|
||||
- New groups are created manually in one of the three supported categories and
|
||||
receive the highest existing category group number plus one.
|
||||
- Group headings always show the stored editable display name; category labels
|
||||
do not overwrite names of the default first groups.
|
||||
- Group reorder buttons move only relative to category peers and never change a
|
||||
group number or BMK.
|
||||
- `Gruppen-BMK neu nummerieren` is an explicit confirmed category-wide action.
|
||||
It follows the persisted group order and updates group prefixes, optional
|
||||
`.0` protection BMKs and circuit BMKs together.
|
||||
- Optional group upstream protection and group FI as well as auxiliary footer
|
||||
components use labeled, type-dependent modals.
|
||||
- Each circuit protection device uses its own modal; only fields valid for the
|
||||
selected type are shown.
|
||||
- Removing a populated group warns with circuit, device-row and group
|
||||
protection counts. The complete deletion remains persistently undoable.
|
||||
|
||||
## Filtering and Sorting
|
||||
|
||||
@@ -118,7 +145,8 @@ Cross-section device moves show confirmation-required feedback before drop. The
|
||||
|
||||
- Column visibility and order are configurable.
|
||||
- BMK column (`equipmentIdentifier`) is locked as first column.
|
||||
- Layout is saved in local storage (`circuitTreeEditor.columnLayout.v1`).
|
||||
- Layout is saved in local storage under a project-specific key so switching
|
||||
distribution boards preserves the same project layout.
|
||||
|
||||
## Undo/Redo
|
||||
|
||||
@@ -138,8 +166,10 @@ Covered operations include:
|
||||
- insert/delete circuit
|
||||
- insert/delete row
|
||||
- edit cell values
|
||||
- moves (single/bulk rows, circuit reorder)
|
||||
- renumber and identifier update flows
|
||||
- moves (single/bulk rows, circuit reorder and same-category group move)
|
||||
- circuit and group renumber/identifier flows
|
||||
- component, group and protection editing
|
||||
- warned populated-group deletion and exact restoration
|
||||
- apply sorted order
|
||||
|
||||
The editor toolbar exposes availability through its Undo/Redo buttons. The
|
||||
|
||||
@@ -16,5 +16,11 @@
|
||||
- Final electrical sizing logic is not implemented yet.
|
||||
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
|
||||
- Sorting is view-only until users explicitly apply sorted order.
|
||||
- Cross-section circuit drag-reorder is intentionally blocked.
|
||||
- Complete-circuit drag supports one circuit at a time between groups of the
|
||||
same category. Cross-category moves and multi-circuit moves across group
|
||||
boundaries are intentionally rejected; multi-circuit reorder inside one
|
||||
group remains supported.
|
||||
- Protection and cable values are planner-owned manual selections. The later
|
||||
sizing/warning engine does not yet validate them against load, length or
|
||||
voltage-drop rules.
|
||||
- Legacy consumer UI and server endpoints are removed; retained source rows and migration mappings remain available only for upgrade traceability.
|
||||
|
||||
@@ -261,8 +261,9 @@ mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
||||
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
|
||||
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
||||
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
||||
Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu
|
||||
zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
|
||||
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
|
||||
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt
|
||||
die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
|
||||
Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen
|
||||
innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die
|
||||
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
||||
@@ -270,8 +271,10 @@ Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
||||
deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das
|
||||
Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den
|
||||
projektgebundenen Endpunkt beschränkt.
|
||||
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
||||
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
||||
`distribution-board.insert` versioniert die Anlage einer Verteilung mit
|
||||
Command-Schema 3 als vollständigen Block aus Verteilung, Stromkreisliste, den
|
||||
drei Gruppen `Beleuchtung 1`, `1-phasig 1`, `3-phasig 1`, Hauptschalter `-Q0`
|
||||
und Überspannungsableiter `-FA`.
|
||||
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
||||
`distribution-board.delete` ist die persistierte Inverse und entfernt nur den
|
||||
vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
|
||||
@@ -280,6 +283,130 @@ Controller-Schreibweg ist entfernt. Verteilungen besitzen eine optionale
|
||||
Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und
|
||||
nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
|
||||
Freigabe der Netzart in den Projekteinstellungen.
|
||||
Gespeicherte Anlage-Commands der Schemas 1 und 2 bleiben mit ihren vier
|
||||
Legacy-Abschnitten und ohne nachträglich erfundene Komponenten ausführbar.
|
||||
`distribution-board-component.insert` und
|
||||
`distribution-board-component.delete` sowie
|
||||
`distribution-board-component.update` versionieren Anlage, Entfernung,
|
||||
Bearbeitung und Sortierung veränderlicher Gruppen-Schutzgeräte und zusätzlicher
|
||||
Verteilergeräte. Die vollständigen Snapshots enthalten stabile
|
||||
Komponenten-UUIDs und, für Gruppen-Schutzgeräte, die getrennte
|
||||
1:1-Schutzgerätekonfiguration. Updates tauschen einen exakt erwarteten gegen
|
||||
einen vollständigen Zielzustand aus; ID, Eigentum, Rolle und Platzierungszone
|
||||
bleiben dabei unverändert. Projekt-, Listen- und Gruppenzugehörigkeit sowie
|
||||
veraltete Zustände werden innerhalb derselben Transaktion geprüft. Die festen
|
||||
Kopfkomponenten Hauptschalter und Überspannungsableiter sind von diesen
|
||||
allgemeinen Commands ausgeschlossen. Veränderliche Gruppen- und Fußkomponenten
|
||||
werden im Editor über eigene Modale und diese Commands verwaltet.
|
||||
Leere Stromkreisgruppen werden über `circuit-group.insert`,
|
||||
`circuit-group.update` und `circuit-group.delete` verwaltet. Ihr vollständiger
|
||||
Snapshot koppelt Kategorie, positive Gruppennummer und daraus abgeleiteten
|
||||
BMK-Präfix; allgemeine Updates ändern ausschließlich den Anzeigenamen.
|
||||
Löschen verlangt einen exakt unveränderten Zustand ohne Stromkreise und ohne
|
||||
Gruppenkomponenten. Befüllte Gruppen verwenden getrennt davon den vollständigen
|
||||
`circuit-group.delete-subtree`-Command mit ausdrücklicher Warnung.
|
||||
`circuit-group.reorder` sortiert Gruppen als vollständige Stromkreislisten-
|
||||
Zuordnung. Jede vorhandene Gruppe muss mit erwarteter und neuer Position
|
||||
enthalten sein. Der Command verändert ausschließlich `sortOrder`; Nummern,
|
||||
Präfixe und Stromkreis-BMKs bleiben stabil. Die gesamte Sortierung bildet eine
|
||||
Revision und einen Undo/Redo-Schritt.
|
||||
`src/domain/services/circuit-group-renumbering.ts` plant explizite
|
||||
Gruppennummerierungen deterministisch. Der reine Plan erhält
|
||||
Stromkreis-Endnummern, leitet Gruppenpräfixe und optionale `.0`-Komponenten-BMKs
|
||||
neu ab und prüft Nummerntausch sowie Kollisionen mit unveränderten Gruppen.
|
||||
`circuit-group.renumber` führt diesen Plan über die gemeinsame
|
||||
Projekt-Command-Transaktion aus. Vor dem Schreiben müssen alle betroffenen
|
||||
Gruppen, Stromkreise und Gruppenkomponenten exakt dem erwarteten Zustand
|
||||
entsprechen. Nummerntausch verwendet kollisionsfreie temporäre Präfixe und
|
||||
BMKs über beide BMK-Tabellen; anschließend werden alle Zielwerte finalisiert.
|
||||
Der vollständige inverse Plan ermöglicht dauerhaftes Undo/Redo.
|
||||
`src/domain/services/circuit-group-move-planning.ts` plant den Wechsel eines
|
||||
vollständigen Stromkreises zwischen zwei verschiedenen Gruppen derselben
|
||||
Kategorie. Die Ziel-BMK verwendet die höchste vorhandene Stromkreis-Endnummer
|
||||
der Zielgruppe plus eins; Lücken werden nicht gefüllt. Quell-/Zielgruppe,
|
||||
Quell-/Zielposition und beide BMKs werden festgeschrieben, damit eine spätere
|
||||
Wiederholung nichts neu berechnet.
|
||||
`circuit.move-group` führt den geplanten Wechsel als eine Projekt-Revision aus.
|
||||
Nur `sectionId`, `equipmentIdentifier` und `sortOrder` des Stromkreises ändern
|
||||
sich. Gerätezeilen und das getrennte 1:1-Schutzgerät bleiben über die stabile
|
||||
Stromkreis-UUID verbunden. Der inverse Wechsel speichert Quellgruppe, BMK und
|
||||
Position vollständig für dauerhaftes Undo/Redo.
|
||||
`CircuitGroupSubtreeSnapshot` ist der kanonische Vertrag für destruktive
|
||||
Gruppenoperationen. Er umfasst Gruppe, optionale Gruppenkomponenten samt
|
||||
1:1-Schutzdaten, vollständige Stromkreise samt 1:1-Schutzdaten und sämtliche
|
||||
Gerätezeilen einschließlich Link- und Override-Metadaten. Seine
|
||||
Warnzusammenfassung liefert Vorsicherung/FI sowie Stromkreis- und
|
||||
Gerätezeilenanzahl.
|
||||
`circuit-group.delete-subtree` nimmt den aktuellen Unterbaum unmittelbar vor
|
||||
dem Löschen erneut auf und verlangt exakte Übereinstimmung mit dem bestätigten
|
||||
Snapshot. `circuit-group.restore-subtree` schreibt ihn in FK-sicherer Reihenfolge
|
||||
mit denselben UUIDs zurück. Beide Richtungen laufen als vollständige inverse
|
||||
Projekt-Commands; späte Fehler hinterlassen weder Teillöschungen noch
|
||||
Teilwiederherstellungen.
|
||||
Der Circuit-Tree liefert zusätzlich `headerComponents`,
|
||||
gruppenbezogene `components` und `footerComponents`. Abschnitte enthalten
|
||||
optionale Gruppenkategorie und -nummer; Stromkreise und Gruppenkomponenten
|
||||
enthalten ihre optionale getrennte Schutzgerätekonfiguration. Die bisherigen
|
||||
flachen Stromkreis-Schutzfelder bleiben während der Übergangsphase additiv
|
||||
erhalten. Separate aktive Read-Repositories lesen Komponenten- und beide
|
||||
Schutzgerätetabellen; Schreibzugriffe bleiben ausschließlich in Commands.
|
||||
`src/frontend/utils/circuit-structure-projection.ts` projiziert diesen Tree
|
||||
rein und deterministisch in die drei Zonen Kopf, vollständige Gruppen und Fuß.
|
||||
Innerhalb einer Gruppe folgen auf die Überschrift optionale Schutzkomponenten,
|
||||
vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleiben
|
||||
sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert.
|
||||
`circuit-grid-projection.ts` normalisiert diese Struktur zusammen mit den
|
||||
bisherigen kompakten und mehrzeiligen Stromkreisblöcken für den Editor. Kopf-,
|
||||
Gruppen- und Fußkomponenten erscheinen als Zeilen über die volle Tabellenbreite
|
||||
und nehmen nicht an Zellauswahl oder Tastatur-Einfügen teil. Hauptschalter und
|
||||
Überspannungsableiter bleiben feste, schreibgeschützte Kopfkomponenten.
|
||||
Optionale Gruppenvorsicherungen, Gruppen-FIs und zusätzliche Verteilergeräte
|
||||
werden über beschriftete Modale angelegt, bearbeitet und nach Bestätigung
|
||||
entfernt. Der Frontend-API-Adapter serialisiert dafür die vorhandenen
|
||||
`distribution-board-component.*`-Commands; die verbindliche
|
||||
geräteabhängige Validierung und atomare Revision bleiben serverseitig.
|
||||
Neue Stromkreisgruppen erhalten im Editor die höchste vorhandene
|
||||
Gruppennummer ihrer Kategorie plus eins und ein daraus abgeleitetes Präfix.
|
||||
Das Bearbeiten ändert ausschließlich den Anzeigenamen. Nur vollständig leere
|
||||
Gruppen können über `circuit-group.delete` entfernt werden; für befüllte
|
||||
Gruppen bleibt die Aktion bis zum gesonderten Warn- und Unterbaumdialog
|
||||
gesperrt.
|
||||
`circuit-protection.update` bildet die eigene persistente Schreibgrenze für
|
||||
die neue 1:1-Stromkreisschutztabelle. Der Command vergleicht den vollständigen
|
||||
erwarteten Datensatz, validiert Typ und abhängige Felder, prüft die
|
||||
Projektzugehörigkeit und schreibt Schutzgerät, Revision und Historienübergang
|
||||
atomar. Benutzer können Schutzdaten anlegen oder ändern, aber nicht entfernen;
|
||||
Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt
|
||||
wiederherstellen.
|
||||
Der vollständige `CircuitSnapshot` kann rückwärtskompatibel einen
|
||||
`protectionDevice`-Datensatz enthalten. Neue gruppierte Stromkreise und durch
|
||||
Geräteverschiebung erzeugte Zielstromkreise verwenden die vereinbarten
|
||||
Kategorie-Standardwerte und schreiben Schutzgerät, Stromkreis sowie
|
||||
Gerätezeilen atomar. Delete/Undo erfasst denselben Datensatz vollständig.
|
||||
Im Editor sind die bisherigen Schutzspalten deshalb nur noch eine
|
||||
schreibgeschützte Projektion der 1:1-Daten; Änderungen erfolgen über ein
|
||||
geräteabhängiges Schutzgeräte-Modal und `circuit-protection.update`.
|
||||
Gruppen lassen sich im Editor schrittweise nur gegenüber einer benachbarten
|
||||
Gruppe derselben Kategorie verschieben. Der Client sendet dabei immer die
|
||||
vollständige erwartete und gewünschte Sortierreihenfolge über
|
||||
`circuit-group.reorder`; Gruppennummer, Präfix und Kind-BMK bleiben unverändert.
|
||||
Eine separate, ausdrücklich bestätigte Editoraktion erzeugt dagegen einen
|
||||
vollständigen `circuit-group.renumber`-Plan für genau eine Kategorie. Sie
|
||||
nummeriert deren Gruppen gemäß aktueller Reihenfolge ab eins und ändert
|
||||
Gruppenpräfixe, optionale Gruppen-Schutz-BMK und Stromkreis-BMK gemeinsam;
|
||||
die Stromkreisnummer hinter dem letzten Punkt bleibt erhalten.
|
||||
Das vorhandene Stromkreis-Drag-Handle kann außerdem genau einen vollständigen
|
||||
Stromkreis in eine andere Gruppe derselben Kategorie verschieben. Der atomare
|
||||
`circuit.move-group`-Befehl erhält Gerätezeilen und Schutzdaten, setzt die
|
||||
Zielposition und vergibt dort die höchste vorhandene Stromkreisnummer plus eins;
|
||||
Lücken werden nicht automatisch gefüllt.
|
||||
Befüllte Gruppen werden im Editor erst nach einer Warnung mit Anzahl der
|
||||
enthaltenen Stromkreise, Gerätezeilen und Gruppenschutzgeräte gelöscht. Der
|
||||
Client sendet dafür den vollständigen aktuellen Unterbaum an
|
||||
`circuit-group.delete-subtree`; der Server vergleicht ihn innerhalb derselben
|
||||
Transaktion mit dem Datenbankstand. Undo stellt Gruppe, Schutzgeräte,
|
||||
Stromkreise, Gerätereihen und Verknüpfungs-/Override-Metadaten vollständig
|
||||
wieder her.
|
||||
`distribution-board.update` versioniert Etage, Netzart und den
|
||||
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
||||
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
||||
@@ -290,6 +417,13 @@ Snapshot-Schema 4 enthält bereits Etage und Netzart; Schema 1/2 sowie
|
||||
gespeicherte Version-1-Strukturcommands werden ohne erfundene Zuordnung
|
||||
hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als
|
||||
Projektauswahl hochgestuft.
|
||||
Snapshot-Schema 7 ergänzt Gruppenkategorie und -nummer,
|
||||
Verteilerkomponenten sowie die getrennten 1:1-Schutzgerätedaten. Capture,
|
||||
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
|
||||
JSON-Importmodi verwenden denselben vollständigen Zustand. Schema 6 und älter
|
||||
bleiben lesbar: Die drei bekannten Abschnittsschlüssel werden deterministisch
|
||||
Gruppe 1 zugeordnet, neue Komponenten- und Schutzgerätesammlungen bleiben leer
|
||||
und bestehende flache Schutzangaben unverändert erhalten.
|
||||
`project-floor.insert` und `project-room.insert` versionieren die Anlage von
|
||||
Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden
|
||||
jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur
|
||||
@@ -335,6 +469,21 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
||||
- Die vollständige Verteilungs-Testfixture liegt unter
|
||||
`tests/support/distribution-board-fixture.ts` und ist kein exportierter
|
||||
Produktions-Schreibweg.
|
||||
- Migration `0024` bildet die additive relationale Grundlage für geschützte
|
||||
Stromkreisgruppen und Verteilerkomponenten. Die drei bestehenden
|
||||
Stromkreisabschnitte erhalten Kategorie und Gruppennummer 1. Separate
|
||||
1:1-Tabellen halten künftig Stromkreis- und Komponenten-Schutzgeräte; die
|
||||
bisherigen flachen Schutzfelder bleiben in dieser Übergangsphase unverändert.
|
||||
Ein triggergeführtes Register erzwingt bereits eine normalisierte,
|
||||
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
|
||||
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden
|
||||
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
|
||||
veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
|
||||
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
|
||||
zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und
|
||||
Fußkomponenten über dedizierte Command-Modale. Gruppenanlage, -umbenennung,
|
||||
-sortierung, explizite Neunummerierung, Same-Category-Stromkreiswechsel,
|
||||
geschütztes Unterbaumlöschen und Stromkreisschutz sind integriert.
|
||||
|
||||
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
||||
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
||||
|
||||
@@ -45,10 +45,16 @@ Projekt- oder Kundendaten versehentlich ins Repository. Für einen lokalen
|
||||
Testbestand:
|
||||
|
||||
1. In der UI ein Projekt `Demo` anlegen.
|
||||
2. Im Projekt eine Verteilung `UV-01` erstellen.
|
||||
2. Im Projekt eine Verteilung `UV-01` erstellen. Die Stromkreisliste enthält
|
||||
danach Hauptschalter, Überspannungsableiter und je eine Gruppe für
|
||||
Beleuchtung, 1-phasige und 3-phasige Stromkreise.
|
||||
3. Ein Projektgerät mit unkritischen Fantasiewerten anlegen.
|
||||
4. Die erzeugte Stromkreisliste öffnen und das Gerät in einen passenden Bereich
|
||||
4. Die erzeugte Stromkreisliste öffnen und das Gerät in eine passende Gruppe
|
||||
ziehen.
|
||||
5. Optional eine zweite Gruppe derselben Kategorie, einen Gruppen-FI und ein
|
||||
zusätzliches Verteilergerät anlegen. Damit lassen sich Gruppensortierung,
|
||||
explizite Neunummerierung, Stromkreiswechsel und persistentes Undo/Redo ohne
|
||||
reale Projekt- oder Kundendaten prüfen.
|
||||
|
||||
Dateien unter `data/` und `data/backups/` dürfen nicht committed werden.
|
||||
|
||||
|
||||
@@ -264,6 +264,6 @@ nur Architekturvorschläge des LLM sind.
|
||||
- `docs/spec/03-data-model-concept.md` – Datenmodellkonzept
|
||||
- `docs/spec/05-linked-devices-and-sync.md` – Regeln für Projektgeräte und
|
||||
kontrollierte Synchronisierung
|
||||
- `docs/spec/07-implementation-phases-todo.md` – Roadmap, insbesondere Phase 13
|
||||
- `docs/spec/07-implementation-phases-todo.md` – Roadmap, insbesondere Phase 14
|
||||
- `docs/circuit-list-editor-known-limitations.md` – noch nicht implementierte
|
||||
Funktionen
|
||||
|
||||
@@ -496,7 +496,41 @@ Acceptance criteria:
|
||||
- restoring an older state does not delete intervening history
|
||||
- concurrent stale commands cannot silently overwrite newer project state
|
||||
|
||||
## Phase 13: External Model Round-Trip
|
||||
## Phase 13: Distribution Board Components and Protection Groups
|
||||
|
||||
Goal:
|
||||
|
||||
Extend the circuit list into the structural basis of a tabular single-line
|
||||
diagram before defining Revit parameter exchange.
|
||||
|
||||
Detailed specification:
|
||||
|
||||
- `docs/spec/09-distribution-board-components-and-protection-groups.md`
|
||||
|
||||
Delivery:
|
||||
|
||||
- add typed protection-device catalogs and defaults
|
||||
- evolve sections into repeatable category-specific circuit groups
|
||||
- add fixed, group and auxiliary distribution-board components
|
||||
- give every new circuit one explicit protection device
|
||||
- implement nested BMK generation, same-category group moves and explicit
|
||||
collision-safe group renumbering
|
||||
- preserve all new state through commands, snapshots, transfer and persistent
|
||||
Undo/Redo
|
||||
- project the header, groups and auxiliary footer in the spreadsheet editor
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- new boards contain the main switch, surge protective device and three default
|
||||
groups
|
||||
- all circuits and components have list-wide unique BMKs
|
||||
- circuit reordering keeps identifiers while cross-group moves assign the next
|
||||
target-group identifier
|
||||
- destructive group operations are confirmed, atomic and fully undoable
|
||||
- existing supported project data migrates without silently changing BMKs or
|
||||
protection selections
|
||||
|
||||
## Phase 14: External Model Round-Trip
|
||||
|
||||
Goal:
|
||||
|
||||
@@ -521,7 +555,7 @@ Acceptance criteria:
|
||||
|
||||
PostgreSQL should be introduced when shared multi-user operation, background jobs or operational scale justify it. External-object count alone does not require an immediate migration.
|
||||
|
||||
## Phase 14: Documentation and Collaboration Handoff
|
||||
## Phase 15: Documentation and Collaboration Handoff
|
||||
|
||||
Goal:
|
||||
|
||||
|
||||
@@ -8,6 +8,48 @@ requirements and intended sequencing, not proof of implementation.
|
||||
- Collect the remaining editor interaction issues before changing additional
|
||||
behavior.
|
||||
|
||||
## Distribution Board Components and Protection Groups
|
||||
|
||||
- [x] Implement the agreed protected circuit-group and distribution-board
|
||||
component model in phased work packages.
|
||||
- [x] Phase A: central protection catalog, validation, defaults and pure grouped
|
||||
BMK rules.
|
||||
- [x] Phase B1: additive relational schema, group backfill, component/protection
|
||||
tables and shared BMK uniqueness.
|
||||
- [x] Phase B2: snapshot/transfer schema upgrade and deterministic compatibility
|
||||
mapping.
|
||||
- [x] Phase C1: versioned new-board command with three groups and fixed header
|
||||
components.
|
||||
- [x] Phase C2a1: persistent insert/delete commands for mutable group
|
||||
protection and auxiliary components.
|
||||
- [x] Phase C2a2: component update and reorder commands.
|
||||
- [x] Phase C2b1: empty-group insert/update/delete commands.
|
||||
- [x] Phase C2b2: complete group reorder command.
|
||||
- [x] Phase D1a: deterministic collision-aware group-renumber plan.
|
||||
- [x] Phase D1b: persistent collision-safe group-renumber command.
|
||||
- [x] Phase D2a: deterministic same-category circuit-move plan.
|
||||
- [x] Phase D2b: persistent same-category circuit-move command.
|
||||
- [x] Phase D3a: complete validated group-subtree snapshot and warning summary.
|
||||
- [x] Phase D3b: persistent populated-group delete/restore command.
|
||||
- [x] Phase E1: tree read model for components, groups and protection devices.
|
||||
- [x] Phase E2a: pure header/group/footer structure projection.
|
||||
- [x] Phase E2b: render the structure projection in the editor grid.
|
||||
- [x] Phase E3a: create, edit and delete mutable group and footer components.
|
||||
- [x] Phase E3b1: create, rename and safely delete empty circuit groups.
|
||||
- [x] Phase E3b2a: persistent circuit-protection update command.
|
||||
- [x] Phase E3b2b: circuit-protection defaults on insert and editor modal.
|
||||
- [x] Phase E4a: persistent same-category group reorder controls.
|
||||
- [x] Phase E4b: explicit group renumber, circuit moves and populated-delete warning.
|
||||
- [x] Explicit same-category group renumbering updates prefixes and all child BMK atomically.
|
||||
- [x] Single-circuit moves between same-category groups with automatic next-free BMK assignment.
|
||||
- [x] Populated-group delete warning and explicit subtree deletion.
|
||||
- [x] Phase E: editor projection and editing.
|
||||
- [x] Phase F: documentation and full GUI verification.
|
||||
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
|
||||
the planner supplies the calculation requirements.
|
||||
- Detailed specification:
|
||||
`docs/spec/09-distribution-board-components-and-protection-groups.md`
|
||||
|
||||
## First Release Baseline
|
||||
|
||||
Complete this immediately before the first supported release, once the schema
|
||||
@@ -90,5 +132,6 @@ and initial feature set are frozen:
|
||||
3. [x] Snapshot-to-revision descriptions and history presentation.
|
||||
4. [x] Distribution-board power summary and project column layout.
|
||||
|
||||
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
|
||||
must not be inferred from these tasks.
|
||||
The Revit/CSV/IFCGUID round-trip follows the distribution-board component
|
||||
foundation as a separate jointly planned phase and must not be inferred from
|
||||
these tasks.
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
# Distribution Board Components and Protection Groups
|
||||
|
||||
## Status
|
||||
|
||||
This document records the agreed and implemented behavior for
|
||||
distribution-board components, protected circuit groups and circuit
|
||||
protection devices. Delivery phases A through E are complete. Phase F retains
|
||||
the final GUI checklist; the current runtime paths are documented in
|
||||
`docs/current-architecture.md`.
|
||||
|
||||
Full rule-based protection and cable sizing is deliberately deferred. This
|
||||
phase establishes the structure and manually selected technical values that a
|
||||
later calculation and warning engine will evaluate.
|
||||
|
||||
## Goals
|
||||
|
||||
- represent the circuit list as the basis of a tabular single-line diagram
|
||||
- list incoming, group-level and auxiliary distribution-board components with
|
||||
stable equipment identifiers
|
||||
- support multiple independently protected groups for lighting, single-phase
|
||||
and three-phase outgoing circuits
|
||||
- give every newly created outgoing circuit one explicit protection device
|
||||
- preserve the existing distinction between a circuit and its device rows
|
||||
- keep every structural change atomic, revisioned and persistently undoable
|
||||
- prepare stable target fields for the later Revit round-trip
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- norm-compliant protection or cable sizing
|
||||
- automatic creation of additional groups based on the incoming rating
|
||||
- automatic phase balancing
|
||||
- manufacturer-specific product catalogs
|
||||
- automatic equipment-identifier generation for auxiliary components
|
||||
- channel-level relationships between actors and controlled circuits
|
||||
- PostgreSQL or multi-user operation
|
||||
|
||||
## Existing Model Audit
|
||||
|
||||
### Keep
|
||||
|
||||
- `Circuit` remains the outgoing-circuit aggregate.
|
||||
- `CircuitDeviceRow` remains a load assigned to a circuit and never receives
|
||||
its own protection device or equipment identifier.
|
||||
- Circuit-level load, cable and control fields remain circuit-owned.
|
||||
- Existing stable UUIDs, project revisions, snapshots and persistent Undo/Redo
|
||||
remain authoritative.
|
||||
- `CircuitSection` remains the migration starting point for a protected circuit
|
||||
group.
|
||||
- Grid projection remains separate from persisted domain structure.
|
||||
|
||||
### Change
|
||||
|
||||
- A `CircuitSection` gains an explicit category and group number and represents
|
||||
one protected circuit group.
|
||||
- Circuit protection changes from loosely related flat strings to an explicit
|
||||
one-to-one protection-device model.
|
||||
- Distribution-board components become independent BMK-bearing list entries.
|
||||
- Equipment-identifier uniqueness covers circuits and all distribution-board
|
||||
components in the complete circuit list.
|
||||
- Moving a circuit to another group of the same category atomically assigns the
|
||||
next identifier of the target group.
|
||||
- New distribution boards create three default groups rather than treating the
|
||||
optional unassigned compatibility section as a normal fourth group.
|
||||
|
||||
### Remove Eventually
|
||||
|
||||
- hard-coded assumptions that each category has exactly one section
|
||||
- numbering that only understands `prefix + integer`
|
||||
- free-text protection-device combinations that cannot be validated
|
||||
- `rcdAssignment` as a substitute for an explicit group relationship
|
||||
|
||||
Removal happens only after migrated state, snapshots, commands and the editor
|
||||
use the replacement model.
|
||||
|
||||
## Target Domain Structure
|
||||
|
||||
```text
|
||||
Project
|
||||
└── DistributionBoard
|
||||
└── CircuitList
|
||||
├── fixed header components
|
||||
│ ├── Main switch
|
||||
│ └── Surge protective device
|
||||
├── CircuitGroup 1..n
|
||||
│ ├── optional upstream protection component
|
||||
│ ├── optional group RCD component
|
||||
│ └── Circuit 0..n
|
||||
│ ├── ProtectionDevice exactly 1
|
||||
│ └── CircuitDeviceRow 0..n
|
||||
└── auxiliary components 0..n
|
||||
```
|
||||
|
||||
The hierarchy defines ownership and electrical grouping. It does not require
|
||||
every level to be visually indented in the table.
|
||||
|
||||
## Circuit Groups
|
||||
|
||||
`CircuitSection` evolves into the circuit-group entity. A group has:
|
||||
|
||||
- stable UUID
|
||||
- circuit-list ownership
|
||||
- category
|
||||
- positive group number
|
||||
- display name
|
||||
- sort order
|
||||
- optional upstream protection component
|
||||
- optional group RCD component
|
||||
- zero or more outgoing circuits
|
||||
|
||||
Supported categories:
|
||||
|
||||
- `lighting`
|
||||
- `single_phase`
|
||||
- `three_phase`
|
||||
|
||||
Each new distribution board creates exactly:
|
||||
|
||||
1. `Beleuchtung 1`
|
||||
2. `1-phasig 1`
|
||||
3. `3-phasig 1`
|
||||
|
||||
Additional groups are created manually. A new group receives the highest
|
||||
existing group number in its category plus one. Gaps are not filled
|
||||
automatically.
|
||||
|
||||
A group may have at most one upstream protection component and at most one
|
||||
group RCD. Both are optional. Every circuit in the group remains downstream of
|
||||
those group components until it is moved to another group.
|
||||
|
||||
## Distribution-Board Components
|
||||
|
||||
Every distribution-board component has:
|
||||
|
||||
- stable UUID
|
||||
- circuit-list ownership
|
||||
- unique equipment identifier
|
||||
- name
|
||||
- role
|
||||
- placement
|
||||
- sort order within its placement
|
||||
|
||||
Initial roles:
|
||||
|
||||
- `main_switch`
|
||||
- `surge_protective_device`
|
||||
- `group_upstream_protection`
|
||||
- `group_residual_current_protection`
|
||||
- `auxiliary`
|
||||
|
||||
Initial placement zones:
|
||||
|
||||
- `header`
|
||||
- `group`
|
||||
- `footer`
|
||||
|
||||
New distribution boards create exactly:
|
||||
|
||||
- `-Q0`, name `Hauptschalter`, role `main_switch`
|
||||
- `-FA`, name `Überspannungsableiter`, role
|
||||
`surge_protective_device`
|
||||
|
||||
These fixed components appear at the top and may initially have no further
|
||||
technical product data.
|
||||
|
||||
Auxiliary components initially require only:
|
||||
|
||||
- manually entered equipment identifier
|
||||
- name
|
||||
- sort order
|
||||
|
||||
Examples include KNX switching actuators, blind actuators, phase-monitoring
|
||||
modules, DALI gateways, relays and other installed board equipment. They appear
|
||||
after all circuit groups and can be reordered by drag-and-drop. Automatic BMK
|
||||
suggestions and channel-to-circuit relationships are deferred.
|
||||
|
||||
## Circuit Protection
|
||||
|
||||
Every newly created outgoing circuit owns exactly one protection device.
|
||||
`CircuitDeviceRow` does not own a protection device.
|
||||
|
||||
The circuit and its protection device are projected as one circuit row. The
|
||||
circuit equipment identifier is also the displayed identifier of its
|
||||
protection device and must not be stored twice.
|
||||
|
||||
Supported protection-device types:
|
||||
|
||||
- `D02`
|
||||
- `NH000`
|
||||
- `NH00`
|
||||
- `NH0`
|
||||
- `NH1`
|
||||
- `NH2`
|
||||
- `NH3`
|
||||
- `LS`
|
||||
- `FI`
|
||||
- `FI_LS`
|
||||
- `AFDD`
|
||||
|
||||
`D01` and `NH4` are intentionally excluded.
|
||||
|
||||
## Protection-Device Fields
|
||||
|
||||
All protection devices have:
|
||||
|
||||
- type
|
||||
- rated current in ampere
|
||||
|
||||
Type-dependent fields:
|
||||
|
||||
- D02 and NH:
|
||||
- fuse utilization category `gG` or `gR`
|
||||
- LS:
|
||||
- trip characteristic `B`, `C`, `D` or `Z`
|
||||
- FI:
|
||||
- RCD type `A`, `AC`, `B` or `B+`
|
||||
- rated residual current in milliampere
|
||||
- FI/LS:
|
||||
- trip characteristic `B`, `C`, `D` or `Z`
|
||||
- RCD type `A`, `AC`, `B` or `B+`
|
||||
- rated residual current in milliampere
|
||||
- AFDD:
|
||||
- trip characteristic `B`, `C`, `D` or `Z`
|
||||
- no RCD type
|
||||
- no rated residual current
|
||||
|
||||
Poles are intentionally not stored. The circuit-group category supplies the
|
||||
required phase context for the current scope.
|
||||
|
||||
## Allowed Ratings
|
||||
|
||||
### D02
|
||||
|
||||
`20, 25, 32, 35, 40, 50, 63 A`
|
||||
|
||||
### NH000 and NH00
|
||||
|
||||
`6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160 A`
|
||||
|
||||
### NH0
|
||||
|
||||
`6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250 A`
|
||||
|
||||
### NH1
|
||||
|
||||
`16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250 A`
|
||||
|
||||
### NH2
|
||||
|
||||
`25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250, 300, 315, 355, 400 A`
|
||||
|
||||
### NH3
|
||||
|
||||
`50, 63, 80, 100, 125, 160, 200, 224, 250, 315, 355, 400, 500, 630, 800 A`
|
||||
|
||||
### LS, FI/LS and AFDD
|
||||
|
||||
`6, 10, 13, 16, 20, 25, 32, 40, 50, 63, 80, 100, 125 A`
|
||||
|
||||
### FI/RCD
|
||||
|
||||
Rated currents:
|
||||
|
||||
`16, 25, 40, 63, 80, 100, 125 A`
|
||||
|
||||
Rated residual currents:
|
||||
|
||||
`10, 30, 100, 300, 500 mA`
|
||||
|
||||
The device type determines which values and characteristics are valid. UI,
|
||||
request validation and domain validation use one shared catalog rather than
|
||||
duplicated free-text rules.
|
||||
|
||||
All supported devices that provide overcurrent protection may be selected as a
|
||||
group upstream protection device. A standalone FI belongs in the separate
|
||||
optional group-RCD role.
|
||||
|
||||
## Default Protection Devices
|
||||
|
||||
New circuits receive:
|
||||
|
||||
- lighting:
|
||||
- LS
|
||||
- 10 A
|
||||
- characteristic B
|
||||
- single-phase:
|
||||
- FI/LS
|
||||
- 16 A
|
||||
- characteristic B
|
||||
- RCD type A
|
||||
- 30 mA
|
||||
- three-phase:
|
||||
- FI/LS
|
||||
- 16 A
|
||||
- characteristic B
|
||||
- RCD type A
|
||||
- 30 mA
|
||||
|
||||
A newly added group RCD defaults to:
|
||||
|
||||
- FI
|
||||
- 40 A
|
||||
- RCD type A
|
||||
- 30 mA
|
||||
|
||||
An upstream protection component has no automatic default and is created only
|
||||
when the planner requests it.
|
||||
|
||||
## Equipment-Identifier Rules
|
||||
|
||||
The leading hyphen is part of every generated identifier.
|
||||
|
||||
Group 1 examples:
|
||||
|
||||
```text
|
||||
-1F1.0 upstream protection, lighting
|
||||
-1Q1.0 group RCD, lighting
|
||||
-1F1.1 first lighting circuit
|
||||
|
||||
-2F1.0 upstream protection, single-phase
|
||||
-2Q1.0 group RCD, single-phase
|
||||
-2F1.1 first single-phase circuit
|
||||
|
||||
-3F1.0 upstream protection, three-phase
|
||||
-3Q1.0 group RCD, three-phase
|
||||
-3F1.1 first three-phase circuit
|
||||
```
|
||||
|
||||
Category numbers:
|
||||
|
||||
- lighting: `1`
|
||||
- single-phase: `2`
|
||||
- three-phase: `3`
|
||||
|
||||
Function letters:
|
||||
|
||||
- overcurrent protection and outgoing circuit: `F`
|
||||
- group RCD: `Q`
|
||||
|
||||
The group number follows the function letter. `.0` is reserved for the group
|
||||
component. Outgoing-circuit suffixes start at `.1`.
|
||||
|
||||
Equipment identifiers must be unique across all circuits and components of the
|
||||
complete circuit list, including header and footer components.
|
||||
|
||||
Manual BMK edits must validate that same shared uniqueness boundary.
|
||||
|
||||
## Reorder, Move and Renumber Behavior
|
||||
|
||||
### Reorder Within One Group
|
||||
|
||||
Changing circuit order changes only `sortOrder`. It never changes an equipment
|
||||
identifier.
|
||||
|
||||
### Move to Another Group
|
||||
|
||||
A circuit may move only to another group of the same category.
|
||||
|
||||
The move:
|
||||
|
||||
1. finds the highest existing outgoing-circuit suffix in the target group
|
||||
2. assigns that suffix plus one
|
||||
3. moves the complete circuit, its one-to-one protection device and all device
|
||||
rows
|
||||
4. commits old and new group, position and identifier atomically
|
||||
|
||||
Gaps are not filled. Undo restores the original group, sort position and
|
||||
equipment identifier. Redo reapplies the same previously determined target
|
||||
identifier rather than calculating another one.
|
||||
|
||||
This automatic identifier change is specific to a cross-group move initiated
|
||||
by the user. A reorder inside one group never renumbers.
|
||||
|
||||
### Reorder Groups
|
||||
|
||||
Changing group display order changes only group `sortOrder`. Group numbers and
|
||||
all equipment identifiers remain unchanged.
|
||||
|
||||
### Explicitly Renumber Groups
|
||||
|
||||
Group renumbering is a separate explicit action. It:
|
||||
|
||||
- changes group numbers according to the requested mapping
|
||||
- preserves every outgoing circuit suffix
|
||||
- updates optional `.0` group-component identifiers
|
||||
- updates every affected circuit identifier
|
||||
- resolves swaps through collision-safe temporary identifiers
|
||||
- commits the complete mapping as one project revision
|
||||
|
||||
Undo restores the exact previous group numbers and identifiers.
|
||||
|
||||
## Delete Behavior
|
||||
|
||||
A group may be deleted even when populated, but only after an explicit warning
|
||||
that summarizes:
|
||||
|
||||
- optional upstream protection
|
||||
- optional group RCD
|
||||
- number of circuits
|
||||
- number of circuit device rows
|
||||
|
||||
Confirmation deletes the complete group subtree atomically. The inverse
|
||||
command stores the complete stable snapshots needed to restore all UUIDs,
|
||||
fields, links, protection devices, group relationships, identifiers and sort
|
||||
positions.
|
||||
|
||||
A stale or partially changed subtree must not be silently deleted or restored.
|
||||
|
||||
## UI Projection
|
||||
|
||||
The circuit-list editor projects three fixed zones:
|
||||
|
||||
1. header:
|
||||
- `-Q0 Hauptschalter`
|
||||
- `-FA Überspannungsableiter`
|
||||
2. groups:
|
||||
- group heading
|
||||
- optional upstream protection row
|
||||
- optional group RCD row
|
||||
- circuit rows and their optional expanded device rows
|
||||
3. footer:
|
||||
- auxiliary distribution-board components
|
||||
|
||||
Group indentation is optional presentation. Group ownership must remain clear
|
||||
through headings, separators or a dedicated group indication.
|
||||
|
||||
The grid remains a custom spreadsheet component:
|
||||
|
||||
- static text by default
|
||||
- edit mode only when invoked
|
||||
- group and auxiliary-component drag handles
|
||||
- clear valid and invalid drop indicators
|
||||
- German user-facing labels
|
||||
|
||||
Protection editing must show only fields valid for the selected device type.
|
||||
Changing the type resets or explicitly confirms removal of fields that the new
|
||||
type cannot use.
|
||||
|
||||
## History and Command Boundary
|
||||
|
||||
New project-scoped writes must use typed persistent commands and the existing
|
||||
shared project-command transaction boundary.
|
||||
|
||||
Required logical operations:
|
||||
|
||||
- insert/update/delete auxiliary component
|
||||
- reorder auxiliary components
|
||||
- insert/update/delete group protection component
|
||||
- insert/delete group
|
||||
- reorder groups
|
||||
- explicitly renumber groups
|
||||
- move circuit between same-category groups with deterministic BMK assignment
|
||||
- update circuit protection device
|
||||
|
||||
Creating a distribution board includes its fixed components, three default
|
||||
groups, circuit list and all stable UUIDs in one `distribution-board.insert`
|
||||
command.
|
||||
|
||||
Every command must:
|
||||
|
||||
- require `expectedRevision`
|
||||
- validate complete project/list/group ownership
|
||||
- validate shared BMK uniqueness inside the write transaction
|
||||
- contain deterministic forward and inverse data
|
||||
- commit the domain mutation, revision and history transition atomically
|
||||
- remain undoable and redoable after application restart
|
||||
|
||||
## Snapshot and Transfer Compatibility
|
||||
|
||||
The supported logical project snapshot and portable JSON transfer must include:
|
||||
|
||||
- group category and group number
|
||||
- fixed, group and auxiliary components
|
||||
- circuit protection-device state
|
||||
- all new component relationships and sort positions
|
||||
|
||||
Introducing the model requires a snapshot-schema version increase and explicit
|
||||
upgraders for supported older payloads.
|
||||
|
||||
Database migration rules:
|
||||
|
||||
- preserve all existing stable UUIDs
|
||||
- preserve existing circuit equipment identifiers during schema migration
|
||||
- map the existing lighting, single-phase and three-phase sections to group 1
|
||||
of the respective category
|
||||
- preserve an existing unassigned section only when required by existing data;
|
||||
it is not created for a new distribution board
|
||||
- preserve existing flat circuit protection values when creating the new
|
||||
one-to-one protection state
|
||||
- do not silently replace an existing protection selection with a new default
|
||||
- allow legacy identifiers to remain until an explicit, collision-safe
|
||||
conversion or renumber action is approved
|
||||
|
||||
New defaults apply to newly created circuits and boards, not as an implicit
|
||||
rewrite of existing planning data.
|
||||
|
||||
Historical persisted commands and snapshots that remain in the supported
|
||||
compatibility window must still deserialize and execute after the migration.
|
||||
|
||||
## Later Sizing and Warning Engine
|
||||
|
||||
The selected protection device is the planner-owned value. Later sizing logic
|
||||
will calculate a recommendation separately.
|
||||
|
||||
Target behavior:
|
||||
|
||||
```text
|
||||
stored selection
|
||||
-> calculation and validation
|
||||
-> valid: no warning
|
||||
-> invalid: colored warning plus recommended protection
|
||||
-> user explicitly accepts or rejects the recommendation
|
||||
```
|
||||
|
||||
The same pattern applies later to cable cross-section and length. Suggestions
|
||||
never silently overwrite selected values.
|
||||
|
||||
Dimensioning rules and additional inputs are defined in a separate future
|
||||
specification supplied by the planner.
|
||||
|
||||
## Delivery Phases
|
||||
|
||||
### A. Catalog and Pure Domain Rules
|
||||
|
||||
Status: Complete.
|
||||
|
||||
- introduce typed component roles, categories and protection-device types
|
||||
- centralize rating and characteristic catalogs
|
||||
- validate device-specific field combinations
|
||||
- define default circuit and group-RCD protection values
|
||||
- test numbering parsing and formatting independently from persistence
|
||||
|
||||
Implemented foundation:
|
||||
|
||||
- shared circuit-group categories, German labels and category numbers
|
||||
- one protection-device catalog with all agreed ratings and characteristics
|
||||
- strict device-dependent validation for fuse, LS, FI, FI/LS and AFDD fields
|
||||
- pure factories for circuit and group-RCD defaults
|
||||
- pure nested BMK formatting, parsing and highest-number-plus-one rules
|
||||
- no runtime database or editor integration before delivery phase B
|
||||
|
||||
Acceptance:
|
||||
|
||||
- every agreed valid combination is accepted
|
||||
- invalid rating/type/field combinations are rejected
|
||||
- defaults produce the exact agreed values
|
||||
|
||||
### B. Persistence and Compatibility Model
|
||||
|
||||
Status: Complete.
|
||||
|
||||
- add group category and group number
|
||||
- add distribution-board component persistence
|
||||
- add one-to-one circuit protection persistence
|
||||
- implement shared BMK uniqueness
|
||||
- generate and inspect one additive migration
|
||||
- add snapshot/transfer schema upgrade
|
||||
- preserve existing identifiers and protection values
|
||||
|
||||
Implemented in B1:
|
||||
|
||||
- additive migration `0024` adds group identity to circuit sections and maps
|
||||
the three established sections to group 1 without changing UUIDs
|
||||
- separate component and circuit/component protection tables preserve the
|
||||
one-to-one ownership boundaries
|
||||
- a trigger-maintained circuit-list registry rejects normalized BMK collisions
|
||||
across circuits and distribution-board components
|
||||
- the existing flat circuit protection fields remain untouched until their
|
||||
deterministic snapshot/runtime transition is implemented
|
||||
- clean and populated pre-migration databases are covered by migration tests
|
||||
- newly created legacy-compatible board structures receive the same initial
|
||||
group identity while their persisted command format remains unchanged
|
||||
|
||||
Implemented in B2:
|
||||
|
||||
- snapshot schema version 7 includes group identity, distribution-board
|
||||
components and both one-to-one protection collections
|
||||
- capture, named and automatic snapshots, restore, Undo/Redo and portable JSON
|
||||
transfers preserve the complete new relational state
|
||||
- duplicate imports remap component UUIDs and both protection-owner references
|
||||
together with the existing project structure
|
||||
- versions 1 through 6 remain readable; version 6 maps the three established
|
||||
section keys to group 1 and leaves new collections empty
|
||||
- older flat circuit protection values remain present and are not guessed into
|
||||
strict new protection configurations
|
||||
- current snapshots reject invalid ownership, duplicate group numbers,
|
||||
cross-entity BMKs and invalid protection configurations before persistence
|
||||
|
||||
Acceptance:
|
||||
|
||||
- a current database migrates without data loss
|
||||
- an empty database creates the complete schema
|
||||
- old supported snapshots and transfers upgrade deterministically
|
||||
- BMK collisions across circuits and components are rejected
|
||||
|
||||
### C. Persistent Commands and Board Defaults
|
||||
|
||||
Status: Complete.
|
||||
|
||||
- extend `distribution-board.insert` with fixed components and three groups
|
||||
- implement component and group CRUD commands
|
||||
- implement group and footer reordering
|
||||
- add complete inverse snapshots and rollback tests
|
||||
|
||||
Implemented in C1:
|
||||
|
||||
- `distribution-board.insert` schema version 3 carries stable UUIDs for three
|
||||
initial groups, main switch `-Q0` and surge protective device `-FA`
|
||||
- its inverse verifies and removes the unchanged complete structure; Redo
|
||||
restores the same UUIDs
|
||||
- a late revision/history failure rolls back board, list, groups and components
|
||||
together
|
||||
- stored schema versions 1 and 2 retain four legacy sections and receive no
|
||||
invented components
|
||||
|
||||
Implemented in C2a1:
|
||||
|
||||
- `distribution-board-component.insert` and
|
||||
`distribution-board-component.delete` persist complete component snapshots
|
||||
with their optional one-to-one protection-device configuration
|
||||
- mutable group protection components validate their group ownership and
|
||||
protection-device combination before writing
|
||||
- auxiliary components remain footer entries without protection-device data
|
||||
- fixed header roles are excluded from these general CRUD commands
|
||||
- deletion requires an exact unchanged snapshot; Undo/Redo restores the same
|
||||
component UUID and a late revision/history failure rolls back the complete
|
||||
write
|
||||
|
||||
Implemented in C2a2:
|
||||
|
||||
- `distribution-board-component.update` exchanges one complete expected
|
||||
component/protection snapshot for one complete target snapshot
|
||||
- name, BMK, protection configuration and `sortOrder` can change atomically
|
||||
- component id, list/group ownership, role and placement zone remain immutable
|
||||
through this general update command
|
||||
- stale expected state and cross-entity BMK collisions are rejected; Undo/Redo
|
||||
restores the exact previous snapshot
|
||||
|
||||
Implemented in C2b1:
|
||||
|
||||
- `circuit-group.insert`, `circuit-group.update` and
|
||||
`circuit-group.delete` persist complete group snapshots with stable UUIDs
|
||||
- category, group number and prefix are validated as one consistent identity;
|
||||
general updates change only the display name
|
||||
- deletion requires an exact unchanged and empty group; populated destructive
|
||||
deletion remains the separately confirmed Phase-D operation
|
||||
- project/list ownership, stale state, Undo/Redo and late rollback are covered
|
||||
at the shared transaction boundary
|
||||
|
||||
Implemented in C2b2:
|
||||
|
||||
- `circuit-group.reorder` requires every group in the circuit list with its
|
||||
exact expected and target `sortOrder`
|
||||
- sorting changes no group number, prefix or circuit equipment identifier
|
||||
- incomplete or stale assignments are rejected before history is recorded
|
||||
- the complete reorder is one project revision and one persistent Undo/Redo
|
||||
step; late failures roll back every position
|
||||
|
||||
Acceptance:
|
||||
|
||||
- new boards contain the agreed fixed structure
|
||||
- every structural write survives reload and supports Undo/Redo
|
||||
- forced late failures leave no partial components or groups
|
||||
|
||||
### D. Group Numbering and Circuit Moves
|
||||
|
||||
Status: Complete.
|
||||
|
||||
- implement nested identifier generation
|
||||
- support same-category cross-group circuit moves
|
||||
- implement explicit collision-safe group renumbering
|
||||
- implement confirmed populated-group deletion
|
||||
|
||||
Implemented in D1a:
|
||||
|
||||
- a pure renumber planner derives group prefixes, circuit BMKs and optional
|
||||
upstream-protection/RCD BMKs from category and target group number
|
||||
- outgoing-circuit suffixes remain unchanged
|
||||
- number swaps are allowed only when every conflicting group participates
|
||||
- collisions with unchanged groups, no-op mappings and mismatched current BMKs
|
||||
are rejected before persistence
|
||||
|
||||
Implemented in D1b:
|
||||
|
||||
- `circuit-group.renumber` persists the complete expected/target plan as one
|
||||
project revision
|
||||
- group-number swaps use collision-safe temporary group identities and BMKs
|
||||
across both circuit and component tables
|
||||
- every current circuit and group component must be included unchanged before
|
||||
execution starts
|
||||
- Undo/Redo uses the exact inverse plan; late revision/history failures roll
|
||||
back every group, prefix and BMK
|
||||
|
||||
Implemented in D2a:
|
||||
|
||||
- a pure planner permits moves only between distinct groups of the same
|
||||
category
|
||||
- it validates the source BMK and stores source/target group and sort position
|
||||
- the target BMK uses the highest existing target-group circuit suffix plus one
|
||||
and never fills gaps
|
||||
- the computed target BMK is part of the plan and is not recalculated for Redo
|
||||
|
||||
Implemented in D2b:
|
||||
|
||||
- `circuit.move-group` atomically changes only the circuit's group, BMK and
|
||||
sort position
|
||||
- the circuit UUID remains stable, so its device rows and one-to-one protection
|
||||
device remain connected without copying
|
||||
- execution revalidates project/list ownership, both group identities and the
|
||||
complete expected circuit state
|
||||
- Undo/Redo uses the stored inverse move and late failures restore source
|
||||
group, BMK and position
|
||||
|
||||
Implemented in D3a:
|
||||
|
||||
- one validated subtree snapshot combines the group, optional group components
|
||||
and their protection devices, complete circuits and their protection devices,
|
||||
and all circuit device rows
|
||||
- linked-project-device and override metadata remain part of every row snapshot
|
||||
- ownership and BMK uniqueness are checked across the complete subtree
|
||||
- the snapshot derives the confirmation summary for upstream protection, RCD,
|
||||
circuit count and device-row count
|
||||
|
||||
Implemented in D3b:
|
||||
|
||||
- `circuit-group.delete-subtree` requires the exact previously confirmed
|
||||
snapshot and re-captures the full current subtree before deletion
|
||||
- `circuit-group.restore-subtree` restores stable UUIDs in foreign-key-safe
|
||||
order, including both protection layers and row link/override metadata
|
||||
- deletion and restoration are exact persistent inverses for Undo/Redo
|
||||
- stale confirmation state and late history failures leave no partial delete
|
||||
or restore
|
||||
|
||||
Acceptance:
|
||||
|
||||
- target identifiers use highest suffix plus one
|
||||
- reordering never renumbers
|
||||
- cross-category moves are rejected
|
||||
- group renumbering preserves circuit suffixes
|
||||
- delete, move and renumber Undo restore exact prior UUIDs and BMKs
|
||||
|
||||
### E. Editor Projection and Editing
|
||||
|
||||
Status: Complete.
|
||||
|
||||
- render fixed header components
|
||||
- render group components and circuit blocks
|
||||
- render sortable auxiliary footer components
|
||||
- add group management and destructive confirmations
|
||||
- add type-dependent protection editing
|
||||
- keep spreadsheet selection, keyboard and drag behavior coherent
|
||||
|
||||
Implemented in E1/E2a/E2b/E3a:
|
||||
|
||||
- the tree response exposes fixed header, group and auxiliary footer
|
||||
components plus both one-to-one protection layers
|
||||
- a pure projection produces the stable order header, complete groups and
|
||||
footer without mixing zones
|
||||
- each group keeps its heading, optional protection components, complete
|
||||
circuit blocks and free placeholder together
|
||||
- empty groups remain visible and circuit device rows remain owned by their
|
||||
circuit block
|
||||
- the editor renders fixed header, group protection and auxiliary footer
|
||||
components as read-only full-width rows
|
||||
- structural component rows are excluded from spreadsheet cell navigation,
|
||||
insertion and deletion commands until their dedicated editing controls exist
|
||||
- group headings offer at most one optional upstream protection device and one
|
||||
optional group RCD; the agreed group-RCD default is applied only when it is
|
||||
created
|
||||
- mutable group protection and auxiliary footer components use labeled modals,
|
||||
type-dependent protection fields and persistent project commands
|
||||
- fixed main-switch and surge-protection header components remain read-only
|
||||
- users can create a group in any supported category; its number is the
|
||||
category's highest existing number plus one and its prefix is generated
|
||||
- group editing changes only the display name; category, number, prefix and
|
||||
child BMKs remain stable
|
||||
- an exactly empty group can be removed through the compact persistent group
|
||||
command
|
||||
- `circuit-protection.update` inserts or updates one validated 1:1 protection
|
||||
snapshot, rejects stale state and preserves exact persistent Undo/Redo
|
||||
- user commands cannot remove circuit protection; a nullable target exists
|
||||
only as the inverse of adding protection to a retained circuit without it
|
||||
- newly inserted circuits and placeholder targets carry the category-specific
|
||||
default protection in their complete atomic snapshot
|
||||
- circuit deletion and restoration preserve the exact 1:1 protection row
|
||||
together with the circuit and all device rows
|
||||
- the editor modal exposes only fields valid for the selected protection type;
|
||||
the old flat protection columns now project the new 1:1 data read-only
|
||||
- group controls move a group only to the adjacent peer of the same category;
|
||||
the complete list order is one persistent command and no number or BMK changes
|
||||
- explicit category-wide group renumbering follows the visible persisted group
|
||||
order, updates every affected prefix and child BMK atomically and preserves
|
||||
outgoing-circuit suffixes
|
||||
- dragging one circuit into another group of the same category moves the
|
||||
complete circuit and assigns the highest target suffix plus one; same-group
|
||||
multi-circuit drag remains a pure reorder
|
||||
- populated-group deletion shows the affected circuit, device-row and group
|
||||
protection counts before sending the complete subtree to the guarded
|
||||
delete/restore command
|
||||
|
||||
Acceptance:
|
||||
|
||||
- the visible order is header, groups, footer
|
||||
- users can create, configure, reorder and delete groups
|
||||
- users can configure circuit and group protection without invalid fields
|
||||
- device rows remain visually and technically subordinate to their circuit
|
||||
|
||||
### F. Documentation and Full Verification
|
||||
|
||||
Status: Complete. Documentation, automated verification and the agreed GUI
|
||||
checklist are complete.
|
||||
|
||||
- update current architecture, API and interaction documentation
|
||||
- update the safe local demo-data instructions; no versioned customer-like
|
||||
seed data is introduced
|
||||
- run focused domain and transaction tests
|
||||
- run the complete required test, build and type-check suite
|
||||
- perform the agreed GUI test checklist
|
||||
|
||||
Acceptance:
|
||||
|
||||
- documentation distinguishes implemented behavior from later sizing
|
||||
- clean install and migrated database both work
|
||||
- snapshots, JSON transfer and Undo/Redo cover all new state
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- Mixed legacy and grouped BMKs:
|
||||
preserve old identifiers and provide an explicit conversion path.
|
||||
- Cross-entity BMK collisions:
|
||||
enforce one transaction-level uniqueness boundary for the complete list.
|
||||
- Partial destructive writes:
|
||||
persist complete subtree commands through the shared transaction wrapper.
|
||||
- Bloated grid logic:
|
||||
add pure projection and ownership modules before React rendering changes.
|
||||
- Invalid protection combinations:
|
||||
use one shared typed catalog for UI, API and domain validation.
|
||||
- Silent planning changes:
|
||||
apply defaults only on creation and keep later recommendations explicit.
|
||||
- Snapshot incompatibility:
|
||||
version payloads and test every supported upgrade path.
|
||||
|
||||
## Resolved Implementation Decisions
|
||||
|
||||
- a trigger-maintained circuit-list registry enforces shared BMK uniqueness
|
||||
across circuits and distribution-board components
|
||||
- group headings and full-width structural rows show ownership without adding
|
||||
a permanent group column
|
||||
- structural component rows remain outside editable-cell filtering and
|
||||
spreadsheet navigation; circuit filtering continues to preserve complete
|
||||
circuit blocks
|
||||
- explicit UI for converting retained legacy identifiers to grouped numbering
|
||||
- whether fixed header components become editable beyond name and BMK in this
|
||||
phase
|
||||
+2
-2
@@ -15,8 +15,8 @@
|
||||
"build:web": "next build",
|
||||
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
|
||||
"start": "node dist/server/index.js",
|
||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"test": "tsx --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/distribution-board-component-schema-migration.test.ts tests/circuit-group-numbering.test.ts tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"test:watch": "tsx --watch --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/distribution-board-component-schema-migration.test.ts tests/circuit-group-numbering.test.ts tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:backup": "tsx scripts/db-backup.ts",
|
||||
|
||||
@@ -8,12 +8,21 @@ const requiredTables = [
|
||||
"circuit_sections",
|
||||
"circuits",
|
||||
"circuit_device_rows",
|
||||
"circuit_list_equipment_identifiers",
|
||||
"circuit_protection_devices",
|
||||
"distribution_board_components",
|
||||
"distribution_board_component_protection_devices",
|
||||
"legacy_consumer_circuit_migrations",
|
||||
"legacy_consumer_migration_reports",
|
||||
];
|
||||
|
||||
const tablePlaceholders = requiredTables.map(() => "?").join(", ");
|
||||
const rows = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?, ?, ?)")
|
||||
.prepare(
|
||||
`SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name IN (${tablePlaceholders})`
|
||||
)
|
||||
.all(...requiredTables);
|
||||
|
||||
const existing = new Set(rows.map((row) => row.name));
|
||||
@@ -48,6 +57,16 @@ const circuitColumns = new Set(
|
||||
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
|
||||
);
|
||||
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
|
||||
const requiredCircuitSectionColumns = ["category", "group_number"];
|
||||
const circuitSectionColumns = new Set(
|
||||
db
|
||||
.prepare("PRAGMA table_info(circuit_sections)")
|
||||
.all()
|
||||
.map((column) => column.name)
|
||||
);
|
||||
const missingCircuitSectionColumns = requiredCircuitSectionColumns.filter(
|
||||
(name) => !circuitSectionColumns.has(name)
|
||||
);
|
||||
const requiredProjectColumns = [
|
||||
"internal_project_number",
|
||||
"external_project_number",
|
||||
@@ -61,7 +80,11 @@ const projectColumns = new Set(
|
||||
const missingProjectColumns = requiredProjectColumns.filter(
|
||||
(name) => !projectColumns.has(name)
|
||||
);
|
||||
const requiredDistributionBoardColumns = ["floor_id", "supply_type"];
|
||||
const requiredDistributionBoardColumns = [
|
||||
"floor_id",
|
||||
"supply_type",
|
||||
"simultaneity_factor",
|
||||
];
|
||||
const distributionBoardColumns = new Set(
|
||||
db
|
||||
.prepare("PRAGMA table_info(distribution_boards)")
|
||||
@@ -72,6 +95,28 @@ const missingDistributionBoardColumns =
|
||||
requiredDistributionBoardColumns.filter(
|
||||
(name) => !distributionBoardColumns.has(name)
|
||||
);
|
||||
const requiredEquipmentIdentifierTriggers = [
|
||||
"circuits_equipment_identifier_insert",
|
||||
"circuits_equipment_identifier_update",
|
||||
"circuits_equipment_identifier_delete",
|
||||
"distribution_board_components_equipment_identifier_insert",
|
||||
"distribution_board_components_equipment_identifier_update",
|
||||
"distribution_board_components_equipment_identifier_delete",
|
||||
];
|
||||
const equipmentIdentifierTriggers = new Set(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'trigger'`
|
||||
)
|
||||
.all()
|
||||
.map((trigger) => trigger.name)
|
||||
);
|
||||
const missingEquipmentIdentifierTriggers =
|
||||
requiredEquipmentIdentifierTriggers.filter(
|
||||
(name) => !equipmentIdentifierTriggers.has(name)
|
||||
);
|
||||
const integrityCheck = db.pragma("integrity_check", { simple: true });
|
||||
const foreignKeyViolations = db.pragma("foreign_key_check");
|
||||
|
||||
@@ -80,11 +125,19 @@ console.log("Required tables:", requiredTables.join(", "));
|
||||
console.log("Existing tables:", [...existing].join(", ") || "(none)");
|
||||
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
|
||||
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
|
||||
console.log(
|
||||
"Required circuit-section columns:",
|
||||
requiredCircuitSectionColumns.join(", ")
|
||||
);
|
||||
console.log("Required project columns:", requiredProjectColumns.join(", "));
|
||||
console.log(
|
||||
"Required distribution-board columns:",
|
||||
requiredDistributionBoardColumns.join(", ")
|
||||
);
|
||||
console.log(
|
||||
"Required BMK synchronization triggers:",
|
||||
requiredEquipmentIdentifierTriggers.join(", ")
|
||||
);
|
||||
console.log("Integrity:", integrityCheck);
|
||||
console.log("Foreign-key violations:", foreignKeyViolations.length);
|
||||
|
||||
@@ -111,6 +164,14 @@ if (missingCircuitColumns.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (missingCircuitSectionColumns.length > 0) {
|
||||
console.error(
|
||||
"Missing circuit-section columns:",
|
||||
missingCircuitSectionColumns.join(", ")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (missingProjectColumns.length > 0) {
|
||||
console.error("Missing project columns:", missingProjectColumns.join(", "));
|
||||
process.exit(1);
|
||||
@@ -124,9 +185,17 @@ if (missingDistributionBoardColumns.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (missingEquipmentIdentifierTriggers.length > 0) {
|
||||
console.error(
|
||||
"Missing BMK synchronization triggers:",
|
||||
missingEquipmentIdentifierTriggers.join(", ")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (integrityCheck !== "ok" || foreignKeyViolations.length > 0) {
|
||||
console.error("Database integrity or foreign-key verification failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Circuit-first schema verification passed.");
|
||||
console.log("Circuit-first and distribution-component schema verification passed.");
|
||||
|
||||
@@ -951,6 +951,57 @@ a.kpi:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-row td {
|
||||
padding: 0.38rem 0.6rem;
|
||||
border-bottom-color: #d8dee9;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-row.headerComponent td {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-row.groupComponent td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-row.footerComponent td {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-content {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-content strong {
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-protection {
|
||||
color: #475569;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-actions button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.18rem 0.45rem;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-fixed {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tree-grid .section-drop-cell {
|
||||
padding: 0.45rem 0.6rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
CREATE TABLE `circuit_list_equipment_identifiers` (
|
||||
`owner_type` text NOT NULL,
|
||||
`owner_id` text NOT NULL,
|
||||
`circuit_list_id` text NOT NULL,
|
||||
`equipment_identifier` text NOT NULL,
|
||||
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_owner_unique` ON `circuit_list_equipment_identifiers` (`owner_type`,`owner_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_list_identifier_unique` ON `circuit_list_equipment_identifiers` (`circuit_list_id`,`equipment_identifier`);--> statement-breakpoint
|
||||
CREATE TABLE `circuit_protection_devices` (
|
||||
`circuit_id` text PRIMARY KEY NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`rated_current_a` real NOT NULL,
|
||||
`fuse_utilization_category` text,
|
||||
`trip_characteristic` text,
|
||||
`rcd_type` text,
|
||||
`rated_residual_current_ma` real,
|
||||
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `distribution_board_component_protection_devices` (
|
||||
`component_id` text PRIMARY KEY NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`rated_current_a` real NOT NULL,
|
||||
`fuse_utilization_category` text,
|
||||
`trip_characteristic` text,
|
||||
`rcd_type` text,
|
||||
`rated_residual_current_ma` real,
|
||||
FOREIGN KEY (`component_id`) REFERENCES `distribution_board_components`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `distribution_board_components` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`circuit_list_id` text NOT NULL,
|
||||
`section_id` text,
|
||||
`equipment_identifier` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`placement` text NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`section_id`) REFERENCES `circuit_sections`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `distribution_board_components_list_identifier_unique` ON `distribution_board_components` (`circuit_list_id`,`equipment_identifier`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `distribution_board_components_section_role_unique` ON `distribution_board_components` (`section_id`,`role`);--> statement-breakpoint
|
||||
ALTER TABLE `circuit_sections` ADD `category` text;--> statement-breakpoint
|
||||
ALTER TABLE `circuit_sections` ADD `group_number` integer;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `circuit_sections_list_category_group_unique` ON `circuit_sections` (`circuit_list_id`,`category`,`group_number`);--> statement-breakpoint
|
||||
UPDATE `circuit_sections`
|
||||
SET
|
||||
`category` = CASE `key`
|
||||
WHEN 'lighting' THEN 'lighting'
|
||||
WHEN 'single_phase' THEN 'single_phase'
|
||||
WHEN 'three_phase' THEN 'three_phase'
|
||||
END,
|
||||
`group_number` = 1
|
||||
WHERE `key` IN ('lighting', 'single_phase', 'three_phase');--> statement-breakpoint
|
||||
INSERT INTO `circuit_list_equipment_identifiers` (
|
||||
`owner_type`,
|
||||
`owner_id`,
|
||||
`circuit_list_id`,
|
||||
`equipment_identifier`
|
||||
)
|
||||
SELECT
|
||||
'circuit',
|
||||
`id`,
|
||||
`circuit_list_id`,
|
||||
`equipment_identifier`
|
||||
FROM `circuits`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_normalized_unique`
|
||||
ON `circuit_list_equipment_identifiers` (
|
||||
`circuit_list_id`,
|
||||
lower(trim(`equipment_identifier`))
|
||||
);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `distribution_board_components_fixed_role_unique`
|
||||
ON `distribution_board_components` (`circuit_list_id`, `role`)
|
||||
WHERE `role` IN ('main_switch', 'surge_protective_device');--> statement-breakpoint
|
||||
CREATE TRIGGER `circuits_equipment_identifier_insert`
|
||||
AFTER INSERT ON `circuits`
|
||||
BEGIN
|
||||
INSERT INTO `circuit_list_equipment_identifiers` (
|
||||
`owner_type`,
|
||||
`owner_id`,
|
||||
`circuit_list_id`,
|
||||
`equipment_identifier`
|
||||
)
|
||||
VALUES (
|
||||
'circuit',
|
||||
NEW.`id`,
|
||||
NEW.`circuit_list_id`,
|
||||
NEW.`equipment_identifier`
|
||||
);
|
||||
END;--> statement-breakpoint
|
||||
CREATE TRIGGER `circuits_equipment_identifier_update`
|
||||
AFTER UPDATE OF `circuit_list_id`, `equipment_identifier` ON `circuits`
|
||||
BEGIN
|
||||
UPDATE `circuit_list_equipment_identifiers`
|
||||
SET
|
||||
`circuit_list_id` = NEW.`circuit_list_id`,
|
||||
`equipment_identifier` = NEW.`equipment_identifier`
|
||||
WHERE
|
||||
`owner_type` = 'circuit'
|
||||
AND `owner_id` = OLD.`id`;
|
||||
END;--> statement-breakpoint
|
||||
CREATE TRIGGER `circuits_equipment_identifier_delete`
|
||||
AFTER DELETE ON `circuits`
|
||||
BEGIN
|
||||
DELETE FROM `circuit_list_equipment_identifiers`
|
||||
WHERE
|
||||
`owner_type` = 'circuit'
|
||||
AND `owner_id` = OLD.`id`;
|
||||
END;--> statement-breakpoint
|
||||
CREATE TRIGGER `distribution_board_components_equipment_identifier_insert`
|
||||
AFTER INSERT ON `distribution_board_components`
|
||||
BEGIN
|
||||
INSERT INTO `circuit_list_equipment_identifiers` (
|
||||
`owner_type`,
|
||||
`owner_id`,
|
||||
`circuit_list_id`,
|
||||
`equipment_identifier`
|
||||
)
|
||||
VALUES (
|
||||
'distribution_board_component',
|
||||
NEW.`id`,
|
||||
NEW.`circuit_list_id`,
|
||||
NEW.`equipment_identifier`
|
||||
);
|
||||
END;--> statement-breakpoint
|
||||
CREATE TRIGGER `distribution_board_components_equipment_identifier_update`
|
||||
AFTER UPDATE OF `circuit_list_id`, `equipment_identifier`
|
||||
ON `distribution_board_components`
|
||||
BEGIN
|
||||
UPDATE `circuit_list_equipment_identifiers`
|
||||
SET
|
||||
`circuit_list_id` = NEW.`circuit_list_id`,
|
||||
`equipment_identifier` = NEW.`equipment_identifier`
|
||||
WHERE
|
||||
`owner_type` = 'distribution_board_component'
|
||||
AND `owner_id` = OLD.`id`;
|
||||
END;--> statement-breakpoint
|
||||
CREATE TRIGGER `distribution_board_components_equipment_identifier_delete`
|
||||
AFTER DELETE ON `distribution_board_components`
|
||||
BEGIN
|
||||
DELETE FROM `circuit_list_equipment_identifiers`
|
||||
WHERE
|
||||
`owner_type` = 'distribution_board_component'
|
||||
AND `owner_id` = OLD.`id`;
|
||||
END;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -169,6 +169,13 @@
|
||||
"when": 1785343645615,
|
||||
"tag": "0023_cheerful_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "6",
|
||||
"when": 1785431250141,
|
||||
"tag": "0024_damp_mister_sinister",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
@@ -396,6 +397,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
remark: snapshot.remark,
|
||||
})
|
||||
.run();
|
||||
if (snapshot.protectionDevice) {
|
||||
database
|
||||
.insert(circuitProtectionDevices)
|
||||
.values(snapshot.protectionDevice)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
private assertTargetCircuitUnchanged(
|
||||
@@ -438,6 +445,27 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
"Created target circuit changed before command execution."
|
||||
);
|
||||
}
|
||||
const protectionDevice = database
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, snapshot.id))
|
||||
.get();
|
||||
if (
|
||||
snapshot.protectionDevice === undefined
|
||||
? protectionDevice !== undefined
|
||||
: snapshot.protectionDevice === null
|
||||
? protectionDevice !== undefined
|
||||
: !protectionDevice ||
|
||||
Object.entries(snapshot.protectionDevice).some(
|
||||
([key, value]) =>
|
||||
(protectionDevice as Record<string, unknown>)[key] !==
|
||||
value
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Created target circuit protection changed before command execution."
|
||||
);
|
||||
}
|
||||
|
||||
const currentRowIds = database
|
||||
.select({ id: circuitDeviceRows.id })
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitGroupMoveProjectCommand,
|
||||
createCircuitGroupMoveProjectCommand,
|
||||
invertCircuitGroupMovePlan,
|
||||
} from "../../domain/models/circuit-group-move-project-command.model.js";
|
||||
import type {
|
||||
CircuitGroupMoveProjectCommandStore,
|
||||
ExecuteCircuitGroupMoveCommandInput,
|
||||
} from "../../domain/ports/circuit-group-move-project-command.store.js";
|
||||
import { parseGroupedEquipmentIdentifier } from "../../domain/services/circuit-group-numbering.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitGroupMoveProjectCommandRepository
|
||||
implements CircuitGroupMoveProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitGroupMoveCommandInput) {
|
||||
assertCircuitGroupMoveProjectCommand(input.command);
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
input: ExecuteCircuitGroupMoveCommandInput
|
||||
) {
|
||||
const plan = input.command.payload;
|
||||
const list = database
|
||||
.select({ projectId: circuitLists.projectId })
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, plan.circuitListId))
|
||||
.get();
|
||||
if (!list || list.projectId !== input.projectId) {
|
||||
throw new Error("Circuit list does not belong to project.");
|
||||
}
|
||||
const source = database
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, plan.expectedSectionId))
|
||||
.get();
|
||||
const target = database
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, plan.targetSectionId))
|
||||
.get();
|
||||
const expectedBmk = parseGroupedEquipmentIdentifier(
|
||||
plan.expectedEquipmentIdentifier
|
||||
);
|
||||
const targetBmk = parseGroupedEquipmentIdentifier(
|
||||
plan.targetEquipmentIdentifier
|
||||
);
|
||||
if (
|
||||
!source ||
|
||||
!target ||
|
||||
source.circuitListId !== plan.circuitListId ||
|
||||
target.circuitListId !== plan.circuitListId ||
|
||||
source.category === null ||
|
||||
source.category !== target.category ||
|
||||
source.category !== expectedBmk?.category ||
|
||||
source.groupNumber !== expectedBmk.groupNumber ||
|
||||
target.category !== targetBmk?.category ||
|
||||
target.groupNumber !== targetBmk.groupNumber
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit move requires matching source and target groups."
|
||||
);
|
||||
}
|
||||
const updated = database
|
||||
.update(circuits)
|
||||
.set({
|
||||
sectionId: plan.targetSectionId,
|
||||
equipmentIdentifier: plan.targetEquipmentIdentifier,
|
||||
sortOrder: plan.targetSortOrder,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.id, plan.circuitId),
|
||||
eq(circuits.circuitListId, plan.circuitListId),
|
||||
eq(circuits.sectionId, plan.expectedSectionId),
|
||||
eq(
|
||||
circuits.equipmentIdentifier,
|
||||
plan.expectedEquipmentIdentifier
|
||||
),
|
||||
eq(circuits.sortOrder, plan.expectedSortOrder)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Circuit changed before group move.");
|
||||
}
|
||||
return createCircuitGroupMoveProjectCommand(
|
||||
invertCircuitGroupMovePlan(plan)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitGroupRenumberProjectCommand,
|
||||
createCircuitGroupRenumberProjectCommand,
|
||||
type CircuitGroupRenumberProjectCommand,
|
||||
} from "../../domain/models/circuit-group-renumber-project-command.model.js";
|
||||
import type { CircuitGroupRenumberProjectCommandStore } from "../../domain/ports/circuit-group-renumber-project-command.store.js";
|
||||
import type { ExecuteCircuitGroupRenumberCommandInput } from "../../domain/ports/circuit-group-renumber-project-command.store.js";
|
||||
import type { CircuitGroupRenumberPlan } from "../../domain/services/circuit-group-renumbering.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitGroupRenumberProjectCommandRepository
|
||||
implements CircuitGroupRenumberProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitGroupRenumberCommandInput) {
|
||||
assertCircuitGroupRenumberProjectCommand(input.command);
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: CircuitGroupRenumberProjectCommand
|
||||
) {
|
||||
const { circuitListId, groups } = command.payload;
|
||||
this.assertCurrentState(database, projectId, circuitListId, groups);
|
||||
const inverse = createCircuitGroupRenumberProjectCommand(
|
||||
circuitListId,
|
||||
invertPlan(groups)
|
||||
);
|
||||
const occupiedIdentifiers = new Set(
|
||||
[
|
||||
...database
|
||||
.select({ value: circuits.equipmentIdentifier })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.circuitListId, circuitListId))
|
||||
.all(),
|
||||
...database
|
||||
.select({ value: distributionBoardComponents.equipmentIdentifier })
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
eq(distributionBoardComponents.circuitListId, circuitListId)
|
||||
)
|
||||
.all(),
|
||||
].map(({ value }) => normalizeIdentifier(value))
|
||||
);
|
||||
const temporaryIdentifiers = new Map<string, string>();
|
||||
for (const group of groups) {
|
||||
for (const assignment of [
|
||||
...group.circuits.map((entry) => ({
|
||||
id: entry.circuitId,
|
||||
expected: entry.expectedEquipmentIdentifier,
|
||||
table: "circuit" as const,
|
||||
})),
|
||||
...group.components.map((entry) => ({
|
||||
id: entry.componentId,
|
||||
expected: entry.expectedEquipmentIdentifier,
|
||||
table: "component" as const,
|
||||
})),
|
||||
]) {
|
||||
const key = `${assignment.table}:${assignment.id}`;
|
||||
const temporary = createTemporaryIdentifier(
|
||||
key,
|
||||
occupiedIdentifiers
|
||||
);
|
||||
temporaryIdentifiers.set(key, temporary);
|
||||
this.updateEntityIdentifier(
|
||||
database,
|
||||
assignment.table,
|
||||
assignment.id,
|
||||
circuitListId,
|
||||
assignment.expected,
|
||||
temporary
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const occupiedPrefixes = new Set(
|
||||
database
|
||||
.select({ prefix: circuitSections.prefix })
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||
.all()
|
||||
.map(({ prefix }) => prefix)
|
||||
);
|
||||
const temporaryPrefixes = new Map<string, string>();
|
||||
for (const group of groups) {
|
||||
const temporaryPrefix = createTemporaryValue(
|
||||
`__tmp_group_prefix_${group.groupId}`,
|
||||
occupiedPrefixes
|
||||
);
|
||||
temporaryPrefixes.set(group.groupId, temporaryPrefix);
|
||||
const updated = database
|
||||
.update(circuitSections)
|
||||
.set({ groupNumber: null, prefix: temporaryPrefix })
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, group.groupId),
|
||||
eq(circuitSections.circuitListId, circuitListId),
|
||||
eq(circuitSections.groupNumber, group.expectedGroupNumber),
|
||||
eq(circuitSections.prefix, group.expectedPrefix)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Circuit group changed during renumbering.");
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
const updated = database
|
||||
.update(circuitSections)
|
||||
.set({
|
||||
groupNumber: group.targetGroupNumber,
|
||||
prefix: group.targetPrefix,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, group.groupId),
|
||||
eq(circuitSections.circuitListId, circuitListId),
|
||||
eq(circuitSections.prefix, temporaryPrefixes.get(group.groupId)!)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Circuit group changed during final renumbering.");
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
for (const assignment of group.circuits) {
|
||||
this.updateEntityIdentifier(
|
||||
database,
|
||||
"circuit",
|
||||
assignment.circuitId,
|
||||
circuitListId,
|
||||
temporaryIdentifiers.get(`circuit:${assignment.circuitId}`)!,
|
||||
assignment.targetEquipmentIdentifier
|
||||
);
|
||||
}
|
||||
for (const assignment of group.components) {
|
||||
this.updateEntityIdentifier(
|
||||
database,
|
||||
"component",
|
||||
assignment.componentId,
|
||||
circuitListId,
|
||||
temporaryIdentifiers.get(`component:${assignment.componentId}`)!,
|
||||
assignment.targetEquipmentIdentifier
|
||||
);
|
||||
}
|
||||
}
|
||||
return inverse;
|
||||
}
|
||||
|
||||
private assertCurrentState(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitListId: string,
|
||||
groups: CircuitGroupRenumberPlan["groups"]
|
||||
) {
|
||||
const list = database
|
||||
.select({ projectId: circuitLists.projectId })
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, circuitListId))
|
||||
.get();
|
||||
if (!list || list.projectId !== projectId) {
|
||||
throw new Error("Circuit-group list does not belong to project.");
|
||||
}
|
||||
const selectedIds = new Set(groups.map((group) => group.groupId));
|
||||
const allGroups = database
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||
.all();
|
||||
for (const group of groups) {
|
||||
const current = allGroups.find((entry) => entry.id === group.groupId);
|
||||
if (
|
||||
!current ||
|
||||
current.category !== group.category ||
|
||||
current.groupNumber !== group.expectedGroupNumber ||
|
||||
current.prefix !== group.expectedPrefix
|
||||
) {
|
||||
throw new Error("Circuit group changed before renumbering.");
|
||||
}
|
||||
const currentCircuits = database
|
||||
.select({
|
||||
id: circuits.id,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
})
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, group.groupId))
|
||||
.all();
|
||||
const currentComponents = database
|
||||
.select({
|
||||
id: distributionBoardComponents.id,
|
||||
equipmentIdentifier:
|
||||
distributionBoardComponents.equipmentIdentifier,
|
||||
})
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.sectionId, group.groupId))
|
||||
.all();
|
||||
if (
|
||||
!matchesAssignments(currentCircuits, group.circuits, "circuitId") ||
|
||||
!matchesAssignments(
|
||||
currentComponents,
|
||||
group.components,
|
||||
"componentId"
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit-group renumber must include every unchanged child."
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const group of groups) {
|
||||
if (
|
||||
allGroups.some(
|
||||
(entry) =>
|
||||
!selectedIds.has(entry.id) &&
|
||||
entry.category === group.category &&
|
||||
entry.groupNumber === group.targetGroupNumber
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Target group number belongs to an unchanged group."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateEntityIdentifier(
|
||||
database: AppDatabase,
|
||||
table: "circuit" | "component",
|
||||
id: string,
|
||||
circuitListId: string,
|
||||
expected: string,
|
||||
target: string
|
||||
) {
|
||||
const schema =
|
||||
table === "circuit" ? circuits : distributionBoardComponents;
|
||||
const updated = database
|
||||
.update(schema)
|
||||
.set({ equipmentIdentifier: target })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.id, id),
|
||||
eq(schema.circuitListId, circuitListId),
|
||||
eq(schema.equipmentIdentifier, expected)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Circuit-group child changed during renumbering."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function matchesAssignments<
|
||||
TAssignment extends {
|
||||
expectedEquipmentIdentifier: string;
|
||||
},
|
||||
>(
|
||||
current: Array<{ id: string; equipmentIdentifier: string }>,
|
||||
assignments: TAssignment[],
|
||||
idField: keyof TAssignment
|
||||
) {
|
||||
if (current.length !== assignments.length) {
|
||||
return false;
|
||||
}
|
||||
const currentById = new Map(current.map((entry) => [entry.id, entry]));
|
||||
return assignments.every((assignment) => {
|
||||
const id = assignment[idField];
|
||||
return (
|
||||
typeof id === "string" &&
|
||||
currentById.get(id)?.equipmentIdentifier ===
|
||||
assignment.expectedEquipmentIdentifier
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function invertPlan(
|
||||
groups: CircuitGroupRenumberPlan["groups"]
|
||||
): CircuitGroupRenumberPlan {
|
||||
return {
|
||||
groups: groups.map((group) => ({
|
||||
...group,
|
||||
expectedGroupNumber: group.targetGroupNumber,
|
||||
targetGroupNumber: group.expectedGroupNumber,
|
||||
expectedPrefix: group.targetPrefix,
|
||||
targetPrefix: group.expectedPrefix,
|
||||
circuits: group.circuits.map((circuit) => ({
|
||||
...circuit,
|
||||
expectedEquipmentIdentifier:
|
||||
circuit.targetEquipmentIdentifier,
|
||||
targetEquipmentIdentifier:
|
||||
circuit.expectedEquipmentIdentifier,
|
||||
})),
|
||||
components: group.components.map((component) => ({
|
||||
...component,
|
||||
expectedEquipmentIdentifier:
|
||||
component.targetEquipmentIdentifier,
|
||||
targetEquipmentIdentifier:
|
||||
component.expectedEquipmentIdentifier,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createTemporaryIdentifier(
|
||||
key: string,
|
||||
occupied: Set<string>
|
||||
) {
|
||||
return createTemporaryValue(
|
||||
`__tmp_group_renumber_${key.replace(":", "_")}`,
|
||||
occupied,
|
||||
normalizeIdentifier
|
||||
);
|
||||
}
|
||||
|
||||
function createTemporaryValue(
|
||||
base: string,
|
||||
occupied: Set<string>,
|
||||
normalize: (value: string) => string = (value) => value
|
||||
) {
|
||||
let candidate = base;
|
||||
while (occupied.has(normalize(candidate))) {
|
||||
candidate += "_";
|
||||
}
|
||||
occupied.add(normalize(candidate));
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function normalizeIdentifier(value: string) {
|
||||
return value.trim().toLocaleLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitGroupDeleteProjectCommand,
|
||||
assertCircuitGroupInsertProjectCommand,
|
||||
assertCircuitGroupReorderProjectCommand,
|
||||
assertCircuitGroupUpdateProjectCommand,
|
||||
circuitGroupDeleteCommandType,
|
||||
circuitGroupInsertCommandType,
|
||||
circuitGroupReorderCommandType,
|
||||
circuitGroupUpdateCommandType,
|
||||
createCircuitGroupDeleteProjectCommand,
|
||||
createCircuitGroupInsertProjectCommand,
|
||||
createCircuitGroupReorderProjectCommand,
|
||||
createCircuitGroupUpdateProjectCommand,
|
||||
type CircuitGroupReorderAssignment,
|
||||
type CircuitGroupSnapshot,
|
||||
type CircuitGroupStructureProjectCommand,
|
||||
} from "../../domain/models/circuit-group-structure-project-command.model.js";
|
||||
import type {
|
||||
CircuitGroupStructureProjectCommandStore,
|
||||
ExecuteCircuitGroupStructureCommandInput,
|
||||
} from "../../domain/ports/circuit-group-structure-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitGroupStructureProjectCommandRepository
|
||||
implements CircuitGroupStructureProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitGroupStructureCommandInput) {
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: CircuitGroupStructureProjectCommand
|
||||
): CircuitGroupStructureProjectCommand {
|
||||
if (command.type === circuitGroupInsertCommandType) {
|
||||
assertCircuitGroupInsertProjectCommand(command);
|
||||
this.insert(database, projectId, command.payload.snapshot);
|
||||
return createCircuitGroupDeleteProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
if (command.type === circuitGroupDeleteCommandType) {
|
||||
assertCircuitGroupDeleteProjectCommand(command);
|
||||
this.delete(database, projectId, command.payload.snapshot);
|
||||
return createCircuitGroupInsertProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
if (command.type === circuitGroupUpdateCommandType) {
|
||||
assertCircuitGroupUpdateProjectCommand(command);
|
||||
this.update(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.expected,
|
||||
command.payload.target
|
||||
);
|
||||
return createCircuitGroupUpdateProjectCommand(
|
||||
command.payload.target,
|
||||
command.payload.expected
|
||||
);
|
||||
}
|
||||
if (command.type === circuitGroupReorderCommandType) {
|
||||
assertCircuitGroupReorderProjectCommand(command);
|
||||
const inverseAssignments = this.reorder(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.circuitListId,
|
||||
command.payload.assignments
|
||||
);
|
||||
return createCircuitGroupReorderProjectCommand(
|
||||
command.payload.circuitListId,
|
||||
inverseAssignments
|
||||
);
|
||||
}
|
||||
throw new Error("Unsupported circuit-group structure command.");
|
||||
}
|
||||
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: CircuitGroupSnapshot
|
||||
) {
|
||||
this.assertListOwnership(database, projectId, snapshot.circuitListId);
|
||||
const existing = database
|
||||
.select({ id: circuitSections.id })
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Circuit-group id already exists.");
|
||||
}
|
||||
database.insert(circuitSections).values(snapshot).run();
|
||||
}
|
||||
|
||||
private delete(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: CircuitGroupSnapshot
|
||||
) {
|
||||
this.assertListOwnership(database, projectId, snapshot.circuitListId);
|
||||
const current = this.getCurrent(database, snapshot.id);
|
||||
if (!current || !sameRecord(snapshot, current)) {
|
||||
throw new Error("Circuit group changed before deletion.");
|
||||
}
|
||||
const circuit = database
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, snapshot.id))
|
||||
.get();
|
||||
const component = database
|
||||
.select({ id: distributionBoardComponents.id })
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.sectionId, snapshot.id))
|
||||
.get();
|
||||
if (circuit || component) {
|
||||
throw new Error(
|
||||
"Only empty circuit groups can be deleted by this command."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(circuitSections)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, snapshot.id),
|
||||
eq(circuitSections.circuitListId, snapshot.circuitListId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Circuit group could not be deleted.");
|
||||
}
|
||||
}
|
||||
|
||||
private update(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: CircuitGroupSnapshot,
|
||||
target: CircuitGroupSnapshot
|
||||
) {
|
||||
this.assertListOwnership(database, projectId, expected.circuitListId);
|
||||
const current = this.getCurrent(database, expected.id);
|
||||
if (!current || !sameRecord(expected, current)) {
|
||||
throw new Error("Circuit group changed before update.");
|
||||
}
|
||||
const updated = database
|
||||
.update(circuitSections)
|
||||
.set({ displayName: target.displayName })
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, expected.id),
|
||||
eq(circuitSections.circuitListId, expected.circuitListId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Circuit group could not be updated.");
|
||||
}
|
||||
}
|
||||
|
||||
private assertListOwnership(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitListId: string
|
||||
) {
|
||||
const list = database
|
||||
.select({ projectId: circuitLists.projectId })
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, circuitListId))
|
||||
.get();
|
||||
if (!list || list.projectId !== projectId) {
|
||||
throw new Error("Circuit group does not belong to project.");
|
||||
}
|
||||
}
|
||||
|
||||
private reorder(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitListId: string,
|
||||
assignments: CircuitGroupReorderAssignment[]
|
||||
) {
|
||||
this.assertListOwnership(database, projectId, circuitListId);
|
||||
const persisted = database
|
||||
.select({
|
||||
id: circuitSections.id,
|
||||
sortOrder: circuitSections.sortOrder,
|
||||
})
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||
.all();
|
||||
const persistedById = new Map(
|
||||
persisted.map((group) => [group.id, group])
|
||||
);
|
||||
if (
|
||||
persisted.length !== assignments.length ||
|
||||
assignments.some((assignment) => {
|
||||
const group = persistedById.get(assignment.groupId);
|
||||
return !group || group.sortOrder !== assignment.expectedSortOrder;
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit-group reorder must include every unchanged group in the list."
|
||||
);
|
||||
}
|
||||
for (const assignment of assignments) {
|
||||
if (assignment.expectedSortOrder === assignment.targetSortOrder) {
|
||||
continue;
|
||||
}
|
||||
const updated = database
|
||||
.update(circuitSections)
|
||||
.set({ sortOrder: assignment.targetSortOrder })
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, assignment.groupId),
|
||||
eq(circuitSections.circuitListId, circuitListId),
|
||||
eq(circuitSections.sortOrder, assignment.expectedSortOrder)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Circuit group changed during reorder.");
|
||||
}
|
||||
}
|
||||
return assignments.map((assignment) => ({
|
||||
groupId: assignment.groupId,
|
||||
expectedSortOrder: assignment.targetSortOrder,
|
||||
targetSortOrder: assignment.expectedSortOrder,
|
||||
}));
|
||||
}
|
||||
|
||||
private getCurrent(database: AppDatabase, id: string) {
|
||||
return database
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, id))
|
||||
.get();
|
||||
}
|
||||
}
|
||||
|
||||
function sameRecord(expected: object, actual: object) {
|
||||
const expectedEntries = Object.entries(expected);
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
return (
|
||||
expectedEntries.length === Object.keys(actual).length &&
|
||||
expectedEntries.every(
|
||||
([key, value]) => actualRecord[key] === value
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import {
|
||||
circuitGroupDeleteSubtreeCommandType,
|
||||
circuitGroupRestoreSubtreeCommandType,
|
||||
assertCircuitGroupDeleteSubtreeProjectCommand,
|
||||
assertCircuitGroupRestoreSubtreeProjectCommand,
|
||||
createCircuitGroupDeleteSubtreeProjectCommand,
|
||||
createCircuitGroupRestoreSubtreeProjectCommand,
|
||||
type CircuitGroupSubtreeProjectCommand,
|
||||
} from "../../domain/models/circuit-group-subtree-project-command.model.js";
|
||||
import type {
|
||||
CircuitGroupSubtreeSnapshot,
|
||||
CircuitProtectionDeviceSnapshot,
|
||||
} from "../../domain/models/circuit-group-subtree-snapshot.model.js";
|
||||
import type {
|
||||
CircuitGroupSubtreeProjectCommandStore,
|
||||
ExecuteCircuitGroupSubtreeCommandInput,
|
||||
} from "../../domain/ports/circuit-group-subtree-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitGroupSubtreeProjectCommandRepository
|
||||
implements CircuitGroupSubtreeProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
capture(projectId: string, groupId: string) {
|
||||
return this.captureFrom(this.database, projectId, groupId);
|
||||
}
|
||||
|
||||
execute(input: ExecuteCircuitGroupSubtreeCommandInput) {
|
||||
if (input.command.type === circuitGroupDeleteSubtreeCommandType) {
|
||||
assertCircuitGroupDeleteSubtreeProjectCommand(input.command);
|
||||
} else {
|
||||
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
|
||||
}
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: CircuitGroupSubtreeProjectCommand
|
||||
) {
|
||||
if (command.type === circuitGroupDeleteSubtreeCommandType) {
|
||||
const current = this.captureFrom(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.snapshot.group.id
|
||||
);
|
||||
if (!sameSnapshot(current, command.payload.snapshot)) {
|
||||
throw new Error(
|
||||
"Circuit-group subtree changed before deletion."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(circuitSections)
|
||||
.where(eq(circuitSections.id, current.group.id))
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Circuit-group subtree could not be deleted.");
|
||||
}
|
||||
return createCircuitGroupRestoreSubtreeProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
if (command.type === circuitGroupRestoreSubtreeCommandType) {
|
||||
this.restore(database, projectId, command.payload.snapshot);
|
||||
return createCircuitGroupDeleteSubtreeProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
throw new Error("Unsupported circuit-group subtree command.");
|
||||
}
|
||||
|
||||
private captureFrom(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
groupId: string
|
||||
): CircuitGroupSubtreeSnapshot {
|
||||
const group = database
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, groupId))
|
||||
.get();
|
||||
if (!group || group.category === null || group.groupNumber === null) {
|
||||
throw new Error("Circuit group not found.");
|
||||
}
|
||||
this.assertListOwnership(database, projectId, group.circuitListId);
|
||||
const components = database
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.sectionId, group.id))
|
||||
.orderBy(
|
||||
asc(distributionBoardComponents.sortOrder),
|
||||
asc(distributionBoardComponents.id)
|
||||
)
|
||||
.all()
|
||||
.map((component) => ({
|
||||
component: {
|
||||
...component,
|
||||
role: component.role as
|
||||
| "group_upstream_protection"
|
||||
| "group_residual_current_protection",
|
||||
},
|
||||
protectionDevice:
|
||||
database
|
||||
.select()
|
||||
.from(distributionBoardComponentProtectionDevices)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponentProtectionDevices.componentId,
|
||||
component.id
|
||||
)
|
||||
)
|
||||
.get() ?? null,
|
||||
}));
|
||||
const circuitSnapshots = database
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, group.id))
|
||||
.orderBy(asc(circuits.sortOrder), asc(circuits.id))
|
||||
.all()
|
||||
.map((circuit) => {
|
||||
const { isReserve, ...values } = circuit;
|
||||
return {
|
||||
circuit: {
|
||||
...values,
|
||||
isReserve: Boolean(isReserve),
|
||||
deviceRows: database
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, circuit.id))
|
||||
.orderBy(
|
||||
asc(circuitDeviceRows.sortOrder),
|
||||
asc(circuitDeviceRows.id)
|
||||
)
|
||||
.all(),
|
||||
},
|
||||
protectionDevice:
|
||||
(database
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
|
||||
.get() as CircuitProtectionDeviceSnapshot | undefined) ??
|
||||
null,
|
||||
};
|
||||
});
|
||||
return {
|
||||
group: {
|
||||
...group,
|
||||
category: group.category,
|
||||
groupNumber: group.groupNumber,
|
||||
},
|
||||
components,
|
||||
circuits: circuitSnapshots,
|
||||
};
|
||||
}
|
||||
|
||||
private restore(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: CircuitGroupSubtreeSnapshot
|
||||
) {
|
||||
this.assertListOwnership(
|
||||
database,
|
||||
projectId,
|
||||
snapshot.group.circuitListId
|
||||
);
|
||||
const existing = database
|
||||
.select({ id: circuitSections.id })
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.group.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Circuit group already exists before restoration.");
|
||||
}
|
||||
database.insert(circuitSections).values(snapshot.group).run();
|
||||
for (const component of snapshot.components) {
|
||||
database
|
||||
.insert(distributionBoardComponents)
|
||||
.values(component.component)
|
||||
.run();
|
||||
if (component.protectionDevice !== null) {
|
||||
database
|
||||
.insert(distributionBoardComponentProtectionDevices)
|
||||
.values(component.protectionDevice)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
for (const entry of snapshot.circuits) {
|
||||
const { deviceRows, isReserve, ...circuit } = entry.circuit;
|
||||
database
|
||||
.insert(circuits)
|
||||
.values({ ...circuit, isReserve: isReserve ? 1 : 0 })
|
||||
.run();
|
||||
if (entry.protectionDevice !== null) {
|
||||
database
|
||||
.insert(circuitProtectionDevices)
|
||||
.values(entry.protectionDevice)
|
||||
.run();
|
||||
}
|
||||
if (deviceRows.length > 0) {
|
||||
database.insert(circuitDeviceRows).values(deviceRows).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private assertListOwnership(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitListId: string
|
||||
) {
|
||||
const list = database
|
||||
.select({ projectId: circuitLists.projectId })
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, circuitListId))
|
||||
.get();
|
||||
if (!list || list.projectId !== projectId) {
|
||||
throw new Error("Circuit-group list does not belong to project.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameSnapshot(
|
||||
left: CircuitGroupSubtreeSnapshot,
|
||||
right: CircuitGroupSubtreeSnapshot
|
||||
) {
|
||||
return canonicalJson(left) === canonicalJson(right);
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(canonicalJson).join(",")}]`;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return `{${Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(
|
||||
([key, child]) =>
|
||||
`${JSON.stringify(key)}:${canonicalJson(child)}`
|
||||
)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { inArray } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
|
||||
export class CircuitProtectionDeviceRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByCircuitIds(circuitIds: string[]) {
|
||||
if (circuitIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return this.database
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(inArray(circuitProtectionDevices.circuitId, circuitIds));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitProtectionUpdateProjectCommand,
|
||||
createCircuitProtectionUpdateProjectCommand,
|
||||
type CircuitProtectionSnapshot,
|
||||
} from "../../domain/models/circuit-protection-project-command.model.js";
|
||||
import type {
|
||||
CircuitProtectionProjectCommandStore,
|
||||
ExecuteCircuitProtectionCommandInput,
|
||||
} from "../../domain/ports/circuit-protection-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitProtectionProjectCommandRepository
|
||||
implements CircuitProtectionProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitProtectionCommandInput) {
|
||||
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||
assertCircuitProtectionUpdateProjectCommand(input.command);
|
||||
const { circuitId, expected, target } = input.command.payload;
|
||||
if (input.source === "user" && target === null) {
|
||||
throw new Error("User commands cannot remove circuit protection.");
|
||||
}
|
||||
this.assertOwnership(tx, input.projectId, circuitId);
|
||||
const actual = tx
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, circuitId))
|
||||
.get();
|
||||
if (!sameNullableSnapshot(expected, actual)) {
|
||||
throw new Error("Circuit protection changed before update.");
|
||||
}
|
||||
if (target === null) {
|
||||
tx.delete(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, circuitId))
|
||||
.run();
|
||||
} else if (actual) {
|
||||
tx.update(circuitProtectionDevices)
|
||||
.set(target)
|
||||
.where(eq(circuitProtectionDevices.circuitId, circuitId))
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(circuitProtectionDevices).values(target).run();
|
||||
}
|
||||
return createCircuitProtectionUpdateProjectCommand(
|
||||
circuitId,
|
||||
target,
|
||||
expected
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private assertOwnership(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitId: string
|
||||
) {
|
||||
const circuit = database
|
||||
.select({ projectId: circuitLists.projectId })
|
||||
.from(circuits)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, circuits.circuitListId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.id, circuitId),
|
||||
eq(circuitLists.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!circuit) {
|
||||
throw new Error("Circuit protection does not belong to project.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameNullableSnapshot(
|
||||
expected: CircuitProtectionSnapshot | null,
|
||||
actual: typeof circuitProtectionDevices.$inferSelect | undefined
|
||||
) {
|
||||
if (expected === null) {
|
||||
return actual === undefined;
|
||||
}
|
||||
if (!actual) {
|
||||
return false;
|
||||
}
|
||||
return Object.entries(expected).every(
|
||||
([key, value]) =>
|
||||
(actual as Record<string, unknown>)[key] === value
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { isElectricalPhaseType } from "../../domain/services/project-voltage.ser
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import {
|
||||
@@ -175,6 +176,12 @@ export class CircuitStructureProjectCommandRepository
|
||||
if (snapshot.deviceRows.length > 0) {
|
||||
database.insert(circuitDeviceRows).values(snapshot.deviceRows).run();
|
||||
}
|
||||
if (snapshot.protectionDevice) {
|
||||
database
|
||||
.insert(circuitProtectionDevices)
|
||||
.values(snapshot.protectionDevice)
|
||||
.run();
|
||||
}
|
||||
|
||||
return createCircuitDeleteProjectCommand(
|
||||
snapshot.id,
|
||||
@@ -219,6 +226,11 @@ export class CircuitStructureProjectCommandRepository
|
||||
asc(circuitDeviceRows.id)
|
||||
)
|
||||
.all();
|
||||
const protectionDevice = database
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
|
||||
.get();
|
||||
const inverse = createCircuitInsertProjectCommand({
|
||||
id: circuit.id,
|
||||
circuitListId: circuit.circuitListId,
|
||||
@@ -240,6 +252,7 @@ export class CircuitStructureProjectCommandRepository
|
||||
isReserve: Boolean(circuit.isReserve),
|
||||
remark: circuit.remark,
|
||||
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
|
||||
protectionDevice: protectionDevice ?? null,
|
||||
});
|
||||
|
||||
const result = database
|
||||
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertDistributionBoardComponentDeleteProjectCommand,
|
||||
assertDistributionBoardComponentInsertProjectCommand,
|
||||
assertDistributionBoardComponentUpdateProjectCommand,
|
||||
createDistributionBoardComponentDeleteProjectCommand,
|
||||
createDistributionBoardComponentInsertProjectCommand,
|
||||
createDistributionBoardComponentUpdateProjectCommand,
|
||||
distributionBoardComponentDeleteCommandType,
|
||||
distributionBoardComponentInsertCommandType,
|
||||
distributionBoardComponentUpdateCommandType,
|
||||
type DistributionBoardComponentSnapshot,
|
||||
type DistributionBoardComponentStructureProjectCommand,
|
||||
} from "../../domain/models/distribution-board-component-structure-project-command.model.js";
|
||||
import type {
|
||||
DistributionBoardComponentStructureProjectCommandStore,
|
||||
ExecuteDistributionBoardComponentStructureCommandInput,
|
||||
} from "../../domain/ports/distribution-board-component-structure-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class DistributionBoardComponentStructureProjectCommandRepository
|
||||
implements DistributionBoardComponentStructureProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(
|
||||
input: ExecuteDistributionBoardComponentStructureCommandInput
|
||||
) {
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(
|
||||
tx,
|
||||
input.projectId,
|
||||
input.command
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: DistributionBoardComponentStructureProjectCommand
|
||||
): DistributionBoardComponentStructureProjectCommand {
|
||||
if (command.type === distributionBoardComponentInsertCommandType) {
|
||||
assertDistributionBoardComponentInsertProjectCommand(command);
|
||||
this.insert(database, projectId, command.payload.snapshot);
|
||||
return createDistributionBoardComponentDeleteProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
if (command.type === distributionBoardComponentDeleteCommandType) {
|
||||
assertDistributionBoardComponentDeleteProjectCommand(command);
|
||||
this.delete(database, projectId, command.payload.snapshot);
|
||||
return createDistributionBoardComponentInsertProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
if (command.type === distributionBoardComponentUpdateCommandType) {
|
||||
assertDistributionBoardComponentUpdateProjectCommand(command);
|
||||
this.update(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.expected,
|
||||
command.payload.target
|
||||
);
|
||||
return createDistributionBoardComponentUpdateProjectCommand(
|
||||
command.payload.target,
|
||||
command.payload.expected
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
"Unsupported distribution-board component structure command."
|
||||
);
|
||||
}
|
||||
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
this.assertOwnership(database, projectId, snapshot);
|
||||
const existing = database
|
||||
.select({ id: distributionBoardComponents.id })
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, snapshot.component.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Distribution-board component id already exists.");
|
||||
}
|
||||
database
|
||||
.insert(distributionBoardComponents)
|
||||
.values(snapshot.component)
|
||||
.run();
|
||||
if (snapshot.protectionDevice !== null) {
|
||||
database
|
||||
.insert(distributionBoardComponentProtectionDevices)
|
||||
.values(snapshot.protectionDevice)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
private delete(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
this.assertOwnership(database, projectId, snapshot);
|
||||
const component = database
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, snapshot.component.id))
|
||||
.get();
|
||||
const protection = database
|
||||
.select()
|
||||
.from(distributionBoardComponentProtectionDevices)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponentProtectionDevices.componentId,
|
||||
snapshot.component.id
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (
|
||||
!component ||
|
||||
!sameRecord(snapshot.component, component) ||
|
||||
!sameNullableRecord(snapshot.protectionDevice, protection)
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board component changed before deletion."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(distributionBoardComponents)
|
||||
.where(
|
||||
and(
|
||||
eq(distributionBoardComponents.id, component.id),
|
||||
eq(
|
||||
distributionBoardComponents.circuitListId,
|
||||
snapshot.component.circuitListId
|
||||
)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error(
|
||||
"Distribution-board component could not be deleted."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private update(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: DistributionBoardComponentSnapshot,
|
||||
target: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
this.assertOwnership(database, projectId, expected);
|
||||
this.assertOwnership(database, projectId, target);
|
||||
const component = database
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, expected.component.id))
|
||||
.get();
|
||||
const protection = database
|
||||
.select()
|
||||
.from(distributionBoardComponentProtectionDevices)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponentProtectionDevices.componentId,
|
||||
expected.component.id
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (
|
||||
!component ||
|
||||
!sameRecord(expected.component, component) ||
|
||||
!sameNullableRecord(expected.protectionDevice, protection)
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board component changed before update."
|
||||
);
|
||||
}
|
||||
database
|
||||
.update(distributionBoardComponents)
|
||||
.set(target.component)
|
||||
.where(eq(distributionBoardComponents.id, expected.component.id))
|
||||
.run();
|
||||
if (
|
||||
expected.protectionDevice !== null &&
|
||||
target.protectionDevice !== null
|
||||
) {
|
||||
database
|
||||
.update(distributionBoardComponentProtectionDevices)
|
||||
.set(target.protectionDevice)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponentProtectionDevices.componentId,
|
||||
expected.component.id
|
||||
)
|
||||
)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
private assertOwnership(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
const list = database
|
||||
.select({ projectId: circuitLists.projectId })
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.id, snapshot.component.circuitListId))
|
||||
.get();
|
||||
if (!list || list.projectId !== projectId) {
|
||||
throw new Error(
|
||||
"Distribution-board component circuit list does not belong to project."
|
||||
);
|
||||
}
|
||||
if (snapshot.component.sectionId !== null) {
|
||||
const section = database
|
||||
.select({ circuitListId: circuitSections.circuitListId })
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.component.sectionId))
|
||||
.get();
|
||||
if (
|
||||
!section ||
|
||||
section.circuitListId !== snapshot.component.circuitListId
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board component section does not belong to circuit list."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameNullableRecord(
|
||||
expected: object | null,
|
||||
actual: object | undefined
|
||||
) {
|
||||
return expected === null
|
||||
? actual === undefined
|
||||
: actual !== undefined && sameRecord(expected, actual);
|
||||
}
|
||||
|
||||
function sameRecord(expected: object, actual: object) {
|
||||
const expectedEntries = Object.entries(expected);
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
return (
|
||||
expectedEntries.length === Object.keys(actual).length &&
|
||||
expectedEntries.every(
|
||||
([key, value]) => actualRecord[key] === value
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
|
||||
export class DistributionBoardComponentRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByCircuitList(circuitListId: string) {
|
||||
return this.database
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
eq(distributionBoardComponents.circuitListId, circuitListId)
|
||||
)
|
||||
.orderBy(
|
||||
asc(distributionBoardComponents.sortOrder),
|
||||
asc(distributionBoardComponents.id)
|
||||
);
|
||||
}
|
||||
|
||||
async listProtectionByComponentIds(componentIds: string[]) {
|
||||
if (componentIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return this.database
|
||||
.select()
|
||||
.from(distributionBoardComponentProtectionDevices)
|
||||
.where(
|
||||
inArray(
|
||||
distributionBoardComponentProtectionDevices.componentId,
|
||||
componentIds
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,12 @@ import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
assertDistributionBoardDeleteProjectCommand,
|
||||
assertDistributionBoardInsertProjectCommand,
|
||||
createDistributionBoardDeleteProjectCommand,
|
||||
createDistributionBoardInsertProjectCommand,
|
||||
distributionBoardDeleteCommandType,
|
||||
distributionBoardInsertCommandType,
|
||||
invertDistributionBoardStructureProjectCommand,
|
||||
normalizeDistributionBoardStructureProjectCommand,
|
||||
type DistributionBoardStructureProjectCommand,
|
||||
type DistributionBoardStructureSnapshot,
|
||||
legacyDistributionBoardStructureCommandSchemaVersion,
|
||||
type NormalizedDistributionBoardStructureSnapshot,
|
||||
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardUpdateProjectCommand,
|
||||
@@ -28,6 +26,7 @@ import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
@@ -68,31 +67,15 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
projectId: string,
|
||||
command: DistributionBoardStructureProjectCommand
|
||||
): DistributionBoardStructureProjectCommand {
|
||||
const normalized =
|
||||
const structure =
|
||||
normalizeDistributionBoardStructureProjectCommand(command);
|
||||
if (normalized.type === distributionBoardInsertCommandType) {
|
||||
assertDistributionBoardInsertProjectCommand(normalized);
|
||||
const inverse = this.insert(
|
||||
database,
|
||||
projectId,
|
||||
normalized.payload.structure
|
||||
);
|
||||
return command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
? toLegacyStructureCommand(inverse)
|
||||
: inverse;
|
||||
if (command.type === distributionBoardInsertCommandType) {
|
||||
this.insert(database, projectId, structure);
|
||||
return invertDistributionBoardStructureProjectCommand(command);
|
||||
}
|
||||
if (normalized.type === distributionBoardDeleteCommandType) {
|
||||
assertDistributionBoardDeleteProjectCommand(normalized);
|
||||
const inverse = this.delete(
|
||||
database,
|
||||
projectId,
|
||||
normalized.payload.structure
|
||||
);
|
||||
return command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
? toLegacyStructureCommand(inverse)
|
||||
: inverse;
|
||||
if (command.type === distributionBoardDeleteCommandType) {
|
||||
this.delete(database, projectId, structure);
|
||||
return invertDistributionBoardStructureProjectCommand(command);
|
||||
}
|
||||
throw new Error("Unsupported distribution-board structure command.");
|
||||
}
|
||||
@@ -100,7 +83,7 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
structure: DistributionBoardStructureSnapshot
|
||||
structure: NormalizedDistributionBoardStructureSnapshot
|
||||
) {
|
||||
if (
|
||||
structure.distributionBoard.projectId !== projectId ||
|
||||
@@ -159,7 +142,26 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
)
|
||||
.limit(1)
|
||||
.get();
|
||||
if (existingBoard || existingList || existingSection) {
|
||||
const existingComponent =
|
||||
structure.components.length === 0
|
||||
? undefined
|
||||
: database
|
||||
.select({ id: distributionBoardComponents.id })
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
inArray(
|
||||
distributionBoardComponents.id,
|
||||
structure.components.map((component) => component.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.get();
|
||||
if (
|
||||
existingBoard ||
|
||||
existingList ||
|
||||
existingSection ||
|
||||
existingComponent
|
||||
) {
|
||||
throw new Error("Distribution-board structure id already exists.");
|
||||
}
|
||||
|
||||
@@ -168,14 +170,22 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
.values(structure.distributionBoard)
|
||||
.run();
|
||||
database.insert(circuitLists).values(structure.circuitList).run();
|
||||
database.insert(circuitSections).values(structure.sections).run();
|
||||
return createDistributionBoardDeleteProjectCommand(structure);
|
||||
database
|
||||
.insert(circuitSections)
|
||||
.values(structure.sections)
|
||||
.run();
|
||||
if (structure.components.length > 0) {
|
||||
database
|
||||
.insert(distributionBoardComponents)
|
||||
.values(structure.components)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
private delete(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
structure: DistributionBoardStructureSnapshot
|
||||
structure: NormalizedDistributionBoardStructureSnapshot
|
||||
) {
|
||||
const board = database
|
||||
.select()
|
||||
@@ -198,11 +208,30 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
asc(circuitSections.id)
|
||||
)
|
||||
.all();
|
||||
const components = database
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponents.circuitListId,
|
||||
structure.circuitList.id
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
asc(distributionBoardComponents.sortOrder),
|
||||
asc(distributionBoardComponents.id)
|
||||
)
|
||||
.all();
|
||||
if (
|
||||
!board ||
|
||||
!list ||
|
||||
board.projectId !== projectId ||
|
||||
!structureMatches(structure, { board, list, sections })
|
||||
!structureMatches(structure, {
|
||||
board,
|
||||
list,
|
||||
sections,
|
||||
components,
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board structure changed before deletion."
|
||||
@@ -235,7 +264,6 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Distribution board could not be deleted.");
|
||||
}
|
||||
return createDistributionBoardInsertProjectCommand(structure);
|
||||
}
|
||||
|
||||
private update(
|
||||
@@ -350,28 +378,6 @@ function assertSupplyTypeEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
function toLegacyStructureCommand(
|
||||
command:
|
||||
| ReturnType<typeof createDistributionBoardInsertProjectCommand>
|
||||
| ReturnType<typeof createDistributionBoardDeleteProjectCommand>
|
||||
): DistributionBoardStructureProjectCommand {
|
||||
const {
|
||||
floorId: _floorId,
|
||||
supplyType: _supplyType,
|
||||
...distributionBoard
|
||||
} = command.payload.structure.distributionBoard;
|
||||
return {
|
||||
schemaVersion: legacyDistributionBoardStructureCommandSchemaVersion,
|
||||
type: command.type,
|
||||
payload: {
|
||||
structure: {
|
||||
...command.payload.structure,
|
||||
distributionBoard,
|
||||
},
|
||||
},
|
||||
} as DistributionBoardStructureProjectCommand;
|
||||
}
|
||||
|
||||
function getDistributionBoardFieldValue<
|
||||
TField extends DistributionBoardUpdateField,
|
||||
>(
|
||||
@@ -382,11 +388,12 @@ function getDistributionBoardFieldValue<
|
||||
}
|
||||
|
||||
function structureMatches(
|
||||
expected: DistributionBoardStructureSnapshot,
|
||||
expected: NormalizedDistributionBoardStructureSnapshot,
|
||||
actual: {
|
||||
board: typeof distributionBoards.$inferSelect;
|
||||
list: typeof circuitLists.$inferSelect;
|
||||
sections: Array<typeof circuitSections.$inferSelect>;
|
||||
components: Array<typeof distributionBoardComponents.$inferSelect>;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
@@ -400,7 +407,8 @@ function structureMatches(
|
||||
actualDistributionBoard
|
||||
) ||
|
||||
!sameRecord(expected.circuitList, actual.list) ||
|
||||
expected.sections.length !== actual.sections.length
|
||||
expected.sections.length !== actual.sections.length ||
|
||||
expected.components.length !== actual.components.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -409,20 +417,32 @@ function structureMatches(
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
return expectedSections.every((section, index) =>
|
||||
const sectionsMatch = expectedSections.every((section, index) =>
|
||||
sameRecord(section, actual.sections[index])
|
||||
);
|
||||
const expectedComponents = [...expected.components].sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
return (
|
||||
sectionsMatch &&
|
||||
expectedComponents.every((component, index) =>
|
||||
sameRecord(component, actual.components[index])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function sameRecord(
|
||||
expected: Record<string, unknown>,
|
||||
actual: Record<string, unknown>
|
||||
expected: object,
|
||||
actual: object
|
||||
) {
|
||||
const expectedEntries = Object.entries(expected);
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
return (
|
||||
expectedEntries.length === Object.keys(actual).length &&
|
||||
expectedEntries.every(
|
||||
([key, value]) => actual[key] === value
|
||||
([key, value]) => actualRecord[key] === value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
legacyProjectStateSnapshotSchemaVersion,
|
||||
previousProjectStateSnapshotSchemaVersion,
|
||||
projectStateSnapshotSchemaVersion,
|
||||
simultaneityFactorProjectStateSnapshotSchemaVersion,
|
||||
supplyTypesProjectStateSnapshotSchemaVersion,
|
||||
voltageProjectStateSnapshotSchemaVersion,
|
||||
} from "../../domain/models/project-state-snapshot.model.js";
|
||||
@@ -152,6 +153,8 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
||||
supplyTypesProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
voltageProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
simultaneityFactorProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !== projectStateSnapshotSchemaVersion
|
||||
) {
|
||||
throw new Error("Project snapshot schema version is not supported.");
|
||||
|
||||
@@ -15,9 +15,12 @@ import type {
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { consumers } from "../schema/consumers.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
||||
@@ -141,6 +144,17 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||
distributionBoards: byId(state.distributionBoards),
|
||||
circuitLists: byId(state.circuitLists),
|
||||
circuitSections: byId(state.circuitSections),
|
||||
distributionBoardComponents: byId(
|
||||
state.distributionBoardComponents
|
||||
),
|
||||
circuitProtectionDevices: byKey(
|
||||
state.circuitProtectionDevices,
|
||||
"circuitId"
|
||||
),
|
||||
distributionBoardComponentProtectionDevices: byKey(
|
||||
state.distributionBoardComponentProtectionDevices,
|
||||
"componentId"
|
||||
),
|
||||
circuits: byId(
|
||||
state.circuits.map((circuit) => ({
|
||||
...circuit,
|
||||
@@ -159,6 +173,15 @@ function byId<T extends { id: string }>(entries: readonly T[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function byKey<
|
||||
TKey extends string,
|
||||
TEntry extends Record<TKey, string>,
|
||||
>(entries: readonly TEntry[], key: TKey) {
|
||||
return [...entries].sort((left, right) =>
|
||||
left[key].localeCompare(right[key])
|
||||
);
|
||||
}
|
||||
|
||||
function replaceProjectState(
|
||||
database: AppDatabase,
|
||||
state: ProjectStateSnapshot
|
||||
@@ -214,6 +237,21 @@ function replaceProjectState(
|
||||
isReserve: circuit.isReserve ? 1 : 0,
|
||||
}))
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
distributionBoardComponents,
|
||||
state.distributionBoardComponents
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
circuitProtectionDevices,
|
||||
state.circuitProtectionDevices
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
distributionBoardComponentProtectionDevices,
|
||||
state.distributionBoardComponentProtectionDevices
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
circuitDeviceRows,
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
@@ -69,6 +72,8 @@ export function readProjectStateSnapshot(
|
||||
displayName: circuitSections.displayName,
|
||||
prefix: circuitSections.prefix,
|
||||
sortOrder: circuitSections.sortOrder,
|
||||
category: circuitSections.category,
|
||||
groupNumber: circuitSections.groupNumber,
|
||||
})
|
||||
.from(circuitSections)
|
||||
.innerJoin(
|
||||
@@ -82,6 +87,56 @@ export function readProjectStateSnapshot(
|
||||
asc(circuitSections.id)
|
||||
)
|
||||
.all();
|
||||
const componentRows = database
|
||||
.select({ component: distributionBoardComponents })
|
||||
.from(distributionBoardComponents)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, distributionBoardComponents.circuitListId)
|
||||
)
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.orderBy(
|
||||
asc(distributionBoardComponents.circuitListId),
|
||||
asc(distributionBoardComponents.placement),
|
||||
asc(distributionBoardComponents.sortOrder),
|
||||
asc(distributionBoardComponents.id)
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.component);
|
||||
const circuitProtectionRows = database
|
||||
.select({ protection: circuitProtectionDevices })
|
||||
.from(circuitProtectionDevices)
|
||||
.innerJoin(
|
||||
circuits,
|
||||
eq(circuits.id, circuitProtectionDevices.circuitId)
|
||||
)
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.orderBy(asc(circuitProtectionDevices.circuitId))
|
||||
.all()
|
||||
.map((row) => row.protection);
|
||||
const componentProtectionRows = database
|
||||
.select({
|
||||
protection: distributionBoardComponentProtectionDevices,
|
||||
})
|
||||
.from(distributionBoardComponentProtectionDevices)
|
||||
.innerJoin(
|
||||
distributionBoardComponents,
|
||||
eq(
|
||||
distributionBoardComponents.id,
|
||||
distributionBoardComponentProtectionDevices.componentId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, distributionBoardComponents.circuitListId)
|
||||
)
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.orderBy(
|
||||
asc(distributionBoardComponentProtectionDevices.componentId)
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.protection);
|
||||
const circuitRows = database
|
||||
.select({
|
||||
id: circuits.id,
|
||||
@@ -190,6 +245,10 @@ export function readProjectStateSnapshot(
|
||||
distributionBoards: boardRows,
|
||||
circuitLists: listRows,
|
||||
circuitSections: sectionRows,
|
||||
distributionBoardComponents: componentRows,
|
||||
circuitProtectionDevices: circuitProtectionRows,
|
||||
distributionBoardComponentProtectionDevices:
|
||||
componentProtectionRows,
|
||||
circuits: circuitRows.map((circuit) => ({
|
||||
...circuit,
|
||||
isReserve: Boolean(circuit.isReserve),
|
||||
|
||||
@@ -10,8 +10,11 @@ import type { ProjectTransferStore } from "../../domain/ports/project-transfer.s
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
@@ -127,6 +130,21 @@ function insertProjectState(
|
||||
isReserve: circuit.isReserve ? 1 : 0,
|
||||
}))
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
distributionBoardComponents,
|
||||
state.distributionBoardComponents
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
circuitProtectionDevices,
|
||||
state.circuitProtectionDevices
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
distributionBoardComponentProtectionDevices,
|
||||
state.distributionBoardComponentProtectionDevices
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
circuitDeviceRows,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||
import { circuitLists } from "./circuit-lists.js";
|
||||
|
||||
export const circuitListEquipmentIdentifiers = sqliteTable(
|
||||
"circuit_list_equipment_identifiers",
|
||||
{
|
||||
ownerType: text("owner_type")
|
||||
.$type<"circuit" | "distribution_board_component">()
|
||||
.notNull(),
|
||||
ownerId: text("owner_id").notNull(),
|
||||
circuitListId: text("circuit_list_id")
|
||||
.notNull()
|
||||
.references(() => circuitLists.id, { onDelete: "cascade" }),
|
||||
equipmentIdentifier: text("equipment_identifier").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
unique("circuit_list_equipment_identifiers_owner_unique").on(
|
||||
table.ownerType,
|
||||
table.ownerId
|
||||
),
|
||||
unique("circuit_list_equipment_identifiers_list_identifier_unique").on(
|
||||
table.circuitListId,
|
||||
table.equipmentIdentifier
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
import { circuits } from "./circuits.js";
|
||||
|
||||
export const circuitProtectionDevices = sqliteTable(
|
||||
"circuit_protection_devices",
|
||||
{
|
||||
circuitId: text("circuit_id")
|
||||
.primaryKey()
|
||||
.references(() => circuits.id, { onDelete: "cascade" }),
|
||||
type: text("type").$type<ProtectionDeviceType>().notNull(),
|
||||
ratedCurrentA: real("rated_current_a").notNull(),
|
||||
fuseUtilizationCategory: text(
|
||||
"fuse_utilization_category"
|
||||
).$type<FuseUtilizationCategory>(),
|
||||
tripCharacteristic: text(
|
||||
"trip_characteristic"
|
||||
).$type<BreakerTripCharacteristic>(),
|
||||
rcdType: text("rcd_type").$type<RcdType>(),
|
||||
ratedResidualCurrentMa: real("rated_residual_current_ma"),
|
||||
}
|
||||
);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||
import { circuitLists } from "./circuit-lists.js";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
|
||||
export const circuitSections = sqliteTable(
|
||||
"circuit_sections",
|
||||
@@ -12,10 +13,17 @@ export const circuitSections = sqliteTable(
|
||||
displayName: text("display_name").notNull(),
|
||||
prefix: text("prefix").notNull(),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
category: text("category").$type<CircuitGroupCategory>(),
|
||||
groupNumber: integer("group_number"),
|
||||
},
|
||||
(table) => [
|
||||
unique("circuit_sections_list_key_unique").on(table.circuitListId, table.key),
|
||||
unique("circuit_sections_list_prefix_unique").on(table.circuitListId, table.prefix),
|
||||
unique("circuit_sections_list_category_group_unique").on(
|
||||
table.circuitListId,
|
||||
table.category,
|
||||
table.groupNumber
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
import { distributionBoardComponents } from "./distribution-board-components.js";
|
||||
|
||||
export const distributionBoardComponentProtectionDevices = sqliteTable(
|
||||
"distribution_board_component_protection_devices",
|
||||
{
|
||||
componentId: text("component_id")
|
||||
.primaryKey()
|
||||
.references(() => distributionBoardComponents.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
type: text("type").$type<ProtectionDeviceType>().notNull(),
|
||||
ratedCurrentA: real("rated_current_a").notNull(),
|
||||
fuseUtilizationCategory: text(
|
||||
"fuse_utilization_category"
|
||||
).$type<FuseUtilizationCategory>(),
|
||||
tripCharacteristic: text(
|
||||
"trip_characteristic"
|
||||
).$type<BreakerTripCharacteristic>(),
|
||||
rcdType: text("rcd_type").$type<RcdType>(),
|
||||
ratedResidualCurrentMa: real("rated_residual_current_ma"),
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||
import type {
|
||||
DistributionBoardComponentPlacement,
|
||||
DistributionBoardComponentRole,
|
||||
} from "../../shared/constants/distribution-board-component.js";
|
||||
import { circuitLists } from "./circuit-lists.js";
|
||||
import { circuitSections } from "./circuit-sections.js";
|
||||
|
||||
export const distributionBoardComponents = sqliteTable(
|
||||
"distribution_board_components",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
circuitListId: text("circuit_list_id")
|
||||
.notNull()
|
||||
.references(() => circuitLists.id, { onDelete: "cascade" }),
|
||||
sectionId: text("section_id").references(() => circuitSections.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
equipmentIdentifier: text("equipment_identifier").notNull(),
|
||||
name: text("name").notNull(),
|
||||
role: text("role").$type<DistributionBoardComponentRole>().notNull(),
|
||||
placement: text("placement")
|
||||
.$type<DistributionBoardComponentPlacement>()
|
||||
.notNull(),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
},
|
||||
(table) => [
|
||||
unique("distribution_board_components_list_identifier_unique").on(
|
||||
table.circuitListId,
|
||||
table.equipmentIdentifier
|
||||
),
|
||||
unique("distribution_board_components_section_role_unique").on(
|
||||
table.sectionId,
|
||||
table.role
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,92 @@
|
||||
import { parseGroupedEquipmentIdentifier } from "../services/circuit-group-numbering.js";
|
||||
import type { CircuitGroupMovePlan } from "../services/circuit-group-move-planning.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitGroupMoveCommandType = "circuit.move-group" as const;
|
||||
export const circuitGroupMoveCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface CircuitGroupMoveProjectCommand
|
||||
extends SerializedProjectCommand<CircuitGroupMovePlan> {
|
||||
schemaVersion: typeof circuitGroupMoveCommandSchemaVersion;
|
||||
type: typeof circuitGroupMoveCommandType;
|
||||
}
|
||||
|
||||
export function createCircuitGroupMoveProjectCommand(
|
||||
plan: CircuitGroupMovePlan
|
||||
): CircuitGroupMoveProjectCommand {
|
||||
const command: CircuitGroupMoveProjectCommand = {
|
||||
schemaVersion: circuitGroupMoveCommandSchemaVersion,
|
||||
type: circuitGroupMoveCommandType,
|
||||
payload: plan,
|
||||
};
|
||||
assertCircuitGroupMoveProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitGroupMoveProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupMoveProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupMoveCommandSchemaVersion ||
|
||||
command.type !== circuitGroupMoveCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 8
|
||||
) {
|
||||
throw new Error("Unsupported circuit group-move command.");
|
||||
}
|
||||
for (const field of [
|
||||
"circuitId",
|
||||
"circuitListId",
|
||||
"expectedSectionId",
|
||||
"targetSectionId",
|
||||
"expectedEquipmentIdentifier",
|
||||
"targetEquipmentIdentifier",
|
||||
] as const) {
|
||||
if (
|
||||
typeof command.payload[field] !== "string" ||
|
||||
!command.payload[field].trim()
|
||||
) {
|
||||
throw new Error(`Circuit group-move ${field} is invalid.`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
command.payload.expectedSectionId === command.payload.targetSectionId ||
|
||||
!Number.isFinite(command.payload.expectedSortOrder) ||
|
||||
!Number.isFinite(command.payload.targetSortOrder)
|
||||
) {
|
||||
throw new Error("Circuit group-move positions are invalid.");
|
||||
}
|
||||
const expected = parseGroupedEquipmentIdentifier(
|
||||
command.payload.expectedEquipmentIdentifier as string
|
||||
);
|
||||
const target = parseGroupedEquipmentIdentifier(
|
||||
command.payload.targetEquipmentIdentifier as string
|
||||
);
|
||||
if (
|
||||
expected?.kind !== "circuit" ||
|
||||
target?.kind !== "circuit" ||
|
||||
expected.category !== target.category ||
|
||||
expected.groupNumber === target.groupNumber
|
||||
) {
|
||||
throw new Error("Circuit group-move BMKs are incompatible.");
|
||||
}
|
||||
}
|
||||
|
||||
export function invertCircuitGroupMovePlan(
|
||||
plan: CircuitGroupMovePlan
|
||||
): CircuitGroupMovePlan {
|
||||
return {
|
||||
circuitId: plan.circuitId,
|
||||
circuitListId: plan.circuitListId,
|
||||
expectedSectionId: plan.targetSectionId,
|
||||
targetSectionId: plan.expectedSectionId,
|
||||
expectedEquipmentIdentifier: plan.targetEquipmentIdentifier,
|
||||
targetEquipmentIdentifier: plan.expectedEquipmentIdentifier,
|
||||
expectedSortOrder: plan.targetSortOrder,
|
||||
targetSortOrder: plan.expectedSortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
circuitGroupCategories,
|
||||
type CircuitGroupCategory,
|
||||
} from "../../shared/constants/circuit-group.js";
|
||||
import {
|
||||
formatCircuitGroupPrefix,
|
||||
parseGroupedEquipmentIdentifier,
|
||||
} from "../services/circuit-group-numbering.js";
|
||||
import type { CircuitGroupRenumberPlan } from "../services/circuit-group-renumbering.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitGroupRenumberCommandType =
|
||||
"circuit-group.renumber" as const;
|
||||
export const circuitGroupRenumberCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface CircuitGroupRenumberCommandPayload
|
||||
extends CircuitGroupRenumberPlan {
|
||||
circuitListId: string;
|
||||
}
|
||||
|
||||
export interface CircuitGroupRenumberProjectCommand
|
||||
extends SerializedProjectCommand<CircuitGroupRenumberCommandPayload> {
|
||||
schemaVersion: typeof circuitGroupRenumberCommandSchemaVersion;
|
||||
type: typeof circuitGroupRenumberCommandType;
|
||||
}
|
||||
|
||||
export function createCircuitGroupRenumberProjectCommand(
|
||||
circuitListId: string,
|
||||
plan: CircuitGroupRenumberPlan
|
||||
): CircuitGroupRenumberProjectCommand {
|
||||
const command: CircuitGroupRenumberProjectCommand = {
|
||||
schemaVersion: circuitGroupRenumberCommandSchemaVersion,
|
||||
type: circuitGroupRenumberCommandType,
|
||||
payload: { circuitListId, groups: plan.groups },
|
||||
};
|
||||
assertCircuitGroupRenumberProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitGroupRenumberProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupRenumberProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupRenumberCommandSchemaVersion ||
|
||||
command.type !== circuitGroupRenumberCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 2 ||
|
||||
typeof command.payload.circuitListId !== "string" ||
|
||||
!command.payload.circuitListId.trim() ||
|
||||
!Array.isArray(command.payload.groups) ||
|
||||
command.payload.groups.length === 0
|
||||
) {
|
||||
throw new Error("Unsupported circuit-group renumber command.");
|
||||
}
|
||||
const groupIds = new Set<string>();
|
||||
const entityIds = new Set<string>();
|
||||
const targetGroupKeys = new Set<string>();
|
||||
const targetIdentifiers = new Set<string>();
|
||||
let hasChange = false;
|
||||
for (const group of command.payload.groups) {
|
||||
if (
|
||||
!isPlainObject(group) ||
|
||||
Object.keys(group).length !== 8 ||
|
||||
typeof group.groupId !== "string" ||
|
||||
!group.groupId.trim() ||
|
||||
groupIds.has(group.groupId) ||
|
||||
!circuitGroupCategories.includes(
|
||||
group.category as CircuitGroupCategory
|
||||
) ||
|
||||
!positiveInteger(group.expectedGroupNumber) ||
|
||||
!positiveInteger(group.targetGroupNumber) ||
|
||||
group.expectedPrefix !==
|
||||
formatCircuitGroupPrefix(
|
||||
group.category as CircuitGroupCategory,
|
||||
group.expectedGroupNumber as number
|
||||
) ||
|
||||
group.targetPrefix !==
|
||||
formatCircuitGroupPrefix(
|
||||
group.category as CircuitGroupCategory,
|
||||
group.targetGroupNumber as number
|
||||
) ||
|
||||
!Array.isArray(group.circuits) ||
|
||||
!Array.isArray(group.components)
|
||||
) {
|
||||
throw new Error("Circuit-group renumber assignment is invalid.");
|
||||
}
|
||||
const targetGroupKey = `${group.category}:${group.targetGroupNumber}`;
|
||||
if (targetGroupKeys.has(targetGroupKey)) {
|
||||
throw new Error(
|
||||
"Circuit-group renumber contains duplicate target group numbers."
|
||||
);
|
||||
}
|
||||
hasChange ||= group.expectedGroupNumber !== group.targetGroupNumber;
|
||||
groupIds.add(group.groupId);
|
||||
targetGroupKeys.add(targetGroupKey);
|
||||
for (const circuit of group.circuits) {
|
||||
assertEntityAssignment(
|
||||
circuit,
|
||||
"circuitId",
|
||||
"circuit",
|
||||
group.category as CircuitGroupCategory,
|
||||
group.expectedGroupNumber as number,
|
||||
group.targetGroupNumber as number,
|
||||
entityIds,
|
||||
targetIdentifiers
|
||||
);
|
||||
}
|
||||
for (const component of group.components) {
|
||||
assertEntityAssignment(
|
||||
component,
|
||||
"componentId",
|
||||
"component",
|
||||
group.category as CircuitGroupCategory,
|
||||
group.expectedGroupNumber as number,
|
||||
group.targetGroupNumber as number,
|
||||
entityIds,
|
||||
targetIdentifiers
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!hasChange) {
|
||||
throw new Error(
|
||||
"Circuit-group renumber must change at least one group number."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEntityAssignment(
|
||||
value: unknown,
|
||||
idField: "circuitId" | "componentId",
|
||||
kind: "circuit" | "component",
|
||||
category: CircuitGroupCategory,
|
||||
expectedGroupNumber: number,
|
||||
targetGroupNumber: number,
|
||||
entityIds: Set<string>,
|
||||
targetIdentifiers: Set<string>
|
||||
) {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
Object.keys(value).length !== 3 ||
|
||||
typeof value[idField] !== "string" ||
|
||||
!(value[idField] as string).trim() ||
|
||||
typeof value.expectedEquipmentIdentifier !== "string" ||
|
||||
typeof value.targetEquipmentIdentifier !== "string" ||
|
||||
entityIds.has(value[idField] as string) ||
|
||||
targetIdentifiers.has(value.targetEquipmentIdentifier)
|
||||
) {
|
||||
throw new Error(`Circuit-group renumber ${kind} assignment is invalid.`);
|
||||
}
|
||||
const expected = parseGroupedEquipmentIdentifier(
|
||||
value.expectedEquipmentIdentifier
|
||||
);
|
||||
const target = parseGroupedEquipmentIdentifier(
|
||||
value.targetEquipmentIdentifier
|
||||
);
|
||||
if (
|
||||
!expected ||
|
||||
!target ||
|
||||
expected.kind !== target.kind ||
|
||||
expected.category !== category ||
|
||||
target.category !== category ||
|
||||
expected.groupNumber !== expectedGroupNumber ||
|
||||
target.groupNumber !== targetGroupNumber ||
|
||||
(kind === "circuit" &&
|
||||
(expected.kind !== "circuit" ||
|
||||
expected.circuitNumber !== target.circuitNumber)) ||
|
||||
(kind === "component" && expected.kind === "circuit")
|
||||
) {
|
||||
throw new Error(
|
||||
`Circuit-group renumber ${kind} identifiers are incompatible.`
|
||||
);
|
||||
}
|
||||
entityIds.add(value[idField] as string);
|
||||
targetIdentifiers.add(value.targetEquipmentIdentifier);
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) > 0;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
circuitGroupCategories,
|
||||
type CircuitGroupCategory,
|
||||
} from "../../shared/constants/circuit-group.js";
|
||||
import { formatCircuitGroupPrefix } from "../services/circuit-group-numbering.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitGroupInsertCommandType = "circuit-group.insert" as const;
|
||||
export const circuitGroupDeleteCommandType = "circuit-group.delete" as const;
|
||||
export const circuitGroupUpdateCommandType = "circuit-group.update" as const;
|
||||
export const circuitGroupReorderCommandType = "circuit-group.reorder" as const;
|
||||
export const circuitGroupStructureCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface CircuitGroupSnapshot {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
key: string;
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
}
|
||||
|
||||
interface SnapshotPayload {
|
||||
snapshot: CircuitGroupSnapshot;
|
||||
}
|
||||
|
||||
interface UpdatePayload {
|
||||
expected: CircuitGroupSnapshot;
|
||||
target: CircuitGroupSnapshot;
|
||||
}
|
||||
|
||||
export interface CircuitGroupReorderAssignment {
|
||||
groupId: string;
|
||||
expectedSortOrder: number;
|
||||
targetSortOrder: number;
|
||||
}
|
||||
|
||||
interface ReorderPayload {
|
||||
circuitListId: string;
|
||||
assignments: CircuitGroupReorderAssignment[];
|
||||
}
|
||||
|
||||
export interface CircuitGroupInsertProjectCommand
|
||||
extends SerializedProjectCommand<SnapshotPayload> {
|
||||
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||
type: typeof circuitGroupInsertCommandType;
|
||||
}
|
||||
|
||||
export interface CircuitGroupDeleteProjectCommand
|
||||
extends SerializedProjectCommand<SnapshotPayload> {
|
||||
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||
type: typeof circuitGroupDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface CircuitGroupUpdateProjectCommand
|
||||
extends SerializedProjectCommand<UpdatePayload> {
|
||||
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||
type: typeof circuitGroupUpdateCommandType;
|
||||
}
|
||||
|
||||
export interface CircuitGroupReorderProjectCommand
|
||||
extends SerializedProjectCommand<ReorderPayload> {
|
||||
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||
type: typeof circuitGroupReorderCommandType;
|
||||
}
|
||||
|
||||
export type CircuitGroupStructureProjectCommand =
|
||||
| CircuitGroupInsertProjectCommand
|
||||
| CircuitGroupDeleteProjectCommand
|
||||
| CircuitGroupUpdateProjectCommand
|
||||
| CircuitGroupReorderProjectCommand;
|
||||
|
||||
export function createCircuitGroupInsertProjectCommand(
|
||||
snapshot: CircuitGroupSnapshot
|
||||
): CircuitGroupInsertProjectCommand {
|
||||
const command: CircuitGroupInsertProjectCommand = {
|
||||
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||
type: circuitGroupInsertCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertCircuitGroupInsertProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createCircuitGroupDeleteProjectCommand(
|
||||
snapshot: CircuitGroupSnapshot
|
||||
): CircuitGroupDeleteProjectCommand {
|
||||
const command: CircuitGroupDeleteProjectCommand = {
|
||||
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||
type: circuitGroupDeleteCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertCircuitGroupDeleteProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createCircuitGroupUpdateProjectCommand(
|
||||
expected: CircuitGroupSnapshot,
|
||||
target: CircuitGroupSnapshot
|
||||
): CircuitGroupUpdateProjectCommand {
|
||||
const command: CircuitGroupUpdateProjectCommand = {
|
||||
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||
type: circuitGroupUpdateCommandType,
|
||||
payload: { expected, target },
|
||||
};
|
||||
assertCircuitGroupUpdateProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createCircuitGroupReorderProjectCommand(
|
||||
circuitListId: string,
|
||||
assignments: CircuitGroupReorderAssignment[]
|
||||
): CircuitGroupReorderProjectCommand {
|
||||
const command: CircuitGroupReorderProjectCommand = {
|
||||
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||
type: circuitGroupReorderCommandType,
|
||||
payload: { circuitListId, assignments },
|
||||
};
|
||||
assertCircuitGroupReorderProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitGroupInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupInsertProjectCommand {
|
||||
assertSnapshotCommand(command, circuitGroupInsertCommandType);
|
||||
}
|
||||
|
||||
export function assertCircuitGroupDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupDeleteProjectCommand {
|
||||
assertSnapshotCommand(command, circuitGroupDeleteCommandType);
|
||||
}
|
||||
|
||||
export function assertCircuitGroupUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
|
||||
command.type !== circuitGroupUpdateCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 2
|
||||
) {
|
||||
throw new Error("Unsupported circuit-group update command.");
|
||||
}
|
||||
assertCircuitGroupSnapshot(command.payload.expected);
|
||||
assertCircuitGroupSnapshot(command.payload.target);
|
||||
for (const field of [
|
||||
"id",
|
||||
"circuitListId",
|
||||
"key",
|
||||
"prefix",
|
||||
"sortOrder",
|
||||
"category",
|
||||
"groupNumber",
|
||||
] as const) {
|
||||
if (command.payload.expected[field] !== command.payload.target[field]) {
|
||||
throw new Error(
|
||||
"Circuit-group updates can only change the display name."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCircuitGroupReorderProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupReorderProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
|
||||
command.type !== circuitGroupReorderCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 2 ||
|
||||
typeof command.payload.circuitListId !== "string" ||
|
||||
!command.payload.circuitListId.trim() ||
|
||||
!Array.isArray(command.payload.assignments) ||
|
||||
command.payload.assignments.length === 0
|
||||
) {
|
||||
throw new Error("Unsupported circuit-group reorder command.");
|
||||
}
|
||||
const groupIds = new Set<string>();
|
||||
let hasChange = false;
|
||||
for (const assignment of command.payload.assignments) {
|
||||
if (
|
||||
!isPlainObject(assignment) ||
|
||||
typeof assignment.groupId !== "string" ||
|
||||
!assignment.groupId.trim() ||
|
||||
!Number.isFinite(assignment.expectedSortOrder) ||
|
||||
!Number.isFinite(assignment.targetSortOrder) ||
|
||||
groupIds.has(assignment.groupId)
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit-group reorder contains an invalid or duplicate assignment."
|
||||
);
|
||||
}
|
||||
hasChange ||= assignment.expectedSortOrder !== assignment.targetSortOrder;
|
||||
groupIds.add(assignment.groupId);
|
||||
}
|
||||
if (!hasChange) {
|
||||
throw new Error("Circuit-group reorder must change at least one position.");
|
||||
}
|
||||
}
|
||||
|
||||
function assertSnapshotCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
type:
|
||||
| typeof circuitGroupInsertCommandType
|
||||
| typeof circuitGroupDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
|
||||
command.type !== type ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported circuit-group structure command.");
|
||||
}
|
||||
assertCircuitGroupSnapshot(command.payload.snapshot);
|
||||
}
|
||||
|
||||
export function assertCircuitGroupSnapshot(
|
||||
value: unknown
|
||||
): asserts value is CircuitGroupSnapshot {
|
||||
if (!isPlainObject(value) || Object.keys(value).length !== 8) {
|
||||
throw new Error("Circuit-group snapshot is invalid.");
|
||||
}
|
||||
for (const field of [
|
||||
"id",
|
||||
"circuitListId",
|
||||
"key",
|
||||
"displayName",
|
||||
"prefix",
|
||||
] as const) {
|
||||
if (typeof value[field] !== "string" || !value[field].trim()) {
|
||||
throw new Error(`${field} must be a non-empty string.`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!circuitGroupCategories.includes(
|
||||
value.category as CircuitGroupCategory
|
||||
) ||
|
||||
!Number.isInteger(value.groupNumber) ||
|
||||
(value.groupNumber as number) < 1 ||
|
||||
!Number.isFinite(value.sortOrder)
|
||||
) {
|
||||
throw new Error("Circuit-group category, number or order is invalid.");
|
||||
}
|
||||
if (
|
||||
value.prefix !==
|
||||
formatCircuitGroupPrefix(
|
||||
value.category as CircuitGroupCategory,
|
||||
value.groupNumber as number
|
||||
)
|
||||
) {
|
||||
throw new Error("Circuit-group prefix does not match its category and number.");
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
assertCircuitGroupSubtreeSnapshot,
|
||||
type CircuitGroupSubtreeSnapshot,
|
||||
} from "./circuit-group-subtree-snapshot.model.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitGroupDeleteSubtreeCommandType =
|
||||
"circuit-group.delete-subtree" as const;
|
||||
export const circuitGroupRestoreSubtreeCommandType =
|
||||
"circuit-group.restore-subtree" as const;
|
||||
export const circuitGroupSubtreeCommandSchemaVersion = 1 as const;
|
||||
|
||||
interface Payload {
|
||||
snapshot: CircuitGroupSubtreeSnapshot;
|
||||
}
|
||||
|
||||
export interface CircuitGroupDeleteSubtreeProjectCommand
|
||||
extends SerializedProjectCommand<Payload> {
|
||||
schemaVersion: typeof circuitGroupSubtreeCommandSchemaVersion;
|
||||
type: typeof circuitGroupDeleteSubtreeCommandType;
|
||||
}
|
||||
|
||||
export interface CircuitGroupRestoreSubtreeProjectCommand
|
||||
extends SerializedProjectCommand<Payload> {
|
||||
schemaVersion: typeof circuitGroupSubtreeCommandSchemaVersion;
|
||||
type: typeof circuitGroupRestoreSubtreeCommandType;
|
||||
}
|
||||
|
||||
export type CircuitGroupSubtreeProjectCommand =
|
||||
| CircuitGroupDeleteSubtreeProjectCommand
|
||||
| CircuitGroupRestoreSubtreeProjectCommand;
|
||||
|
||||
export function createCircuitGroupDeleteSubtreeProjectCommand(
|
||||
snapshot: CircuitGroupSubtreeSnapshot
|
||||
): CircuitGroupDeleteSubtreeProjectCommand {
|
||||
const command: CircuitGroupDeleteSubtreeProjectCommand = {
|
||||
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
|
||||
type: circuitGroupDeleteSubtreeCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertCircuitGroupDeleteSubtreeProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createCircuitGroupRestoreSubtreeProjectCommand(
|
||||
snapshot: CircuitGroupSubtreeSnapshot
|
||||
): CircuitGroupRestoreSubtreeProjectCommand {
|
||||
const command: CircuitGroupRestoreSubtreeProjectCommand = {
|
||||
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
|
||||
type: circuitGroupRestoreSubtreeCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertCircuitGroupRestoreSubtreeProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitGroupDeleteSubtreeProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupDeleteSubtreeProjectCommand {
|
||||
assertCommand(command, circuitGroupDeleteSubtreeCommandType);
|
||||
}
|
||||
|
||||
export function assertCircuitGroupRestoreSubtreeProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupRestoreSubtreeProjectCommand {
|
||||
assertCommand(command, circuitGroupRestoreSubtreeCommandType);
|
||||
}
|
||||
|
||||
function assertCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
type:
|
||||
| typeof circuitGroupDeleteSubtreeCommandType
|
||||
| typeof circuitGroupRestoreSubtreeCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupSubtreeCommandSchemaVersion ||
|
||||
command.type !== type ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported circuit-group subtree command.");
|
||||
}
|
||||
assertCircuitGroupSubtreeSnapshot(command.payload.snapshot);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
|
||||
import {
|
||||
assertCircuitGroupSnapshot,
|
||||
type CircuitGroupSnapshot,
|
||||
} from "./circuit-group-structure-project-command.model.js";
|
||||
import {
|
||||
assertCircuitInsertProjectCommand,
|
||||
circuitInsertCommandType,
|
||||
circuitStructureCommandSchemaVersion,
|
||||
type CircuitSnapshot,
|
||||
} from "./circuit-structure-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardComponentSnapshot,
|
||||
type DistributionBoardComponentSnapshot,
|
||||
} from "./distribution-board-component-structure-project-command.model.js";
|
||||
|
||||
export interface CircuitProtectionDeviceSnapshot {
|
||||
circuitId: string;
|
||||
type: ProtectionDeviceType;
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory: FuseUtilizationCategory | null;
|
||||
tripCharacteristic: BreakerTripCharacteristic | null;
|
||||
rcdType: RcdType | null;
|
||||
ratedResidualCurrentMa: number | null;
|
||||
}
|
||||
|
||||
export interface CircuitGroupSubtreeSnapshot {
|
||||
group: CircuitGroupSnapshot;
|
||||
components: DistributionBoardComponentSnapshot[];
|
||||
circuits: Array<{
|
||||
circuit: CircuitSnapshot;
|
||||
protectionDevice: CircuitProtectionDeviceSnapshot | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CircuitGroupDeletionSummary {
|
||||
hasUpstreamProtection: boolean;
|
||||
hasResidualCurrentProtection: boolean;
|
||||
circuitCount: number;
|
||||
deviceRowCount: number;
|
||||
}
|
||||
|
||||
export function assertCircuitGroupSubtreeSnapshot(
|
||||
value: unknown
|
||||
): asserts value is CircuitGroupSubtreeSnapshot {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
Object.keys(value).length !== 3 ||
|
||||
!Array.isArray(value.components) ||
|
||||
!Array.isArray(value.circuits)
|
||||
) {
|
||||
throw new Error("Circuit-group subtree snapshot is invalid.");
|
||||
}
|
||||
assertCircuitGroupSnapshot(value.group);
|
||||
const group = value.group;
|
||||
const entityIds = new Set<string>();
|
||||
const equipmentIdentifiers = new Set<string>();
|
||||
for (const component of value.components) {
|
||||
assertDistributionBoardComponentSnapshot(component);
|
||||
if (
|
||||
component.component.circuitListId !== group.circuitListId ||
|
||||
component.component.sectionId !== group.id ||
|
||||
entityIds.has(component.component.id) ||
|
||||
equipmentIdentifiers.has(component.component.equipmentIdentifier)
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit-group component snapshot has invalid ownership or duplicates."
|
||||
);
|
||||
}
|
||||
entityIds.add(component.component.id);
|
||||
equipmentIdentifiers.add(component.component.equipmentIdentifier);
|
||||
}
|
||||
for (const entry of value.circuits) {
|
||||
if (
|
||||
!isPlainObject(entry) ||
|
||||
Object.keys(entry).length !== 2 ||
|
||||
!isPlainObject(entry.circuit)
|
||||
) {
|
||||
throw new Error("Circuit-group circuit snapshot is invalid.");
|
||||
}
|
||||
assertCircuitInsertProjectCommand({
|
||||
schemaVersion: circuitStructureCommandSchemaVersion,
|
||||
type: circuitInsertCommandType,
|
||||
payload: { circuit: entry.circuit },
|
||||
});
|
||||
const circuit = entry.circuit as unknown as CircuitSnapshot;
|
||||
if (
|
||||
circuit.circuitListId !== group.circuitListId ||
|
||||
circuit.sectionId !== group.id ||
|
||||
entityIds.has(circuit.id) ||
|
||||
equipmentIdentifiers.has(circuit.equipmentIdentifier)
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit-group circuit snapshot has invalid ownership or duplicates."
|
||||
);
|
||||
}
|
||||
entityIds.add(circuit.id);
|
||||
equipmentIdentifiers.add(circuit.equipmentIdentifier);
|
||||
if (entry.protectionDevice !== null) {
|
||||
assertCircuitProtectionDeviceSnapshot(
|
||||
entry.protectionDevice,
|
||||
circuit.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeCircuitGroupDeletion(
|
||||
snapshot: CircuitGroupSubtreeSnapshot
|
||||
): CircuitGroupDeletionSummary {
|
||||
assertCircuitGroupSubtreeSnapshot(snapshot);
|
||||
return {
|
||||
hasUpstreamProtection: snapshot.components.some(
|
||||
({ component }) =>
|
||||
component.role === "group_upstream_protection"
|
||||
),
|
||||
hasResidualCurrentProtection: snapshot.components.some(
|
||||
({ component }) =>
|
||||
component.role === "group_residual_current_protection"
|
||||
),
|
||||
circuitCount: snapshot.circuits.length,
|
||||
deviceRowCount: snapshot.circuits.reduce(
|
||||
(count, { circuit }) => count + circuit.deviceRows.length,
|
||||
0
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function assertCircuitProtectionDeviceSnapshot(
|
||||
value: unknown,
|
||||
circuitId: string
|
||||
): asserts value is CircuitProtectionDeviceSnapshot {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
Object.keys(value).length !== 7 ||
|
||||
value.circuitId !== circuitId
|
||||
) {
|
||||
throw new Error("Circuit protection-device snapshot is invalid.");
|
||||
}
|
||||
const result = protectionDeviceConfigurationSchema.safeParse({
|
||||
type: value.type,
|
||||
ratedCurrentA: value.ratedCurrentA,
|
||||
...(value.fuseUtilizationCategory === null
|
||||
? {}
|
||||
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
|
||||
...(value.tripCharacteristic === null
|
||||
? {}
|
||||
: { tripCharacteristic: value.tripCharacteristic }),
|
||||
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
|
||||
...(value.ratedResidualCurrentMa === null
|
||||
? {}
|
||||
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
"Circuit protection-device configuration is invalid."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitProtectionUpdateCommandType =
|
||||
"circuit-protection.update" as const;
|
||||
export const circuitProtectionUpdateCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface CircuitProtectionSnapshot {
|
||||
circuitId: string;
|
||||
type: ProtectionDeviceType;
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory: FuseUtilizationCategory | null;
|
||||
tripCharacteristic: BreakerTripCharacteristic | null;
|
||||
rcdType: RcdType | null;
|
||||
ratedResidualCurrentMa: number | null;
|
||||
}
|
||||
|
||||
interface CircuitProtectionUpdatePayload {
|
||||
circuitId: string;
|
||||
expected: CircuitProtectionSnapshot | null;
|
||||
target: CircuitProtectionSnapshot | null;
|
||||
}
|
||||
|
||||
export interface CircuitProtectionUpdateProjectCommand
|
||||
extends SerializedProjectCommand<CircuitProtectionUpdatePayload> {
|
||||
schemaVersion: typeof circuitProtectionUpdateCommandSchemaVersion;
|
||||
type: typeof circuitProtectionUpdateCommandType;
|
||||
}
|
||||
|
||||
export function createCircuitProtectionUpdateProjectCommand(
|
||||
circuitId: string,
|
||||
expected: CircuitProtectionSnapshot | null,
|
||||
target: CircuitProtectionSnapshot | null
|
||||
): CircuitProtectionUpdateProjectCommand {
|
||||
const command: CircuitProtectionUpdateProjectCommand = {
|
||||
schemaVersion: circuitProtectionUpdateCommandSchemaVersion,
|
||||
type: circuitProtectionUpdateCommandType,
|
||||
payload: { circuitId, expected, target },
|
||||
};
|
||||
assertCircuitProtectionUpdateProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitProtectionUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitProtectionUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== circuitProtectionUpdateCommandSchemaVersion ||
|
||||
command.type !== circuitProtectionUpdateCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 3
|
||||
) {
|
||||
throw new Error("Unsupported circuit-protection update command.");
|
||||
}
|
||||
const { circuitId, expected, target } = command.payload;
|
||||
if (typeof circuitId !== "string" || !circuitId.trim()) {
|
||||
throw new Error("Circuit protection requires a circuit id.");
|
||||
}
|
||||
if (expected === null && target === null) {
|
||||
throw new Error("Circuit protection update must change state.");
|
||||
}
|
||||
if (expected !== null) {
|
||||
assertCircuitProtectionSnapshot(expected, circuitId);
|
||||
}
|
||||
if (target !== null) {
|
||||
assertCircuitProtectionSnapshot(target, circuitId);
|
||||
}
|
||||
if (JSON.stringify(expected) === JSON.stringify(target)) {
|
||||
throw new Error("Circuit protection update must change state.");
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCircuitProtectionSnapshot(
|
||||
value: unknown,
|
||||
circuitId: string
|
||||
) {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
Object.keys(value).length !== 7 ||
|
||||
value.circuitId !== circuitId
|
||||
) {
|
||||
throw new Error("Circuit-protection snapshot is invalid.");
|
||||
}
|
||||
const result = protectionDeviceConfigurationSchema.safeParse({
|
||||
type: value.type,
|
||||
ratedCurrentA: value.ratedCurrentA,
|
||||
...(value.fuseUtilizationCategory === null
|
||||
? {}
|
||||
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
|
||||
...(value.tripCharacteristic === null
|
||||
? {}
|
||||
: { tripCharacteristic: value.tripCharacteristic }),
|
||||
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
|
||||
...(value.ratedResidualCurrentMa === null
|
||||
? {}
|
||||
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error("Circuit-protection configuration is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
type CircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure-project-command.model.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
import {
|
||||
assertCircuitProtectionSnapshot,
|
||||
type CircuitProtectionSnapshot,
|
||||
} from "./circuit-protection-project-command.model.js";
|
||||
|
||||
export const circuitInsertCommandType = "circuit.insert" as const;
|
||||
export const circuitDeleteCommandType = "circuit.delete" as const;
|
||||
@@ -31,6 +35,7 @@ export interface CircuitSnapshot {
|
||||
isReserve: boolean;
|
||||
remark: string | null;
|
||||
deviceRows: CircuitDeviceRowSnapshot[];
|
||||
protectionDevice?: CircuitProtectionSnapshot | null;
|
||||
}
|
||||
|
||||
export interface CircuitInsertCommandPayload {
|
||||
@@ -142,6 +147,15 @@ export function assertCircuitInsertProjectCommand(
|
||||
if (!Array.isArray(circuit.deviceRows)) {
|
||||
throw new Error("circuit.deviceRows must be an array.");
|
||||
}
|
||||
if (
|
||||
circuit.protectionDevice !== undefined &&
|
||||
circuit.protectionDevice !== null
|
||||
) {
|
||||
assertCircuitProtectionSnapshot(
|
||||
circuit.protectionDevice,
|
||||
circuit.id as string
|
||||
);
|
||||
}
|
||||
if (circuit.isReserve !== (circuit.deviceRows.length === 0)) {
|
||||
throw new Error(
|
||||
"Circuit reserve state must match whether device rows exist."
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface CircuitTreeCircuit {
|
||||
remark?: string;
|
||||
circuitTotalPower: number;
|
||||
deviceRows: CircuitTreeDeviceRow[];
|
||||
protectionDevice?: CircuitTreeProtectionDevice;
|
||||
}
|
||||
|
||||
export interface CircuitTreeSectionBlock {
|
||||
@@ -52,7 +53,10 @@ export interface CircuitTreeSectionBlock {
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
category?: CircuitGroupCategory;
|
||||
groupNumber?: number;
|
||||
sectionTotalPower: number;
|
||||
components: CircuitTreeComponent[];
|
||||
circuits: CircuitTreeCircuit[];
|
||||
}
|
||||
|
||||
@@ -64,7 +68,9 @@ export interface CircuitTreeResponse {
|
||||
distributionBoardSimultaneityFactor: number;
|
||||
distributionBoardTotalPower: number;
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||
headerComponents: CircuitTreeComponent[];
|
||||
sections: CircuitTreeSectionBlock[];
|
||||
footerComponents: CircuitTreeComponent[];
|
||||
}
|
||||
|
||||
export interface LegacyMigrationReport {
|
||||
@@ -78,3 +84,35 @@ export interface LegacyMigrationReport {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
import type {
|
||||
DistributionBoardComponentPlacement,
|
||||
DistributionBoardComponentRole,
|
||||
} from "../../shared/constants/distribution-board-component.js";
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
|
||||
export interface CircuitTreeProtectionDevice {
|
||||
type: ProtectionDeviceType;
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory?: FuseUtilizationCategory;
|
||||
tripCharacteristic?: BreakerTripCharacteristic;
|
||||
rcdType?: RcdType;
|
||||
ratedResidualCurrentMa?: number;
|
||||
}
|
||||
|
||||
export interface CircuitTreeComponent {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
sectionId?: string;
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
role: DistributionBoardComponentRole;
|
||||
placement: DistributionBoardComponentPlacement;
|
||||
sortOrder: number;
|
||||
protectionDevice?: CircuitTreeProtectionDevice;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { distributionBoardComponentPlacements } from "../../shared/constants/distribution-board-component.js";
|
||||
import type { DistributionBoardComponentPlacement } from "../../shared/constants/distribution-board-component.js";
|
||||
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const distributionBoardComponentInsertCommandType =
|
||||
"distribution-board-component.insert" as const;
|
||||
export const distributionBoardComponentDeleteCommandType =
|
||||
"distribution-board-component.delete" as const;
|
||||
export const distributionBoardComponentUpdateCommandType =
|
||||
"distribution-board-component.update" as const;
|
||||
export const distributionBoardComponentStructureCommandSchemaVersion =
|
||||
1 as const;
|
||||
|
||||
type MutableDistributionBoardComponentRole =
|
||||
| "group_upstream_protection"
|
||||
| "group_residual_current_protection"
|
||||
| "auxiliary";
|
||||
|
||||
export interface DistributionBoardComponentSnapshot {
|
||||
component: {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
sectionId: string | null;
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
role: MutableDistributionBoardComponentRole;
|
||||
placement: DistributionBoardComponentPlacement;
|
||||
sortOrder: number;
|
||||
};
|
||||
protectionDevice: {
|
||||
componentId: string;
|
||||
type: ProtectionDeviceType;
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory: FuseUtilizationCategory | null;
|
||||
tripCharacteristic: BreakerTripCharacteristic | null;
|
||||
rcdType: RcdType | null;
|
||||
ratedResidualCurrentMa: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface DistributionBoardComponentStructurePayload {
|
||||
snapshot: DistributionBoardComponentSnapshot;
|
||||
}
|
||||
|
||||
interface DistributionBoardComponentUpdatePayload {
|
||||
expected: DistributionBoardComponentSnapshot;
|
||||
target: DistributionBoardComponentSnapshot;
|
||||
}
|
||||
|
||||
export interface DistributionBoardComponentInsertProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardComponentStructurePayload> {
|
||||
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
|
||||
type: typeof distributionBoardComponentInsertCommandType;
|
||||
}
|
||||
|
||||
export interface DistributionBoardComponentDeleteProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardComponentStructurePayload> {
|
||||
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
|
||||
type: typeof distributionBoardComponentDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface DistributionBoardComponentUpdateProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardComponentUpdatePayload> {
|
||||
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
|
||||
type: typeof distributionBoardComponentUpdateCommandType;
|
||||
}
|
||||
|
||||
export type DistributionBoardComponentStructureProjectCommand =
|
||||
| DistributionBoardComponentInsertProjectCommand
|
||||
| DistributionBoardComponentDeleteProjectCommand
|
||||
| DistributionBoardComponentUpdateProjectCommand;
|
||||
|
||||
export function createDistributionBoardComponentInsertProjectCommand(
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
): DistributionBoardComponentInsertProjectCommand {
|
||||
const command: DistributionBoardComponentInsertProjectCommand = {
|
||||
schemaVersion:
|
||||
distributionBoardComponentStructureCommandSchemaVersion,
|
||||
type: distributionBoardComponentInsertCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertDistributionBoardComponentInsertProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createDistributionBoardComponentDeleteProjectCommand(
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
): DistributionBoardComponentDeleteProjectCommand {
|
||||
const command: DistributionBoardComponentDeleteProjectCommand = {
|
||||
schemaVersion:
|
||||
distributionBoardComponentStructureCommandSchemaVersion,
|
||||
type: distributionBoardComponentDeleteCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertDistributionBoardComponentDeleteProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createDistributionBoardComponentUpdateProjectCommand(
|
||||
expected: DistributionBoardComponentSnapshot,
|
||||
target: DistributionBoardComponentSnapshot
|
||||
): DistributionBoardComponentUpdateProjectCommand {
|
||||
const command: DistributionBoardComponentUpdateProjectCommand = {
|
||||
schemaVersion:
|
||||
distributionBoardComponentStructureCommandSchemaVersion,
|
||||
type: distributionBoardComponentUpdateCommandType,
|
||||
payload: { expected, target },
|
||||
};
|
||||
assertDistributionBoardComponentUpdateProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertDistributionBoardComponentInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardComponentInsertProjectCommand {
|
||||
assertStructureCommand(
|
||||
command,
|
||||
distributionBoardComponentInsertCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertDistributionBoardComponentDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardComponentDeleteProjectCommand {
|
||||
assertStructureCommand(
|
||||
command,
|
||||
distributionBoardComponentDeleteCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertDistributionBoardComponentUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardComponentUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !==
|
||||
distributionBoardComponentStructureCommandSchemaVersion ||
|
||||
command.type !== distributionBoardComponentUpdateCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 2
|
||||
) {
|
||||
throw new Error(
|
||||
"Unsupported distribution-board component update command."
|
||||
);
|
||||
}
|
||||
assertDistributionBoardComponentSnapshot(command.payload.expected);
|
||||
assertDistributionBoardComponentSnapshot(command.payload.target);
|
||||
assertSameComponentIdentity(
|
||||
command.payload.expected,
|
||||
command.payload.target
|
||||
);
|
||||
}
|
||||
|
||||
function assertStructureCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
type:
|
||||
| typeof distributionBoardComponentInsertCommandType
|
||||
| typeof distributionBoardComponentDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !==
|
||||
distributionBoardComponentStructureCommandSchemaVersion ||
|
||||
command.type !== type ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
"Unsupported distribution-board component structure command."
|
||||
);
|
||||
}
|
||||
assertDistributionBoardComponentSnapshot(command.payload.snapshot);
|
||||
}
|
||||
|
||||
function assertSameComponentIdentity(
|
||||
expected: DistributionBoardComponentSnapshot,
|
||||
target: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
for (const field of [
|
||||
"id",
|
||||
"circuitListId",
|
||||
"sectionId",
|
||||
"role",
|
||||
"placement",
|
||||
] as const) {
|
||||
if (expected.component[field] !== target.component[field]) {
|
||||
throw new Error(
|
||||
"Distribution-board component updates cannot change ownership or role."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertDistributionBoardComponentSnapshot(
|
||||
value: unknown
|
||||
): asserts value is DistributionBoardComponentSnapshot {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
Object.keys(value).length !== 2 ||
|
||||
!isPlainObject(value.component)
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board component snapshot is invalid."
|
||||
);
|
||||
}
|
||||
const component = value.component;
|
||||
if (Object.keys(component).length !== 8) {
|
||||
throw new Error(
|
||||
"Distribution-board component snapshot is incomplete."
|
||||
);
|
||||
}
|
||||
for (const field of [
|
||||
"id",
|
||||
"circuitListId",
|
||||
"equipmentIdentifier",
|
||||
"name",
|
||||
] as const) {
|
||||
assertNonEmptyString(component[field], `component.${field}`);
|
||||
}
|
||||
if (
|
||||
component.role !== "group_upstream_protection" &&
|
||||
component.role !== "group_residual_current_protection" &&
|
||||
component.role !== "auxiliary"
|
||||
) {
|
||||
throw new Error(
|
||||
"Fixed distribution-board components cannot use component CRUD commands."
|
||||
);
|
||||
}
|
||||
if (
|
||||
!distributionBoardComponentPlacements.includes(
|
||||
component.placement as DistributionBoardComponentPlacement
|
||||
) ||
|
||||
!Number.isFinite(component.sortOrder)
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board component placement or sort order is invalid."
|
||||
);
|
||||
}
|
||||
const groupComponent = component.role !== "auxiliary";
|
||||
if (
|
||||
groupComponent !==
|
||||
(component.placement === "group" &&
|
||||
typeof component.sectionId === "string" &&
|
||||
Boolean(component.sectionId.trim()))
|
||||
) {
|
||||
throw new Error(
|
||||
"Group protection components require group placement and a section."
|
||||
);
|
||||
}
|
||||
if (
|
||||
!groupComponent &&
|
||||
(component.placement !== "footer" ||
|
||||
component.sectionId !== null)
|
||||
) {
|
||||
throw new Error(
|
||||
"Auxiliary components require footer placement without a section."
|
||||
);
|
||||
}
|
||||
|
||||
if (groupComponent) {
|
||||
if (!isPlainObject(value.protectionDevice)) {
|
||||
throw new Error(
|
||||
"Group protection components require protection data."
|
||||
);
|
||||
}
|
||||
assertProtectionDevice(
|
||||
value.protectionDevice,
|
||||
component.id as string
|
||||
);
|
||||
} else if (value.protectionDevice !== null) {
|
||||
throw new Error(
|
||||
"Auxiliary components cannot contain protection data."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertProtectionDevice(
|
||||
value: Record<string, unknown>,
|
||||
componentId: string
|
||||
) {
|
||||
if (
|
||||
Object.keys(value).length !== 7 ||
|
||||
value.componentId !== componentId
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board component protection snapshot is invalid."
|
||||
);
|
||||
}
|
||||
const result = protectionDeviceConfigurationSchema.safeParse({
|
||||
type: value.type,
|
||||
ratedCurrentA: value.ratedCurrentA,
|
||||
...(value.fuseUtilizationCategory === null
|
||||
? {}
|
||||
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
|
||||
...(value.tripCharacteristic === null
|
||||
? {}
|
||||
: { tripCharacteristic: value.tripCharacteristic }),
|
||||
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
|
||||
...(value.ratedResidualCurrentMa === null
|
||||
? {}
|
||||
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
"Distribution-board component protection configuration is invalid."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonEmptyString(value: unknown, field: string) {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${field} must be a non-empty string.`);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -3,6 +3,15 @@ import {
|
||||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import {
|
||||
defaultMainSwitchComponent,
|
||||
defaultSurgeProtectiveDeviceComponent,
|
||||
} from "../../shared/constants/distribution-board-component.js";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
import type {
|
||||
DistributionBoardComponentPlacement,
|
||||
DistributionBoardComponentRole,
|
||||
} from "../../shared/constants/distribution-board-component.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const distributionBoardInsertCommandType =
|
||||
@@ -10,9 +19,10 @@ export const distributionBoardInsertCommandType =
|
||||
export const distributionBoardDeleteCommandType =
|
||||
"distribution-board.delete" as const;
|
||||
export const legacyDistributionBoardStructureCommandSchemaVersion = 1 as const;
|
||||
export const distributionBoardStructureCommandSchemaVersion = 2 as const;
|
||||
export const previousDistributionBoardStructureCommandSchemaVersion = 2 as const;
|
||||
export const distributionBoardStructureCommandSchemaVersion = 3 as const;
|
||||
|
||||
export const defaultCircuitSectionDefinitions = [
|
||||
const legacyDefaultCircuitSectionDefinitions = [
|
||||
{
|
||||
key: "lighting",
|
||||
displayName: "Lighting",
|
||||
@@ -39,6 +49,33 @@ export const defaultCircuitSectionDefinitions = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const defaultCircuitSectionDefinitions = [
|
||||
{
|
||||
key: "lighting",
|
||||
displayName: "Beleuchtung 1",
|
||||
prefix: "-1F1.",
|
||||
sortOrder: 10,
|
||||
category: "lighting",
|
||||
groupNumber: 1,
|
||||
},
|
||||
{
|
||||
key: "single_phase",
|
||||
displayName: "1-phasig 1",
|
||||
prefix: "-2F1.",
|
||||
sortOrder: 20,
|
||||
category: "single_phase",
|
||||
groupNumber: 1,
|
||||
},
|
||||
{
|
||||
key: "three_phase",
|
||||
displayName: "3-phasig 1",
|
||||
prefix: "-3F1.",
|
||||
sortOrder: 30,
|
||||
category: "three_phase",
|
||||
groupNumber: 1,
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface LegacyDistributionBoardStructureSnapshot {
|
||||
distributionBoard: {
|
||||
id: string;
|
||||
@@ -46,7 +83,22 @@ interface LegacyDistributionBoardStructureSnapshot {
|
||||
name: string;
|
||||
};
|
||||
circuitList: DistributionBoardStructureSnapshot["circuitList"];
|
||||
sections: DistributionBoardStructureSnapshot["sections"];
|
||||
sections: LegacyCircuitSectionSnapshot[];
|
||||
}
|
||||
|
||||
interface PreviousDistributionBoardStructureSnapshot {
|
||||
distributionBoard: DistributionBoardStructureSnapshot["distributionBoard"];
|
||||
circuitList: DistributionBoardStructureSnapshot["circuitList"];
|
||||
sections: LegacyCircuitSectionSnapshot[];
|
||||
}
|
||||
|
||||
interface LegacyCircuitSectionSnapshot {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
key: string;
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface DistributionBoardStructureSnapshot {
|
||||
@@ -70,7 +122,31 @@ export interface DistributionBoardStructureSnapshot {
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
}>;
|
||||
components: Array<{
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
sectionId: null;
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
role: DistributionBoardComponentRole;
|
||||
placement: DistributionBoardComponentPlacement;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface NormalizedDistributionBoardStructureSnapshot {
|
||||
distributionBoard: DistributionBoardStructureSnapshot["distributionBoard"];
|
||||
circuitList: DistributionBoardStructureSnapshot["circuitList"];
|
||||
sections: Array<
|
||||
LegacyCircuitSectionSnapshot & {
|
||||
category: CircuitGroupCategory | null;
|
||||
groupNumber: number | null;
|
||||
}
|
||||
>;
|
||||
components: DistributionBoardStructureSnapshot["components"];
|
||||
}
|
||||
|
||||
interface DistributionBoardStructureCommandPayload<
|
||||
@@ -97,6 +173,16 @@ interface LegacyDistributionBoardStructureProjectCommand
|
||||
| typeof distributionBoardDeleteCommandType;
|
||||
}
|
||||
|
||||
interface PreviousDistributionBoardStructureProjectCommand
|
||||
extends SerializedProjectCommand<
|
||||
DistributionBoardStructureCommandPayload<PreviousDistributionBoardStructureSnapshot>
|
||||
> {
|
||||
schemaVersion: typeof previousDistributionBoardStructureCommandSchemaVersion;
|
||||
type:
|
||||
| typeof distributionBoardInsertCommandType
|
||||
| typeof distributionBoardDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface DistributionBoardInsertProjectCommand
|
||||
extends CurrentDistributionBoardStructureProjectCommand {
|
||||
type: typeof distributionBoardInsertCommandType;
|
||||
@@ -110,6 +196,7 @@ export interface DistributionBoardDeleteProjectCommand
|
||||
export type DistributionBoardStructureProjectCommand =
|
||||
| DistributionBoardInsertProjectCommand
|
||||
| DistributionBoardDeleteProjectCommand
|
||||
| PreviousDistributionBoardStructureProjectCommand
|
||||
| LegacyDistributionBoardStructureProjectCommand;
|
||||
|
||||
export function createDistributionBoardStructureSnapshot(
|
||||
@@ -143,6 +230,22 @@ export function createDistributionBoardStructureSnapshot(
|
||||
circuitListId: structureId,
|
||||
...section,
|
||||
})),
|
||||
components: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
circuitListId: structureId,
|
||||
sectionId: null,
|
||||
...defaultMainSwitchComponent,
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
circuitListId: structureId,
|
||||
sectionId: null,
|
||||
...defaultSurgeProtectiveDeviceComponent,
|
||||
sortOrder: 20,
|
||||
},
|
||||
],
|
||||
};
|
||||
assertDistributionBoardStructureSnapshot(structure);
|
||||
return structure;
|
||||
@@ -192,32 +295,71 @@ export function assertDistributionBoardDeleteProjectCommand(
|
||||
|
||||
export function normalizeDistributionBoardStructureProjectCommand(
|
||||
command: DistributionBoardStructureProjectCommand
|
||||
): DistributionBoardInsertProjectCommand | DistributionBoardDeleteProjectCommand {
|
||||
): NormalizedDistributionBoardStructureSnapshot {
|
||||
if (
|
||||
command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
const structure: DistributionBoardStructureSnapshot = {
|
||||
...command.payload.structure,
|
||||
return {
|
||||
distributionBoard: {
|
||||
...command.payload.structure.distributionBoard,
|
||||
floorId: null,
|
||||
supplyType: null,
|
||||
},
|
||||
circuitList: command.payload.structure.circuitList,
|
||||
sections: command.payload.structure.sections.map(
|
||||
normalizeLegacySection
|
||||
),
|
||||
components: [],
|
||||
};
|
||||
return command.type === distributionBoardInsertCommandType
|
||||
? createDistributionBoardInsertProjectCommand(structure)
|
||||
: createDistributionBoardDeleteProjectCommand(structure);
|
||||
}
|
||||
return command as
|
||||
| DistributionBoardInsertProjectCommand
|
||||
| DistributionBoardDeleteProjectCommand;
|
||||
if (
|
||||
command.schemaVersion ===
|
||||
previousDistributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
return {
|
||||
...command.payload.structure,
|
||||
sections: command.payload.structure.sections.map(
|
||||
normalizeLegacySection
|
||||
),
|
||||
components: [],
|
||||
};
|
||||
}
|
||||
return command.payload.structure;
|
||||
}
|
||||
|
||||
export function invertDistributionBoardStructureProjectCommand(
|
||||
command: DistributionBoardStructureProjectCommand
|
||||
): DistributionBoardStructureProjectCommand {
|
||||
return {
|
||||
...command,
|
||||
type:
|
||||
command.type === distributionBoardInsertCommandType
|
||||
? distributionBoardDeleteCommandType
|
||||
: distributionBoardInsertCommandType,
|
||||
} as DistributionBoardStructureProjectCommand;
|
||||
}
|
||||
|
||||
function normalizeLegacySection(
|
||||
section: LegacyCircuitSectionSnapshot
|
||||
): NormalizedDistributionBoardStructureSnapshot["sections"][number] {
|
||||
const category =
|
||||
section.key === "lighting" ||
|
||||
section.key === "single_phase" ||
|
||||
section.key === "three_phase"
|
||||
? section.key
|
||||
: null;
|
||||
return {
|
||||
...section,
|
||||
category,
|
||||
groupNumber: category === null ? null : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertDistributionBoardStructureSnapshot(
|
||||
structure: unknown
|
||||
): asserts structure is DistributionBoardStructureSnapshot {
|
||||
assertDistributionBoardStructureSnapshotVersion(structure, false);
|
||||
assertDistributionBoardStructureSnapshotVersion(structure, "current");
|
||||
}
|
||||
|
||||
function assertDistributionBoardStructureProjectCommand(
|
||||
@@ -239,36 +381,57 @@ function assertDistributionBoardStructureProjectCommand(
|
||||
) {
|
||||
assertDistributionBoardStructureSnapshotVersion(
|
||||
command.payload.structure,
|
||||
true
|
||||
"legacy"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
command.schemaVersion !== distributionBoardStructureCommandSchemaVersion
|
||||
command.schemaVersion ===
|
||||
previousDistributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
assertDistributionBoardStructureSnapshotVersion(
|
||||
command.payload.structure,
|
||||
"previous"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
command.schemaVersion !==
|
||||
distributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
throw new Error("Unsupported distribution-board structure command.");
|
||||
}
|
||||
assertDistributionBoardStructureSnapshotVersion(
|
||||
command.payload.structure,
|
||||
false
|
||||
"current"
|
||||
);
|
||||
}
|
||||
|
||||
function assertDistributionBoardStructureSnapshotVersion(
|
||||
structure: unknown,
|
||||
legacy: boolean
|
||||
version: "legacy" | "previous" | "current"
|
||||
) {
|
||||
const current = version === "current";
|
||||
if (
|
||||
!isPlainObject(structure) ||
|
||||
Object.keys(structure).length !== (current ? 4 : 3)
|
||||
) {
|
||||
if (!isPlainObject(structure) || Object.keys(structure).length !== 3) {
|
||||
throw new Error("Distribution-board structure is invalid.");
|
||||
}
|
||||
const { distributionBoard, circuitList, sections } = structure;
|
||||
const { distributionBoard, circuitList, sections, components } =
|
||||
structure;
|
||||
const expectedSections = current
|
||||
? defaultCircuitSectionDefinitions
|
||||
: legacyDefaultCircuitSectionDefinitions;
|
||||
if (
|
||||
!isPlainObject(distributionBoard) ||
|
||||
Object.keys(distributionBoard).length !== (legacy ? 3 : 5) ||
|
||||
Object.keys(distributionBoard).length !==
|
||||
(version === "legacy" ? 3 : 5) ||
|
||||
!isPlainObject(circuitList) ||
|
||||
Object.keys(circuitList).length !== 4 ||
|
||||
!Array.isArray(sections) ||
|
||||
sections.length !== defaultCircuitSectionDefinitions.length
|
||||
sections.length !== expectedSections.length ||
|
||||
(current && (!Array.isArray(components) || components.length !== 2))
|
||||
) {
|
||||
throw new Error("Distribution-board structure is incomplete.");
|
||||
}
|
||||
@@ -278,7 +441,7 @@ function assertDistributionBoardStructureSnapshotVersion(
|
||||
`distributionBoard.${field}`
|
||||
);
|
||||
}
|
||||
if (!legacy) {
|
||||
if (version !== "legacy") {
|
||||
assertNullableId(distributionBoard.floorId, "distributionBoard.floorId");
|
||||
assertNullableSupplyType(distributionBoard.supplyType);
|
||||
}
|
||||
@@ -296,8 +459,11 @@ function assertDistributionBoardStructureSnapshotVersion(
|
||||
const sectionIds = new Set<string>();
|
||||
for (let index = 0; index < sections.length; index += 1) {
|
||||
const section = sections[index];
|
||||
const expected = defaultCircuitSectionDefinitions[index];
|
||||
if (!isPlainObject(section) || Object.keys(section).length !== 6) {
|
||||
const expected = expectedSections[index];
|
||||
if (
|
||||
!isPlainObject(section) ||
|
||||
Object.keys(section).length !== (current ? 8 : 6)
|
||||
) {
|
||||
throw new Error("Default circuit section is invalid.");
|
||||
}
|
||||
assertNonEmptyString(section.id, "section.id");
|
||||
@@ -310,13 +476,64 @@ function assertDistributionBoardStructureSnapshotVersion(
|
||||
section.key !== expected.key ||
|
||||
section.displayName !== expected.displayName ||
|
||||
section.prefix !== expected.prefix ||
|
||||
section.sortOrder !== expected.sortOrder
|
||||
section.sortOrder !== expected.sortOrder ||
|
||||
(current &&
|
||||
(!("category" in expected) ||
|
||||
section.category !== expected.category ||
|
||||
section.groupNumber !== expected.groupNumber))
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board structure has invalid default sections."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
assertDefaultComponents(components, circuitList.id);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDefaultComponents(
|
||||
components: unknown,
|
||||
circuitListId: unknown
|
||||
) {
|
||||
if (!Array.isArray(components)) {
|
||||
throw new Error("Default distribution-board components are invalid.");
|
||||
}
|
||||
const expectedComponents = [
|
||||
{ ...defaultMainSwitchComponent, sortOrder: 10 },
|
||||
{ ...defaultSurgeProtectiveDeviceComponent, sortOrder: 20 },
|
||||
];
|
||||
const componentIds = new Set<string>();
|
||||
for (let index = 0; index < expectedComponents.length; index += 1) {
|
||||
const component = components[index];
|
||||
const expected = expectedComponents[index];
|
||||
if (!isPlainObject(component) || Object.keys(component).length !== 8) {
|
||||
throw new Error(
|
||||
"Default distribution-board component is invalid."
|
||||
);
|
||||
}
|
||||
assertNonEmptyString(component.id, "component.id");
|
||||
if (componentIds.has(component.id)) {
|
||||
throw new Error(
|
||||
"Default distribution-board component ids must be unique."
|
||||
);
|
||||
}
|
||||
componentIds.add(component.id);
|
||||
if (
|
||||
component.circuitListId !== circuitListId ||
|
||||
component.sectionId !== null ||
|
||||
component.equipmentIdentifier !== expected.equipmentIdentifier ||
|
||||
component.name !== expected.name ||
|
||||
component.role !== expected.role ||
|
||||
component.placement !== expected.placement ||
|
||||
component.sortOrder !== expected.sortOrder
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board structure has invalid default components."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertNullableId(value: unknown, field: string) {
|
||||
|
||||
@@ -3,6 +3,21 @@ import {
|
||||
defaultDistributionBoardSupplyTypes,
|
||||
distributionBoardSupplyTypes,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import {
|
||||
circuitGroupCategories,
|
||||
type CircuitGroupCategory,
|
||||
} from "../../shared/constants/circuit-group.js";
|
||||
import {
|
||||
distributionBoardComponentPlacements,
|
||||
distributionBoardComponentRoles,
|
||||
} from "../../shared/constants/distribution-board-component.js";
|
||||
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
|
||||
import {
|
||||
breakerTripCharacteristics,
|
||||
fuseUtilizationCategories,
|
||||
protectionDeviceTypes,
|
||||
rcdTypes,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
import {
|
||||
resolveCircuitPhaseType,
|
||||
resolveProjectVoltage,
|
||||
@@ -14,7 +29,8 @@ export const previousProjectStateSnapshotSchemaVersion = 2 as const;
|
||||
export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const;
|
||||
export const supplyTypesProjectStateSnapshotSchemaVersion = 4 as const;
|
||||
export const voltageProjectStateSnapshotSchemaVersion = 5 as const;
|
||||
export const projectStateSnapshotSchemaVersion = 6 as const;
|
||||
export const simultaneityFactorProjectStateSnapshotSchemaVersion = 6 as const;
|
||||
export const projectStateSnapshotSchemaVersion = 7 as const;
|
||||
|
||||
const idSchema = z.string().trim().min(1);
|
||||
const nullableStringSchema = z.string().nullable();
|
||||
@@ -73,7 +89,7 @@ const circuitListSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const circuitSectionSchema = z
|
||||
const legacyCircuitSectionSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
circuitListId: idSchema,
|
||||
@@ -84,6 +100,22 @@ const circuitSectionSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const circuitSectionSchema = legacyCircuitSectionSchema
|
||||
.extend({
|
||||
category: z.enum(circuitGroupCategories).nullable(),
|
||||
groupNumber: z.number().int().positive().nullable(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((section, context) => {
|
||||
if ((section.category === null) !== (section.groupNumber === null)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Circuit-section category and group number must both be set or both be null.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const circuitDeviceRowSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
@@ -173,21 +205,106 @@ const roomSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const commonProjectStateSnapshotContents = {
|
||||
const distributionBoardComponentSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
circuitListId: idSchema,
|
||||
sectionId: idSchema.nullable(),
|
||||
equipmentIdentifier: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
role: z.enum(distributionBoardComponentRoles),
|
||||
placement: z.enum(distributionBoardComponentPlacements),
|
||||
sortOrder: finiteNumberSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const persistedProtectionDeviceFields = {
|
||||
type: z.enum(protectionDeviceTypes),
|
||||
ratedCurrentA: finiteNumberSchema.positive(),
|
||||
fuseUtilizationCategory: z.enum(fuseUtilizationCategories).nullable(),
|
||||
tripCharacteristic: z.enum(breakerTripCharacteristics).nullable(),
|
||||
rcdType: z.enum(rcdTypes).nullable(),
|
||||
ratedResidualCurrentMa: finiteNumberSchema.positive().nullable(),
|
||||
};
|
||||
|
||||
function validatePersistedProtectionDevice(
|
||||
value: {
|
||||
type: (typeof protectionDeviceTypes)[number];
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory:
|
||||
| (typeof fuseUtilizationCategories)[number]
|
||||
| null;
|
||||
tripCharacteristic:
|
||||
| (typeof breakerTripCharacteristics)[number]
|
||||
| null;
|
||||
rcdType: (typeof rcdTypes)[number] | null;
|
||||
ratedResidualCurrentMa: number | null;
|
||||
},
|
||||
context: z.RefinementCtx
|
||||
) {
|
||||
const result = protectionDeviceConfigurationSchema.safeParse({
|
||||
type: value.type,
|
||||
ratedCurrentA: value.ratedCurrentA,
|
||||
...(value.fuseUtilizationCategory === null
|
||||
? {}
|
||||
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
|
||||
...(value.tripCharacteristic === null
|
||||
? {}
|
||||
: { tripCharacteristic: value.tripCharacteristic }),
|
||||
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
|
||||
...(value.ratedResidualCurrentMa === null
|
||||
? {}
|
||||
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
|
||||
});
|
||||
if (!result.success) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Snapshot protection-device configuration is invalid.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const circuitProtectionDeviceSchema = z
|
||||
.object({
|
||||
circuitId: idSchema,
|
||||
...persistedProtectionDeviceFields,
|
||||
})
|
||||
.strict()
|
||||
.superRefine(validatePersistedProtectionDevice);
|
||||
|
||||
const componentProtectionDeviceSchema = z
|
||||
.object({
|
||||
componentId: idSchema,
|
||||
...persistedProtectionDeviceFields,
|
||||
})
|
||||
.strict()
|
||||
.superRefine(validatePersistedProtectionDevice);
|
||||
|
||||
const legacyProjectStateSnapshotContents = {
|
||||
circuitLists: z.array(circuitListSchema),
|
||||
circuitSections: z.array(circuitSectionSchema),
|
||||
circuitSections: z.array(legacyCircuitSectionSchema),
|
||||
circuits: z.array(circuitSchema),
|
||||
projectDevices: z.array(projectDeviceSchema),
|
||||
floors: z.array(floorSchema),
|
||||
rooms: z.array(roomSchema),
|
||||
};
|
||||
|
||||
const currentProjectStateSnapshotContents = {
|
||||
...legacyProjectStateSnapshotContents,
|
||||
circuitSections: z.array(circuitSectionSchema),
|
||||
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
||||
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
|
||||
distributionBoardComponentProtectionDevices: z.array(
|
||||
componentProtectionDeviceSchema
|
||||
),
|
||||
};
|
||||
|
||||
const legacyProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||
project: legacyProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
...legacyProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -196,7 +313,7 @@ const previousProjectStateSnapshotSchema = z
|
||||
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
...legacyProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -207,7 +324,7 @@ const distributionBoardProjectStateSnapshotSchema = z
|
||||
),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
...legacyProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -216,7 +333,7 @@ const supplyTypesProjectStateSnapshotSchema = z
|
||||
schemaVersion: z.literal(supplyTypesProjectStateSnapshotSchemaVersion),
|
||||
project: currentProjectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
...legacyProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -227,12 +344,20 @@ const voltageProjectStateSnapshotSchema =
|
||||
),
|
||||
});
|
||||
|
||||
export const projectStateSnapshotSchema =
|
||||
const simultaneityFactorProjectStateSnapshotSchema =
|
||||
voltageProjectStateSnapshotSchema.extend({
|
||||
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||
schemaVersion: z.literal(
|
||||
simultaneityFactorProjectStateSnapshotSchemaVersion
|
||||
),
|
||||
distributionBoards: z.array(currentDistributionBoardSchema),
|
||||
});
|
||||
|
||||
export const projectStateSnapshotSchema =
|
||||
simultaneityFactorProjectStateSnapshotSchema.extend({
|
||||
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||
...currentProjectStateSnapshotContents,
|
||||
});
|
||||
|
||||
export type ProjectStateSnapshot = z.infer<
|
||||
typeof projectStateSnapshotSchema
|
||||
>;
|
||||
@@ -243,7 +368,8 @@ export function parseProjectStateSnapshot(
|
||||
const version = isPlainObject(value) ? value.schemaVersion : undefined;
|
||||
const parsedSnapshot =
|
||||
version === legacyProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
upgradePreviousProjectStateSnapshot(
|
||||
@@ -254,8 +380,10 @@ export function parseProjectStateSnapshot(
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === previousProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
upgradePreviousProjectStateSnapshot(
|
||||
@@ -264,24 +392,40 @@ export function parseProjectStateSnapshot(
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === distributionBoardProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
distributionBoardProjectStateSnapshotSchema.parse(value)
|
||||
distributionBoardProjectStateSnapshotSchema.parse(
|
||||
value
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === supplyTypesProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
supplyTypesProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === voltageProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
upgradeVoltageProjectStateSnapshot(
|
||||
voltageProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
: version ===
|
||||
simultaneityFactorProjectStateSnapshotSchemaVersion
|
||||
? upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
simultaneityFactorProjectStateSnapshotSchema.parse(
|
||||
value
|
||||
)
|
||||
)
|
||||
: projectStateSnapshotSchema.parse(value);
|
||||
const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot);
|
||||
assertProjectStateSnapshotRelations(snapshot);
|
||||
@@ -412,10 +556,10 @@ function upgradeSupplyTypesProjectStateSnapshot(
|
||||
|
||||
function upgradeVoltageProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof voltageProjectStateSnapshotSchema>
|
||||
): ProjectStateSnapshot {
|
||||
): z.infer<typeof simultaneityFactorProjectStateSnapshotSchema> {
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
schemaVersion: simultaneityFactorProjectStateSnapshotSchemaVersion,
|
||||
distributionBoards: snapshot.distributionBoards.map((board) => ({
|
||||
...board,
|
||||
simultaneityFactor: 1,
|
||||
@@ -423,6 +567,39 @@ function upgradeVoltageProjectStateSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeSimultaneityFactorProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof simultaneityFactorProjectStateSnapshotSchema>
|
||||
): ProjectStateSnapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
circuitSections: snapshot.circuitSections.map((section) => {
|
||||
const category = initialCategoryBySectionKey(section.key);
|
||||
return {
|
||||
...section,
|
||||
category,
|
||||
groupNumber: category === null ? null : 1,
|
||||
};
|
||||
}),
|
||||
distributionBoardComponents: [],
|
||||
circuitProtectionDevices: [],
|
||||
distributionBoardComponentProtectionDevices: [],
|
||||
};
|
||||
}
|
||||
|
||||
function initialCategoryBySectionKey(
|
||||
key: string
|
||||
): CircuitGroupCategory | null {
|
||||
if (
|
||||
key === "lighting" ||
|
||||
key === "single_phase" ||
|
||||
key === "three_phase"
|
||||
) {
|
||||
return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -461,10 +638,22 @@ function assertProjectStateSnapshotRelations(
|
||||
);
|
||||
const floorIds = uniqueIds(snapshot.floors, "floor");
|
||||
const roomIds = uniqueIds(snapshot.rooms, "room");
|
||||
uniqueIds(snapshot.circuits, "circuit");
|
||||
const circuitIds = uniqueIds(snapshot.circuits, "circuit");
|
||||
const componentIds = uniqueIds(
|
||||
snapshot.distributionBoardComponents,
|
||||
"distribution board component"
|
||||
);
|
||||
const sectionById = new Map(
|
||||
snapshot.circuitSections.map((entry) => [entry.id, entry])
|
||||
);
|
||||
const componentById = new Map(
|
||||
snapshot.distributionBoardComponents.map((entry) => [
|
||||
entry.id,
|
||||
entry,
|
||||
])
|
||||
);
|
||||
const groupKeys = new Set<string>();
|
||||
const equipmentIdentifiersByList = new Map<string, Set<string>>();
|
||||
|
||||
for (const board of snapshot.distributionBoards) {
|
||||
assertProjectOwnership(board.projectId, projectId, "distribution board");
|
||||
@@ -500,6 +689,19 @@ function assertProjectStateSnapshotRelations(
|
||||
section.circuitListId,
|
||||
"circuit section list"
|
||||
);
|
||||
if (section.category !== null && section.groupNumber !== null) {
|
||||
const groupKey = [
|
||||
section.circuitListId,
|
||||
section.category,
|
||||
section.groupNumber,
|
||||
].join(":");
|
||||
if (groupKeys.has(groupKey)) {
|
||||
throw new Error(
|
||||
"Snapshot contains a duplicate circuit group number."
|
||||
);
|
||||
}
|
||||
groupKeys.add(groupKey);
|
||||
}
|
||||
}
|
||||
for (const device of snapshot.projectDevices) {
|
||||
assertProjectOwnership(device.projectId, projectId, "project device");
|
||||
@@ -553,6 +755,11 @@ function assertProjectStateSnapshotRelations(
|
||||
"Snapshot circuit voltage must match project settings."
|
||||
);
|
||||
}
|
||||
registerEquipmentIdentifier(
|
||||
equipmentIdentifiersByList,
|
||||
circuit.circuitListId,
|
||||
circuit.equipmentIdentifier
|
||||
);
|
||||
for (const row of circuit.deviceRows) {
|
||||
if (row.circuitId !== circuit.id) {
|
||||
throw new Error(
|
||||
@@ -575,6 +782,148 @@ function assertProjectStateSnapshotRelations(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fixedComponentRolesByList = new Set<string>();
|
||||
const groupComponentRolesBySection = new Set<string>();
|
||||
for (const component of snapshot.distributionBoardComponents) {
|
||||
assertReference(
|
||||
circuitListIds,
|
||||
component.circuitListId,
|
||||
"distribution board component circuit list"
|
||||
);
|
||||
if (component.sectionId !== null) {
|
||||
assertReference(
|
||||
sectionIds,
|
||||
component.sectionId,
|
||||
"distribution board component section"
|
||||
);
|
||||
if (
|
||||
sectionById.get(component.sectionId)?.circuitListId !==
|
||||
component.circuitListId
|
||||
) {
|
||||
throw new Error(
|
||||
"Snapshot distribution board component section belongs to a different circuit list."
|
||||
);
|
||||
}
|
||||
}
|
||||
assertComponentPlacement(component);
|
||||
registerEquipmentIdentifier(
|
||||
equipmentIdentifiersByList,
|
||||
component.circuitListId,
|
||||
component.equipmentIdentifier
|
||||
);
|
||||
if (
|
||||
component.role === "main_switch" ||
|
||||
component.role === "surge_protective_device"
|
||||
) {
|
||||
assertUniqueKey(
|
||||
fixedComponentRolesByList,
|
||||
`${component.circuitListId}:${component.role}`,
|
||||
"Snapshot contains duplicate fixed distribution board components."
|
||||
);
|
||||
}
|
||||
if (
|
||||
component.role === "group_upstream_protection" ||
|
||||
component.role === "group_residual_current_protection"
|
||||
) {
|
||||
assertUniqueKey(
|
||||
groupComponentRolesBySection,
|
||||
`${component.sectionId}:${component.role}`,
|
||||
"Snapshot contains duplicate group protection components."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const protectedCircuitIds = new Set<string>();
|
||||
for (const protection of snapshot.circuitProtectionDevices) {
|
||||
assertReference(
|
||||
circuitIds,
|
||||
protection.circuitId,
|
||||
"circuit protection device circuit"
|
||||
);
|
||||
assertUniqueKey(
|
||||
protectedCircuitIds,
|
||||
protection.circuitId,
|
||||
"Snapshot contains duplicate circuit protection devices."
|
||||
);
|
||||
}
|
||||
|
||||
const protectedComponentIds = new Set<string>();
|
||||
for (const protection of snapshot.distributionBoardComponentProtectionDevices) {
|
||||
assertReference(
|
||||
componentIds,
|
||||
protection.componentId,
|
||||
"component protection device component"
|
||||
);
|
||||
assertUniqueKey(
|
||||
protectedComponentIds,
|
||||
protection.componentId,
|
||||
"Snapshot contains duplicate component protection devices."
|
||||
);
|
||||
const component = componentById.get(protection.componentId)!;
|
||||
if (
|
||||
component.role !== "group_upstream_protection" &&
|
||||
component.role !== "group_residual_current_protection"
|
||||
) {
|
||||
throw new Error(
|
||||
"Snapshot protection data belongs to a non-protection component."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertComponentPlacement(
|
||||
component: ProjectStateSnapshot["distributionBoardComponents"][number]
|
||||
) {
|
||||
const isFixedHeader =
|
||||
component.role === "main_switch" ||
|
||||
component.role === "surge_protective_device";
|
||||
const isGroupProtection =
|
||||
component.role === "group_upstream_protection" ||
|
||||
component.role === "group_residual_current_protection";
|
||||
if (
|
||||
(isFixedHeader &&
|
||||
(component.placement !== "header" ||
|
||||
component.sectionId !== null)) ||
|
||||
(isGroupProtection &&
|
||||
(component.placement !== "group" ||
|
||||
component.sectionId === null)) ||
|
||||
(component.role === "auxiliary" &&
|
||||
(component.placement !== "footer" ||
|
||||
component.sectionId !== null))
|
||||
) {
|
||||
throw new Error(
|
||||
"Snapshot distribution board component placement is invalid."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function registerEquipmentIdentifier(
|
||||
identifiersByList: Map<string, Set<string>>,
|
||||
circuitListId: string,
|
||||
equipmentIdentifier: string
|
||||
) {
|
||||
const identifiers =
|
||||
identifiersByList.get(circuitListId) ?? new Set<string>();
|
||||
const normalized = equipmentIdentifier.trim().toLocaleLowerCase("de");
|
||||
if (identifiers.has(normalized)) {
|
||||
throw new Error(
|
||||
"Snapshot contains duplicate equipment identifiers in one circuit list."
|
||||
);
|
||||
}
|
||||
identifiers.add(normalized);
|
||||
identifiersByList.set(circuitListId, identifiers);
|
||||
}
|
||||
|
||||
function assertUniqueKey(
|
||||
keys: Set<string>,
|
||||
key: string,
|
||||
message: string
|
||||
) {
|
||||
if (keys.has(key)) {
|
||||
throw new Error(message);
|
||||
}
|
||||
keys.add(key);
|
||||
}
|
||||
|
||||
function uniqueIds(
|
||||
|
||||
@@ -79,6 +79,10 @@ export function remapProjectState(
|
||||
const listIds = idMap(source.circuitLists, createId);
|
||||
const sectionIds = idMap(source.circuitSections, createId);
|
||||
const circuitIds = idMap(source.circuits, createId);
|
||||
const componentIds = idMap(
|
||||
source.distributionBoardComponents,
|
||||
createId
|
||||
);
|
||||
const deviceIds = idMap(source.projectDevices, createId);
|
||||
const floorIds = idMap(source.floors, createId);
|
||||
const roomIds = idMap(source.rooms, createId);
|
||||
@@ -110,6 +114,35 @@ export function remapProjectState(
|
||||
id: requiredId(sectionIds, section.id),
|
||||
circuitListId: requiredId(listIds, section.circuitListId),
|
||||
})),
|
||||
distributionBoardComponents:
|
||||
source.distributionBoardComponents.map((component) => ({
|
||||
...component,
|
||||
id: requiredId(componentIds, component.id),
|
||||
circuitListId: requiredId(
|
||||
listIds,
|
||||
component.circuitListId
|
||||
),
|
||||
sectionId:
|
||||
component.sectionId === null
|
||||
? null
|
||||
: requiredId(sectionIds, component.sectionId),
|
||||
})),
|
||||
circuitProtectionDevices: source.circuitProtectionDevices.map(
|
||||
(protection) => ({
|
||||
...protection,
|
||||
circuitId: requiredId(circuitIds, protection.circuitId),
|
||||
})
|
||||
),
|
||||
distributionBoardComponentProtectionDevices:
|
||||
source.distributionBoardComponentProtectionDevices.map(
|
||||
(protection) => ({
|
||||
...protection,
|
||||
componentId: requiredId(
|
||||
componentIds,
|
||||
protection.componentId
|
||||
),
|
||||
})
|
||||
),
|
||||
circuits: source.circuits.map((circuit) => ({
|
||||
...circuit,
|
||||
id: requiredId(circuitIds, circuit.id),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseProtectionDeviceType,
|
||||
FuseUtilizationCategory,
|
||||
RatedResidualCurrentMa,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
|
||||
export interface FuseProtectionDeviceConfiguration {
|
||||
type: FuseProtectionDeviceType;
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory: FuseUtilizationCategory;
|
||||
}
|
||||
|
||||
export interface BreakerProtectionDeviceConfiguration {
|
||||
type: "LS" | "AFDD";
|
||||
ratedCurrentA: number;
|
||||
tripCharacteristic: BreakerTripCharacteristic;
|
||||
}
|
||||
|
||||
export interface ResidualCurrentDeviceConfiguration {
|
||||
type: "FI";
|
||||
ratedCurrentA: number;
|
||||
rcdType: RcdType;
|
||||
ratedResidualCurrentMa: RatedResidualCurrentMa;
|
||||
}
|
||||
|
||||
export interface CombinedResidualCurrentBreakerConfiguration {
|
||||
type: "FI_LS";
|
||||
ratedCurrentA: number;
|
||||
tripCharacteristic: BreakerTripCharacteristic;
|
||||
rcdType: RcdType;
|
||||
ratedResidualCurrentMa: RatedResidualCurrentMa;
|
||||
}
|
||||
|
||||
export type ProtectionDeviceConfiguration =
|
||||
| FuseProtectionDeviceConfiguration
|
||||
| BreakerProtectionDeviceConfiguration
|
||||
| ResidualCurrentDeviceConfiguration
|
||||
| CombinedResidualCurrentBreakerConfiguration;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CircuitGroupMoveProjectCommand } from "../models/circuit-group-move-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitGroupMoveCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitGroupMoveProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitGroupMoveProjectCommandStore {
|
||||
execute(input: ExecuteCircuitGroupMoveCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitGroupMoveProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CircuitGroupRenumberProjectCommand } from "../models/circuit-group-renumber-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitGroupRenumberCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitGroupRenumberProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitGroupRenumberProjectCommandStore {
|
||||
execute(input: ExecuteCircuitGroupRenumberCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitGroupRenumberProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CircuitGroupStructureProjectCommand } from "../models/circuit-group-structure-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitGroupStructureCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitGroupStructureProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitGroupStructureProjectCommandStore {
|
||||
execute(input: ExecuteCircuitGroupStructureCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitGroupStructureProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CircuitGroupSubtreeSnapshot } from "../models/circuit-group-subtree-snapshot.model.js";
|
||||
import type { CircuitGroupSubtreeProjectCommand } from "../models/circuit-group-subtree-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitGroupSubtreeCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitGroupSubtreeProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitGroupSubtreeProjectCommandStore {
|
||||
capture(projectId: string, groupId: string): CircuitGroupSubtreeSnapshot;
|
||||
execute(input: ExecuteCircuitGroupSubtreeCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitGroupSubtreeProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
CircuitProtectionUpdateProjectCommand,
|
||||
} from "../models/circuit-protection-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitProtectionCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitProtectionUpdateProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitProtectionProjectCommandStore {
|
||||
execute(input: ExecuteCircuitProtectionCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitProtectionUpdateProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { DistributionBoardComponentStructureProjectCommand } from "../models/distribution-board-component-structure-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteDistributionBoardComponentStructureCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: DistributionBoardComponentStructureProjectCommand;
|
||||
}
|
||||
|
||||
export interface DistributionBoardComponentStructureProjectCommandStore {
|
||||
execute(
|
||||
input: ExecuteDistributionBoardComponentStructureCommandInput
|
||||
): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: DistributionBoardComponentStructureProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
import {
|
||||
formatCircuitGroupPrefix,
|
||||
getNextGroupedCircuitIdentifier,
|
||||
parseGroupedEquipmentIdentifier,
|
||||
} from "./circuit-group-numbering.js";
|
||||
|
||||
export interface CircuitMoveGroupIdentity {
|
||||
id: string;
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export interface CircuitGroupMovePlan {
|
||||
circuitId: string;
|
||||
circuitListId: string;
|
||||
expectedSectionId: string;
|
||||
targetSectionId: string;
|
||||
expectedEquipmentIdentifier: string;
|
||||
targetEquipmentIdentifier: string;
|
||||
expectedSortOrder: number;
|
||||
targetSortOrder: number;
|
||||
}
|
||||
|
||||
export function createCircuitGroupMovePlan(input: {
|
||||
circuit: {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
sectionId: string;
|
||||
equipmentIdentifier: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
sourceGroup: CircuitMoveGroupIdentity;
|
||||
targetGroup: CircuitMoveGroupIdentity;
|
||||
targetCircuitEquipmentIdentifiers: Iterable<string>;
|
||||
targetSortOrder: number;
|
||||
}): CircuitGroupMovePlan {
|
||||
assertGroupIdentity(input.sourceGroup);
|
||||
assertGroupIdentity(input.targetGroup);
|
||||
if (
|
||||
input.sourceGroup.id === input.targetGroup.id ||
|
||||
input.sourceGroup.category !== input.targetGroup.category
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuits can move only between different groups of the same category."
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.circuit.sectionId !== input.sourceGroup.id ||
|
||||
!input.circuit.id.trim() ||
|
||||
!input.circuit.circuitListId.trim() ||
|
||||
!Number.isFinite(input.circuit.sortOrder) ||
|
||||
!Number.isFinite(input.targetSortOrder)
|
||||
) {
|
||||
throw new Error("Circuit move source or target position is invalid.");
|
||||
}
|
||||
const parsed = parseGroupedEquipmentIdentifier(
|
||||
input.circuit.equipmentIdentifier
|
||||
);
|
||||
if (
|
||||
parsed?.kind !== "circuit" ||
|
||||
parsed.category !== input.sourceGroup.category ||
|
||||
parsed.groupNumber !== input.sourceGroup.groupNumber
|
||||
) {
|
||||
throw new Error("Circuit BMK does not match its source group.");
|
||||
}
|
||||
|
||||
return {
|
||||
circuitId: input.circuit.id,
|
||||
circuitListId: input.circuit.circuitListId,
|
||||
expectedSectionId: input.sourceGroup.id,
|
||||
targetSectionId: input.targetGroup.id,
|
||||
expectedEquipmentIdentifier:
|
||||
input.circuit.equipmentIdentifier,
|
||||
targetEquipmentIdentifier:
|
||||
getNextGroupedCircuitIdentifier(
|
||||
input.targetGroup.category,
|
||||
input.targetGroup.groupNumber,
|
||||
input.targetCircuitEquipmentIdentifiers
|
||||
),
|
||||
expectedSortOrder: input.circuit.sortOrder,
|
||||
targetSortOrder: input.targetSortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
function assertGroupIdentity(group: CircuitMoveGroupIdentity) {
|
||||
if (
|
||||
!group.id.trim() ||
|
||||
!Number.isInteger(group.groupNumber) ||
|
||||
group.groupNumber < 1 ||
|
||||
group.prefix !==
|
||||
formatCircuitGroupPrefix(group.category, group.groupNumber)
|
||||
) {
|
||||
throw new Error("Circuit move group identity is invalid.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
circuitGroupCategoryNumbers,
|
||||
type CircuitGroupCategory,
|
||||
} from "../../shared/constants/circuit-group.js";
|
||||
|
||||
export type GroupedEquipmentIdentifierKind =
|
||||
| "group_upstream_protection"
|
||||
| "group_rcd"
|
||||
| "circuit";
|
||||
|
||||
export interface ParsedGroupedEquipmentIdentifier {
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
kind: GroupedEquipmentIdentifierKind;
|
||||
circuitNumber: number | null;
|
||||
}
|
||||
|
||||
const categoryByNumber: Record<string, CircuitGroupCategory> = {
|
||||
"1": "lighting",
|
||||
"2": "single_phase",
|
||||
"3": "three_phase",
|
||||
};
|
||||
|
||||
function requirePositiveInteger(value: number, fieldName: string): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new RangeError(`${fieldName} must be a positive integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function getGroupStem(
|
||||
category: CircuitGroupCategory,
|
||||
functionLetter: "F" | "Q",
|
||||
groupNumber: number
|
||||
): string {
|
||||
requirePositiveInteger(groupNumber, "groupNumber");
|
||||
return `-${circuitGroupCategoryNumbers[category]}${functionLetter}${groupNumber}`;
|
||||
}
|
||||
|
||||
export function formatCircuitGroupPrefix(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number
|
||||
) {
|
||||
return `${getGroupStem(category, "F", groupNumber)}.`;
|
||||
}
|
||||
|
||||
export function formatGroupUpstreamProtectionIdentifier(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number
|
||||
): string {
|
||||
return `${getGroupStem(category, "F", groupNumber)}.0`;
|
||||
}
|
||||
|
||||
export function formatGroupRcdIdentifier(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number
|
||||
): string {
|
||||
return `${getGroupStem(category, "Q", groupNumber)}.0`;
|
||||
}
|
||||
|
||||
export function formatGroupedCircuitIdentifier(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number,
|
||||
circuitNumber: number
|
||||
): string {
|
||||
requirePositiveInteger(circuitNumber, "circuitNumber");
|
||||
return `${getGroupStem(category, "F", groupNumber)}.${circuitNumber}`;
|
||||
}
|
||||
|
||||
export function parseGroupedEquipmentIdentifier(
|
||||
equipmentIdentifier: string
|
||||
): ParsedGroupedEquipmentIdentifier | null {
|
||||
const match = /^-(1|2|3)(F|Q)([1-9]\d*)\.(0|[1-9]\d*)$/.exec(
|
||||
equipmentIdentifier
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, categoryNumber, functionLetter, groupNumberValue, suffixValue] =
|
||||
match;
|
||||
const category = categoryByNumber[categoryNumber];
|
||||
const groupNumber = Number(groupNumberValue);
|
||||
const suffix = Number(suffixValue);
|
||||
|
||||
if (functionLetter === "Q") {
|
||||
return suffix === 0
|
||||
? {
|
||||
category,
|
||||
groupNumber,
|
||||
kind: "group_rcd",
|
||||
circuitNumber: null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (suffix === 0) {
|
||||
return {
|
||||
category,
|
||||
groupNumber,
|
||||
kind: "group_upstream_protection",
|
||||
circuitNumber: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
category,
|
||||
groupNumber,
|
||||
kind: "circuit",
|
||||
circuitNumber: suffix,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNextGroupedCircuitNumber(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number,
|
||||
equipmentIdentifiers: Iterable<string>
|
||||
): number {
|
||||
requirePositiveInteger(groupNumber, "groupNumber");
|
||||
let highestCircuitNumber = 0;
|
||||
|
||||
for (const equipmentIdentifier of equipmentIdentifiers) {
|
||||
const parsed = parseGroupedEquipmentIdentifier(equipmentIdentifier);
|
||||
if (
|
||||
parsed?.kind === "circuit" &&
|
||||
parsed.category === category &&
|
||||
parsed.groupNumber === groupNumber &&
|
||||
parsed.circuitNumber !== null
|
||||
) {
|
||||
highestCircuitNumber = Math.max(
|
||||
highestCircuitNumber,
|
||||
parsed.circuitNumber
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return highestCircuitNumber + 1;
|
||||
}
|
||||
|
||||
export function getNextGroupedCircuitIdentifier(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number,
|
||||
equipmentIdentifiers: Iterable<string>
|
||||
): string {
|
||||
return formatGroupedCircuitIdentifier(
|
||||
category,
|
||||
groupNumber,
|
||||
getNextGroupedCircuitNumber(
|
||||
category,
|
||||
groupNumber,
|
||||
equipmentIdentifiers
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getNextCircuitGroupNumber(
|
||||
category: CircuitGroupCategory,
|
||||
groups: Iterable<{
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
}>
|
||||
): number {
|
||||
let highestGroupNumber = 0;
|
||||
|
||||
for (const group of groups) {
|
||||
if (group.category === category) {
|
||||
requirePositiveInteger(group.groupNumber, "groupNumber");
|
||||
highestGroupNumber = Math.max(highestGroupNumber, group.groupNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return highestGroupNumber + 1;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
import {
|
||||
formatCircuitGroupPrefix,
|
||||
formatGroupRcdIdentifier,
|
||||
formatGroupedCircuitIdentifier,
|
||||
formatGroupUpstreamProtectionIdentifier,
|
||||
parseGroupedEquipmentIdentifier,
|
||||
} from "./circuit-group-numbering.js";
|
||||
|
||||
export interface RenumberableCircuitGroup {
|
||||
id: string;
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
prefix: string;
|
||||
circuits: Array<{
|
||||
id: string;
|
||||
equipmentIdentifier: string;
|
||||
}>;
|
||||
components: Array<{
|
||||
id: string;
|
||||
role:
|
||||
| "group_upstream_protection"
|
||||
| "group_residual_current_protection";
|
||||
equipmentIdentifier: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CircuitGroupRenumberRequest {
|
||||
groupId: string;
|
||||
targetGroupNumber: number;
|
||||
}
|
||||
|
||||
export interface CircuitGroupRenumberPlan {
|
||||
groups: Array<{
|
||||
groupId: string;
|
||||
category: CircuitGroupCategory;
|
||||
expectedGroupNumber: number;
|
||||
targetGroupNumber: number;
|
||||
expectedPrefix: string;
|
||||
targetPrefix: string;
|
||||
circuits: Array<{
|
||||
circuitId: string;
|
||||
expectedEquipmentIdentifier: string;
|
||||
targetEquipmentIdentifier: string;
|
||||
}>;
|
||||
components: Array<{
|
||||
componentId: string;
|
||||
expectedEquipmentIdentifier: string;
|
||||
targetEquipmentIdentifier: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function createCircuitGroupRenumberPlan(
|
||||
groups: RenumberableCircuitGroup[],
|
||||
requests: CircuitGroupRenumberRequest[]
|
||||
): CircuitGroupRenumberPlan {
|
||||
if (requests.length === 0) {
|
||||
throw new Error("Circuit-group renumbering requires requests.");
|
||||
}
|
||||
const groupsById = new Map(groups.map((group) => [group.id, group]));
|
||||
if (groupsById.size !== groups.length) {
|
||||
throw new Error("Circuit-group renumbering contains duplicate group ids.");
|
||||
}
|
||||
const requestGroupIds = new Set<string>();
|
||||
const targetKeys = new Set<string>();
|
||||
const selected = requests.map((request) => {
|
||||
const group = groupsById.get(request.groupId);
|
||||
if (
|
||||
!group ||
|
||||
requestGroupIds.has(request.groupId) ||
|
||||
!Number.isInteger(request.targetGroupNumber) ||
|
||||
request.targetGroupNumber < 1
|
||||
) {
|
||||
throw new Error("Circuit-group renumber request is invalid.");
|
||||
}
|
||||
const targetKey = `${group.category}:${request.targetGroupNumber}`;
|
||||
if (targetKeys.has(targetKey)) {
|
||||
throw new Error(
|
||||
"Circuit-group renumbering contains duplicate target numbers."
|
||||
);
|
||||
}
|
||||
requestGroupIds.add(request.groupId);
|
||||
targetKeys.add(targetKey);
|
||||
return { group, targetGroupNumber: request.targetGroupNumber };
|
||||
});
|
||||
if (
|
||||
selected.every(
|
||||
({ group, targetGroupNumber }) =>
|
||||
group.groupNumber === targetGroupNumber
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit-group renumbering must change at least one number."
|
||||
);
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
if (
|
||||
!requestGroupIds.has(group.id) &&
|
||||
targetKeys.has(`${group.category}:${group.groupNumber}`)
|
||||
) {
|
||||
throw new Error(
|
||||
"Target group number belongs to an unchanged group."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groups: selected.map(({ group, targetGroupNumber }) => {
|
||||
assertCurrentGroupIdentity(group);
|
||||
return {
|
||||
groupId: group.id,
|
||||
category: group.category,
|
||||
expectedGroupNumber: group.groupNumber,
|
||||
targetGroupNumber,
|
||||
expectedPrefix: group.prefix,
|
||||
targetPrefix: formatCircuitGroupPrefix(
|
||||
group.category,
|
||||
targetGroupNumber
|
||||
),
|
||||
circuits: group.circuits.map((circuit) => {
|
||||
const parsed = parseGroupedEquipmentIdentifier(
|
||||
circuit.equipmentIdentifier
|
||||
);
|
||||
if (
|
||||
parsed?.kind !== "circuit" ||
|
||||
parsed.category !== group.category ||
|
||||
parsed.groupNumber !== group.groupNumber ||
|
||||
parsed.circuitNumber === null
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit BMK does not match its current group."
|
||||
);
|
||||
}
|
||||
return {
|
||||
circuitId: circuit.id,
|
||||
expectedEquipmentIdentifier:
|
||||
circuit.equipmentIdentifier,
|
||||
targetEquipmentIdentifier:
|
||||
formatGroupedCircuitIdentifier(
|
||||
group.category,
|
||||
targetGroupNumber,
|
||||
parsed.circuitNumber
|
||||
),
|
||||
};
|
||||
}),
|
||||
components: group.components.map((component) => {
|
||||
const expected =
|
||||
component.role === "group_upstream_protection"
|
||||
? formatGroupUpstreamProtectionIdentifier(
|
||||
group.category,
|
||||
group.groupNumber
|
||||
)
|
||||
: formatGroupRcdIdentifier(
|
||||
group.category,
|
||||
group.groupNumber
|
||||
);
|
||||
if (component.equipmentIdentifier !== expected) {
|
||||
throw new Error(
|
||||
"Group component BMK does not match its current group."
|
||||
);
|
||||
}
|
||||
return {
|
||||
componentId: component.id,
|
||||
expectedEquipmentIdentifier: expected,
|
||||
targetEquipmentIdentifier:
|
||||
component.role === "group_upstream_protection"
|
||||
? formatGroupUpstreamProtectionIdentifier(
|
||||
group.category,
|
||||
targetGroupNumber
|
||||
)
|
||||
: formatGroupRcdIdentifier(
|
||||
group.category,
|
||||
targetGroupNumber
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function assertCurrentGroupIdentity(group: RenumberableCircuitGroup) {
|
||||
if (
|
||||
!Number.isInteger(group.groupNumber) ||
|
||||
group.groupNumber < 1 ||
|
||||
group.prefix !==
|
||||
formatCircuitGroupPrefix(group.category, group.groupNumber)
|
||||
) {
|
||||
throw new Error("Circuit group has an invalid current identity.");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
assertCircuitUpdateProjectCommand,
|
||||
circuitUpdateCommandType,
|
||||
} from "../models/circuit-project-command.model.js";
|
||||
import {
|
||||
assertCircuitProtectionUpdateProjectCommand,
|
||||
circuitProtectionUpdateCommandType,
|
||||
} from "../models/circuit-protection-project-command.model.js";
|
||||
import {
|
||||
assertCircuitSectionReorderProjectCommand,
|
||||
circuitSectionReorderCommandType,
|
||||
@@ -41,6 +45,14 @@ import {
|
||||
assertDistributionBoardUpdateProjectCommand,
|
||||
distributionBoardUpdateCommandType,
|
||||
} from "../models/distribution-board-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardComponentDeleteProjectCommand,
|
||||
assertDistributionBoardComponentInsertProjectCommand,
|
||||
assertDistributionBoardComponentUpdateProjectCommand,
|
||||
distributionBoardComponentDeleteCommandType,
|
||||
distributionBoardComponentInsertCommandType,
|
||||
distributionBoardComponentUpdateCommandType,
|
||||
} from "../models/distribution-board-component-structure-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardDeleteProjectCommand,
|
||||
assertDistributionBoardInsertProjectCommand,
|
||||
@@ -87,10 +99,16 @@ import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-devic
|
||||
import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js";
|
||||
import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
|
||||
import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
|
||||
import type { CircuitProtectionProjectCommandStore } from "../ports/circuit-protection-project-command.store.js";
|
||||
import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
|
||||
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
|
||||
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
|
||||
import type { CircuitGroupStructureProjectCommandStore } from "../ports/circuit-group-structure-project-command.store.js";
|
||||
import type { CircuitGroupRenumberProjectCommandStore } from "../ports/circuit-group-renumber-project-command.store.js";
|
||||
import type { CircuitGroupMoveProjectCommandStore } from "../ports/circuit-group-move-project-command.store.js";
|
||||
import type { CircuitGroupSubtreeProjectCommandStore } from "../ports/circuit-group-subtree-project-command.store.js";
|
||||
import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js";
|
||||
import type { DistributionBoardComponentStructureProjectCommandStore } from "../ports/distribution-board-component-structure-project-command.store.js";
|
||||
import type {
|
||||
ExecuteProjectHistoryCommandInput,
|
||||
ExecutedProjectCommand,
|
||||
@@ -101,6 +119,30 @@ import type {
|
||||
ProjectHistoryDirection,
|
||||
ProjectHistoryStore,
|
||||
} from "../ports/project-history.store.js";
|
||||
import {
|
||||
assertCircuitGroupDeleteProjectCommand,
|
||||
assertCircuitGroupInsertProjectCommand,
|
||||
assertCircuitGroupReorderProjectCommand,
|
||||
assertCircuitGroupUpdateProjectCommand,
|
||||
circuitGroupDeleteCommandType,
|
||||
circuitGroupInsertCommandType,
|
||||
circuitGroupReorderCommandType,
|
||||
circuitGroupUpdateCommandType,
|
||||
} from "../models/circuit-group-structure-project-command.model.js";
|
||||
import {
|
||||
assertCircuitGroupRenumberProjectCommand,
|
||||
circuitGroupRenumberCommandType,
|
||||
} from "../models/circuit-group-renumber-project-command.model.js";
|
||||
import {
|
||||
assertCircuitGroupMoveProjectCommand,
|
||||
circuitGroupMoveCommandType,
|
||||
} from "../models/circuit-group-move-project-command.model.js";
|
||||
import {
|
||||
assertCircuitGroupDeleteSubtreeProjectCommand,
|
||||
assertCircuitGroupRestoreSubtreeProjectCommand,
|
||||
circuitGroupDeleteSubtreeCommandType,
|
||||
circuitGroupRestoreSubtreeCommandType,
|
||||
} from "../models/circuit-group-subtree-project-command.model.js";
|
||||
import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js";
|
||||
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
|
||||
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
|
||||
@@ -135,6 +177,12 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
|
||||
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
|
||||
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
|
||||
private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore,
|
||||
private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore,
|
||||
private readonly circuitGroupRenumberStore: CircuitGroupRenumberProjectCommandStore,
|
||||
private readonly circuitGroupMoveStore: CircuitGroupMoveProjectCommandStore,
|
||||
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
|
||||
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
|
||||
private readonly historyStore: ProjectHistoryStore
|
||||
) {}
|
||||
|
||||
@@ -296,6 +344,96 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case distributionBoardComponentInsertCommandType: {
|
||||
assertDistributionBoardComponentInsertProjectCommand(
|
||||
input.command
|
||||
);
|
||||
return this.distributionBoardComponentStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case distributionBoardComponentDeleteCommandType: {
|
||||
assertDistributionBoardComponentDeleteProjectCommand(
|
||||
input.command
|
||||
);
|
||||
return this.distributionBoardComponentStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case distributionBoardComponentUpdateCommandType: {
|
||||
assertDistributionBoardComponentUpdateProjectCommand(
|
||||
input.command
|
||||
);
|
||||
return this.distributionBoardComponentStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupInsertCommandType: {
|
||||
assertCircuitGroupInsertProjectCommand(input.command);
|
||||
return this.circuitGroupStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupDeleteCommandType: {
|
||||
assertCircuitGroupDeleteProjectCommand(input.command);
|
||||
return this.circuitGroupStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupUpdateCommandType: {
|
||||
assertCircuitGroupUpdateProjectCommand(input.command);
|
||||
return this.circuitGroupStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupReorderCommandType: {
|
||||
assertCircuitGroupReorderProjectCommand(input.command);
|
||||
return this.circuitGroupStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupRenumberCommandType: {
|
||||
assertCircuitGroupRenumberProjectCommand(input.command);
|
||||
return this.circuitGroupRenumberStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupMoveCommandType: {
|
||||
assertCircuitGroupMoveProjectCommand(input.command);
|
||||
return this.circuitGroupMoveStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupDeleteSubtreeCommandType: {
|
||||
assertCircuitGroupDeleteSubtreeProjectCommand(input.command);
|
||||
return this.circuitGroupSubtreeStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupRestoreSubtreeCommandType: {
|
||||
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
|
||||
return this.circuitGroupSubtreeStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitProtectionUpdateCommandType: {
|
||||
assertCircuitProtectionUpdateProjectCommand(input.command);
|
||||
return this.circuitProtectionStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case projectFloorInsertCommandType: {
|
||||
assertProjectFloorInsertProjectCommand(input.command);
|
||||
return this.projectLocationStructureStore.execute({
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ProtectionDeviceConfiguration } from "../models/protection-device.model.js";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
|
||||
const defaultCircuitProtectionByCategory = {
|
||||
lighting: {
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
tripCharacteristic: "B",
|
||||
},
|
||||
single_phase: {
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
three_phase: {
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
} as const satisfies Record<CircuitGroupCategory, ProtectionDeviceConfiguration>;
|
||||
|
||||
const defaultGroupRcd = {
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
} as const satisfies ProtectionDeviceConfiguration;
|
||||
|
||||
export function createDefaultCircuitProtection(
|
||||
category: CircuitGroupCategory
|
||||
): ProtectionDeviceConfiguration {
|
||||
return { ...defaultCircuitProtectionByCategory[category] };
|
||||
}
|
||||
|
||||
export function createDefaultGroupRcd(): ProtectionDeviceConfiguration {
|
||||
return { ...defaultGroupRcd };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
import {
|
||||
circuitGroupCategories,
|
||||
circuitGroupCategoryLabels,
|
||||
type CircuitGroupCategory,
|
||||
} from "../../shared/constants/circuit-group";
|
||||
import type { CircuitTreeSectionDto } from "../types";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
interface CircuitGroupModalProps {
|
||||
initialSection?: CircuitTreeSectionDto;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (values: {
|
||||
category: CircuitGroupCategory;
|
||||
displayName: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CircuitGroupModal({
|
||||
initialSection,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: CircuitGroupModalProps) {
|
||||
const [category, setCategory] = useState<CircuitGroupCategory>(
|
||||
initialSection?.category ?? "lighting"
|
||||
);
|
||||
const [displayName, setDisplayName] = useState(
|
||||
initialSection?.displayName ?? ""
|
||||
);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
await onSave({ category, displayName });
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
description={
|
||||
initialSection
|
||||
? "Nur der Anzeigename wird geändert. Kategorie, Nummer, Präfix und vorhandene BMK bleiben stabil."
|
||||
: "Die nächste freie Gruppennummer der gewählten Kategorie wird automatisch vergeben."
|
||||
}
|
||||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={Boolean(initialSection && !displayName.trim())}
|
||||
submitLabel={initialSection ? "Änderungen speichern" : "Gruppe anlegen"}
|
||||
title={initialSection ? "Stromkreisgruppe bearbeiten" : "Stromkreisgruppe hinzufügen"}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-group-category">
|
||||
Kategorie
|
||||
</label>
|
||||
<select
|
||||
autoFocus={!initialSection}
|
||||
className="form-select"
|
||||
disabled={Boolean(initialSection)}
|
||||
id="circuit-group-category"
|
||||
onChange={(event) =>
|
||||
setCategory(event.target.value as CircuitGroupCategory)
|
||||
}
|
||||
value={category}
|
||||
>
|
||||
{circuitGroupCategories.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
{circuitGroupCategoryLabels[entry]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-group-name">
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
autoFocus={Boolean(initialSection)}
|
||||
className="form-control"
|
||||
id="circuit-group-name"
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder={
|
||||
initialSection
|
||||
? undefined
|
||||
: "Leer lassen für den automatisch erzeugten Namen"
|
||||
}
|
||||
required={Boolean(initialSection)}
|
||||
value={displayName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
import {
|
||||
allowedRatedCurrentsAByProtectionDeviceType,
|
||||
breakerTripCharacteristics,
|
||||
fuseProtectionDeviceTypes,
|
||||
fuseUtilizationCategories,
|
||||
protectionDeviceTypeLabels,
|
||||
protectionDeviceTypes,
|
||||
ratedResidualCurrentsMa,
|
||||
rcdTypes,
|
||||
type ProtectionDeviceType,
|
||||
} from "../../shared/constants/protection-device";
|
||||
import type { CircuitTreeProtectionDeviceDto } from "../types";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
interface CircuitProtectionModalProps {
|
||||
equipmentIdentifier: string;
|
||||
initialProtection: CircuitTreeProtectionDeviceDto;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (protection: CircuitTreeProtectionDeviceDto) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CircuitProtectionModal({
|
||||
equipmentIdentifier,
|
||||
initialProtection,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: CircuitProtectionModalProps) {
|
||||
const [type, setType] = useState(initialProtection.type);
|
||||
const [ratedCurrentA, setRatedCurrentA] = useState(
|
||||
initialProtection.ratedCurrentA
|
||||
);
|
||||
const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState(
|
||||
initialProtection.fuseUtilizationCategory ?? "gG"
|
||||
);
|
||||
const [tripCharacteristic, setTripCharacteristic] = useState(
|
||||
initialProtection.tripCharacteristic ?? "B"
|
||||
);
|
||||
const [rcdType, setRcdType] = useState(
|
||||
initialProtection.rcdType ?? "A"
|
||||
);
|
||||
const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState(
|
||||
initialProtection.ratedResidualCurrentMa ?? 30
|
||||
);
|
||||
const usesFuseCategory = (
|
||||
fuseProtectionDeviceTypes as readonly string[]
|
||||
).includes(type);
|
||||
const usesTripCharacteristic =
|
||||
type === "LS" || type === "FI_LS" || type === "AFDD";
|
||||
const usesResidualCurrent = type === "FI" || type === "FI_LS";
|
||||
const allowedRatedCurrents =
|
||||
allowedRatedCurrentsAByProtectionDeviceType[type];
|
||||
|
||||
function handleTypeChange(nextType: ProtectionDeviceType) {
|
||||
setType(nextType);
|
||||
setRatedCurrentA(
|
||||
allowedRatedCurrentsAByProtectionDeviceType[nextType][0]
|
||||
);
|
||||
setFuseUtilizationCategory("gG");
|
||||
setTripCharacteristic("B");
|
||||
setRcdType("A");
|
||||
setRatedResidualCurrentMa(30);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
await onSave({
|
||||
type,
|
||||
ratedCurrentA,
|
||||
...(usesFuseCategory ? { fuseUtilizationCategory } : {}),
|
||||
...(usesTripCharacteristic ? { tripCharacteristic } : {}),
|
||||
...(usesResidualCurrent
|
||||
? { rcdType, ratedResidualCurrentMa }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
description="Die Auswahl ist planerisch gesetzt. Eine spätere Dimensionierung wird nur Empfehlungen und Warnungen ergänzen."
|
||||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={
|
||||
!(allowedRatedCurrents as readonly number[]).includes(
|
||||
ratedCurrentA
|
||||
)
|
||||
}
|
||||
submitLabel="Schutzgerät speichern"
|
||||
title={`Schutzgerät bearbeiten – ${equipmentIdentifier}`}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-protection-type">
|
||||
Schutzgerät
|
||||
</label>
|
||||
<select
|
||||
autoFocus
|
||||
className="form-select"
|
||||
id="circuit-protection-type"
|
||||
onChange={(event) =>
|
||||
handleTypeChange(
|
||||
event.target.value as ProtectionDeviceType
|
||||
)
|
||||
}
|
||||
value={type}
|
||||
>
|
||||
{protectionDeviceTypes.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
{protectionDeviceTypeLabels[entry]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-protection-current">
|
||||
Bemessungsstrom
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="circuit-protection-current"
|
||||
onChange={(event) =>
|
||||
setRatedCurrentA(Number(event.target.value))
|
||||
}
|
||||
value={ratedCurrentA}
|
||||
>
|
||||
{allowedRatedCurrents.map((current) => (
|
||||
<option key={current} value={current}>
|
||||
{current} A
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{usesFuseCategory ? (
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-fuse-category">
|
||||
Sicherungscharakteristik
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="circuit-fuse-category"
|
||||
onChange={(event) =>
|
||||
setFuseUtilizationCategory(
|
||||
event.target
|
||||
.value as (typeof fuseUtilizationCategories)[number]
|
||||
)
|
||||
}
|
||||
value={fuseUtilizationCategory}
|
||||
>
|
||||
{fuseUtilizationCategories.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
{entry}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{usesTripCharacteristic ? (
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-trip-characteristic">
|
||||
Auslösecharakteristik
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="circuit-trip-characteristic"
|
||||
onChange={(event) =>
|
||||
setTripCharacteristic(
|
||||
event.target
|
||||
.value as (typeof breakerTripCharacteristics)[number]
|
||||
)
|
||||
}
|
||||
value={tripCharacteristic}
|
||||
>
|
||||
{breakerTripCharacteristics.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
{entry}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{usesResidualCurrent ? (
|
||||
<>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-rcd-type">
|
||||
FI-Typ
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="circuit-rcd-type"
|
||||
onChange={(event) =>
|
||||
setRcdType(
|
||||
event.target.value as (typeof rcdTypes)[number]
|
||||
)
|
||||
}
|
||||
value={rcdType}
|
||||
>
|
||||
{rcdTypes.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
Typ {entry}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-residual-current">
|
||||
Bemessungsdifferenzstrom
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="circuit-residual-current"
|
||||
onChange={(event) =>
|
||||
setRatedResidualCurrentMa(Number(event.target.value))
|
||||
}
|
||||
value={ratedResidualCurrentMa}
|
||||
>
|
||||
{ratedResidualCurrentsMa.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
{entry} mA
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
import {
|
||||
allowedRatedCurrentsAByProtectionDeviceType,
|
||||
breakerTripCharacteristics,
|
||||
fuseProtectionDeviceTypes,
|
||||
fuseUtilizationCategories,
|
||||
protectionDeviceTypeLabels,
|
||||
ratedResidualCurrentsMa,
|
||||
rcdTypes,
|
||||
type ProtectionDeviceType,
|
||||
} from "../../shared/constants/protection-device";
|
||||
import type { CircuitTreeComponentDto } from "../types";
|
||||
import type {
|
||||
DistributionBoardComponentEditorValues,
|
||||
MutableDistributionBoardComponentRole,
|
||||
} from "../utils/distribution-board-component-editing";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
interface DistributionBoardComponentModalProps {
|
||||
initialComponent?: CircuitTreeComponentDto;
|
||||
initialEquipmentIdentifier?: string;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (values: DistributionBoardComponentEditorValues) => Promise<void>;
|
||||
role: MutableDistributionBoardComponentRole;
|
||||
}
|
||||
|
||||
const upstreamProtectionTypes: readonly ProtectionDeviceType[] = [
|
||||
...fuseProtectionDeviceTypes,
|
||||
"LS",
|
||||
];
|
||||
|
||||
export function DistributionBoardComponentModal({
|
||||
initialComponent,
|
||||
initialEquipmentIdentifier = "",
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
role,
|
||||
}: DistributionBoardComponentModalProps) {
|
||||
const [equipmentIdentifier, setEquipmentIdentifier] = useState(
|
||||
initialComponent?.equipmentIdentifier ?? initialEquipmentIdentifier
|
||||
);
|
||||
const [name, setName] = useState(
|
||||
initialComponent?.name ?? defaultName(role)
|
||||
);
|
||||
const initialProtection = initialComponent?.protectionDevice;
|
||||
const [protectionType, setProtectionType] = useState<
|
||||
ProtectionDeviceType | ""
|
||||
>(
|
||||
initialProtection?.type ??
|
||||
(role === "group_residual_current_protection" ? "FI" : "")
|
||||
);
|
||||
const [ratedCurrentA, setRatedCurrentA] = useState(
|
||||
initialProtection?.ratedCurrentA ??
|
||||
(role === "group_residual_current_protection" ? 40 : 0)
|
||||
);
|
||||
const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState(
|
||||
initialProtection?.fuseUtilizationCategory ?? "gG"
|
||||
);
|
||||
const [tripCharacteristic, setTripCharacteristic] = useState(
|
||||
initialProtection?.tripCharacteristic ?? "B"
|
||||
);
|
||||
const [rcdType, setRcdType] = useState(
|
||||
initialProtection?.rcdType ?? "A"
|
||||
);
|
||||
const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState(
|
||||
initialProtection?.ratedResidualCurrentMa ?? 30
|
||||
);
|
||||
|
||||
const isAuxiliary = role === "auxiliary";
|
||||
const availableProtectionTypes =
|
||||
role === "group_residual_current_protection"
|
||||
? (["FI"] as const)
|
||||
: upstreamProtectionTypes;
|
||||
const usesFuseCategory =
|
||||
protectionType !== "" &&
|
||||
(fuseProtectionDeviceTypes as readonly string[]).includes(protectionType);
|
||||
const usesTripCharacteristic = protectionType === "LS";
|
||||
const usesResidualCurrent = protectionType === "FI";
|
||||
const allowedRatedCurrents =
|
||||
protectionType === ""
|
||||
? []
|
||||
: allowedRatedCurrentsAByProtectionDeviceType[protectionType];
|
||||
|
||||
function handleProtectionTypeChange(type: ProtectionDeviceType) {
|
||||
setProtectionType(type);
|
||||
setRatedCurrentA(allowedRatedCurrentsAByProtectionDeviceType[type][0]);
|
||||
setFuseUtilizationCategory("gG");
|
||||
setTripCharacteristic("B");
|
||||
setRcdType("A");
|
||||
setRatedResidualCurrentMa(30);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
await onSave({
|
||||
equipmentIdentifier,
|
||||
name,
|
||||
...(!isAuxiliary && protectionType
|
||||
? {
|
||||
protectionDevice: {
|
||||
type: protectionType,
|
||||
ratedCurrentA,
|
||||
...(usesFuseCategory
|
||||
? { fuseUtilizationCategory }
|
||||
: {}),
|
||||
...(usesTripCharacteristic
|
||||
? { tripCharacteristic }
|
||||
: {}),
|
||||
...(usesResidualCurrent
|
||||
? { rcdType, ratedResidualCurrentMa }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
const isValid =
|
||||
equipmentIdentifier.trim().length > 0 &&
|
||||
name.trim().length > 0 &&
|
||||
(isAuxiliary ||
|
||||
(protectionType !== "" &&
|
||||
(allowedRatedCurrents as readonly number[]).includes(ratedCurrentA)));
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
description={
|
||||
isAuxiliary
|
||||
? "Zusätzliches Gerät im Verteiler. BMK und Name werden manuell vergeben."
|
||||
: "Schutzgerät dieser Stromkreisgruppe konfigurieren."
|
||||
}
|
||||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={!isValid}
|
||||
submitLabel={initialComponent ? "Änderungen speichern" : "Komponente anlegen"}
|
||||
title={`${initialComponent ? "Komponente bearbeiten" : "Komponente hinzufügen"} – ${defaultName(role)}`}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-equipment-identifier">
|
||||
Betriebsmittelkennzeichen
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="component-equipment-identifier"
|
||||
onChange={(event) => setEquipmentIdentifier(event.target.value)}
|
||||
required
|
||||
value={equipmentIdentifier}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="component-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
{!isAuxiliary ? (
|
||||
<>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-protection-type">
|
||||
Schutzgerät
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
disabled={role === "group_residual_current_protection"}
|
||||
id="component-protection-type"
|
||||
onChange={(event) =>
|
||||
handleProtectionTypeChange(
|
||||
event.target.value as ProtectionDeviceType
|
||||
)
|
||||
}
|
||||
required
|
||||
value={protectionType}
|
||||
>
|
||||
<option value="">Schutzgerät auswählen</option>
|
||||
{availableProtectionTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{protectionDeviceTypeLabels[type]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-rated-current">
|
||||
Bemessungsstrom
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
disabled={!protectionType}
|
||||
id="component-rated-current"
|
||||
onChange={(event) => setRatedCurrentA(Number(event.target.value))}
|
||||
value={ratedCurrentA}
|
||||
>
|
||||
{allowedRatedCurrents.map((ratedCurrent) => (
|
||||
<option key={ratedCurrent} value={ratedCurrent}>
|
||||
{ratedCurrent} A
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{usesFuseCategory ? (
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-fuse-category">
|
||||
Sicherungscharakteristik
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="component-fuse-category"
|
||||
onChange={(event) =>
|
||||
setFuseUtilizationCategory(
|
||||
event.target.value as (typeof fuseUtilizationCategories)[number]
|
||||
)
|
||||
}
|
||||
value={fuseUtilizationCategory}
|
||||
>
|
||||
{fuseUtilizationCategories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{usesTripCharacteristic ? (
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-trip-characteristic">
|
||||
Auslösecharakteristik
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="component-trip-characteristic"
|
||||
onChange={(event) =>
|
||||
setTripCharacteristic(
|
||||
event.target.value as (typeof breakerTripCharacteristics)[number]
|
||||
)
|
||||
}
|
||||
value={tripCharacteristic}
|
||||
>
|
||||
{breakerTripCharacteristics.map((characteristic) => (
|
||||
<option key={characteristic} value={characteristic}>
|
||||
{characteristic}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{usesResidualCurrent ? (
|
||||
<>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-rcd-type">
|
||||
FI-Typ
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="component-rcd-type"
|
||||
onChange={(event) =>
|
||||
setRcdType(event.target.value as (typeof rcdTypes)[number])
|
||||
}
|
||||
value={rcdType}
|
||||
>
|
||||
{rcdTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
Typ {type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-residual-current">
|
||||
Bemessungsdifferenzstrom
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="component-residual-current"
|
||||
onChange={(event) =>
|
||||
setRatedResidualCurrentMa(Number(event.target.value))
|
||||
}
|
||||
value={ratedResidualCurrentMa}
|
||||
>
|
||||
{ratedResidualCurrentsMa.map((ratedResidualCurrent) => (
|
||||
<option key={ratedResidualCurrent} value={ratedResidualCurrent}>
|
||||
{ratedResidualCurrent} mA
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultName(role: MutableDistributionBoardComponentRole): string {
|
||||
if (role === "group_upstream_protection") {
|
||||
return "Gruppenvorsicherung";
|
||||
}
|
||||
if (role === "group_residual_current_protection") {
|
||||
return "Gruppen-FI";
|
||||
}
|
||||
return "Verteilergerät";
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
import type { DistributionBoardSupplyType } from "../shared/constants/distribution-board";
|
||||
import type { CircuitGroupCategory } from "../shared/constants/circuit-group";
|
||||
import type {
|
||||
DistributionBoardComponentPlacement,
|
||||
DistributionBoardComponentRole,
|
||||
} from "../shared/constants/distribution-board-component";
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../shared/constants/protection-device";
|
||||
|
||||
export interface ProjectDto {
|
||||
id: string;
|
||||
@@ -267,6 +278,27 @@ export interface CircuitTreeDeviceRowDto {
|
||||
rowTotalPower: number;
|
||||
}
|
||||
|
||||
export interface CircuitTreeProtectionDeviceDto {
|
||||
type: ProtectionDeviceType;
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory?: FuseUtilizationCategory;
|
||||
tripCharacteristic?: BreakerTripCharacteristic;
|
||||
rcdType?: RcdType;
|
||||
ratedResidualCurrentMa?: number;
|
||||
}
|
||||
|
||||
export interface CircuitTreeComponentDto {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
sectionId?: string;
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
role: DistributionBoardComponentRole;
|
||||
placement: DistributionBoardComponentPlacement;
|
||||
sortOrder: number;
|
||||
protectionDevice?: CircuitTreeProtectionDeviceDto;
|
||||
}
|
||||
|
||||
export interface CircuitTreeCircuitDto {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
@@ -289,6 +321,7 @@ export interface CircuitTreeCircuitDto {
|
||||
remark?: string;
|
||||
circuitTotalPower: number;
|
||||
deviceRows: CircuitTreeDeviceRowDto[];
|
||||
protectionDevice?: CircuitTreeProtectionDeviceDto;
|
||||
}
|
||||
|
||||
export interface CircuitTreeSectionDto {
|
||||
@@ -297,7 +330,10 @@ export interface CircuitTreeSectionDto {
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
category?: CircuitGroupCategory;
|
||||
groupNumber?: number;
|
||||
sectionTotalPower: number;
|
||||
components: CircuitTreeComponentDto[];
|
||||
circuits: CircuitTreeCircuitDto[];
|
||||
}
|
||||
|
||||
@@ -320,7 +356,9 @@ export interface CircuitTreeResponseDto {
|
||||
distributionBoardSimultaneityFactor: number;
|
||||
distributionBoardTotalPower: number;
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||
headerComponents: CircuitTreeComponentDto[];
|
||||
sections: CircuitTreeSectionDto[];
|
||||
footerComponents: CircuitTreeComponentDto[];
|
||||
migrationReport?: CircuitTreeMigrationReportDto;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,25 @@ import type {
|
||||
import type {
|
||||
CircuitSectionRenumberAssignment,
|
||||
} from "../../domain/models/circuit-section-renumber-project-command.model";
|
||||
import type {
|
||||
DistributionBoardComponentSnapshot,
|
||||
} from "../../domain/models/distribution-board-component-structure-project-command.model";
|
||||
import type {
|
||||
CircuitGroupSnapshot,
|
||||
CircuitGroupReorderAssignment,
|
||||
} from "../../domain/models/circuit-group-structure-project-command.model";
|
||||
import type {
|
||||
CircuitProtectionSnapshot,
|
||||
} from "../../domain/models/circuit-protection-project-command.model";
|
||||
import type {
|
||||
CircuitGroupRenumberPlan,
|
||||
} from "../../domain/services/circuit-group-renumbering";
|
||||
import type {
|
||||
CircuitGroupMovePlan,
|
||||
} from "../../domain/services/circuit-group-move-planning";
|
||||
import type {
|
||||
CircuitGroupSubtreeSnapshot,
|
||||
} from "../../domain/models/circuit-group-subtree-snapshot.model";
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
@@ -320,6 +339,199 @@ export function getCircuitTree(projectId: string, circuitListId: string) {
|
||||
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
|
||||
}
|
||||
|
||||
export function insertDistributionBoardComponentCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "distribution-board-component.insert",
|
||||
payload: { snapshot },
|
||||
},
|
||||
"Verteilerkomponente hinzufügen"
|
||||
);
|
||||
}
|
||||
|
||||
export function updateDistributionBoardComponentCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
expected: DistributionBoardComponentSnapshot,
|
||||
target: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "distribution-board-component.update",
|
||||
payload: { expected, target },
|
||||
},
|
||||
"Verteilerkomponente bearbeiten"
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteDistributionBoardComponentCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "distribution-board-component.delete",
|
||||
payload: { snapshot },
|
||||
},
|
||||
"Verteilerkomponente entfernen"
|
||||
);
|
||||
}
|
||||
|
||||
export function insertCircuitGroupCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
snapshot: CircuitGroupSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-group.insert",
|
||||
payload: { snapshot },
|
||||
},
|
||||
"Stromkreisgruppe hinzufügen"
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCircuitGroupCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
expected: CircuitGroupSnapshot,
|
||||
target: CircuitGroupSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-group.update",
|
||||
payload: { expected, target },
|
||||
},
|
||||
"Stromkreisgruppe bearbeiten"
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCircuitGroupCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
snapshot: CircuitGroupSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-group.delete",
|
||||
payload: { snapshot },
|
||||
},
|
||||
"Leere Stromkreisgruppe entfernen"
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCircuitGroupSubtreeCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
snapshot: CircuitGroupSubtreeSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-group.delete-subtree",
|
||||
payload: { snapshot },
|
||||
},
|
||||
"Befüllte Stromkreisgruppe entfernen"
|
||||
);
|
||||
}
|
||||
|
||||
export function reorderCircuitGroupsCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
circuitListId: string,
|
||||
assignments: CircuitGroupReorderAssignment[]
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-group.reorder",
|
||||
payload: { circuitListId, assignments },
|
||||
},
|
||||
"Stromkreisgruppen sortieren"
|
||||
);
|
||||
}
|
||||
|
||||
export function renumberCircuitGroupsCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
circuitListId: string,
|
||||
plan: CircuitGroupRenumberPlan
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-group.renumber",
|
||||
payload: { circuitListId, groups: plan.groups },
|
||||
},
|
||||
"Stromkreisgruppen neu nummerieren"
|
||||
);
|
||||
}
|
||||
|
||||
export function moveCircuitToGroupCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
plan: CircuitGroupMovePlan
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit.move-group",
|
||||
payload: plan,
|
||||
},
|
||||
"Stromkreis in andere Gruppe verschieben"
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCircuitProtectionCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
circuitId: string,
|
||||
expected: CircuitProtectionSnapshot | null,
|
||||
target: CircuitProtectionSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-protection.update",
|
||||
payload: { circuitId, expected, target },
|
||||
},
|
||||
"Stromkreisschutz bearbeiten"
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCircuitById(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
|
||||
@@ -7,6 +7,16 @@ export type GridInsertionRowType =
|
||||
|
||||
export type GridInsertionCellKind = "circuitField" | "deviceField" | "readonly" | "computed";
|
||||
|
||||
export function isGridInsertionRowType(rowType: string): rowType is GridInsertionRowType {
|
||||
return (
|
||||
rowType === "circuitCompact" ||
|
||||
rowType === "circuitSummary" ||
|
||||
rowType === "deviceRow" ||
|
||||
rowType === "reserveCircuit" ||
|
||||
rowType === "placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
export interface GridInsertionSelection {
|
||||
rowType: GridInsertionRowType;
|
||||
cellKind: GridInsertionCellKind;
|
||||
@@ -77,3 +87,26 @@ export function getInsertionSortOrder(
|
||||
const next = entries[index + 1]?.sortOrder;
|
||||
return next === undefined ? current + 10 : current + (next - current) / 2;
|
||||
}
|
||||
|
||||
export function getAdjacentInsertionSortOrder(
|
||||
orderedEntries: Array<{ id: string; sortOrder: number }>,
|
||||
targetId: string,
|
||||
placement: "before" | "after"
|
||||
): number {
|
||||
const entries = [...orderedEntries].sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
const index = entries.findIndex((entry) => entry.id === targetId);
|
||||
if (index < 0) {
|
||||
throw new Error("Der Zielstromkreis wurde nicht gefunden.");
|
||||
}
|
||||
if (placement === "after") {
|
||||
return getInsertionSortOrder(entries, targetId);
|
||||
}
|
||||
const previous = entries[index - 1];
|
||||
return previous
|
||||
? getInsertionSortOrder(entries, previous.id)
|
||||
: entries[index].sortOrder - 10;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
|
||||
import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js";
|
||||
import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js";
|
||||
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
|
||||
|
||||
export type CellKey =
|
||||
| "equipmentIdentifier"
|
||||
@@ -37,12 +38,15 @@ export type CellKey =
|
||||
| "isReserve";
|
||||
|
||||
export type RowType =
|
||||
| "headerComponent"
|
||||
| "section"
|
||||
| "groupComponent"
|
||||
| "circuitCompact"
|
||||
| "circuitSummary"
|
||||
| "deviceRow"
|
||||
| "reserveCircuit"
|
||||
| "placeholder";
|
||||
| "placeholder"
|
||||
| "footerComponent";
|
||||
export type CellKind = "circuitField" | "deviceField" | "computed" | "readonly";
|
||||
export type GridValue = string | number | boolean | undefined;
|
||||
|
||||
@@ -140,8 +144,15 @@ const circuitSectionLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function getCircuitSectionLabel(
|
||||
section: { key: string; displayName: string }
|
||||
section: {
|
||||
key: string;
|
||||
displayName: string;
|
||||
category?: string;
|
||||
}
|
||||
): string {
|
||||
if (section.category) {
|
||||
return section.displayName;
|
||||
}
|
||||
return circuitSectionLabels[section.key] ?? section.displayName;
|
||||
}
|
||||
|
||||
@@ -469,18 +480,54 @@ export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): G
|
||||
}
|
||||
|
||||
export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
|
||||
const protection = circuit.protectionDevice;
|
||||
const protectionCharacteristic = protection
|
||||
? [
|
||||
protection.fuseUtilizationCategory,
|
||||
protection.tripCharacteristic,
|
||||
protection.rcdType ? `Typ ${protection.rcdType}` : undefined,
|
||||
protection.ratedResidualCurrentMa !== undefined
|
||||
? `${protection.ratedResidualCurrentMa} mA`
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: undefined;
|
||||
switch (key) {
|
||||
case "equipmentIdentifier": return circuit.equipmentIdentifier;
|
||||
case "displayName": return circuit.displayName;
|
||||
case "circuitTotalPower": return circuit.circuitTotalPower;
|
||||
case "protectionType": return circuit.protectionType;
|
||||
case "protectionRatedCurrent": return circuit.protectionRatedCurrent;
|
||||
case "protectionCharacteristic": return circuit.protectionCharacteristic;
|
||||
case "protectionType":
|
||||
return protection
|
||||
? protectionDeviceTypeLabels[protection.type]
|
||||
: circuit.protectionType;
|
||||
case "protectionRatedCurrent":
|
||||
return protection?.ratedCurrentA ?? circuit.protectionRatedCurrent;
|
||||
case "protectionCharacteristic":
|
||||
return protectionCharacteristic || circuit.protectionCharacteristic;
|
||||
case "protectionSummary": {
|
||||
const current = circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null
|
||||
if (!protection) {
|
||||
const legacyCurrent =
|
||||
circuit.protectionRatedCurrent !== undefined &&
|
||||
circuit.protectionRatedCurrent !== null
|
||||
? `${circuit.protectionRatedCurrent}A`
|
||||
: "";
|
||||
return [circuit.protectionType, current, circuit.protectionCharacteristic].filter(Boolean).join(" ").trim() || undefined;
|
||||
return [
|
||||
circuit.protectionType,
|
||||
legacyCurrent,
|
||||
circuit.protectionCharacteristic,
|
||||
].filter(Boolean).join(" ").trim() || undefined;
|
||||
}
|
||||
const currentValue =
|
||||
protection.ratedCurrentA;
|
||||
const current = currentValue !== undefined && currentValue !== null
|
||||
? `${currentValue} A`
|
||||
: "";
|
||||
return [
|
||||
protectionDeviceTypeLabels[protection.type],
|
||||
current,
|
||||
protectionCharacteristic,
|
||||
].filter(Boolean).join(" · ").trim() || undefined;
|
||||
}
|
||||
case "cableSummary": {
|
||||
const length = circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : "";
|
||||
@@ -536,7 +583,14 @@ export function compareSortValues(left: GridValue, right: GridValue): number {
|
||||
}
|
||||
|
||||
export function getCellKind(rowType: RowType, key: CellKey): CellKind {
|
||||
if (key === "rowTotalPower" || key === "circuitTotalPower") return "computed";
|
||||
if (
|
||||
key === "rowTotalPower" ||
|
||||
key === "circuitTotalPower" ||
|
||||
key === "protectionType" ||
|
||||
key === "protectionRatedCurrent" ||
|
||||
key === "protectionCharacteristic" ||
|
||||
key === "protectionSummary"
|
||||
) return "computed";
|
||||
if (key === "voltage") return "readonly";
|
||||
if (rowType === "section") return "readonly";
|
||||
if (rowType === "placeholder") {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {
|
||||
CircuitTreeCircuitDto,
|
||||
CircuitTreeComponentDto,
|
||||
CircuitTreeDeviceRowDto,
|
||||
CircuitTreeSectionDto,
|
||||
} from "../types.js";
|
||||
import { buildCircuitStructureProjection } from "./circuit-structure-projection";
|
||||
import {
|
||||
allColumns,
|
||||
circuitOnlyColumns,
|
||||
@@ -42,6 +44,7 @@ export interface VisibleGridRow {
|
||||
sectionId: string;
|
||||
circuit?: CircuitTreeCircuitDto;
|
||||
device?: CircuitTreeDeviceRowDto;
|
||||
component?: CircuitTreeComponentDto;
|
||||
cells: VisibleGridCell[];
|
||||
}
|
||||
|
||||
@@ -160,8 +163,49 @@ function makeVisibleGridRow(
|
||||
}
|
||||
|
||||
export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] {
|
||||
return buildVisibleGridRowsWithStructure(sections, {
|
||||
headerComponents: [],
|
||||
footerComponents: [],
|
||||
});
|
||||
}
|
||||
|
||||
export function buildVisibleGridRowsWithStructure(
|
||||
sections: readonly CircuitTreeSectionDto[],
|
||||
structure: {
|
||||
headerComponents: readonly CircuitTreeComponentDto[];
|
||||
footerComponents: readonly CircuitTreeComponentDto[];
|
||||
}
|
||||
): VisibleGridRow[] {
|
||||
const rows: VisibleGridRow[] = [];
|
||||
for (const section of sections) {
|
||||
const projected = buildCircuitStructureProjection({
|
||||
headerComponents: [...structure.headerComponents],
|
||||
sections: [...sections],
|
||||
footerComponents: [...structure.footerComponents],
|
||||
});
|
||||
for (const row of projected) {
|
||||
if ("component" in row) {
|
||||
const component = row.component;
|
||||
rows.push({
|
||||
rowKey: row.rowKey,
|
||||
rowType: row.rowType,
|
||||
sectionId: component.sectionId ?? `__${row.zone}__`,
|
||||
component,
|
||||
cells: allColumns.map((column) => ({
|
||||
cellKey: column.key,
|
||||
editable: false,
|
||||
kind: "readonly",
|
||||
value:
|
||||
column.key === "equipmentIdentifier"
|
||||
? component.equipmentIdentifier
|
||||
: column.key === "displayName"
|
||||
? component.name
|
||||
: undefined,
|
||||
})),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const section = row.section;
|
||||
if (row.rowType === "groupHeader") {
|
||||
rows.push({
|
||||
rowKey: `section:${section.id}`,
|
||||
rowType: "section",
|
||||
@@ -173,8 +217,10 @@ export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[])
|
||||
value: undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
for (const circuit of section.circuits) {
|
||||
continue;
|
||||
}
|
||||
if (row.rowType === "circuitBlock") {
|
||||
const circuit = row.circuit;
|
||||
if (circuit.deviceRows.length === 0) {
|
||||
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit));
|
||||
} else if (circuit.deviceRows.length === 1) {
|
||||
@@ -185,8 +231,8 @@ export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[])
|
||||
rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push(makeVisibleGridRow("placeholder", section.id));
|
||||
}
|
||||
return rows;
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
import type {
|
||||
CircuitGroupSnapshot,
|
||||
} from "../../domain/models/circuit-group-structure-project-command.model";
|
||||
import {
|
||||
circuitGroupCategoryNumbers,
|
||||
circuitGroupCategoryLabels,
|
||||
type CircuitGroupCategory,
|
||||
} from "../../shared/constants/circuit-group";
|
||||
import type { CircuitTreeSectionDto } from "../types";
|
||||
import type {
|
||||
CircuitGroupReorderAssignment,
|
||||
} from "../../domain/models/circuit-group-structure-project-command.model";
|
||||
import type {
|
||||
CircuitGroupRenumberPlan,
|
||||
} from "../../domain/services/circuit-group-renumbering";
|
||||
import type {
|
||||
CircuitGroupMovePlan,
|
||||
} from "../../domain/services/circuit-group-move-planning";
|
||||
import type {
|
||||
CircuitGroupSubtreeSnapshot,
|
||||
} from "../../domain/models/circuit-group-subtree-snapshot.model";
|
||||
import {
|
||||
toDistributionBoardComponentSnapshot,
|
||||
} from "./distribution-board-component-editing";
|
||||
|
||||
export function toCircuitGroupSnapshot(
|
||||
section: CircuitTreeSectionDto,
|
||||
circuitListId: string
|
||||
): CircuitGroupSnapshot {
|
||||
if (!section.category || !section.groupNumber) {
|
||||
throw new Error("Der Bereich ist keine verwaltbare Stromkreisgruppe.");
|
||||
}
|
||||
return {
|
||||
id: section.id,
|
||||
circuitListId,
|
||||
key: section.key,
|
||||
displayName: section.displayName,
|
||||
prefix: section.prefix,
|
||||
sortOrder: section.sortOrder,
|
||||
category: section.category,
|
||||
groupNumber: section.groupNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNewCircuitGroupSnapshot(input: {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
category: CircuitGroupCategory;
|
||||
displayName?: string;
|
||||
sections: readonly CircuitTreeSectionDto[];
|
||||
}): CircuitGroupSnapshot {
|
||||
const groupedSections = input.sections.filter(
|
||||
(section): section is CircuitTreeSectionDto & {
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
} => Boolean(section.category && section.groupNumber)
|
||||
);
|
||||
const groupNumber =
|
||||
Math.max(
|
||||
0,
|
||||
...groupedSections
|
||||
.filter((section) => section.category === input.category)
|
||||
.map((section) => section.groupNumber)
|
||||
) + 1;
|
||||
return {
|
||||
id: input.id,
|
||||
circuitListId: input.circuitListId,
|
||||
key: `${input.category}_${groupNumber}`,
|
||||
displayName:
|
||||
input.displayName?.trim() ||
|
||||
`${circuitGroupCategoryLabels[input.category]} ${groupNumber}`,
|
||||
prefix: `-${circuitGroupCategoryNumbers[input.category]}F${groupNumber}.`,
|
||||
sortOrder: getNewGroupSortOrder(
|
||||
input.category,
|
||||
groupedSections
|
||||
),
|
||||
category: input.category,
|
||||
groupNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function renameCircuitGroupSnapshot(
|
||||
expected: CircuitGroupSnapshot,
|
||||
displayName: string
|
||||
): CircuitGroupSnapshot {
|
||||
return { ...expected, displayName: displayName.trim() };
|
||||
}
|
||||
|
||||
export function canDeleteCircuitGroup(
|
||||
section: CircuitTreeSectionDto
|
||||
): boolean {
|
||||
return section.circuits.length === 0 && section.components.length === 0;
|
||||
}
|
||||
|
||||
export function buildCircuitGroupSubtreeSnapshot(
|
||||
section: CircuitTreeSectionDto,
|
||||
circuitListId: string
|
||||
): CircuitGroupSubtreeSnapshot {
|
||||
const components = [...section.components]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
)
|
||||
.map(toDistributionBoardComponentSnapshot);
|
||||
const circuits = [...section.circuits]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
)
|
||||
.map((circuit) => ({
|
||||
circuit: {
|
||||
id: circuit.id,
|
||||
circuitListId: circuit.circuitListId,
|
||||
sectionId: circuit.sectionId,
|
||||
equipmentIdentifier: circuit.equipmentIdentifier,
|
||||
displayName: circuit.displayName ?? null,
|
||||
sortOrder: circuit.sortOrder,
|
||||
protectionType: circuit.protectionType ?? null,
|
||||
protectionRatedCurrent:
|
||||
circuit.protectionRatedCurrent ?? null,
|
||||
protectionCharacteristic:
|
||||
circuit.protectionCharacteristic ?? null,
|
||||
cableType: circuit.cableType ?? null,
|
||||
cableCrossSection: circuit.cableCrossSection ?? null,
|
||||
cableLength: circuit.cableLength ?? null,
|
||||
rcdAssignment: circuit.rcdAssignment ?? null,
|
||||
terminalDesignation: circuit.terminalDesignation ?? null,
|
||||
voltage: circuit.voltage ?? null,
|
||||
controlRequirement: circuit.controlRequirement ?? null,
|
||||
status: circuit.status ?? null,
|
||||
isReserve: circuit.isReserve,
|
||||
remark: circuit.remark ?? null,
|
||||
deviceRows: [...circuit.deviceRows]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
circuitId: circuit.id,
|
||||
linkedProjectDeviceId:
|
||||
row.linkedProjectDeviceId ?? null,
|
||||
legacyConsumerId: row.legacyConsumerId ?? null,
|
||||
sortOrder: row.sortOrder,
|
||||
name: row.name,
|
||||
displayName: row.displayName,
|
||||
phaseType: row.phaseType ?? null,
|
||||
connectionKind: row.connectionKind ?? null,
|
||||
costGroup: row.costGroup ?? null,
|
||||
category: row.category ?? null,
|
||||
level: row.level ?? null,
|
||||
roomId: row.roomId ?? null,
|
||||
roomNumberSnapshot: row.roomNumberSnapshot ?? null,
|
||||
roomNameSnapshot: row.roomNameSnapshot ?? null,
|
||||
quantity: row.quantity,
|
||||
powerPerUnit: row.powerPerUnit,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi ?? null,
|
||||
remark: row.remark ?? null,
|
||||
overriddenFields: row.overriddenFields ?? null,
|
||||
})),
|
||||
},
|
||||
protectionDevice: circuit.protectionDevice
|
||||
? {
|
||||
circuitId: circuit.id,
|
||||
type: circuit.protectionDevice.type,
|
||||
ratedCurrentA:
|
||||
circuit.protectionDevice.ratedCurrentA,
|
||||
fuseUtilizationCategory:
|
||||
circuit.protectionDevice.fuseUtilizationCategory ??
|
||||
null,
|
||||
tripCharacteristic:
|
||||
circuit.protectionDevice.tripCharacteristic ?? null,
|
||||
rcdType: circuit.protectionDevice.rcdType ?? null,
|
||||
ratedResidualCurrentMa:
|
||||
circuit.protectionDevice.ratedResidualCurrentMa ??
|
||||
null,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
return {
|
||||
group: toCircuitGroupSnapshot(section, circuitListId),
|
||||
components,
|
||||
circuits,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeCircuitGroupSubtree(
|
||||
snapshot: CircuitGroupSubtreeSnapshot
|
||||
) {
|
||||
return {
|
||||
circuitCount: snapshot.circuits.length,
|
||||
deviceRowCount: snapshot.circuits.reduce(
|
||||
(count, entry) =>
|
||||
count + entry.circuit.deviceRows.length,
|
||||
0
|
||||
),
|
||||
protectionComponentCount: snapshot.components.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCircuitGroupReorderAssignments(
|
||||
sections: readonly CircuitTreeSectionDto[],
|
||||
groupId: string,
|
||||
direction: -1 | 1
|
||||
): CircuitGroupReorderAssignment[] | null {
|
||||
const current = sections.find((section) => section.id === groupId);
|
||||
if (!current?.category) {
|
||||
return null;
|
||||
}
|
||||
const categoryGroups = sections
|
||||
.filter((section) => section.category === current.category)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
const currentIndex = categoryGroups.findIndex(
|
||||
(section) => section.id === groupId
|
||||
);
|
||||
const target = categoryGroups[currentIndex + direction];
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
return sections.map((section) => ({
|
||||
groupId: section.id,
|
||||
expectedSortOrder: section.sortOrder,
|
||||
targetSortOrder:
|
||||
section.id === current.id
|
||||
? target.sortOrder
|
||||
: section.id === target.id
|
||||
? current.sortOrder
|
||||
: section.sortOrder,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildCircuitGroupRenumberPlan(
|
||||
sections: readonly CircuitTreeSectionDto[],
|
||||
category: CircuitGroupCategory
|
||||
): CircuitGroupRenumberPlan | null {
|
||||
const groups = getOrderedCategoryGroups(sections, category);
|
||||
if (!canRenumberCircuitGroups(sections, category)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
groups: groups.map((group, index) => {
|
||||
const targetGroupNumber = index + 1;
|
||||
const expectedPrefix = formatGroupPrefix(
|
||||
category,
|
||||
group.groupNumber
|
||||
);
|
||||
if (group.prefix !== expectedPrefix) {
|
||||
throw new Error(
|
||||
`Das Präfix der Gruppe „${group.displayName}“ passt nicht zu ihrer Gruppennummer.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
groupId: group.id,
|
||||
category,
|
||||
expectedGroupNumber: group.groupNumber,
|
||||
targetGroupNumber,
|
||||
expectedPrefix,
|
||||
targetPrefix: formatGroupPrefix(category, targetGroupNumber),
|
||||
circuits: group.circuits.map((circuit) => {
|
||||
const circuitNumber = parseCircuitNumber(
|
||||
circuit.equipmentIdentifier,
|
||||
category,
|
||||
group.groupNumber
|
||||
);
|
||||
return {
|
||||
circuitId: circuit.id,
|
||||
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
|
||||
targetEquipmentIdentifier: formatCircuitIdentifier(
|
||||
category,
|
||||
targetGroupNumber,
|
||||
circuitNumber
|
||||
),
|
||||
};
|
||||
}),
|
||||
components: group.components.map((component) => ({
|
||||
componentId: component.id,
|
||||
expectedEquipmentIdentifier: component.equipmentIdentifier,
|
||||
targetEquipmentIdentifier: formatGroupComponentIdentifier(
|
||||
category,
|
||||
targetGroupNumber,
|
||||
component.role
|
||||
),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function canRenumberCircuitGroups(
|
||||
sections: readonly CircuitTreeSectionDto[],
|
||||
category: CircuitGroupCategory
|
||||
) {
|
||||
const groups = getOrderedCategoryGroups(sections, category);
|
||||
return (
|
||||
groups.length > 0 &&
|
||||
groups.some((group, index) => group.groupNumber !== index + 1)
|
||||
);
|
||||
}
|
||||
|
||||
export function canMoveCircuitToGroup(
|
||||
sections: readonly CircuitTreeSectionDto[],
|
||||
circuitId: string,
|
||||
targetSectionId: string
|
||||
) {
|
||||
const source = sections.find((section) =>
|
||||
section.circuits.some((circuit) => circuit.id === circuitId)
|
||||
);
|
||||
const target = sections.find(
|
||||
(section) => section.id === targetSectionId
|
||||
);
|
||||
return Boolean(
|
||||
source?.category &&
|
||||
target?.category &&
|
||||
source.id !== target.id &&
|
||||
source.category === target.category
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCircuitGroupMovePlan(input: {
|
||||
sections: readonly CircuitTreeSectionDto[];
|
||||
circuitId: string;
|
||||
targetSectionId: string;
|
||||
placement:
|
||||
| { kind: "end" }
|
||||
| {
|
||||
kind: "before" | "after";
|
||||
targetCircuitId: string;
|
||||
};
|
||||
}): CircuitGroupMovePlan {
|
||||
const sourceGroup = input.sections.find((section) =>
|
||||
section.circuits.some(
|
||||
(circuit) => circuit.id === input.circuitId
|
||||
)
|
||||
);
|
||||
const targetGroup = input.sections.find(
|
||||
(section) => section.id === input.targetSectionId
|
||||
);
|
||||
const circuit = sourceGroup?.circuits.find(
|
||||
(entry) => entry.id === input.circuitId
|
||||
);
|
||||
if (
|
||||
!sourceGroup?.category ||
|
||||
!sourceGroup.groupNumber ||
|
||||
!targetGroup?.category ||
|
||||
!targetGroup.groupNumber ||
|
||||
!circuit ||
|
||||
sourceGroup.id === targetGroup.id ||
|
||||
sourceGroup.category !== targetGroup.category
|
||||
) {
|
||||
throw new Error(
|
||||
"Stromkreise können nur zwischen verschiedenen Gruppen derselben Kategorie verschoben werden."
|
||||
);
|
||||
}
|
||||
parseCircuitNumber(
|
||||
circuit.equipmentIdentifier,
|
||||
sourceGroup.category,
|
||||
sourceGroup.groupNumber
|
||||
);
|
||||
const targetCircuitNumbers = targetGroup.circuits.map((entry) =>
|
||||
parseCircuitNumber(
|
||||
entry.equipmentIdentifier,
|
||||
targetGroup.category!,
|
||||
targetGroup.groupNumber!
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
circuitId: circuit.id,
|
||||
circuitListId: circuit.circuitListId,
|
||||
expectedSectionId: sourceGroup.id,
|
||||
targetSectionId: targetGroup.id,
|
||||
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
|
||||
targetEquipmentIdentifier: formatCircuitIdentifier(
|
||||
targetGroup.category,
|
||||
targetGroup.groupNumber,
|
||||
Math.max(0, ...targetCircuitNumbers) + 1
|
||||
),
|
||||
expectedSortOrder: circuit.sortOrder,
|
||||
targetSortOrder: getCircuitMoveSortOrder(
|
||||
targetGroup.circuits,
|
||||
input.placement
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getNewGroupSortOrder(
|
||||
category: CircuitGroupCategory,
|
||||
sections: readonly (CircuitTreeSectionDto & {
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
})[]
|
||||
): number {
|
||||
const ordered = [...sections].sort(
|
||||
(left, right) => left.sortOrder - right.sortOrder
|
||||
);
|
||||
const sameCategory = ordered.filter(
|
||||
(section) => section.category === category
|
||||
);
|
||||
const previous = sameCategory[sameCategory.length - 1];
|
||||
if (!previous) {
|
||||
return ordered.length === 0
|
||||
? 10
|
||||
: Math.max(...ordered.map((section) => section.sortOrder)) + 10;
|
||||
}
|
||||
const next = ordered.find(
|
||||
(section) => section.sortOrder > previous.sortOrder
|
||||
);
|
||||
return next
|
||||
? previous.sortOrder + (next.sortOrder - previous.sortOrder) / 2
|
||||
: previous.sortOrder + 10;
|
||||
}
|
||||
|
||||
function formatGroupPrefix(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number
|
||||
) {
|
||||
return `-${circuitGroupCategoryNumbers[category]}F${groupNumber}.`;
|
||||
}
|
||||
|
||||
function getOrderedCategoryGroups(
|
||||
sections: readonly CircuitTreeSectionDto[],
|
||||
category: CircuitGroupCategory
|
||||
) {
|
||||
return sections
|
||||
.filter(
|
||||
(section): section is CircuitTreeSectionDto & {
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
} =>
|
||||
section.category === category &&
|
||||
Number.isInteger(section.groupNumber) &&
|
||||
(section.groupNumber ?? 0) > 0
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function formatCircuitIdentifier(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number,
|
||||
circuitNumber: number
|
||||
) {
|
||||
return `${formatGroupPrefix(category, groupNumber)}${circuitNumber}`;
|
||||
}
|
||||
|
||||
function parseCircuitNumber(
|
||||
equipmentIdentifier: string,
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number
|
||||
) {
|
||||
const prefix = formatGroupPrefix(category, groupNumber);
|
||||
const suffix = equipmentIdentifier.startsWith(prefix)
|
||||
? equipmentIdentifier.slice(prefix.length)
|
||||
: "";
|
||||
const circuitNumber = Number(suffix);
|
||||
if (
|
||||
!/^[1-9]\d*$/.test(suffix) ||
|
||||
!Number.isSafeInteger(circuitNumber)
|
||||
) {
|
||||
throw new Error(
|
||||
`Das Stromkreis-BMK „${equipmentIdentifier}“ passt nicht zu seiner Gruppe.`
|
||||
);
|
||||
}
|
||||
return circuitNumber;
|
||||
}
|
||||
|
||||
function formatGroupComponentIdentifier(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number,
|
||||
role: CircuitTreeSectionDto["components"][number]["role"]
|
||||
) {
|
||||
const categoryNumber = circuitGroupCategoryNumbers[category];
|
||||
if (role === "group_upstream_protection") {
|
||||
return `-${categoryNumber}F${groupNumber}.0`;
|
||||
}
|
||||
if (role === "group_residual_current_protection") {
|
||||
return `-${categoryNumber}Q${groupNumber}.0`;
|
||||
}
|
||||
throw new Error(
|
||||
"Die Stromkreisgruppe enthält eine nicht unterstützte Verteilerkomponente."
|
||||
);
|
||||
}
|
||||
|
||||
function getCircuitMoveSortOrder(
|
||||
circuits: CircuitTreeSectionDto["circuits"],
|
||||
placement:
|
||||
| { kind: "end" }
|
||||
| {
|
||||
kind: "before" | "after";
|
||||
targetCircuitId: string;
|
||||
}
|
||||
) {
|
||||
const ordered = [...circuits].sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
if (placement.kind === "end") {
|
||||
return ordered.length === 0
|
||||
? 10
|
||||
: ordered[ordered.length - 1].sortOrder + 10;
|
||||
}
|
||||
const index = ordered.findIndex(
|
||||
(circuit) => circuit.id === placement.targetCircuitId
|
||||
);
|
||||
if (index < 0) {
|
||||
throw new Error("Der Zielstromkreis wurde nicht gefunden.");
|
||||
}
|
||||
const current = ordered[index].sortOrder;
|
||||
if (placement.kind === "before") {
|
||||
const previous = ordered[index - 1]?.sortOrder;
|
||||
return previous === undefined
|
||||
? current - 10
|
||||
: previous + (current - previous) / 2;
|
||||
}
|
||||
const next = ordered[index + 1]?.sortOrder;
|
||||
return next === undefined
|
||||
? current + 10
|
||||
: current + (next - current) / 2;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
CircuitProtectionSnapshot,
|
||||
} from "../../domain/models/circuit-protection-project-command.model";
|
||||
import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
|
||||
import type {
|
||||
CircuitTreeProtectionDeviceDto,
|
||||
} from "../types";
|
||||
|
||||
export function toCircuitProtectionSnapshot(
|
||||
circuitId: string,
|
||||
protection: CircuitTreeProtectionDeviceDto
|
||||
): CircuitProtectionSnapshot {
|
||||
return {
|
||||
circuitId,
|
||||
type: protection.type,
|
||||
ratedCurrentA: protection.ratedCurrentA,
|
||||
fuseUtilizationCategory:
|
||||
protection.fuseUtilizationCategory ?? null,
|
||||
tripCharacteristic: protection.tripCharacteristic ?? null,
|
||||
rcdType: protection.rcdType ?? null,
|
||||
ratedResidualCurrentMa:
|
||||
protection.ratedResidualCurrentMa ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCircuitProtectionEditorInitialValue(
|
||||
category: CircuitGroupCategory | undefined,
|
||||
protection: CircuitTreeProtectionDeviceDto | undefined
|
||||
): CircuitTreeProtectionDeviceDto {
|
||||
if (protection) {
|
||||
return protection;
|
||||
}
|
||||
const fallback = createDefaultCircuitProtection(category ?? "lighting");
|
||||
return { ...fallback };
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
CreateCircuitDeviceRowInputDto,
|
||||
CreateCircuitInputDto,
|
||||
} from "../types.js";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
|
||||
import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults";
|
||||
|
||||
export function buildCircuitDeviceRowInsertSnapshot(input: {
|
||||
id: string;
|
||||
@@ -46,8 +48,12 @@ export function buildCircuitInsertSnapshot(input: {
|
||||
circuitListId: string;
|
||||
values: CreateCircuitInputDto;
|
||||
deviceRows: CircuitDeviceRowSnapshot[];
|
||||
category?: CircuitGroupCategory;
|
||||
}): CircuitSnapshot {
|
||||
const { id, circuitListId, values, deviceRows } = input;
|
||||
const { id, circuitListId, values, deviceRows, category } = input;
|
||||
const defaultProtection = category
|
||||
? createDefaultCircuitProtection(category)
|
||||
: null;
|
||||
return {
|
||||
id,
|
||||
circuitListId,
|
||||
@@ -69,5 +75,30 @@ export function buildCircuitInsertSnapshot(input: {
|
||||
isReserve: deviceRows.length === 0,
|
||||
remark: values.remark ?? null,
|
||||
deviceRows,
|
||||
...(defaultProtection
|
||||
? {
|
||||
protectionDevice: {
|
||||
circuitId: id,
|
||||
type: defaultProtection.type,
|
||||
ratedCurrentA: defaultProtection.ratedCurrentA,
|
||||
fuseUtilizationCategory:
|
||||
"fuseUtilizationCategory" in defaultProtection
|
||||
? defaultProtection.fuseUtilizationCategory
|
||||
: null,
|
||||
tripCharacteristic:
|
||||
"tripCharacteristic" in defaultProtection
|
||||
? defaultProtection.tripCharacteristic
|
||||
: null,
|
||||
rcdType:
|
||||
"rcdType" in defaultProtection
|
||||
? defaultProtection.rcdType
|
||||
: null,
|
||||
ratedResidualCurrentMa:
|
||||
"ratedResidualCurrentMa" in defaultProtection
|
||||
? defaultProtection.ratedResidualCurrentMa
|
||||
: null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
CircuitTreeCircuitDto,
|
||||
CircuitTreeComponentDto,
|
||||
CircuitTreeResponseDto,
|
||||
CircuitTreeSectionDto,
|
||||
} from "../types.js";
|
||||
|
||||
export type CircuitStructureProjectionRow =
|
||||
| {
|
||||
rowKey: string;
|
||||
rowType: "headerComponent" | "footerComponent";
|
||||
zone: "header" | "footer";
|
||||
component: CircuitTreeComponentDto;
|
||||
}
|
||||
| {
|
||||
rowKey: string;
|
||||
rowType: "groupHeader" | "groupPlaceholder";
|
||||
zone: "group";
|
||||
section: CircuitTreeSectionDto;
|
||||
}
|
||||
| {
|
||||
rowKey: string;
|
||||
rowType: "groupComponent";
|
||||
zone: "group";
|
||||
section: CircuitTreeSectionDto;
|
||||
component: CircuitTreeComponentDto;
|
||||
}
|
||||
| {
|
||||
rowKey: string;
|
||||
rowType: "circuitBlock";
|
||||
zone: "group";
|
||||
section: CircuitTreeSectionDto;
|
||||
circuit: CircuitTreeCircuitDto;
|
||||
};
|
||||
|
||||
export function buildCircuitStructureProjection(
|
||||
tree: Pick<
|
||||
CircuitTreeResponseDto,
|
||||
"headerComponents" | "sections" | "footerComponents"
|
||||
>
|
||||
): CircuitStructureProjectionRow[] {
|
||||
const rows: CircuitStructureProjectionRow[] = [];
|
||||
for (const component of ordered(tree.headerComponents)) {
|
||||
rows.push({
|
||||
rowKey: `header-component:${component.id}`,
|
||||
rowType: "headerComponent",
|
||||
zone: "header",
|
||||
component,
|
||||
});
|
||||
}
|
||||
for (const section of ordered(tree.sections)) {
|
||||
rows.push({
|
||||
rowKey: `group:${section.id}`,
|
||||
rowType: "groupHeader",
|
||||
zone: "group",
|
||||
section,
|
||||
});
|
||||
for (const component of ordered(section.components ?? [])) {
|
||||
rows.push({
|
||||
rowKey: `group-component:${component.id}`,
|
||||
rowType: "groupComponent",
|
||||
zone: "group",
|
||||
section,
|
||||
component,
|
||||
});
|
||||
}
|
||||
for (const circuit of section.circuits) {
|
||||
rows.push({
|
||||
rowKey: `circuit-block:${circuit.id}`,
|
||||
rowType: "circuitBlock",
|
||||
zone: "group",
|
||||
section,
|
||||
circuit,
|
||||
});
|
||||
}
|
||||
rows.push({
|
||||
rowKey: `group-placeholder:${section.id}`,
|
||||
rowType: "groupPlaceholder",
|
||||
zone: "group",
|
||||
section,
|
||||
});
|
||||
}
|
||||
for (const component of ordered(tree.footerComponents)) {
|
||||
rows.push({
|
||||
rowKey: `footer-component:${component.id}`,
|
||||
rowType: "footerComponent",
|
||||
zone: "footer",
|
||||
component,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function ordered<T extends { id: string; sortOrder: number }>(
|
||||
values: readonly T[]
|
||||
) {
|
||||
return [...values].sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
DistributionBoardComponentSnapshot,
|
||||
} from "../../domain/models/distribution-board-component-structure-project-command.model";
|
||||
import type {
|
||||
CircuitTreeComponentDto,
|
||||
CircuitTreeProtectionDeviceDto,
|
||||
CircuitTreeSectionDto,
|
||||
} from "../types";
|
||||
import { circuitGroupCategoryNumbers } from "../../shared/constants/circuit-group";
|
||||
|
||||
export type MutableDistributionBoardComponentRole =
|
||||
| "group_upstream_protection"
|
||||
| "group_residual_current_protection"
|
||||
| "auxiliary";
|
||||
|
||||
export interface DistributionBoardComponentEditorValues {
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
protectionDevice?: CircuitTreeProtectionDeviceDto;
|
||||
}
|
||||
|
||||
export function toDistributionBoardComponentSnapshot(
|
||||
component: CircuitTreeComponentDto
|
||||
): DistributionBoardComponentSnapshot {
|
||||
if (
|
||||
component.role !== "group_upstream_protection" &&
|
||||
component.role !== "group_residual_current_protection" &&
|
||||
component.role !== "auxiliary"
|
||||
) {
|
||||
throw new Error("Feste Verteilerkomponenten können hier nicht bearbeitet werden.");
|
||||
}
|
||||
return {
|
||||
component: {
|
||||
id: component.id,
|
||||
circuitListId: component.circuitListId,
|
||||
sectionId: component.sectionId ?? null,
|
||||
equipmentIdentifier: component.equipmentIdentifier,
|
||||
name: component.name,
|
||||
role: component.role,
|
||||
placement: component.placement,
|
||||
sortOrder: component.sortOrder,
|
||||
},
|
||||
protectionDevice: component.protectionDevice
|
||||
? {
|
||||
componentId: component.id,
|
||||
type: component.protectionDevice.type,
|
||||
ratedCurrentA: component.protectionDevice.ratedCurrentA,
|
||||
fuseUtilizationCategory:
|
||||
component.protectionDevice.fuseUtilizationCategory ?? null,
|
||||
tripCharacteristic:
|
||||
component.protectionDevice.tripCharacteristic ?? null,
|
||||
rcdType: component.protectionDevice.rcdType ?? null,
|
||||
ratedResidualCurrentMa:
|
||||
component.protectionDevice.ratedResidualCurrentMa ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDistributionBoardComponentSnapshot(input: {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
role: MutableDistributionBoardComponentRole;
|
||||
sectionId?: string;
|
||||
sortOrder: number;
|
||||
values: DistributionBoardComponentEditorValues;
|
||||
}): DistributionBoardComponentSnapshot {
|
||||
const groupComponent = input.role !== "auxiliary";
|
||||
if (groupComponent && !input.sectionId) {
|
||||
throw new Error("Für den Gruppenschutz fehlt die Stromkreisgruppe.");
|
||||
}
|
||||
if (groupComponent && !input.values.protectionDevice) {
|
||||
throw new Error("Für den Gruppenschutz fehlt die Schutzgerätekonfiguration.");
|
||||
}
|
||||
return {
|
||||
component: {
|
||||
id: input.id,
|
||||
circuitListId: input.circuitListId,
|
||||
sectionId: groupComponent ? input.sectionId! : null,
|
||||
equipmentIdentifier: input.values.equipmentIdentifier.trim(),
|
||||
name: input.values.name.trim(),
|
||||
role: input.role,
|
||||
placement: groupComponent ? "group" : "footer",
|
||||
sortOrder: input.sortOrder,
|
||||
},
|
||||
protectionDevice:
|
||||
groupComponent && input.values.protectionDevice
|
||||
? {
|
||||
componentId: input.id,
|
||||
type: input.values.protectionDevice.type,
|
||||
ratedCurrentA: input.values.protectionDevice.ratedCurrentA,
|
||||
fuseUtilizationCategory:
|
||||
input.values.protectionDevice.fuseUtilizationCategory ?? null,
|
||||
tripCharacteristic:
|
||||
input.values.protectionDevice.tripCharacteristic ?? null,
|
||||
rcdType: input.values.protectionDevice.rcdType ?? null,
|
||||
ratedResidualCurrentMa:
|
||||
input.values.protectionDevice.ratedResidualCurrentMa ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateDistributionBoardComponentSnapshot(
|
||||
expected: DistributionBoardComponentSnapshot,
|
||||
values: DistributionBoardComponentEditorValues
|
||||
): DistributionBoardComponentSnapshot {
|
||||
return buildDistributionBoardComponentSnapshot({
|
||||
id: expected.component.id,
|
||||
circuitListId: expected.component.circuitListId,
|
||||
role: expected.component.role,
|
||||
sectionId: expected.component.sectionId ?? undefined,
|
||||
sortOrder: expected.component.sortOrder,
|
||||
values,
|
||||
});
|
||||
}
|
||||
|
||||
export function getSuggestedGroupComponentIdentifier(
|
||||
section: CircuitTreeSectionDto,
|
||||
role:
|
||||
| "group_upstream_protection"
|
||||
| "group_residual_current_protection"
|
||||
): string {
|
||||
if (!section.category || !section.groupNumber) {
|
||||
return "";
|
||||
}
|
||||
const categoryNumber = circuitGroupCategoryNumbers[section.category];
|
||||
const deviceLetter =
|
||||
role === "group_upstream_protection" ? "F" : "Q";
|
||||
return `-${categoryNumber}${deviceLetter}${section.groupNumber}.0`;
|
||||
}
|
||||
|
||||
export function getNextComponentSortOrder(
|
||||
components: readonly { sortOrder: number }[]
|
||||
): number {
|
||||
return components.length === 0
|
||||
? 10
|
||||
: Math.max(...components.map((component) => component.sortOrder)) + 10;
|
||||
}
|
||||
@@ -33,6 +33,17 @@ const commandTypeLabels: Record<string, string> = {
|
||||
"distribution-board.insert": "Verteilung angelegt",
|
||||
"distribution-board.update": "Verteilung bearbeitet",
|
||||
"distribution-board.delete": "Verteilung entfernt",
|
||||
"distribution-board-component.insert": "Verteilergerät angelegt",
|
||||
"distribution-board-component.delete": "Verteilergerät entfernt",
|
||||
"distribution-board-component.update": "Verteilergerät bearbeitet",
|
||||
"circuit-group.insert": "Stromkreisgruppe angelegt",
|
||||
"circuit-group.delete": "Stromkreisgruppe entfernt",
|
||||
"circuit-group.update": "Stromkreisgruppe bearbeitet",
|
||||
"circuit-group.reorder": "Stromkreisgruppen sortiert",
|
||||
"circuit-group.renumber": "Stromkreisgruppen neu nummeriert",
|
||||
"circuit.move-group": "Stromkreis in andere Gruppe verschoben",
|
||||
"circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt",
|
||||
"circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt",
|
||||
"project-floor.insert": "Geschoss angelegt",
|
||||
"project-floor.delete": "Geschoss entfernt",
|
||||
"project-room.insert": "Raum angelegt",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device
|
||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||
import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit-protection-device.repository.js";
|
||||
import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js";
|
||||
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
||||
@@ -15,6 +17,10 @@ export const circuitDeviceRowRepository =
|
||||
export const circuitListRepository = new CircuitListRepository(db);
|
||||
export const circuitSectionRepository = new CircuitSectionRepository(db);
|
||||
export const circuitRepository = new CircuitRepository(db);
|
||||
export const circuitProtectionDeviceRepository =
|
||||
new CircuitProtectionDeviceRepository(db);
|
||||
export const distributionBoardComponentRepository =
|
||||
new DistributionBoardComponentRepository(db);
|
||||
export const distributionBoardRepository =
|
||||
new DistributionBoardRepository(db);
|
||||
export const floorRepository = new FloorRepository(db);
|
||||
|
||||
@@ -6,7 +6,13 @@ import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-p
|
||||
import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
|
||||
import { CircuitProtectionProjectCommandRepository } from "../../db/repositories/circuit-protection-project-command.repository.js";
|
||||
import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-structure-project-command.repository.js";
|
||||
import { CircuitGroupRenumberProjectCommandRepository } from "../../db/repositories/circuit-group-renumber-project-command.repository.js";
|
||||
import { CircuitGroupMoveProjectCommandRepository } from "../../db/repositories/circuit-group-move-project-command.repository.js";
|
||||
import { CircuitGroupSubtreeProjectCommandRepository } from "../../db/repositories/circuit-group-subtree-project-command.repository.js";
|
||||
import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js";
|
||||
import { DistributionBoardComponentStructureProjectCommandRepository } from "../../db/repositories/distribution-board-component-structure-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
|
||||
import { ProjectLocationStructureProjectCommandRepository } from "../../db/repositories/project-location-structure-project-command.repository.js";
|
||||
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
|
||||
@@ -25,8 +31,20 @@ export const circuitDeviceRowMoveProjectCommandStore =
|
||||
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
||||
export const circuitStructureProjectCommandStore =
|
||||
new CircuitStructureProjectCommandRepository(db);
|
||||
export const circuitProtectionProjectCommandStore =
|
||||
new CircuitProtectionProjectCommandRepository(db);
|
||||
export const circuitGroupStructureProjectCommandStore =
|
||||
new CircuitGroupStructureProjectCommandRepository(db);
|
||||
export const circuitGroupRenumberProjectCommandStore =
|
||||
new CircuitGroupRenumberProjectCommandRepository(db);
|
||||
export const circuitGroupMoveProjectCommandStore =
|
||||
new CircuitGroupMoveProjectCommandRepository(db);
|
||||
export const circuitGroupSubtreeProjectCommandStore =
|
||||
new CircuitGroupSubtreeProjectCommandRepository(db);
|
||||
export const distributionBoardStructureProjectCommandStore =
|
||||
new DistributionBoardStructureProjectCommandRepository(db);
|
||||
export const distributionBoardComponentStructureProjectCommandStore =
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(db);
|
||||
export const projectLocationStructureProjectCommandStore =
|
||||
new ProjectLocationStructureProjectCommandRepository(db);
|
||||
export const circuitSectionReorderProjectCommandStore =
|
||||
@@ -59,5 +77,11 @@ export const projectCommandService = new ProjectCommandService(
|
||||
projectDeviceRowSyncProjectCommandStore,
|
||||
projectSettingsProjectCommandStore,
|
||||
projectStateRestoreCommandStore,
|
||||
distributionBoardComponentStructureProjectCommandStore,
|
||||
circuitGroupStructureProjectCommandStore,
|
||||
circuitGroupRenumberProjectCommandStore,
|
||||
circuitGroupMoveProjectCommandStore,
|
||||
circuitGroupSubtreeProjectCommandStore,
|
||||
circuitProtectionProjectCommandStore,
|
||||
projectHistoryStore
|
||||
);
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
calculateSectionTotalPower,
|
||||
} from "../../domain/calculations/circuit-power-calculation.js";
|
||||
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
|
||||
import type { CircuitTreeProtectionDevice } from "../../domain/models/circuit-tree.model.js";
|
||||
import {
|
||||
circuitDeviceRowRepository,
|
||||
circuitListRepository,
|
||||
circuitProtectionDeviceRepository,
|
||||
circuitRepository,
|
||||
circuitSectionRepository,
|
||||
distributionBoardComponentRepository,
|
||||
distributionBoardRepository,
|
||||
projectRepository,
|
||||
} from "../composition/application-repositories.js";
|
||||
@@ -23,7 +26,9 @@ export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
|
||||
return (
|
||||
error.message.includes("no such table: circuit_sections") ||
|
||||
error.message.includes("no such table: circuits") ||
|
||||
error.message.includes("no such table: circuit_device_rows")
|
||||
error.message.includes("no such table: circuit_device_rows") ||
|
||||
error.message.includes("no such table: distribution_board_components") ||
|
||||
error.message.includes("no such table: circuit_protection_devices")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,9 +61,28 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
||||
const circuits = await circuitRepository.listByCircuitList(circuitListId);
|
||||
const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id));
|
||||
const components =
|
||||
await distributionBoardComponentRepository.listByCircuitList(
|
||||
circuitListId
|
||||
);
|
||||
const [circuitProtections, componentProtections] =
|
||||
await Promise.all([
|
||||
circuitProtectionDeviceRepository.listByCircuitIds(
|
||||
circuits.map((entry) => entry.id)
|
||||
),
|
||||
distributionBoardComponentRepository.listProtectionByComponentIds(
|
||||
components.map((entry) => entry.id)
|
||||
),
|
||||
]);
|
||||
|
||||
const sectionById = new Map(sections.map((section) => [section.id, section]));
|
||||
const rowsByCircuitId = new Map<string, typeof rows>();
|
||||
const circuitProtectionById = new Map(
|
||||
circuitProtections.map((entry) => [entry.circuitId, entry])
|
||||
);
|
||||
const componentProtectionById = new Map(
|
||||
componentProtections.map((entry) => [entry.componentId, entry])
|
||||
);
|
||||
for (const row of rows) {
|
||||
if (!rowsByCircuitId.has(row.circuitId)) {
|
||||
rowsByCircuitId.set(row.circuitId, []);
|
||||
@@ -75,17 +99,46 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
distributionBoard.simultaneityFactor,
|
||||
distributionBoardTotalPower: 0,
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
||||
headerComponents: [],
|
||||
sections: sections.map((section) => ({
|
||||
id: section.id,
|
||||
key: section.key,
|
||||
displayName: section.displayName,
|
||||
prefix: section.prefix,
|
||||
sortOrder: section.sortOrder,
|
||||
category: section.category ?? undefined,
|
||||
groupNumber: section.groupNumber ?? undefined,
|
||||
sectionTotalPower: 0,
|
||||
components: [],
|
||||
circuits: [],
|
||||
})),
|
||||
footerComponents: [],
|
||||
};
|
||||
const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section]));
|
||||
for (const component of components) {
|
||||
const protection =
|
||||
componentProtectionById.get(component.id);
|
||||
const dto = {
|
||||
id: component.id,
|
||||
circuitListId: component.circuitListId,
|
||||
sectionId: component.sectionId ?? undefined,
|
||||
equipmentIdentifier: component.equipmentIdentifier,
|
||||
name: component.name,
|
||||
role: component.role,
|
||||
placement: component.placement,
|
||||
sortOrder: component.sortOrder,
|
||||
protectionDevice: protection
|
||||
? toProtectionDeviceDto(protection)
|
||||
: undefined,
|
||||
};
|
||||
if (component.placement === "header") {
|
||||
tree.headerComponents.push(dto);
|
||||
} else if (component.placement === "footer") {
|
||||
tree.footerComponents.push(dto);
|
||||
} else if (component.sectionId !== null) {
|
||||
sectionBlocks.get(component.sectionId)?.components.push(dto);
|
||||
}
|
||||
}
|
||||
|
||||
for (const circuit of circuits) {
|
||||
const section = sectionById.get(circuit.sectionId);
|
||||
@@ -116,6 +169,7 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
rowTotalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
|
||||
}));
|
||||
const circuitTotalPower = calculateCircuitTotalPower(deviceRows);
|
||||
const protection = circuitProtectionById.get(circuit.id);
|
||||
|
||||
sectionBlocks.get(section.id)?.circuits.push({
|
||||
id: circuit.id,
|
||||
@@ -139,6 +193,9 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
remark: circuit.remark ?? undefined,
|
||||
circuitTotalPower,
|
||||
deviceRows,
|
||||
protectionDevice: protection
|
||||
? toProtectionDeviceDto(protection)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,7 +224,9 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
distributionBoard.simultaneityFactor,
|
||||
distributionBoardTotalPower: 0,
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
||||
headerComponents: [],
|
||||
sections: [],
|
||||
footerComponents: [],
|
||||
warning:
|
||||
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
||||
});
|
||||
@@ -175,3 +234,23 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function toProtectionDeviceDto(value: {
|
||||
type: CircuitTreeProtectionDevice["type"];
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory: CircuitTreeProtectionDevice["fuseUtilizationCategory"] | null;
|
||||
tripCharacteristic: CircuitTreeProtectionDevice["tripCharacteristic"] | null;
|
||||
rcdType: CircuitTreeProtectionDevice["rcdType"] | null;
|
||||
ratedResidualCurrentMa: number | null;
|
||||
}): CircuitTreeProtectionDevice {
|
||||
return {
|
||||
type: value.type,
|
||||
ratedCurrentA: value.ratedCurrentA,
|
||||
fuseUtilizationCategory:
|
||||
value.fuseUtilizationCategory ?? undefined,
|
||||
tripCharacteristic: value.tripCharacteristic ?? undefined,
|
||||
rcdType: value.rcdType ?? undefined,
|
||||
ratedResidualCurrentMa:
|
||||
value.ratedResidualCurrentMa ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export const circuitGroupCategories = [
|
||||
"lighting",
|
||||
"single_phase",
|
||||
"three_phase",
|
||||
] as const;
|
||||
|
||||
export type CircuitGroupCategory = (typeof circuitGroupCategories)[number];
|
||||
|
||||
export const circuitGroupCategoryNumbers: Record<CircuitGroupCategory, 1 | 2 | 3> =
|
||||
{
|
||||
lighting: 1,
|
||||
single_phase: 2,
|
||||
three_phase: 3,
|
||||
};
|
||||
|
||||
export const circuitGroupCategoryLabels: Record<CircuitGroupCategory, string> = {
|
||||
lighting: "Beleuchtung",
|
||||
single_phase: "1-phasig",
|
||||
three_phase: "3-phasig",
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
export const distributionBoardComponentRoles = [
|
||||
"main_switch",
|
||||
"surge_protective_device",
|
||||
"group_upstream_protection",
|
||||
"group_residual_current_protection",
|
||||
"auxiliary",
|
||||
] as const;
|
||||
|
||||
export type DistributionBoardComponentRole =
|
||||
(typeof distributionBoardComponentRoles)[number];
|
||||
|
||||
export const distributionBoardComponentPlacements = [
|
||||
"header",
|
||||
"group",
|
||||
"footer",
|
||||
] as const;
|
||||
|
||||
export type DistributionBoardComponentPlacement =
|
||||
(typeof distributionBoardComponentPlacements)[number];
|
||||
|
||||
export const distributionBoardComponentRoleLabels: Record<
|
||||
DistributionBoardComponentRole,
|
||||
string
|
||||
> = {
|
||||
main_switch: "Hauptschalter",
|
||||
surge_protective_device: "Überspannungsableiter",
|
||||
group_upstream_protection: "Gruppenvorsicherung",
|
||||
group_residual_current_protection: "Gruppen-FI",
|
||||
auxiliary: "Verteilergerät",
|
||||
};
|
||||
|
||||
export const defaultMainSwitchComponent = {
|
||||
equipmentIdentifier: "-Q0",
|
||||
name: "Hauptschalter",
|
||||
role: "main_switch",
|
||||
placement: "header",
|
||||
} as const satisfies {
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
role: DistributionBoardComponentRole;
|
||||
placement: DistributionBoardComponentPlacement;
|
||||
};
|
||||
|
||||
export const defaultSurgeProtectiveDeviceComponent = {
|
||||
equipmentIdentifier: "-FA",
|
||||
name: "Überspannungsableiter",
|
||||
role: "surge_protective_device",
|
||||
placement: "header",
|
||||
} as const satisfies {
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
role: DistributionBoardComponentRole;
|
||||
placement: DistributionBoardComponentPlacement;
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
export const fuseProtectionDeviceTypes = [
|
||||
"D02",
|
||||
"NH000",
|
||||
"NH00",
|
||||
"NH0",
|
||||
"NH1",
|
||||
"NH2",
|
||||
"NH3",
|
||||
] as const;
|
||||
|
||||
export type FuseProtectionDeviceType =
|
||||
(typeof fuseProtectionDeviceTypes)[number];
|
||||
|
||||
export const breakerProtectionDeviceTypes = ["LS", "FI_LS", "AFDD"] as const;
|
||||
|
||||
export type BreakerProtectionDeviceType =
|
||||
(typeof breakerProtectionDeviceTypes)[number];
|
||||
|
||||
export const protectionDeviceTypes = [
|
||||
...fuseProtectionDeviceTypes,
|
||||
"LS",
|
||||
"FI",
|
||||
"FI_LS",
|
||||
"AFDD",
|
||||
] as const;
|
||||
|
||||
export type ProtectionDeviceType = (typeof protectionDeviceTypes)[number];
|
||||
|
||||
export const fuseUtilizationCategories = ["gG", "gR"] as const;
|
||||
export type FuseUtilizationCategory =
|
||||
(typeof fuseUtilizationCategories)[number];
|
||||
|
||||
export const breakerTripCharacteristics = ["B", "C", "D", "Z"] as const;
|
||||
export type BreakerTripCharacteristic =
|
||||
(typeof breakerTripCharacteristics)[number];
|
||||
|
||||
export const rcdTypes = ["A", "AC", "B", "B+"] as const;
|
||||
export type RcdType = (typeof rcdTypes)[number];
|
||||
|
||||
export const ratedResidualCurrentsMa = [10, 30, 100, 300, 500] as const;
|
||||
export type RatedResidualCurrentMa =
|
||||
(typeof ratedResidualCurrentsMa)[number];
|
||||
|
||||
const d02RatedCurrentsA = [20, 25, 32, 35, 40, 50, 63] as const;
|
||||
const nh000AndNh00RatedCurrentsA = [
|
||||
6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160,
|
||||
] as const;
|
||||
const nh0RatedCurrentsA = [
|
||||
6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250,
|
||||
] as const;
|
||||
const nh1RatedCurrentsA = [
|
||||
16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250,
|
||||
] as const;
|
||||
const nh2RatedCurrentsA = [
|
||||
25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250, 300, 315, 355,
|
||||
400,
|
||||
] as const;
|
||||
const nh3RatedCurrentsA = [
|
||||
50, 63, 80, 100, 125, 160, 200, 224, 250, 315, 355, 400, 500, 630, 800,
|
||||
] as const;
|
||||
const breakerRatedCurrentsA = [
|
||||
6, 10, 13, 16, 20, 25, 32, 40, 50, 63, 80, 100, 125,
|
||||
] as const;
|
||||
const rcdRatedCurrentsA = [16, 25, 40, 63, 80, 100, 125] as const;
|
||||
|
||||
export const allowedRatedCurrentsAByProtectionDeviceType = {
|
||||
D02: d02RatedCurrentsA,
|
||||
NH000: nh000AndNh00RatedCurrentsA,
|
||||
NH00: nh000AndNh00RatedCurrentsA,
|
||||
NH0: nh0RatedCurrentsA,
|
||||
NH1: nh1RatedCurrentsA,
|
||||
NH2: nh2RatedCurrentsA,
|
||||
NH3: nh3RatedCurrentsA,
|
||||
LS: breakerRatedCurrentsA,
|
||||
FI: rcdRatedCurrentsA,
|
||||
FI_LS: breakerRatedCurrentsA,
|
||||
AFDD: breakerRatedCurrentsA,
|
||||
} as const satisfies Record<ProtectionDeviceType, readonly number[]>;
|
||||
|
||||
export const protectionDeviceTypeLabels: Record<ProtectionDeviceType, string> = {
|
||||
D02: "D02 / Neozed",
|
||||
NH000: "NH000",
|
||||
NH00: "NH00",
|
||||
NH0: "NH0",
|
||||
NH1: "NH1",
|
||||
NH2: "NH2",
|
||||
NH3: "NH3",
|
||||
LS: "LS",
|
||||
FI: "FI",
|
||||
FI_LS: "FI/LS",
|
||||
AFDD: "AFDD",
|
||||
};
|
||||
|
||||
export function isAllowedRatedCurrentA(
|
||||
type: ProtectionDeviceType,
|
||||
ratedCurrentA: number
|
||||
): boolean {
|
||||
const allowedValues: readonly number[] =
|
||||
allowedRatedCurrentsAByProtectionDeviceType[type];
|
||||
return allowedValues.includes(ratedCurrentA);
|
||||
}
|
||||
|
||||
export function isAllowedRatedResidualCurrentMa(
|
||||
ratedResidualCurrentMa: number
|
||||
): ratedResidualCurrentMa is RatedResidualCurrentMa {
|
||||
const allowedValues: readonly number[] = ratedResidualCurrentsMa;
|
||||
return allowedValues.includes(ratedResidualCurrentMa);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
breakerTripCharacteristics,
|
||||
fuseProtectionDeviceTypes,
|
||||
fuseUtilizationCategories,
|
||||
isAllowedRatedCurrentA,
|
||||
isAllowedRatedResidualCurrentMa,
|
||||
protectionDeviceTypes,
|
||||
rcdTypes,
|
||||
} from "../constants/protection-device.js";
|
||||
|
||||
function hasValue(value: unknown): boolean {
|
||||
return value !== undefined;
|
||||
}
|
||||
|
||||
export const protectionDeviceConfigurationSchema = z
|
||||
.object({
|
||||
type: z.enum(protectionDeviceTypes),
|
||||
ratedCurrentA: z.number().finite().positive(),
|
||||
fuseUtilizationCategory: z.enum(fuseUtilizationCategories).optional(),
|
||||
tripCharacteristic: z.enum(breakerTripCharacteristics).optional(),
|
||||
rcdType: z.enum(rcdTypes).optional(),
|
||||
ratedResidualCurrentMa: z
|
||||
.number()
|
||||
.int()
|
||||
.refine(isAllowedRatedResidualCurrentMa, {
|
||||
message: "Der Bemessungsdifferenzstrom ist nicht zulässig.",
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, context) => {
|
||||
if (!isAllowedRatedCurrentA(value.type, value.ratedCurrentA)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["ratedCurrentA"],
|
||||
message: "Der Bemessungsstrom ist für diesen Schutzgerätetyp nicht zulässig.",
|
||||
});
|
||||
}
|
||||
|
||||
const isFuse = (fuseProtectionDeviceTypes as readonly string[]).includes(
|
||||
value.type
|
||||
);
|
||||
const hasBreakerFunction =
|
||||
value.type === "LS" || value.type === "FI_LS" || value.type === "AFDD";
|
||||
const hasResidualCurrentFunction =
|
||||
value.type === "FI" || value.type === "FI_LS";
|
||||
|
||||
if (isFuse !== hasValue(value.fuseUtilizationCategory)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["fuseUtilizationCategory"],
|
||||
message: isFuse
|
||||
? "Für diesen Sicherungstyp ist eine Sicherungscharakteristik erforderlich."
|
||||
: "Dieser Schutzgerätetyp darf keine Sicherungscharakteristik enthalten.",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasBreakerFunction !== hasValue(value.tripCharacteristic)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["tripCharacteristic"],
|
||||
message: hasBreakerFunction
|
||||
? "Für diesen Schutzgerätetyp ist eine Auslösecharakteristik erforderlich."
|
||||
: "Dieser Schutzgerätetyp darf keine Auslösecharakteristik enthalten.",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasResidualCurrentFunction !== hasValue(value.rcdType)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["rcdType"],
|
||||
message: hasResidualCurrentFunction
|
||||
? "Für diesen Schutzgerätetyp ist ein FI-Typ erforderlich."
|
||||
: "Dieser Schutzgerätetyp darf keinen FI-Typ enthalten.",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
hasResidualCurrentFunction !==
|
||||
hasValue(value.ratedResidualCurrentMa)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["ratedResidualCurrentMa"],
|
||||
message: hasResidualCurrentFunction
|
||||
? "Für diesen Schutzgerätetyp ist ein Bemessungsdifferenzstrom erforderlich."
|
||||
: "Dieser Schutzgerätetyp darf keinen Bemessungsdifferenzstrom enthalten.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type ProtectionDeviceConfigurationInput = z.infer<
|
||||
typeof protectionDeviceConfigurationSchema
|
||||
>;
|
||||
@@ -11,6 +11,7 @@ import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/reposito
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
@@ -193,6 +194,15 @@ function createMoveTargetSnapshot(
|
||||
isReserve: true,
|
||||
remark: null,
|
||||
deviceRows: [],
|
||||
protectionDevice: {
|
||||
circuitId: "circuit-new",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -520,6 +530,14 @@ describe("circuit device-row move project-command repository", () => {
|
||||
"-1F4"
|
||||
);
|
||||
assert.equal(isReserve(fixture.context, "circuit-new"), false);
|
||||
assert.equal(
|
||||
fixture.context.db
|
||||
.select({ ratedCurrentA: circuitProtectionDevices.ratedCurrentA })
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
|
||||
.get()?.ratedCurrentA,
|
||||
10
|
||||
);
|
||||
assert.equal(isReserve(fixture.context, "circuit-1"), false);
|
||||
assert.equal(isReserve(fixture.context, "circuit-2"), true);
|
||||
|
||||
@@ -538,6 +556,14 @@ describe("circuit device-row move project-command repository", () => {
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
assert.equal(
|
||||
fixture.context.db
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
assert.deepEqual(
|
||||
[
|
||||
getRow(fixture.context, "row-1"),
|
||||
@@ -562,6 +588,14 @@ describe("circuit device-row move project-command repository", () => {
|
||||
getRow(fixture.context, "row-2")?.circuitId,
|
||||
"circuit-new"
|
||||
);
|
||||
assert.equal(
|
||||
fixture.context.db
|
||||
.select({ type: circuitProtectionDevices.type })
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
|
||||
.get()?.type,
|
||||
"LS"
|
||||
);
|
||||
assert.deepEqual(
|
||||
new ProjectHistoryRepository(fixture.context.db).getState(
|
||||
"project-1"
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
getAdjacentInsertionSortOrder,
|
||||
getInsertionSortOrder,
|
||||
isGridInsertionRowType,
|
||||
resolveGridInsertionIntent,
|
||||
} from "../src/frontend/utils/circuit-grid-insertion.js";
|
||||
|
||||
describe("circuit grid insertion", () => {
|
||||
it("excludes structural rows from insertion and deletion commands", () => {
|
||||
assert.equal(isGridInsertionRowType("headerComponent"), false);
|
||||
assert.equal(isGridInsertionRowType("groupComponent"), false);
|
||||
assert.equal(isGridInsertionRowType("footerComponent"), false);
|
||||
assert.equal(isGridInsertionRowType("circuitCompact"), true);
|
||||
});
|
||||
|
||||
it("inserts a circuit after circuit-level selections", () => {
|
||||
assert.deepEqual(
|
||||
resolveGridInsertionIntent(
|
||||
@@ -81,5 +90,26 @@ describe("circuit grid insertion", () => {
|
||||
assert.equal(getInsertionSortOrder(entries, "c"), 40);
|
||||
assert.equal(getInsertionSortOrder(entries), 40);
|
||||
assert.equal(getInsertionSortOrder([], "missing"), 10);
|
||||
assert.equal(
|
||||
getAdjacentInsertionSortOrder(entries, "a", "before"),
|
||||
0
|
||||
);
|
||||
assert.equal(
|
||||
getAdjacentInsertionSortOrder(entries, "b", "before"),
|
||||
15
|
||||
);
|
||||
assert.equal(
|
||||
getAdjacentInsertionSortOrder(entries, "b", "after"),
|
||||
25
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
getAdjacentInsertionSortOrder(
|
||||
entries,
|
||||
"missing",
|
||||
"before"
|
||||
),
|
||||
/nicht gefunden/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +101,14 @@ describe("circuit grid model", () => {
|
||||
});
|
||||
|
||||
it("shows stable circuit sections with German labels", () => {
|
||||
assert.equal(
|
||||
getCircuitSectionLabel({
|
||||
key: "lighting",
|
||||
displayName: "Lighting",
|
||||
category: "lighting",
|
||||
}),
|
||||
"Lighting"
|
||||
);
|
||||
assert.equal(
|
||||
getCircuitSectionLabel({
|
||||
key: "lighting",
|
||||
@@ -128,12 +136,29 @@ describe("circuit grid model", () => {
|
||||
assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField");
|
||||
assert.equal(getCellKind("deviceRow", "controlRequirement"), "readonly");
|
||||
assert.equal(getCellKind("circuitCompact", "rowTotalPower"), "computed");
|
||||
assert.equal(getCellKind("circuitCompact", "protectionType"), "computed");
|
||||
});
|
||||
|
||||
it("projects circuit and device values without mixing ownership", () => {
|
||||
assert.equal(getDeviceValue(device, "roomSummary"), "1.01 Office");
|
||||
assert.equal(getDeviceValue(device, "rowTotalPower"), 0.08);
|
||||
assert.equal(getCircuitValue(circuit, "protectionSummary"), "MCB 16A B");
|
||||
assert.equal(
|
||||
getCircuitValue(
|
||||
{
|
||||
...circuit,
|
||||
protectionDevice: {
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
},
|
||||
"protectionSummary"
|
||||
),
|
||||
"FI/LS · 16 A · B · Typ A · 30 mA"
|
||||
);
|
||||
assert.equal(getCircuitValue(circuit, "voltage"), 230);
|
||||
assert.equal(getCircuitValue(circuit, "controlRequirement"), "DALI");
|
||||
assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m");
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
buildVisibleGridRows,
|
||||
buildVisibleGridRowsWithStructure,
|
||||
filterAndSortCircuitSections,
|
||||
getDistinctFilterValues,
|
||||
} from "../src/frontend/utils/circuit-grid-projection.js";
|
||||
@@ -10,6 +11,7 @@ import type {
|
||||
CircuitTreeDeviceRowDto,
|
||||
CircuitTreeSectionDto,
|
||||
} from "../src/frontend/types.js";
|
||||
import "./circuit-structure-projection.test.js";
|
||||
|
||||
function makeDevice(
|
||||
id: string,
|
||||
@@ -175,6 +177,66 @@ describe("circuit grid projection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes structural component rows around complete circuit groups", () => {
|
||||
const component = {
|
||||
circuitListId: "list-1",
|
||||
equipmentIdentifier: "-Q0",
|
||||
name: "Hauptschalter",
|
||||
role: "main_switch" as const,
|
||||
placement: "header" as const,
|
||||
sortOrder: 10,
|
||||
};
|
||||
const groupedSections = sections.map((section) => ({
|
||||
...section,
|
||||
components: [
|
||||
{
|
||||
...component,
|
||||
id: "group-rcd",
|
||||
sectionId: section.id,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection" as const,
|
||||
placement: "group" as const,
|
||||
},
|
||||
],
|
||||
}));
|
||||
const rows = buildVisibleGridRowsWithStructure(groupedSections, {
|
||||
headerComponents: [{ ...component, id: "main" }],
|
||||
footerComponents: [
|
||||
{
|
||||
...component,
|
||||
id: "actor",
|
||||
equipmentIdentifier: "-K1",
|
||||
name: "KNX Aktor",
|
||||
role: "auxiliary",
|
||||
placement: "footer",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.rowType),
|
||||
[
|
||||
"headerComponent",
|
||||
"section",
|
||||
"groupComponent",
|
||||
"circuitCompact",
|
||||
"circuitSummary",
|
||||
"deviceRow",
|
||||
"deviceRow",
|
||||
"reserveCircuit",
|
||||
"placeholder",
|
||||
"footerComponent",
|
||||
]
|
||||
);
|
||||
assert.equal(
|
||||
rows.find((row) => row.rowType === "groupComponent")?.cells.some(
|
||||
(cell) => cell.editable
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("collects filter options from both circuit and device values", () => {
|
||||
const values = getDistinctFilterValues(sections, [
|
||||
{ key: "displayName", label: "Display name" },
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import { createCircuitGroupMoveProjectCommand } from "../src/domain/models/circuit-group-move-project-command.model.js";
|
||||
import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values({ id: "project-1", name: "Projekt" })
|
||||
.run();
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
const list = context.db.select().from(circuitLists).get()!;
|
||||
const source = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.category, "lighting"))
|
||||
.get()!;
|
||||
context.db
|
||||
.insert(circuitSections)
|
||||
.values({
|
||||
id: "lighting-move-target",
|
||||
circuitListId: list.id,
|
||||
key: "lighting_2",
|
||||
displayName: "Beleuchtung 2",
|
||||
prefix: "-1F2.",
|
||||
sortOrder: 40,
|
||||
category: "lighting",
|
||||
groupNumber: 2,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values([
|
||||
{
|
||||
id: "circuit-move",
|
||||
circuitListId: list.id,
|
||||
sectionId: source.id,
|
||||
equipmentIdentifier: "-1F1.7",
|
||||
displayName: "Zu verschieben",
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
id: "circuit-target-existing",
|
||||
circuitListId: list.id,
|
||||
sectionId: "lighting-move-target",
|
||||
equipmentIdentifier: "-1F2.4",
|
||||
displayName: "Bestand",
|
||||
sortOrder: 10,
|
||||
},
|
||||
])
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitDeviceRows)
|
||||
.values({
|
||||
id: "row-move",
|
||||
circuitId: "circuit-move",
|
||||
sortOrder: 10,
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte",
|
||||
quantity: 2,
|
||||
powerPerUnit: 0.1,
|
||||
simultaneityFactor: 1,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitProtectionDevices)
|
||||
.values({
|
||||
circuitId: "circuit-move",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
tripCharacteristic: "B",
|
||||
})
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
function createMoveCommand(context: DatabaseContext) {
|
||||
const circuit = context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.get()!;
|
||||
const source = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, circuit.sectionId))
|
||||
.get()!;
|
||||
const target = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, "lighting-move-target"))
|
||||
.get()!;
|
||||
return createCircuitGroupMoveProjectCommand(
|
||||
createCircuitGroupMovePlan({
|
||||
circuit,
|
||||
sourceGroup: {
|
||||
id: source.id,
|
||||
category: source.category!,
|
||||
groupNumber: source.groupNumber!,
|
||||
prefix: source.prefix,
|
||||
},
|
||||
targetGroup: {
|
||||
id: target.id,
|
||||
category: target.category!,
|
||||
groupNumber: target.groupNumber!,
|
||||
prefix: target.prefix,
|
||||
},
|
||||
targetCircuitEquipmentIdentifiers: context.db
|
||||
.select({ value: circuits.equipmentIdentifier })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, target.id))
|
||||
.all()
|
||||
.map(({ value }) => value),
|
||||
targetSortOrder: 30,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe("circuit group-move project command", () => {
|
||||
it("moves a complete circuit while preserving rows and protection through undo/redo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const command = createMoveCommand(context);
|
||||
const repository =
|
||||
new CircuitGroupMoveProjectCommandRepository(context.db);
|
||||
const moved = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select({
|
||||
sectionId: circuits.sectionId,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
sortOrder: circuits.sortOrder,
|
||||
})
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.get(),
|
||||
{
|
||||
sectionId: "lighting-move-target",
|
||||
equipmentIdentifier: "-1F2.5",
|
||||
sortOrder: 30,
|
||||
}
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, "row-move"))
|
||||
.get()?.circuitId,
|
||||
"circuit-move"
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-move"))
|
||||
.get()?.ratedCurrentA,
|
||||
10
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: moved.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F1.7"
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "redo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "redo")?.changeSetId,
|
||||
command,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F2.5"
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects stale state without moving the circuit", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const command = createMoveCommand(context);
|
||||
context.db
|
||||
.update(circuits)
|
||||
.set({ sortOrder: 99 })
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.run();
|
||||
assert.throws(
|
||||
() =>
|
||||
new CircuitGroupMoveProjectCommandRepository(context.db).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
}),
|
||||
/changed before group move/
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.get()?.sectionId,
|
||||
command.payload.expectedSectionId
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back the circuit move when history persistence fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const command = createMoveCommand(context);
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_circuit_group_move_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced circuit group move history failure');
|
||||
END;
|
||||
`);
|
||||
assert.throws(
|
||||
() =>
|
||||
new CircuitGroupMoveProjectCommandRepository(context.db).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
}),
|
||||
/forced circuit group move history failure/
|
||||
);
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select({
|
||||
sectionId: circuits.sectionId,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
sortOrder: circuits.sortOrder,
|
||||
})
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-move"))
|
||||
.get(),
|
||||
{
|
||||
sectionId: command.payload.expectedSectionId,
|
||||
equipmentIdentifier: "-1F1.7",
|
||||
sortOrder: 20,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,746 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
formatGroupedCircuitIdentifier,
|
||||
formatGroupRcdIdentifier,
|
||||
formatGroupUpstreamProtectionIdentifier,
|
||||
getNextCircuitGroupNumber,
|
||||
getNextGroupedCircuitIdentifier,
|
||||
parseGroupedEquipmentIdentifier,
|
||||
} from "../src/domain/services/circuit-group-numbering.js";
|
||||
import "./circuit-group-structure-project-command.repository.test.js";
|
||||
import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js";
|
||||
import "./circuit-group-renumber-project-command.repository.test.js";
|
||||
import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js";
|
||||
import "./circuit-group-move-project-command.repository.test.js";
|
||||
import "./circuit-group-subtree-snapshot.test.js";
|
||||
import "./circuit-group-subtree-project-command.repository.test.js";
|
||||
import {
|
||||
buildCircuitGroupMovePlan,
|
||||
buildCircuitGroupRenumberPlan,
|
||||
buildCircuitGroupReorderAssignments,
|
||||
buildCircuitGroupSubtreeSnapshot,
|
||||
buildNewCircuitGroupSnapshot,
|
||||
canMoveCircuitToGroup,
|
||||
canRenumberCircuitGroups,
|
||||
canDeleteCircuitGroup,
|
||||
renameCircuitGroupSnapshot,
|
||||
summarizeCircuitGroupSubtree,
|
||||
} from "../src/frontend/utils/circuit-group-editing.js";
|
||||
|
||||
describe("circuit group numbering", () => {
|
||||
it("builds the next group without filling gaps or renumbering existing groups", () => {
|
||||
const sections = [
|
||||
groupSection("lighting-1", "lighting", 1, 10),
|
||||
groupSection("single-1", "single_phase", 1, 20),
|
||||
groupSection("lighting-3", "lighting", 3, 15),
|
||||
];
|
||||
const snapshot = buildNewCircuitGroupSnapshot({
|
||||
id: "lighting-4",
|
||||
circuitListId: "list-1",
|
||||
category: "lighting",
|
||||
sections,
|
||||
});
|
||||
assert.deepEqual(snapshot, {
|
||||
id: "lighting-4",
|
||||
circuitListId: "list-1",
|
||||
key: "lighting_4",
|
||||
displayName: "Beleuchtung 4",
|
||||
prefix: "-1F4.",
|
||||
sortOrder: 17.5,
|
||||
category: "lighting",
|
||||
groupNumber: 4,
|
||||
});
|
||||
assert.equal(
|
||||
renameCircuitGroupSnapshot(snapshot, " Beleuchtung Nord ").displayName,
|
||||
"Beleuchtung Nord"
|
||||
);
|
||||
assert.equal(canDeleteCircuitGroup(sections[0]), true);
|
||||
assert.equal(
|
||||
canDeleteCircuitGroup({
|
||||
...sections[0],
|
||||
components: [
|
||||
{
|
||||
id: "component-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: sections[0].id,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
},
|
||||
],
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("reorders complete group lists only between category peers", () => {
|
||||
const sections = [
|
||||
groupSection("lighting-1", "lighting", 1, 10),
|
||||
groupSection("lighting-2", "lighting", 2, 15),
|
||||
groupSection("single-1", "single_phase", 1, 20),
|
||||
];
|
||||
assert.deepEqual(
|
||||
buildCircuitGroupReorderAssignments(
|
||||
sections,
|
||||
"lighting-1",
|
||||
1
|
||||
),
|
||||
[
|
||||
{
|
||||
groupId: "lighting-1",
|
||||
expectedSortOrder: 10,
|
||||
targetSortOrder: 15,
|
||||
},
|
||||
{
|
||||
groupId: "lighting-2",
|
||||
expectedSortOrder: 15,
|
||||
targetSortOrder: 10,
|
||||
},
|
||||
{
|
||||
groupId: "single-1",
|
||||
expectedSortOrder: 20,
|
||||
targetSortOrder: 20,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert.equal(
|
||||
buildCircuitGroupReorderAssignments(
|
||||
sections,
|
||||
"lighting-1",
|
||||
-1
|
||||
),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
buildCircuitGroupReorderAssignments(
|
||||
sections,
|
||||
"single-1",
|
||||
1
|
||||
),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
it("renumbers a complete category by group order and preserves circuit suffixes", () => {
|
||||
const sections = [
|
||||
{
|
||||
...groupSection("lighting-3", "lighting", 3, 10),
|
||||
prefix: "-1F3.",
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit-7",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-3",
|
||||
equipmentIdentifier: "-1F3.7",
|
||||
sortOrder: 10,
|
||||
isReserve: false,
|
||||
circuitTotalPower: 0,
|
||||
deviceRows: [],
|
||||
},
|
||||
],
|
||||
components: [
|
||||
{
|
||||
id: "rcd-3",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-3",
|
||||
equipmentIdentifier: "-1Q3.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection" as const,
|
||||
placement: "group" as const,
|
||||
sortOrder: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...groupSection("lighting-1", "lighting", 1, 20),
|
||||
prefix: "-1F1.",
|
||||
},
|
||||
groupSection("single-1", "single_phase", 1, 30),
|
||||
];
|
||||
|
||||
assert.deepEqual(buildCircuitGroupRenumberPlan(sections, "lighting"), {
|
||||
groups: [
|
||||
{
|
||||
groupId: "lighting-3",
|
||||
category: "lighting",
|
||||
expectedGroupNumber: 3,
|
||||
targetGroupNumber: 1,
|
||||
expectedPrefix: "-1F3.",
|
||||
targetPrefix: "-1F1.",
|
||||
circuits: [
|
||||
{
|
||||
circuitId: "circuit-7",
|
||||
expectedEquipmentIdentifier: "-1F3.7",
|
||||
targetEquipmentIdentifier: "-1F1.7",
|
||||
},
|
||||
],
|
||||
components: [
|
||||
{
|
||||
componentId: "rcd-3",
|
||||
expectedEquipmentIdentifier: "-1Q3.0",
|
||||
targetEquipmentIdentifier: "-1Q1.0",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupId: "lighting-1",
|
||||
category: "lighting",
|
||||
expectedGroupNumber: 1,
|
||||
targetGroupNumber: 2,
|
||||
expectedPrefix: "-1F1.",
|
||||
targetPrefix: "-1F2.",
|
||||
circuits: [],
|
||||
components: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(
|
||||
buildCircuitGroupRenumberPlan(
|
||||
[
|
||||
groupSection("lighting-1", "lighting", 1, 10),
|
||||
groupSection("lighting-2", "lighting", 2, 20),
|
||||
],
|
||||
"lighting"
|
||||
),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
canRenumberCircuitGroups(sections, "lighting"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
canRenumberCircuitGroups(
|
||||
[
|
||||
groupSection("lighting-1", "lighting", 1, 10),
|
||||
groupSection("lighting-2", "lighting", 2, 20),
|
||||
],
|
||||
"lighting"
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("plans a same-category circuit drop with the next target BMK", () => {
|
||||
const source = {
|
||||
...groupSection("lighting-1", "lighting", 1, 10),
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit-source",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-1",
|
||||
equipmentIdentifier: "-1F1.5",
|
||||
sortOrder: 10,
|
||||
isReserve: false,
|
||||
circuitTotalPower: 0,
|
||||
deviceRows: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const target = {
|
||||
...groupSection("lighting-2", "lighting", 2, 20),
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit-target",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-2",
|
||||
equipmentIdentifier: "-1F2.3",
|
||||
sortOrder: 20,
|
||||
isReserve: false,
|
||||
circuitTotalPower: 0,
|
||||
deviceRows: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
canMoveCircuitToGroup(
|
||||
[source, target],
|
||||
"circuit-source",
|
||||
"lighting-2"
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildCircuitGroupMovePlan({
|
||||
sections: [source, target],
|
||||
circuitId: "circuit-source",
|
||||
targetSectionId: "lighting-2",
|
||||
placement: {
|
||||
kind: "before",
|
||||
targetCircuitId: "circuit-target",
|
||||
},
|
||||
}),
|
||||
{
|
||||
circuitId: "circuit-source",
|
||||
circuitListId: "list-1",
|
||||
expectedSectionId: "lighting-1",
|
||||
targetSectionId: "lighting-2",
|
||||
expectedEquipmentIdentifier: "-1F1.5",
|
||||
targetEquipmentIdentifier: "-1F2.4",
|
||||
expectedSortOrder: 10,
|
||||
targetSortOrder: 10,
|
||||
}
|
||||
);
|
||||
assert.equal(
|
||||
canMoveCircuitToGroup(
|
||||
[
|
||||
source,
|
||||
groupSection("single-1", "single_phase", 1, 20),
|
||||
],
|
||||
"circuit-source",
|
||||
"single-1"
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("captures a complete populated group for warned deletion", () => {
|
||||
const section = {
|
||||
...groupSection("lighting-1", "lighting", 1, 10),
|
||||
components: [
|
||||
{
|
||||
id: "rcd-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-1",
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection" as const,
|
||||
placement: "group" as const,
|
||||
sortOrder: 10,
|
||||
protectionDevice: {
|
||||
type: "FI" as const,
|
||||
ratedCurrentA: 40,
|
||||
rcdType: "A" as const,
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
},
|
||||
],
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-1",
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
displayName: "Flurlicht",
|
||||
sortOrder: 10,
|
||||
voltage: 230,
|
||||
isReserve: false,
|
||||
circuitTotalPower: 0.1,
|
||||
protectionDevice: {
|
||||
type: "LS" as const,
|
||||
ratedCurrentA: 10,
|
||||
tripCharacteristic: "B" as const,
|
||||
},
|
||||
deviceRows: [
|
||||
{
|
||||
id: "row-1",
|
||||
linkedProjectDeviceId: "device-1",
|
||||
sortOrder: 10,
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte Flur",
|
||||
phaseType: "single_phase",
|
||||
quantity: 2,
|
||||
powerPerUnit: 0.05,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 0.95,
|
||||
overriddenFields: "[\"displayName\"]",
|
||||
rowTotalPower: 0.1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const snapshot = buildCircuitGroupSubtreeSnapshot(
|
||||
section,
|
||||
"list-1"
|
||||
);
|
||||
|
||||
assert.deepEqual(summarizeCircuitGroupSubtree(snapshot), {
|
||||
circuitCount: 1,
|
||||
deviceRowCount: 1,
|
||||
protectionComponentCount: 1,
|
||||
});
|
||||
assert.equal(
|
||||
snapshot.circuits[0].circuit.deviceRows[0]
|
||||
.linkedProjectDeviceId,
|
||||
"device-1"
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.circuits[0].circuit.deviceRows[0]
|
||||
.overriddenFields,
|
||||
"[\"displayName\"]"
|
||||
);
|
||||
assert.deepEqual(snapshot.circuits[0].protectionDevice, {
|
||||
circuitId: "circuit-1",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats the agreed identifiers including the leading hyphen", () => {
|
||||
assert.equal(
|
||||
formatGroupUpstreamProtectionIdentifier("lighting", 1),
|
||||
"-1F1.0"
|
||||
);
|
||||
assert.equal(formatGroupRcdIdentifier("lighting", 1), "-1Q1.0");
|
||||
assert.equal(
|
||||
formatGroupedCircuitIdentifier("lighting", 1, 2),
|
||||
"-1F1.2"
|
||||
);
|
||||
assert.equal(
|
||||
formatGroupedCircuitIdentifier("single_phase", 3, 7),
|
||||
"-2F3.7"
|
||||
);
|
||||
assert.equal(
|
||||
formatGroupedCircuitIdentifier("three_phase", 2, 1),
|
||||
"-3F2.1"
|
||||
);
|
||||
});
|
||||
|
||||
it("parses circuit and group component identifiers", () => {
|
||||
assert.deepEqual(parseGroupedEquipmentIdentifier("-1F2.4"), {
|
||||
category: "lighting",
|
||||
groupNumber: 2,
|
||||
kind: "circuit",
|
||||
circuitNumber: 4,
|
||||
});
|
||||
assert.deepEqual(parseGroupedEquipmentIdentifier("-2F3.0"), {
|
||||
category: "single_phase",
|
||||
groupNumber: 3,
|
||||
kind: "group_upstream_protection",
|
||||
circuitNumber: null,
|
||||
});
|
||||
assert.deepEqual(parseGroupedEquipmentIdentifier("-3Q1.0"), {
|
||||
category: "three_phase",
|
||||
groupNumber: 1,
|
||||
kind: "group_rcd",
|
||||
circuitNumber: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects legacy, malformed and unsupported identifiers", () => {
|
||||
for (const identifier of [
|
||||
"-1F1",
|
||||
"1F1.1",
|
||||
"-1Q1.2",
|
||||
"-4F1.1",
|
||||
"-1F0.1",
|
||||
"-1F1.01",
|
||||
]) {
|
||||
assert.equal(parseGroupedEquipmentIdentifier(identifier), null);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the highest target-group suffix plus one without filling gaps", () => {
|
||||
assert.equal(
|
||||
getNextGroupedCircuitIdentifier("lighting", 2, [
|
||||
"-1F2.1",
|
||||
"-1F2.3",
|
||||
"-1F2.7",
|
||||
"-1F1.12",
|
||||
"-2F2.9",
|
||||
"-1F2.0",
|
||||
"-1Q2.0",
|
||||
"-1F8",
|
||||
]),
|
||||
"-1F2.8"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the highest group number in the selected category plus one", () => {
|
||||
assert.equal(
|
||||
getNextCircuitGroupNumber("lighting", [
|
||||
{ category: "lighting", groupNumber: 1 },
|
||||
{ category: "lighting", groupNumber: 4 },
|
||||
{ category: "single_phase", groupNumber: 9 },
|
||||
]),
|
||||
5
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-positive group and circuit numbers", () => {
|
||||
assert.throws(
|
||||
() => formatGroupedCircuitIdentifier("lighting", 0, 1),
|
||||
RangeError
|
||||
);
|
||||
assert.throws(
|
||||
() => formatGroupedCircuitIdentifier("lighting", 1, 0),
|
||||
RangeError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function groupSection(
|
||||
id: string,
|
||||
category: "lighting" | "single_phase" | "three_phase",
|
||||
groupNumber: number,
|
||||
sortOrder: number
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
key: id,
|
||||
displayName: id,
|
||||
prefix: `-${category === "lighting" ? 1 : category === "single_phase" ? 2 : 3}F${groupNumber}.`,
|
||||
sortOrder,
|
||||
category,
|
||||
groupNumber,
|
||||
sectionTotalPower: 0,
|
||||
components: [],
|
||||
circuits: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("circuit group renumber planning", () => {
|
||||
const groups = [
|
||||
{
|
||||
id: "lighting-1",
|
||||
category: "lighting" as const,
|
||||
groupNumber: 1,
|
||||
prefix: "-1F1.",
|
||||
circuits: [
|
||||
{ id: "circuit-1", equipmentIdentifier: "-1F1.1" },
|
||||
{ id: "circuit-2", equipmentIdentifier: "-1F1.7" },
|
||||
],
|
||||
components: [
|
||||
{
|
||||
id: "fuse-1",
|
||||
role: "group_upstream_protection" as const,
|
||||
equipmentIdentifier: "-1F1.0",
|
||||
},
|
||||
{
|
||||
id: "rcd-1",
|
||||
role: "group_residual_current_protection" as const,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lighting-2",
|
||||
category: "lighting" as const,
|
||||
groupNumber: 2,
|
||||
prefix: "-1F2.",
|
||||
circuits: [
|
||||
{ id: "circuit-3", equipmentIdentifier: "-1F2.4" },
|
||||
],
|
||||
components: [],
|
||||
},
|
||||
{
|
||||
id: "single-1",
|
||||
category: "single_phase" as const,
|
||||
groupNumber: 1,
|
||||
prefix: "-2F1.",
|
||||
circuits: [],
|
||||
components: [],
|
||||
},
|
||||
];
|
||||
|
||||
it("preserves circuit suffixes and maps optional group components", () => {
|
||||
const plan = createCircuitGroupRenumberPlan(groups, [
|
||||
{ groupId: "lighting-1", targetGroupNumber: 3 },
|
||||
]);
|
||||
assert.deepEqual(plan.groups[0], {
|
||||
groupId: "lighting-1",
|
||||
category: "lighting",
|
||||
expectedGroupNumber: 1,
|
||||
targetGroupNumber: 3,
|
||||
expectedPrefix: "-1F1.",
|
||||
targetPrefix: "-1F3.",
|
||||
circuits: [
|
||||
{
|
||||
circuitId: "circuit-1",
|
||||
expectedEquipmentIdentifier: "-1F1.1",
|
||||
targetEquipmentIdentifier: "-1F3.1",
|
||||
},
|
||||
{
|
||||
circuitId: "circuit-2",
|
||||
expectedEquipmentIdentifier: "-1F1.7",
|
||||
targetEquipmentIdentifier: "-1F3.7",
|
||||
},
|
||||
],
|
||||
components: [
|
||||
{
|
||||
componentId: "fuse-1",
|
||||
expectedEquipmentIdentifier: "-1F1.0",
|
||||
targetEquipmentIdentifier: "-1F3.0",
|
||||
},
|
||||
{
|
||||
componentId: "rcd-1",
|
||||
expectedEquipmentIdentifier: "-1Q1.0",
|
||||
targetEquipmentIdentifier: "-1Q3.0",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a number swap when every affected group participates", () => {
|
||||
const plan = createCircuitGroupRenumberPlan(groups, [
|
||||
{ groupId: "lighting-1", targetGroupNumber: 2 },
|
||||
{ groupId: "lighting-2", targetGroupNumber: 1 },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
plan.groups.map((group) => [
|
||||
group.expectedGroupNumber,
|
||||
group.targetGroupNumber,
|
||||
]),
|
||||
[
|
||||
[1, 2],
|
||||
[2, 1],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects collisions, no-op requests and mismatched current BMKs", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupRenumberPlan(groups, [
|
||||
{ groupId: "lighting-1", targetGroupNumber: 2 },
|
||||
]),
|
||||
/unchanged group/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupRenumberPlan(groups, [
|
||||
{ groupId: "lighting-1", targetGroupNumber: 1 },
|
||||
]),
|
||||
/must change/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupRenumberPlan(
|
||||
[
|
||||
{
|
||||
...groups[0],
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit-1",
|
||||
equipmentIdentifier: "-1F9.1",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[{ groupId: "lighting-1", targetGroupNumber: 3 }]
|
||||
),
|
||||
/Circuit BMK does not match/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("circuit group move planning", () => {
|
||||
const sourceGroup = {
|
||||
id: "lighting-1",
|
||||
category: "lighting" as const,
|
||||
groupNumber: 1,
|
||||
prefix: "-1F1.",
|
||||
};
|
||||
const targetGroup = {
|
||||
id: "lighting-2",
|
||||
category: "lighting" as const,
|
||||
groupNumber: 2,
|
||||
prefix: "-1F2.",
|
||||
};
|
||||
|
||||
it("uses the highest target suffix plus one and stores both positions", () => {
|
||||
assert.deepEqual(
|
||||
createCircuitGroupMovePlan({
|
||||
circuit: {
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-1",
|
||||
equipmentIdentifier: "-1F1.7",
|
||||
sortOrder: 20,
|
||||
},
|
||||
sourceGroup,
|
||||
targetGroup,
|
||||
targetCircuitEquipmentIdentifiers: [
|
||||
"-1F2.1",
|
||||
"-1F2.4",
|
||||
"-1F2.9",
|
||||
],
|
||||
targetSortOrder: 40,
|
||||
}),
|
||||
{
|
||||
circuitId: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
expectedSectionId: "lighting-1",
|
||||
targetSectionId: "lighting-2",
|
||||
expectedEquipmentIdentifier: "-1F1.7",
|
||||
targetEquipmentIdentifier: "-1F2.10",
|
||||
expectedSortOrder: 20,
|
||||
targetSortOrder: 40,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fill target gaps", () => {
|
||||
assert.equal(
|
||||
createCircuitGroupMovePlan({
|
||||
circuit: {
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-1",
|
||||
equipmentIdentifier: "-1F1.2",
|
||||
sortOrder: 10,
|
||||
},
|
||||
sourceGroup,
|
||||
targetGroup,
|
||||
targetCircuitEquipmentIdentifiers: ["-1F2.1", "-1F2.3"],
|
||||
targetSortOrder: 20,
|
||||
}).targetEquipmentIdentifier,
|
||||
"-1F2.4"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects cross-category, same-group and mismatched source moves", () => {
|
||||
const base = {
|
||||
circuit: {
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-1",
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
sortOrder: 10,
|
||||
},
|
||||
sourceGroup,
|
||||
targetGroup,
|
||||
targetCircuitEquipmentIdentifiers: [] as string[],
|
||||
targetSortOrder: 10,
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupMovePlan({
|
||||
...base,
|
||||
targetGroup: {
|
||||
id: "single-1",
|
||||
category: "single_phase",
|
||||
groupNumber: 1,
|
||||
prefix: "-2F1.",
|
||||
},
|
||||
}),
|
||||
/same category/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupMovePlan({
|
||||
...base,
|
||||
targetGroup: sourceGroup,
|
||||
}),
|
||||
/different groups/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupMovePlan({
|
||||
...base,
|
||||
circuit: {
|
||||
...base.circuit,
|
||||
equipmentIdentifier: "-1F9.1",
|
||||
},
|
||||
}),
|
||||
/does not match/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import { createCircuitGroupRenumberProjectCommand } from "../src/domain/models/circuit-group-renumber-project-command.model.js";
|
||||
import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values({ id: "project-1", name: "Projekt" })
|
||||
.run();
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
const list = context.db.select().from(circuitLists).get()!;
|
||||
const first = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.category, "lighting"))
|
||||
.get()!;
|
||||
context.db
|
||||
.insert(circuitSections)
|
||||
.values({
|
||||
id: "lighting-2",
|
||||
circuitListId: list.id,
|
||||
key: "lighting_2",
|
||||
displayName: "Beleuchtung 2",
|
||||
prefix: "-1F2.",
|
||||
sortOrder: 40,
|
||||
category: "lighting",
|
||||
groupNumber: 2,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values([
|
||||
{
|
||||
id: "circuit-lighting-1",
|
||||
circuitListId: list.id,
|
||||
sectionId: first.id,
|
||||
equipmentIdentifier: "-1F1.7",
|
||||
displayName: "Licht 1",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: "circuit-lighting-2",
|
||||
circuitListId: list.id,
|
||||
sectionId: "lighting-2",
|
||||
equipmentIdentifier: "-1F2.4",
|
||||
displayName: "Licht 2",
|
||||
sortOrder: 10,
|
||||
},
|
||||
])
|
||||
.run();
|
||||
context.db
|
||||
.insert(distributionBoardComponents)
|
||||
.values([
|
||||
{
|
||||
id: "lighting-1-fuse",
|
||||
circuitListId: list.id,
|
||||
sectionId: first.id,
|
||||
equipmentIdentifier: "-1F1.0",
|
||||
name: "Vorsicherung",
|
||||
role: "group_upstream_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: "lighting-1-rcd",
|
||||
circuitListId: list.id,
|
||||
sectionId: first.id,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 20,
|
||||
},
|
||||
])
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
function createSwapCommand(context: DatabaseContext) {
|
||||
const list = context.db.select().from(circuitLists).get()!;
|
||||
const groups = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.category, "lighting"))
|
||||
.all()
|
||||
.sort((left, right) => left.groupNumber! - right.groupNumber!)
|
||||
.map((group) => ({
|
||||
id: group.id,
|
||||
category: group.category!,
|
||||
groupNumber: group.groupNumber!,
|
||||
prefix: group.prefix,
|
||||
circuits: context.db
|
||||
.select({
|
||||
id: circuits.id,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
})
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, group.id))
|
||||
.all(),
|
||||
components: context.db
|
||||
.select({
|
||||
id: distributionBoardComponents.id,
|
||||
role: distributionBoardComponents.role,
|
||||
equipmentIdentifier:
|
||||
distributionBoardComponents.equipmentIdentifier,
|
||||
})
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.sectionId, group.id))
|
||||
.all()
|
||||
.map((component) => ({
|
||||
...component,
|
||||
role: component.role as
|
||||
| "group_upstream_protection"
|
||||
| "group_residual_current_protection",
|
||||
})),
|
||||
}));
|
||||
return createCircuitGroupRenumberProjectCommand(
|
||||
list.id,
|
||||
createCircuitGroupRenumberPlan(groups, [
|
||||
{ groupId: groups[0].id, targetGroupNumber: 2 },
|
||||
{ groupId: groups[1].id, targetGroupNumber: 1 },
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
describe("circuit-group renumber project command", () => {
|
||||
it("swaps group numbers and every derived BMK through undo and redo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const command = createSwapCommand(context);
|
||||
const repository =
|
||||
new CircuitGroupRenumberProjectCommandRepository(context.db);
|
||||
const executed = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, command.payload.groups[0].groupId))
|
||||
.get()?.groupNumber,
|
||||
2
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-lighting-1"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F2.7"
|
||||
);
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select({ value: distributionBoardComponents.equipmentIdentifier })
|
||||
.from(distributionBoardComponents)
|
||||
.where(
|
||||
eq(
|
||||
distributionBoardComponents.sectionId,
|
||||
command.payload.groups[0].groupId
|
||||
)
|
||||
)
|
||||
.all()
|
||||
.map(({ value }) => value)
|
||||
.sort(),
|
||||
["-1F2.0", "-1Q2.0"]
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: executed.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-lighting-1"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F1.7"
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "redo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "redo")?.changeSetId,
|
||||
command,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-lighting-2"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F1.4"
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects incomplete child snapshots without partial changes", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const command = createSwapCommand(context);
|
||||
command.payload.groups[0].components.pop();
|
||||
assert.throws(
|
||||
() =>
|
||||
new CircuitGroupRenumberProjectCommandRepository(
|
||||
context.db
|
||||
).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
}),
|
||||
/include every unchanged child/
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-lighting-1"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F1.7"
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back every group and BMK when history persistence fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const command = createSwapCommand(context);
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_group_renumber_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced group renumber history failure');
|
||||
END;
|
||||
`);
|
||||
assert.throws(
|
||||
() =>
|
||||
new CircuitGroupRenumberProjectCommandRepository(
|
||||
context.db
|
||||
).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
}),
|
||||
/forced group renumber history failure/
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "circuit-lighting-1"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1F1.7"
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.id, "lighting-1-rcd"))
|
||||
.get()?.equipmentIdentifier,
|
||||
"-1Q1.0"
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import {
|
||||
createCircuitGroupDeleteProjectCommand,
|
||||
createCircuitGroupInsertProjectCommand,
|
||||
createCircuitGroupReorderProjectCommand,
|
||||
createCircuitGroupUpdateProjectCommand,
|
||||
type CircuitGroupSnapshot,
|
||||
} from "../src/domain/models/circuit-group-structure-project-command.model.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values([
|
||||
{ id: "project-1", name: "Projekt" },
|
||||
{ id: "project-2", name: "Fremdprojekt" },
|
||||
])
|
||||
.run();
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-2", "UV-02");
|
||||
return context;
|
||||
}
|
||||
|
||||
function getProjectList(context: DatabaseContext, projectId: string) {
|
||||
const list = context.db
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.get();
|
||||
assert.ok(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
function groupSnapshot(circuitListId: string): CircuitGroupSnapshot {
|
||||
return {
|
||||
id: "group-lighting-2",
|
||||
circuitListId,
|
||||
key: "lighting_2",
|
||||
displayName: "Beleuchtung 2",
|
||||
prefix: "-1F2.",
|
||||
sortOrder: 40,
|
||||
category: "lighting",
|
||||
groupNumber: 2,
|
||||
};
|
||||
}
|
||||
|
||||
describe("circuit-group structure project command", () => {
|
||||
it("inserts, renames and restores a group through persistent history", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const expected = groupSnapshot(
|
||||
getProjectList(context, "project-1").id
|
||||
);
|
||||
const target = {
|
||||
...expected,
|
||||
displayName: "Beleuchtung Besprechungsräume",
|
||||
};
|
||||
const repository =
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(expected),
|
||||
});
|
||||
const updated = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitGroupUpdateProjectCommand(
|
||||
expected,
|
||||
target
|
||||
),
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, expected.id))
|
||||
.get()?.displayName,
|
||||
target.displayName
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: updated.inverse,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, expected.id))
|
||||
.get(),
|
||||
expected
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes only an exact empty group and restores the same UUID", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const snapshot = groupSnapshot(
|
||||
getProjectList(context, "project-1").id
|
||||
);
|
||||
const repository =
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||
});
|
||||
const deleted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitGroupDeleteProjectCommand(snapshot),
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: deleted.inverse,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.get(),
|
||||
snapshot
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects foreign lists, invalid prefixes, stale updates and populated deletes", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const snapshot = groupSnapshot(
|
||||
getProjectList(context, "project-1").id
|
||||
);
|
||||
const repository =
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupInsertProjectCommand({
|
||||
...snapshot,
|
||||
prefix: "-1F9.",
|
||||
}),
|
||||
/prefix does not match/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-2",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||
}),
|
||||
/does not belong/
|
||||
);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||
});
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values({
|
||||
id: "group-circuit",
|
||||
circuitListId: snapshot.circuitListId,
|
||||
sectionId: snapshot.id,
|
||||
equipmentIdentifier: "-1F2.1",
|
||||
displayName: "Beleuchtung",
|
||||
sortOrder: 10,
|
||||
})
|
||||
.run();
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitGroupDeleteProjectCommand(snapshot),
|
||||
}),
|
||||
/Only empty circuit groups/
|
||||
);
|
||||
context.db
|
||||
.update(circuitSections)
|
||||
.set({ displayName: "Direkt geändert" })
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.run();
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitGroupUpdateProjectCommand(snapshot, {
|
||||
...snapshot,
|
||||
displayName: "Ziel",
|
||||
}),
|
||||
}),
|
||||
/changed before update/
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
1
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back group insertion when history persistence fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const snapshot = groupSnapshot(
|
||||
getProjectList(context, "project-1").id
|
||||
);
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_group_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced group history failure');
|
||||
END;
|
||||
`);
|
||||
assert.throws(
|
||||
() =>
|
||||
new CircuitGroupStructureProjectCommandRepository(
|
||||
context.db
|
||||
).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||
}),
|
||||
/forced group history failure/
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("reorders every group atomically without changing group identity", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const circuitListId = getProjectList(context, "project-1").id;
|
||||
const groups = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||
.all();
|
||||
const expectedPrefixes = new Map(
|
||||
groups.map((group) => [group.id, group.prefix])
|
||||
);
|
||||
const assignments = groups.map((group, index) => ({
|
||||
groupId: group.id,
|
||||
expectedSortOrder: group.sortOrder,
|
||||
targetSortOrder: (groups.length - index) * 10,
|
||||
}));
|
||||
const repository =
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||
const reordered = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupReorderProjectCommand(
|
||||
circuitListId,
|
||||
assignments
|
||||
),
|
||||
});
|
||||
for (const assignment of assignments) {
|
||||
const group = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, assignment.groupId))
|
||||
.get();
|
||||
assert.equal(group?.sortOrder, assignment.targetSortOrder);
|
||||
assert.equal(
|
||||
group?.prefix,
|
||||
expectedPrefixes.get(assignment.groupId)
|
||||
);
|
||||
}
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: reordered.inverse,
|
||||
});
|
||||
for (const assignment of assignments) {
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, assignment.groupId))
|
||||
.get()?.sortOrder,
|
||||
assignment.expectedSortOrder
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects incomplete reorders and rolls back late failures", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const circuitListId = getProjectList(context, "project-1").id;
|
||||
const groups = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, circuitListId))
|
||||
.all();
|
||||
const assignments = groups.map((group, index) => ({
|
||||
groupId: group.id,
|
||||
expectedSortOrder: group.sortOrder,
|
||||
targetSortOrder: (groups.length - index) * 10,
|
||||
}));
|
||||
const repository =
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupReorderProjectCommand(
|
||||
circuitListId,
|
||||
assignments.slice(1)
|
||||
),
|
||||
}),
|
||||
/include every unchanged group/
|
||||
);
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_group_reorder_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced group reorder history failure');
|
||||
END;
|
||||
`);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupReorderProjectCommand(
|
||||
circuitListId,
|
||||
assignments
|
||||
),
|
||||
}),
|
||||
/forced group reorder history failure/
|
||||
);
|
||||
for (const assignment of assignments) {
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, assignment.groupId))
|
||||
.get()?.sortOrder,
|
||||
assignment.expectedSortOrder
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values({ id: "project-1", name: "Projekt" })
|
||||
.run();
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
const list = context.db.select().from(circuitLists).get()!;
|
||||
const group = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.category, "lighting"))
|
||||
.get()!;
|
||||
context.db
|
||||
.insert(distributionBoardComponents)
|
||||
.values({
|
||||
id: "subtree-rcd",
|
||||
circuitListId: list.id,
|
||||
sectionId: group.id,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(distributionBoardComponentProtectionDevices)
|
||||
.values({
|
||||
componentId: "subtree-rcd",
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values({
|
||||
id: "subtree-circuit",
|
||||
circuitListId: list.id,
|
||||
sectionId: group.id,
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
displayName: "Beleuchtung",
|
||||
sortOrder: 10,
|
||||
voltage: 230,
|
||||
isReserve: 0,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitProtectionDevices)
|
||||
.values({
|
||||
circuitId: "subtree-circuit",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
tripCharacteristic: "B",
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitDeviceRows)
|
||||
.values({
|
||||
id: "subtree-row",
|
||||
circuitId: "subtree-circuit",
|
||||
linkedProjectDeviceId: null,
|
||||
legacyConsumerId: null,
|
||||
sortOrder: 10,
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte",
|
||||
phaseType: "single_phase",
|
||||
connectionKind: null,
|
||||
costGroup: null,
|
||||
category: null,
|
||||
level: null,
|
||||
roomId: null,
|
||||
roomNumberSnapshot: null,
|
||||
roomNameSnapshot: null,
|
||||
quantity: 2,
|
||||
powerPerUnit: 0.03,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 0.95,
|
||||
remark: null,
|
||||
overriddenFields: "[\"displayName\"]",
|
||||
})
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
describe("circuit-group subtree project command", () => {
|
||||
it("deletes and restores the exact populated subtree through undo/redo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new CircuitGroupSubtreeProjectCommandRepository(context.db);
|
||||
const groupId = context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "subtree-circuit"))
|
||||
.get()!.sectionId;
|
||||
const snapshot = repository.capture("project-1", groupId);
|
||||
const command =
|
||||
createCircuitGroupDeleteSubtreeProjectCommand(snapshot);
|
||||
const deleted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, groupId))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, "subtree-row"))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: deleted.inverse,
|
||||
});
|
||||
assert.deepEqual(
|
||||
repository.capture("project-1", groupId),
|
||||
snapshot
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "redo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "redo")?.changeSetId,
|
||||
command,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "subtree-circuit"))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a subtree changed after confirmation", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new CircuitGroupSubtreeProjectCommandRepository(context.db);
|
||||
const groupId = context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "subtree-circuit"))
|
||||
.get()!.sectionId;
|
||||
const snapshot = repository.capture("project-1", groupId);
|
||||
context.db
|
||||
.update(circuitDeviceRows)
|
||||
.set({ displayName: "Geändert" })
|
||||
.where(eq(circuitDeviceRows.id, "subtree-row"))
|
||||
.run();
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
|
||||
}),
|
||||
/changed before deletion/
|
||||
);
|
||||
assert.ok(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, groupId))
|
||||
.get()
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back a cascaded deletion when history persistence fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new CircuitGroupSubtreeProjectCommandRepository(context.db);
|
||||
const groupId = context.db
|
||||
.select()
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, "subtree-circuit"))
|
||||
.get()!.sectionId;
|
||||
const snapshot = repository.capture("project-1", groupId);
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_group_subtree_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced group subtree history failure');
|
||||
END;
|
||||
`);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
|
||||
}),
|
||||
/forced group subtree history failure/
|
||||
);
|
||||
assert.deepEqual(
|
||||
repository.capture("project-1", groupId),
|
||||
snapshot
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
assertCircuitGroupSubtreeSnapshot,
|
||||
summarizeCircuitGroupDeletion,
|
||||
type CircuitGroupSubtreeSnapshot,
|
||||
} from "../src/domain/models/circuit-group-subtree-snapshot.model.js";
|
||||
|
||||
function createSnapshot(): CircuitGroupSubtreeSnapshot {
|
||||
return {
|
||||
group: {
|
||||
id: "group-1",
|
||||
circuitListId: "list-1",
|
||||
key: "lighting",
|
||||
displayName: "Beleuchtung 1",
|
||||
prefix: "-1F1.",
|
||||
sortOrder: 10,
|
||||
category: "lighting",
|
||||
groupNumber: 1,
|
||||
},
|
||||
components: [
|
||||
{
|
||||
component: {
|
||||
id: "fuse-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "group-1",
|
||||
equipmentIdentifier: "-1F1.0",
|
||||
name: "Vorsicherung",
|
||||
role: "group_upstream_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
},
|
||||
protectionDevice: {
|
||||
componentId: "fuse-1",
|
||||
type: "D02",
|
||||
ratedCurrentA: 35,
|
||||
fuseUtilizationCategory: "gG",
|
||||
tripCharacteristic: null,
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: {
|
||||
id: "rcd-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "group-1",
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 20,
|
||||
},
|
||||
protectionDevice: {
|
||||
componentId: "rcd-1",
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: null,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
},
|
||||
],
|
||||
circuits: [
|
||||
{
|
||||
circuit: {
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "group-1",
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
displayName: "Beleuchtung",
|
||||
sortOrder: 10,
|
||||
protectionType: null,
|
||||
protectionRatedCurrent: null,
|
||||
protectionCharacteristic: null,
|
||||
cableType: null,
|
||||
cableCrossSection: null,
|
||||
cableLength: null,
|
||||
rcdAssignment: null,
|
||||
terminalDesignation: null,
|
||||
voltage: 230,
|
||||
controlRequirement: null,
|
||||
status: null,
|
||||
isReserve: false,
|
||||
remark: null,
|
||||
deviceRows: [
|
||||
{
|
||||
id: "row-1",
|
||||
circuitId: "circuit-1",
|
||||
linkedProjectDeviceId: "device-1",
|
||||
legacyConsumerId: null,
|
||||
sortOrder: 10,
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte Flur",
|
||||
phaseType: "single_phase",
|
||||
connectionKind: null,
|
||||
costGroup: "440",
|
||||
category: "Licht",
|
||||
level: "EG",
|
||||
roomId: "room-1",
|
||||
roomNumberSnapshot: "001",
|
||||
roomNameSnapshot: "Flur",
|
||||
quantity: 2,
|
||||
powerPerUnit: 0.03,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 0.95,
|
||||
remark: null,
|
||||
overriddenFields: "[\"displayName\"]",
|
||||
},
|
||||
],
|
||||
},
|
||||
protectionDevice: {
|
||||
circuitId: "circuit-1",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("circuit-group subtree snapshot", () => {
|
||||
it("validates complete relations and derives the deletion warning summary", () => {
|
||||
const snapshot = createSnapshot();
|
||||
assert.doesNotThrow(() =>
|
||||
assertCircuitGroupSubtreeSnapshot(snapshot)
|
||||
);
|
||||
assert.deepEqual(summarizeCircuitGroupDeletion(snapshot), {
|
||||
hasUpstreamProtection: true,
|
||||
hasResidualCurrentProtection: true,
|
||||
circuitCount: 1,
|
||||
deviceRowCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects foreign children, duplicate BMKs and invalid protection", () => {
|
||||
const foreign = createSnapshot();
|
||||
foreign.circuits[0].circuit.sectionId = "group-2";
|
||||
assert.throws(
|
||||
() => assertCircuitGroupSubtreeSnapshot(foreign),
|
||||
/ownership/
|
||||
);
|
||||
|
||||
const duplicate = createSnapshot();
|
||||
duplicate.circuits[0].circuit.equipmentIdentifier = "-1F1.0";
|
||||
assert.throws(
|
||||
() => assertCircuitGroupSubtreeSnapshot(duplicate),
|
||||
/duplicates/
|
||||
);
|
||||
|
||||
const invalidProtection = createSnapshot();
|
||||
invalidProtection.circuits[0].protectionDevice = {
|
||||
...invalidProtection.circuits[0].protectionDevice!,
|
||||
type: "AFDD",
|
||||
ratedResidualCurrentMa: 30,
|
||||
};
|
||||
assert.throws(
|
||||
() => assertCircuitGroupSubtreeSnapshot(invalidProtection),
|
||||
/configuration/
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves device link and override metadata", () => {
|
||||
const row = createSnapshot().circuits[0].circuit.deviceRows[0];
|
||||
assert.equal(row.linkedProjectDeviceId, "device-1");
|
||||
assert.equal(row.overriddenFields, "[\"displayName\"]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { CircuitProtectionProjectCommandRepository } from "../src/db/repositories/circuit-protection-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import {
|
||||
createCircuitProtectionUpdateProjectCommand,
|
||||
type CircuitProtectionSnapshot,
|
||||
} from "../src/domain/models/circuit-protection-project-command.model.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values([
|
||||
{ id: "project-1", name: "Projekt" },
|
||||
{ id: "project-2", name: "Fremdprojekt" },
|
||||
])
|
||||
.run();
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
const list = context.db.select().from(circuitLists).get();
|
||||
assert.ok(list);
|
||||
const section = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, list.id))
|
||||
.get();
|
||||
assert.ok(section);
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values({
|
||||
id: "circuit-1",
|
||||
circuitListId: list.id,
|
||||
sectionId: section.id,
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
sortOrder: 10,
|
||||
})
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
const protection: CircuitProtectionSnapshot = {
|
||||
circuitId: "circuit-1",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
};
|
||||
|
||||
describe("circuit-protection project command", () => {
|
||||
it("inserts, updates and restores protection through exact inverse commands", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository = new CircuitProtectionProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const history = new ProjectHistoryRepository(context.db);
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitProtectionUpdateProjectCommand(
|
||||
"circuit-1",
|
||||
null,
|
||||
protection
|
||||
),
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db.select().from(circuitProtectionDevices).get(),
|
||||
protection
|
||||
);
|
||||
const target = { ...protection, ratedCurrentA: 16 };
|
||||
const updated = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitProtectionUpdateProjectCommand(
|
||||
"circuit-1",
|
||||
protection,
|
||||
target
|
||||
),
|
||||
});
|
||||
assert.equal(
|
||||
context.db.select().from(circuitProtectionDevices).get()
|
||||
?.ratedCurrentA,
|
||||
16
|
||||
);
|
||||
const updateUndo = history.getNextCommand("project-1", "undo");
|
||||
assert.ok(updateUndo);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: updateUndo.changeSetId,
|
||||
command: updated.inverse,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db.select().from(circuitProtectionDevices).get(),
|
||||
protection
|
||||
);
|
||||
const insertUndo = history.getNextCommand("project-1", "undo");
|
||||
assert.ok(insertUndo);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 3,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: insertUndo.changeSetId,
|
||||
command: inserted.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
context.db.select().from(circuitProtectionDevices).get(),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects stale state, foreign ownership and user removal", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository = new CircuitProtectionProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-2",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitProtectionUpdateProjectCommand(
|
||||
"circuit-1",
|
||||
null,
|
||||
protection
|
||||
),
|
||||
}),
|
||||
/does not belong/
|
||||
);
|
||||
context.db.insert(circuitProtectionDevices).values(protection).run();
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitProtectionUpdateProjectCommand(
|
||||
"circuit-1",
|
||||
null,
|
||||
protection
|
||||
),
|
||||
}),
|
||||
/changed before/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitProtectionUpdateProjectCommand(
|
||||
"circuit-1",
|
||||
protection,
|
||||
null
|
||||
),
|
||||
}),
|
||||
/cannot remove/
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -65,12 +65,22 @@ describe("circuit structure command adapters", () => {
|
||||
controlRequirement: "DALI",
|
||||
},
|
||||
deviceRows: [],
|
||||
category: "single_phase",
|
||||
});
|
||||
|
||||
assert.equal(reserve.isReserve, true);
|
||||
assert.equal(reserve.displayName, null);
|
||||
assert.equal(reserve.voltage, 230);
|
||||
assert.equal(reserve.controlRequirement, "DALI");
|
||||
assert.deepEqual(reserve.protectionDevice, {
|
||||
circuitId: "circuit-1",
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
});
|
||||
|
||||
const row = buildCircuitDeviceRowInsertSnapshot({
|
||||
id: "row-1",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CircuitStructureProjectCommandRepository } from "../src/db/repositories
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||
@@ -165,6 +166,18 @@ function createTestDatabase(): TestFixture {
|
||||
},
|
||||
])
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitProtectionDevices)
|
||||
.values({
|
||||
circuitId: "circuit-existing",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
})
|
||||
.run();
|
||||
|
||||
return {
|
||||
context,
|
||||
@@ -377,6 +390,11 @@ describe("circuit structure project-command repository", () => {
|
||||
"circuit-existing"
|
||||
);
|
||||
const beforeRows = getRows(fixture.context, "circuit-existing");
|
||||
const beforeProtection = fixture.context.db
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
|
||||
.get();
|
||||
assert.ok(beforeCircuit);
|
||||
const store = new CircuitStructureProjectCommandRepository(
|
||||
fixture.context.db
|
||||
@@ -395,6 +413,14 @@ describe("circuit structure project-command repository", () => {
|
||||
undefined
|
||||
);
|
||||
assert.equal(getRows(fixture.context, "circuit-existing").length, 0);
|
||||
assert.equal(
|
||||
fixture.context.db
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
|
||||
store.execute({
|
||||
projectId: "project-1",
|
||||
@@ -411,6 +437,14 @@ describe("circuit structure project-command repository", () => {
|
||||
getRows(fixture.context, "circuit-existing"),
|
||||
beforeRows
|
||||
);
|
||||
assert.deepEqual(
|
||||
fixture.context.db
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
|
||||
.get(),
|
||||
beforeProtection
|
||||
);
|
||||
} finally {
|
||||
fixture.context.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { buildCircuitStructureProjection } from "../src/frontend/utils/circuit-structure-projection.js";
|
||||
import type {
|
||||
CircuitTreeCircuitDto,
|
||||
CircuitTreeComponentDto,
|
||||
CircuitTreeSectionDto,
|
||||
} from "../src/frontend/types.js";
|
||||
|
||||
function component(
|
||||
id: string,
|
||||
placement: CircuitTreeComponentDto["placement"],
|
||||
sortOrder: number,
|
||||
sectionId?: string
|
||||
): CircuitTreeComponentDto {
|
||||
return {
|
||||
id,
|
||||
circuitListId: "list-1",
|
||||
sectionId,
|
||||
equipmentIdentifier: `-${id}`,
|
||||
name: id,
|
||||
role:
|
||||
placement === "header"
|
||||
? "main_switch"
|
||||
: placement === "footer"
|
||||
? "auxiliary"
|
||||
: "group_residual_current_protection",
|
||||
placement,
|
||||
sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
function circuit(
|
||||
id: string,
|
||||
sectionId: string
|
||||
): CircuitTreeCircuitDto {
|
||||
return {
|
||||
id,
|
||||
circuitListId: "list-1",
|
||||
sectionId,
|
||||
equipmentIdentifier: `-${id}`,
|
||||
sortOrder: 10,
|
||||
isReserve: true,
|
||||
circuitTotalPower: 0,
|
||||
deviceRows: [],
|
||||
};
|
||||
}
|
||||
|
||||
function section(
|
||||
id: string,
|
||||
sortOrder: number
|
||||
): CircuitTreeSectionDto {
|
||||
return {
|
||||
id,
|
||||
key: id,
|
||||
displayName: id,
|
||||
prefix: "-1F1.",
|
||||
sortOrder,
|
||||
category: "lighting",
|
||||
groupNumber: sortOrder / 10,
|
||||
sectionTotalPower: 0,
|
||||
components: [
|
||||
component(`${id}-rcd-late`, "group", 20, id),
|
||||
component(`${id}-rcd-first`, "group", 10, id),
|
||||
],
|
||||
circuits: [circuit(`${id}-circuit`, id)],
|
||||
};
|
||||
}
|
||||
|
||||
describe("circuit structure projection", () => {
|
||||
it("projects fixed header, complete groups and auxiliary footer in stable order", () => {
|
||||
const rows = buildCircuitStructureProjection({
|
||||
headerComponents: [
|
||||
component("surge", "header", 20),
|
||||
component("main", "header", 10),
|
||||
],
|
||||
sections: [section("group-2", 20), section("group-1", 10)],
|
||||
footerComponents: [
|
||||
component("actor-2", "footer", 20),
|
||||
component("actor-1", "footer", 10),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.rowKey),
|
||||
[
|
||||
"header-component:main",
|
||||
"header-component:surge",
|
||||
"group:group-1",
|
||||
"group-component:group-1-rcd-first",
|
||||
"group-component:group-1-rcd-late",
|
||||
"circuit-block:group-1-circuit",
|
||||
"group-placeholder:group-1",
|
||||
"group:group-2",
|
||||
"group-component:group-2-rcd-first",
|
||||
"group-component:group-2-rcd-late",
|
||||
"circuit-block:group-2-circuit",
|
||||
"group-placeholder:group-2",
|
||||
"footer-component:actor-1",
|
||||
"footer-component:actor-2",
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps structural rows around an empty group", () => {
|
||||
const empty = section("group-empty", 10);
|
||||
empty.components = [];
|
||||
empty.circuits = [];
|
||||
assert.deepEqual(
|
||||
buildCircuitStructureProjection({
|
||||
headerComponents: [],
|
||||
sections: [empty],
|
||||
footerComponents: [],
|
||||
}).map((row) => row.rowType),
|
||||
["groupHeader", "groupPlaceholder"]
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps circuit device rows inside their circuit block", () => {
|
||||
const grouped = section("group-1", 10);
|
||||
grouped.circuits[0].deviceRows = [
|
||||
{
|
||||
id: "device-1",
|
||||
sortOrder: 10,
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte",
|
||||
quantity: 1,
|
||||
powerPerUnit: 0.1,
|
||||
simultaneityFactor: 1,
|
||||
rowTotalPower: 0.1,
|
||||
},
|
||||
];
|
||||
const block = buildCircuitStructureProjection({
|
||||
headerComponents: [],
|
||||
sections: [grouped],
|
||||
footerComponents: [],
|
||||
}).find((row) => row.rowType === "circuitBlock");
|
||||
assert.equal(
|
||||
block?.rowType === "circuitBlock"
|
||||
? block.circuit.deviceRows[0].id
|
||||
: null,
|
||||
"device-1"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,19 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { isMissingCircuitTreeSchemaError } from "../src/server/controllers/circuit-tree.controller.js";
|
||||
import {
|
||||
getCircuitTree,
|
||||
isMissingCircuitTreeSchemaError,
|
||||
} from "../src/server/controllers/circuit-tree.controller.js";
|
||||
import {
|
||||
circuitDeviceRowRepository,
|
||||
circuitListRepository,
|
||||
circuitProtectionDeviceRepository,
|
||||
circuitRepository,
|
||||
circuitSectionRepository,
|
||||
distributionBoardComponentRepository,
|
||||
distributionBoardRepository,
|
||||
projectRepository,
|
||||
} from "../src/server/composition/application-repositories.js";
|
||||
|
||||
describe("circuit tree controller", () => {
|
||||
it("detects missing circuit-first schema errors", () => {
|
||||
@@ -13,7 +26,192 @@ describe("circuit tree controller", () => {
|
||||
isMissingCircuitTreeSchemaError(new Error("SqliteError: no such table: circuit_device_rows")),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isMissingCircuitTreeSchemaError(
|
||||
new Error("SqliteError: no such table: distribution_board_components")
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(isMissingCircuitTreeSchemaError(new Error("Some other error")), false);
|
||||
});
|
||||
|
||||
it("returns header, grouped and footer components with both protection layers", async () => {
|
||||
const originals = {
|
||||
findList: circuitListRepository.findById,
|
||||
findProject: projectRepository.findById,
|
||||
findBoard: distributionBoardRepository.findById,
|
||||
listSections: circuitSectionRepository.listByCircuitList,
|
||||
listCircuits: circuitRepository.listByCircuitList,
|
||||
listRows: circuitDeviceRowRepository.listByCircuitList,
|
||||
listComponents:
|
||||
distributionBoardComponentRepository.listByCircuitList,
|
||||
listComponentProtection:
|
||||
distributionBoardComponentRepository.listProtectionByComponentIds,
|
||||
listCircuitProtection:
|
||||
circuitProtectionDeviceRepository.listByCircuitIds,
|
||||
};
|
||||
circuitListRepository.findById = async () =>
|
||||
({
|
||||
id: "list-1",
|
||||
projectId: "project-1",
|
||||
distributionBoardId: "board-1",
|
||||
name: "UV-01",
|
||||
}) as never;
|
||||
projectRepository.findById = async () =>
|
||||
({
|
||||
id: "project-1",
|
||||
name: "Projekt",
|
||||
currentRevision: 4,
|
||||
singlePhaseVoltageV: 230,
|
||||
threePhaseVoltageV: 400,
|
||||
}) as never;
|
||||
distributionBoardRepository.findById = async () =>
|
||||
({ id: "board-1", simultaneityFactor: 0.8 }) as never;
|
||||
circuitSectionRepository.listByCircuitList = async () =>
|
||||
[
|
||||
{
|
||||
id: "group-1",
|
||||
circuitListId: "list-1",
|
||||
key: "lighting",
|
||||
displayName: "Beleuchtung 1",
|
||||
prefix: "-1F1.",
|
||||
sortOrder: 10,
|
||||
category: "lighting",
|
||||
groupNumber: 1,
|
||||
},
|
||||
] as never;
|
||||
circuitRepository.listByCircuitList = async () =>
|
||||
[
|
||||
{
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "group-1",
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
displayName: "Licht",
|
||||
sortOrder: 10,
|
||||
isReserve: 0,
|
||||
},
|
||||
] as never;
|
||||
circuitDeviceRowRepository.listByCircuitList = async () => [];
|
||||
distributionBoardComponentRepository.listByCircuitList =
|
||||
async () =>
|
||||
[
|
||||
{
|
||||
id: "main",
|
||||
circuitListId: "list-1",
|
||||
sectionId: null,
|
||||
equipmentIdentifier: "-Q0",
|
||||
name: "Hauptschalter",
|
||||
role: "main_switch",
|
||||
placement: "header",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: "rcd",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "group-1",
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: "actor",
|
||||
circuitListId: "list-1",
|
||||
sectionId: null,
|
||||
equipmentIdentifier: "-K1",
|
||||
name: "KNX Aktor",
|
||||
role: "auxiliary",
|
||||
placement: "footer",
|
||||
sortOrder: 10,
|
||||
},
|
||||
] as never;
|
||||
distributionBoardComponentRepository.listProtectionByComponentIds =
|
||||
async () =>
|
||||
[
|
||||
{
|
||||
componentId: "rcd",
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: null,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
] as never;
|
||||
circuitProtectionDeviceRepository.listByCircuitIds = async () =>
|
||||
[
|
||||
{
|
||||
circuitId: "circuit-1",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
},
|
||||
] as never;
|
||||
|
||||
let responseBody: unknown;
|
||||
const response = {
|
||||
status() {
|
||||
return this;
|
||||
},
|
||||
json(value: unknown) {
|
||||
responseBody = value;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
try {
|
||||
await getCircuitTree(
|
||||
{ params: { projectId: "project-1", circuitListId: "list-1" } } as never,
|
||||
response as never
|
||||
);
|
||||
} finally {
|
||||
circuitListRepository.findById = originals.findList;
|
||||
projectRepository.findById = originals.findProject;
|
||||
distributionBoardRepository.findById = originals.findBoard;
|
||||
circuitSectionRepository.listByCircuitList =
|
||||
originals.listSections;
|
||||
circuitRepository.listByCircuitList = originals.listCircuits;
|
||||
circuitDeviceRowRepository.listByCircuitList = originals.listRows;
|
||||
distributionBoardComponentRepository.listByCircuitList =
|
||||
originals.listComponents;
|
||||
distributionBoardComponentRepository.listProtectionByComponentIds =
|
||||
originals.listComponentProtection;
|
||||
circuitProtectionDeviceRepository.listByCircuitIds =
|
||||
originals.listCircuitProtection;
|
||||
}
|
||||
|
||||
const tree = responseBody as {
|
||||
headerComponents: Array<{ equipmentIdentifier: string }>;
|
||||
footerComponents: Array<{ equipmentIdentifier: string }>;
|
||||
sections: Array<{
|
||||
category: string;
|
||||
groupNumber: number;
|
||||
components: Array<{
|
||||
protectionDevice: { ratedResidualCurrentMa: number };
|
||||
}>;
|
||||
circuits: Array<{
|
||||
protectionDevice: { tripCharacteristic: string };
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
assert.equal(tree.headerComponents[0].equipmentIdentifier, "-Q0");
|
||||
assert.equal(tree.footerComponents[0].equipmentIdentifier, "-K1");
|
||||
assert.equal(tree.sections[0].category, "lighting");
|
||||
assert.equal(tree.sections[0].groupNumber, 1);
|
||||
assert.equal(
|
||||
tree.sections[0].components[0].protectionDevice
|
||||
.ratedResidualCurrentMa,
|
||||
30
|
||||
);
|
||||
assert.equal(
|
||||
tree.sections[0].circuits[0].protectionDevice
|
||||
.tripCharacteristic,
|
||||
"B"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user