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