diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 0803e05..b5bec80 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -310,6 +310,11 @@ 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. Die +persistente Ausführung dieses Plans folgt als eigener Command. `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 6d3b4d6..af5421d 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -25,7 +25,10 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase C2a2: component update and reorder commands. - [x] Phase C2b1: empty-group insert/update/delete commands. - [x] Phase C2b2: complete group reorder command. -- [ ] Phase D: group numbering, moves and destructive operations. +- [x] Phase D1a: deterministic collision-aware group-renumber plan. +- [ ] Phase D1b: persistent collision-safe group-renumber command. +- [ ] Phase D2: same-category cross-group circuit moves. +- [ ] Phase D3: confirmed populated-group deletion. - [ ] 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 f5bed5e..829f2ca 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -662,11 +662,24 @@ Acceptance: ### D. Group Numbering and Circuit Moves +Status: In progress. The deterministic renumber planner D1a is complete; +persistent execution D1b, cross-group moves D2 and populated deletion D3 +remain pending. + - 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 + Acceptance: - target identifiers use highest suffix plus one diff --git a/src/domain/services/circuit-group-renumbering.ts b/src/domain/services/circuit-group-renumbering.ts new file mode 100644 index 0000000..b956f71 --- /dev/null +++ b/src/domain/services/circuit-group-renumbering.ts @@ -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(); + const targetKeys = new Set(); + 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."); + } +} diff --git a/tests/circuit-group-numbering.test.ts b/tests/circuit-group-numbering.test.ts index cc5e7dd..99ca7f3 100644 --- a/tests/circuit-group-numbering.test.ts +++ b/tests/circuit-group-numbering.test.ts @@ -9,6 +9,7 @@ import { 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"; describe("circuit group numbering", () => { it("formats the agreed identifiers including the leading hyphen", () => { @@ -103,3 +104,138 @@ 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/ + ); + }); +});