From 07925b2fd941d041032d7fe4303ff89936525385 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 30 Jul 2026 19:56:59 +0200 Subject: [PATCH] Persist circuit group renumbering --- AGENTS.md | 4 + docs/current-architecture.md | 9 +- docs/spec/08-current-product-backlog.md | 2 +- ...-board-components-and-protection-groups.md | 16 +- ...oup-renumber-project-command.repository.ts | 348 ++++++++++++++++++ ...it-group-renumber-project-command.model.ts | 183 +++++++++ ...it-group-renumber-project-command.store.ts | 22 ++ .../services/project-command.service.ts | 13 + src/frontend/utils/project-version-history.ts | 1 + .../composition/project-command-stores.ts | 4 + tests/circuit-group-numbering.test.ts | 1 + ...enumber-project-command.repository.test.ts | 311 ++++++++++++++++ tests/project-command.service.test.ts | 74 ++++ ...t-state-restore-command.repository.test.ts | 2 + tests/project-version-history.test.ts | 6 + 15 files changed, 990 insertions(+), 6 deletions(-) create mode 100644 src/db/repositories/circuit-group-renumber-project-command.repository.ts create mode 100644 src/domain/models/circuit-group-renumber-project-command.model.ts create mode 100644 src/domain/ports/circuit-group-renumber-project-command.store.ts create mode 100644 tests/circuit-group-renumber-project-command.repository.test.ts diff --git a/AGENTS.md b/AGENTS.md index 39bc5b4..d205167 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -317,6 +317,10 @@ 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. 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. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index b5bec80..6cacb51 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -313,8 +313,13 @@ 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. +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. `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 af5421d..68f2f33 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -26,7 +26,7 @@ requirements and intended sequencing, not proof of implementation. - [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. -- [ ] Phase D1b: persistent collision-safe group-renumber command. +- [x] 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. 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 829f2ca..5c81efa 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -662,9 +662,8 @@ 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. +Status: In progress. Deterministic planning D1a and persistent execution D1b +are complete; cross-group moves D2 and populated deletion D3 remain pending. - implement nested identifier generation - support same-category cross-group circuit moves @@ -680,6 +679,17 @@ Implemented in D1a: - 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 + Acceptance: - target identifiers use highest suffix plus one diff --git a/src/db/repositories/circuit-group-renumber-project-command.repository.ts b/src/db/repositories/circuit-group-renumber-project-command.repository.ts new file mode 100644 index 0000000..7f82581 --- /dev/null +++ b/src/db/repositories/circuit-group-renumber-project-command.repository.ts @@ -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(); + 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(); + 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 +) { + return createTemporaryValue( + `__tmp_group_renumber_${key.replace(":", "_")}`, + occupied, + normalizeIdentifier + ); +} + +function createTemporaryValue( + base: string, + occupied: Set, + 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(); +} diff --git a/src/domain/models/circuit-group-renumber-project-command.model.ts b/src/domain/models/circuit-group-renumber-project-command.model.ts new file mode 100644 index 0000000..50ac07d --- /dev/null +++ b/src/domain/models/circuit-group-renumber-project-command.model.ts @@ -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 { + 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 +): 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(); + const entityIds = new Set(); + const targetGroupKeys = new Set(); + const targetIdentifiers = new Set(); + 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, + targetIdentifiers: Set +) { + 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 { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/circuit-group-renumber-project-command.store.ts b/src/domain/ports/circuit-group-renumber-project-command.store.ts new file mode 100644 index 0000000..64fe583 --- /dev/null +++ b/src/domain/ports/circuit-group-renumber-project-command.store.ts @@ -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; + }; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index bd4ccba..342fadd 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -99,6 +99,7 @@ import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit- 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 { 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 { @@ -121,6 +122,10 @@ import { circuitGroupReorderCommandType, circuitGroupUpdateCommandType, } from "../models/circuit-group-structure-project-command.model.js"; +import { + assertCircuitGroupRenumberProjectCommand, + circuitGroupRenumberCommandType, +} from "../models/circuit-group-renumber-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"; @@ -157,6 +162,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore, private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore, private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore, + private readonly circuitGroupRenumberStore: CircuitGroupRenumberProjectCommandStore, private readonly historyStore: ProjectHistoryStore ) {} @@ -373,6 +379,13 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case circuitGroupRenumberCommandType: { + assertCircuitGroupRenumberProjectCommand(input.command); + return this.circuitGroupRenumberStore.execute({ + ...input, + command: input.command, + }).revision; + } case projectFloorInsertCommandType: { assertProjectFloorInsertProjectCommand(input.command); return this.projectLocationStructureStore.execute({ diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index fa49156..9112aa2 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -40,6 +40,7 @@ const commandTypeLabels: Record = { "circuit-group.delete": "Stromkreisgruppe entfernt", "circuit-group.update": "Stromkreisgruppe bearbeitet", "circuit-group.reorder": "Stromkreisgruppen sortiert", + "circuit-group.renumber": "Stromkreisgruppen neu nummeriert", "project-floor.insert": "Geschoss angelegt", "project-floor.delete": "Geschoss entfernt", "project-room.insert": "Raum angelegt", diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 25858bb..eaa0ba7 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -7,6 +7,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../../db/reposito 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 { 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"; @@ -29,6 +30,8 @@ export const circuitStructureProjectCommandStore = new CircuitStructureProjectCommandRepository(db); export const circuitGroupStructureProjectCommandStore = new CircuitGroupStructureProjectCommandRepository(db); +export const circuitGroupRenumberProjectCommandStore = + new CircuitGroupRenumberProjectCommandRepository(db); export const distributionBoardStructureProjectCommandStore = new DistributionBoardStructureProjectCommandRepository(db); export const distributionBoardComponentStructureProjectCommandStore = @@ -67,5 +70,6 @@ export const projectCommandService = new ProjectCommandService( projectStateRestoreCommandStore, distributionBoardComponentStructureProjectCommandStore, circuitGroupStructureProjectCommandStore, + circuitGroupRenumberProjectCommandStore, projectHistoryStore ); diff --git a/tests/circuit-group-numbering.test.ts b/tests/circuit-group-numbering.test.ts index 99ca7f3..7250a55 100644 --- a/tests/circuit-group-numbering.test.ts +++ b/tests/circuit-group-numbering.test.ts @@ -10,6 +10,7 @@ import { } 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"; describe("circuit group numbering", () => { it("formats the agreed identifiers including the leading hyphen", () => { diff --git a/tests/circuit-group-renumber-project-command.repository.test.ts b/tests/circuit-group-renumber-project-command.repository.test.ts new file mode 100644 index 0000000..8ce4d82 --- /dev/null +++ b/tests/circuit-group-renumber-project-command.repository.test.ts @@ -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(); + } + }); +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 957c4ca..ea28056 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -15,6 +15,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../src/db/reposit 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 { 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"; @@ -53,6 +54,8 @@ import { 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 { createDistributionBoardInsertProjectCommand, createDistributionBoardStructureSnapshot, @@ -138,6 +141,7 @@ function createService(context: DatabaseContext) { context.db ), new CircuitGroupStructureProjectCommandRepository(context.db), + new CircuitGroupRenumberProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } @@ -1296,6 +1300,76 @@ describe("project command service", () => { } }); + 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 floor and room setup with their persisted inverses", () => { const context = createTestDatabase(); try { diff --git a/tests/project-state-restore-command.repository.test.ts b/tests/project-state-restore-command.repository.test.ts index 2f50e5a..1e31cbc 100644 --- a/tests/project-state-restore-command.repository.test.ts +++ b/tests/project-state-restore-command.repository.test.ts @@ -15,6 +15,7 @@ import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/reposi 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 { 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"; @@ -197,6 +198,7 @@ function createService(context: DatabaseContext) { context.db ), new CircuitGroupStructureProjectCommandRepository(context.db), + new CircuitGroupRenumberProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } diff --git a/tests/project-version-history.test.ts b/tests/project-version-history.test.ts index 4f87920..84aee3a 100644 --- a/tests/project-version-history.test.ts +++ b/tests/project-version-history.test.ts @@ -163,6 +163,12 @@ describe("project version history presentation", () => { ), "Stromkreisgruppen sortiert" ); + assert.equal( + getProjectRevisionDescription( + revision(12, { commandType: "circuit-group.renumber" }) + ), + "Stromkreisgruppen neu nummeriert" + ); assert.equal(getProjectSnapshotKindLabel("named"), "Benannt"); assert.equal( getProjectSnapshotKindLabel("automatic"),