diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 6cacb51..ebceb5a 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -320,6 +320,12 @@ 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. Die persistente Ausführung 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 68f2f33..c80063d 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -27,7 +27,8 @@ requirements and intended sequencing, not proof of implementation. - [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. -- [ ] Phase D2: same-category cross-group circuit moves. +- [x] Phase D2a: deterministic same-category circuit-move plan. +- [ ] Phase D2b: persistent same-category circuit-move command. - [ ] Phase D3: confirmed populated-group deletion. - [ ] Phase E: editor projection and editing. - [ ] Phase F: documentation and full GUI verification. 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 5c81efa..7e2569f 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -662,8 +662,9 @@ Acceptance: ### D. Group Numbering and Circuit Moves -Status: In progress. Deterministic planning D1a and persistent execution D1b -are complete; cross-group moves D2 and populated deletion D3 remain pending. +Status: In progress. Group renumbering D1 and deterministic circuit-move +planning D2a are complete; persistent moves D2b and populated deletion D3 +remain pending. - implement nested identifier generation - support same-category cross-group circuit moves @@ -690,6 +691,15 @@ Implemented in D1b: - 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 + Acceptance: - target identifiers use highest suffix plus one diff --git a/src/domain/services/circuit-group-move-planning.ts b/src/domain/services/circuit-group-move-planning.ts new file mode 100644 index 0000000..8436c08 --- /dev/null +++ b/src/domain/services/circuit-group-move-planning.ts @@ -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; + 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."); + } +} diff --git a/tests/circuit-group-numbering.test.ts b/tests/circuit-group-numbering.test.ts index 7250a55..41c4648 100644 --- a/tests/circuit-group-numbering.test.ts +++ b/tests/circuit-group-numbering.test.ts @@ -11,6 +11,7 @@ import { 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"; describe("circuit group numbering", () => { it("formats the agreed identifiers including the leading hyphen", () => { @@ -240,3 +241,117 @@ describe("circuit group renumber planning", () => { ); }); }); + +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/ + ); + }); +});