10 Commits

Author SHA1 Message Date
jappel ac21d6eb5d WIP multiple sections 2026-07-30 20:27:17 +02:00
jappel 7a9530a914 Expose protected tree structure 2026-07-30 20:14:45 +02:00
jappel 99bdf6491b Persist populated group deletion 2026-07-30 20:10:10 +02:00
jappel db4c757018 Define circuit group subtrees 2026-07-30 20:06:09 +02:00
jappel 3399fcd88b Persist circuit group moves 2026-07-30 20:03:11 +02:00
jappel 9ad91887b2 Plan circuit group moves 2026-07-30 19:58:45 +02:00
jappel 07925b2fd9 Persist circuit group renumbering 2026-07-30 19:56:59 +02:00
jappel 63650a623c Plan circuit group renumbering 2026-07-30 19:51:44 +02:00
jappel e702712bd8 Add circuit group reordering 2026-07-30 19:49:27 +02:00
jappel 1250f9a6af Add circuit group commands 2026-07-30 19:46:55 +02:00
43 changed files with 5254 additions and 11 deletions
+27
View File
@@ -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.
+47 -3
View File
@@ -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"
}
]
}
```
+60 -2
View File
@@ -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
+11 -2
View File
@@ -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);
}
+38
View File
@@ -44,6 +44,7 @@ export interface CircuitTreeCircuit {
remark?: string;
circuitTotalPower: number;
deviceRows: CircuitTreeDeviceRow[];
protectionDevice?: CircuitTreeProtectionDevice;
}
export interface CircuitTreeSectionBlock {
@@ -52,7 +53,10 @@ export interface CircuitTreeSectionBlock {
displayName: string;
prefix: string;
sortOrder: number;
category?: CircuitGroupCategory;
groupNumber?: number;
sectionTotalPower: number;
components: CircuitTreeComponent[];
circuits: CircuitTreeCircuit[];
}
@@ -64,7 +68,9 @@ export interface CircuitTreeResponse {
distributionBoardSimultaneityFactor: number;
distributionBoardTotalPower: number;
distributionBoardTotalPowerWithSimultaneityFactor: number;
headerComponents: CircuitTreeComponent[];
sections: CircuitTreeSectionBlock[];
footerComponents: CircuitTreeComponent[];
}
export interface LegacyMigrationReport {
@@ -78,3 +84,35 @@ export interface LegacyMigrationReport {
warnings: string[];
}
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import type {
DistributionBoardComponentPlacement,
DistributionBoardComponentRole,
} from "../../shared/constants/distribution-board-component.js";
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
export interface CircuitTreeProtectionDevice {
type: ProtectionDeviceType;
ratedCurrentA: number;
fuseUtilizationCategory?: FuseUtilizationCategory;
tripCharacteristic?: BreakerTripCharacteristic;
rcdType?: RcdType;
ratedResidualCurrentMa?: number;
}
export interface CircuitTreeComponent {
id: string;
circuitListId: string;
sectionId?: string;
equipmentIdentifier: string;
name: string;
role: DistributionBoardComponentRole;
placement: DistributionBoardComponentPlacement;
sortOrder: number;
protectionDevice?: CircuitTreeProtectionDevice;
}
@@ -0,0 +1,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({
+38
View File
@@ -1,4 +1,15 @@
import type { DistributionBoardSupplyType } from "../shared/constants/distribution-board";
import type { CircuitGroupCategory } from "../shared/constants/circuit-group";
import type {
DistributionBoardComponentPlacement,
DistributionBoardComponentRole,
} from "../shared/constants/distribution-board-component";
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../shared/constants/protection-device";
export interface ProjectDto {
id: string;
@@ -267,6 +278,27 @@ export interface CircuitTreeDeviceRowDto {
rowTotalPower: number;
}
export interface CircuitTreeProtectionDeviceDto {
type: ProtectionDeviceType;
ratedCurrentA: number;
fuseUtilizationCategory?: FuseUtilizationCategory;
tripCharacteristic?: BreakerTripCharacteristic;
rcdType?: RcdType;
ratedResidualCurrentMa?: number;
}
export interface CircuitTreeComponentDto {
id: string;
circuitListId: string;
sectionId?: string;
equipmentIdentifier: string;
name: string;
role: DistributionBoardComponentRole;
placement: DistributionBoardComponentPlacement;
sortOrder: number;
protectionDevice?: CircuitTreeProtectionDeviceDto;
}
export interface CircuitTreeCircuitDto {
id: string;
circuitListId: string;
@@ -289,6 +321,7 @@ export interface CircuitTreeCircuitDto {
remark?: string;
circuitTotalPower: number;
deviceRows: CircuitTreeDeviceRowDto[];
protectionDevice?: CircuitTreeProtectionDeviceDto;
}
export interface CircuitTreeSectionDto {
@@ -297,7 +330,10 @@ export interface CircuitTreeSectionDto {
displayName: string;
prefix: string;
sortOrder: number;
category?: CircuitGroupCategory;
groupNumber?: number;
sectionTotalPower: number;
components: CircuitTreeComponentDto[];
circuits: CircuitTreeCircuitDto[];
}
@@ -320,7 +356,9 @@ export interface CircuitTreeResponseDto {
distributionBoardSimultaneityFactor: number;
distributionBoardTotalPower: number;
distributionBoardTotalPowerWithSimultaneityFactor: number;
headerComponents: CircuitTreeComponentDto[];
sections: CircuitTreeSectionDto[];
footerComponents: CircuitTreeComponentDto[];
migrationReport?: CircuitTreeMigrationReportDto;
}
@@ -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,
};
}
+1
View File
@@ -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();
}
});
});
+256
View File
@@ -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\"]");
});
});
+144
View File
@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildCircuitStructureProjection } from "../src/frontend/utils/circuit-structure-projection.js";
import type {
CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeSectionDto,
} from "../src/frontend/types.js";
function component(
id: string,
placement: CircuitTreeComponentDto["placement"],
sortOrder: number,
sectionId?: string
): CircuitTreeComponentDto {
return {
id,
circuitListId: "list-1",
sectionId,
equipmentIdentifier: `-${id}`,
name: id,
role:
placement === "header"
? "main_switch"
: placement === "footer"
? "auxiliary"
: "group_residual_current_protection",
placement,
sortOrder,
};
}
function circuit(
id: string,
sectionId: string
): CircuitTreeCircuitDto {
return {
id,
circuitListId: "list-1",
sectionId,
equipmentIdentifier: `-${id}`,
sortOrder: 10,
isReserve: true,
circuitTotalPower: 0,
deviceRows: [],
};
}
function section(
id: string,
sortOrder: number
): CircuitTreeSectionDto {
return {
id,
key: id,
displayName: id,
prefix: "-1F1.",
sortOrder,
category: "lighting",
groupNumber: sortOrder / 10,
sectionTotalPower: 0,
components: [
component(`${id}-rcd-late`, "group", 20, id),
component(`${id}-rcd-first`, "group", 10, id),
],
circuits: [circuit(`${id}-circuit`, id)],
};
}
describe("circuit structure projection", () => {
it("projects fixed header, complete groups and auxiliary footer in stable order", () => {
const rows = buildCircuitStructureProjection({
headerComponents: [
component("surge", "header", 20),
component("main", "header", 10),
],
sections: [section("group-2", 20), section("group-1", 10)],
footerComponents: [
component("actor-2", "footer", 20),
component("actor-1", "footer", 10),
],
});
assert.deepEqual(
rows.map((row) => row.rowKey),
[
"header-component:main",
"header-component:surge",
"group:group-1",
"group-component:group-1-rcd-first",
"group-component:group-1-rcd-late",
"circuit-block:group-1-circuit",
"group-placeholder:group-1",
"group:group-2",
"group-component:group-2-rcd-first",
"group-component:group-2-rcd-late",
"circuit-block:group-2-circuit",
"group-placeholder:group-2",
"footer-component:actor-1",
"footer-component:actor-2",
]
);
});
it("keeps structural rows around an empty group", () => {
const empty = section("group-empty", 10);
empty.components = [];
empty.circuits = [];
assert.deepEqual(
buildCircuitStructureProjection({
headerComponents: [],
sections: [empty],
footerComponents: [],
}).map((row) => row.rowType),
["groupHeader", "groupPlaceholder"]
);
});
it("keeps circuit device rows inside their circuit block", () => {
const grouped = section("group-1", 10);
grouped.circuits[0].deviceRows = [
{
id: "device-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
rowTotalPower: 0.1,
},
];
const block = buildCircuitStructureProjection({
headerComponents: [],
sections: [grouped],
footerComponents: [],
}).find((row) => row.rowType === "circuitBlock");
assert.equal(
block?.rowType === "circuitBlock"
? block.circuit.deviceRows[0].id
: null,
"device-1"
);
});
});
+199 -1
View File
@@ -1,6 +1,19 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { isMissingCircuitTreeSchemaError } from "../src/server/controllers/circuit-tree.controller.js";
import {
getCircuitTree,
isMissingCircuitTreeSchemaError,
} from "../src/server/controllers/circuit-tree.controller.js";
import {
circuitDeviceRowRepository,
circuitListRepository,
circuitProtectionDeviceRepository,
circuitRepository,
circuitSectionRepository,
distributionBoardComponentRepository,
distributionBoardRepository,
projectRepository,
} from "../src/server/composition/application-repositories.js";
describe("circuit tree controller", () => {
it("detects missing circuit-first schema errors", () => {
@@ -13,7 +26,192 @@ describe("circuit tree controller", () => {
isMissingCircuitTreeSchemaError(new Error("SqliteError: no such table: circuit_device_rows")),
true
);
assert.equal(
isMissingCircuitTreeSchemaError(
new Error("SqliteError: no such table: distribution_board_components")
),
true
);
assert.equal(isMissingCircuitTreeSchemaError(new Error("Some other error")), false);
});
it("returns header, grouped and footer components with both protection layers", async () => {
const originals = {
findList: circuitListRepository.findById,
findProject: projectRepository.findById,
findBoard: distributionBoardRepository.findById,
listSections: circuitSectionRepository.listByCircuitList,
listCircuits: circuitRepository.listByCircuitList,
listRows: circuitDeviceRowRepository.listByCircuitList,
listComponents:
distributionBoardComponentRepository.listByCircuitList,
listComponentProtection:
distributionBoardComponentRepository.listProtectionByComponentIds,
listCircuitProtection:
circuitProtectionDeviceRepository.listByCircuitIds,
};
circuitListRepository.findById = async () =>
({
id: "list-1",
projectId: "project-1",
distributionBoardId: "board-1",
name: "UV-01",
}) as never;
projectRepository.findById = async () =>
({
id: "project-1",
name: "Projekt",
currentRevision: 4,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
}) as never;
distributionBoardRepository.findById = async () =>
({ id: "board-1", simultaneityFactor: 0.8 }) as never;
circuitSectionRepository.listByCircuitList = async () =>
[
{
id: "group-1",
circuitListId: "list-1",
key: "lighting",
displayName: "Beleuchtung 1",
prefix: "-1F1.",
sortOrder: 10,
category: "lighting",
groupNumber: 1,
},
] as never;
circuitRepository.listByCircuitList = async () =>
[
{
id: "circuit-1",
circuitListId: "list-1",
sectionId: "group-1",
equipmentIdentifier: "-1F1.1",
displayName: "Licht",
sortOrder: 10,
isReserve: 0,
},
] as never;
circuitDeviceRowRepository.listByCircuitList = async () => [];
distributionBoardComponentRepository.listByCircuitList =
async () =>
[
{
id: "main",
circuitListId: "list-1",
sectionId: null,
equipmentIdentifier: "-Q0",
name: "Hauptschalter",
role: "main_switch",
placement: "header",
sortOrder: 10,
},
{
id: "rcd",
circuitListId: "list-1",
sectionId: "group-1",
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 10,
},
{
id: "actor",
circuitListId: "list-1",
sectionId: null,
equipmentIdentifier: "-K1",
name: "KNX Aktor",
role: "auxiliary",
placement: "footer",
sortOrder: 10,
},
] as never;
distributionBoardComponentRepository.listProtectionByComponentIds =
async () =>
[
{
componentId: "rcd",
type: "FI",
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A",
ratedResidualCurrentMa: 30,
},
] as never;
circuitProtectionDeviceRepository.listByCircuitIds = async () =>
[
{
circuitId: "circuit-1",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
},
] as never;
let responseBody: unknown;
const response = {
status() {
return this;
},
json(value: unknown) {
responseBody = value;
return this;
},
};
try {
await getCircuitTree(
{ params: { projectId: "project-1", circuitListId: "list-1" } } as never,
response as never
);
} finally {
circuitListRepository.findById = originals.findList;
projectRepository.findById = originals.findProject;
distributionBoardRepository.findById = originals.findBoard;
circuitSectionRepository.listByCircuitList =
originals.listSections;
circuitRepository.listByCircuitList = originals.listCircuits;
circuitDeviceRowRepository.listByCircuitList = originals.listRows;
distributionBoardComponentRepository.listByCircuitList =
originals.listComponents;
distributionBoardComponentRepository.listProtectionByComponentIds =
originals.listComponentProtection;
circuitProtectionDeviceRepository.listByCircuitIds =
originals.listCircuitProtection;
}
const tree = responseBody as {
headerComponents: Array<{ equipmentIdentifier: string }>;
footerComponents: Array<{ equipmentIdentifier: string }>;
sections: Array<{
category: string;
groupNumber: number;
components: Array<{
protectionDevice: { ratedResidualCurrentMa: number };
}>;
circuits: Array<{
protectionDevice: { tripCharacteristic: string };
}>;
}>;
};
assert.equal(tree.headerComponents[0].equipmentIdentifier, "-Q0");
assert.equal(tree.footerComponents[0].equipmentIdentifier, "-K1");
assert.equal(tree.sections[0].category, "lighting");
assert.equal(tree.sections[0].groupNumber, 1);
assert.equal(
tree.sections[0].components[0].protectionDevice
.ratedResidualCurrentMa,
30
);
assert.equal(
tree.sections[0].circuits[0].protectionDevice
.tripCharacteristic,
"B"
);
});
});
+284
View File
@@ -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)
);
}
+52
View File
@@ -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"),