diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 5b4fccf..eebd593 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -331,6 +331,12 @@ 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. Persistentes Löschen/Wiederherstellen folgt separat. `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 diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index 8cbe856..9603824 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -29,7 +29,8 @@ requirements and intended sequencing, not proof of implementation. - [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. -- [ ] Phase D3: confirmed populated-group deletion. +- [x] Phase D3a: complete validated group-subtree snapshot and warning summary. +- [ ] Phase D3b: persistent populated-group delete/restore command. - [ ] Phase E: editor projection and editing. - [ ] Phase F: documentation and full GUI verification. - [ ] Keep full electrical sizing and cable-dimensioning rules separate until diff --git a/docs/spec/09-distribution-board-components-and-protection-groups.md b/docs/spec/09-distribution-board-components-and-protection-groups.md index 8cfadb3..28d60bc 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -663,8 +663,8 @@ Acceptance: ### D. Group Numbering and Circuit Moves Status: In progress. Group renumbering D1 and deterministic circuit-move -planning and persistent moves D2 are complete; populated deletion D3 remains -pending. +planning and persistent moves D2 plus the deletion snapshot D3a are complete; +persistent populated deletion D3b remains pending. - implement nested identifier generation - support same-category cross-group circuit moves @@ -711,6 +711,16 @@ Implemented in D2b: - 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 + Acceptance: - target identifiers use highest suffix plus one diff --git a/src/domain/models/circuit-group-subtree-snapshot.model.ts b/src/domain/models/circuit-group-subtree-snapshot.model.ts new file mode 100644 index 0000000..b9cb4b1 --- /dev/null +++ b/src/domain/models/circuit-group-subtree-snapshot.model.ts @@ -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(); + const equipmentIdentifiers = new Set(); + 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 { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/tests/circuit-group-numbering.test.ts b/tests/circuit-group-numbering.test.ts index 2cc8129..e481435 100644 --- a/tests/circuit-group-numbering.test.ts +++ b/tests/circuit-group-numbering.test.ts @@ -13,6 +13,7 @@ import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-g 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"; describe("circuit group numbering", () => { it("formats the agreed identifiers including the leading hyphen", () => { diff --git a/tests/circuit-group-subtree-snapshot.test.ts b/tests/circuit-group-subtree-snapshot.test.ts new file mode 100644 index 0000000..dfe1105 --- /dev/null +++ b/tests/circuit-group-subtree-snapshot.test.ts @@ -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\"]"); + }); +});