Persist circuit group renumbering

This commit is contained in:
2026-07-30 19:56:59 +02:00
parent 63650a623c
commit 07925b2fd9
15 changed files with 990 additions and 6 deletions
+4
View File
@@ -317,6 +317,10 @@ components; populated deletion remains a separate confirmed command.
`circuit-group.reorder` requires a complete expected/target sort assignment for `circuit-group.reorder` requires a complete expected/target sort assignment for
every group in one circuit list. It changes only sort positions and never every group in one circuit list. It changes only sort positions and never
renumbers groups, prefixes or circuits. 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 floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one `distribution-board.update`; both values are snapshot/export fields and one
persistent undo step. persistent undo step.
+7 -2
View File
@@ -313,8 +313,13 @@ Revision und einen Undo/Redo-Schritt.
`src/domain/services/circuit-group-renumbering.ts` plant explizite `src/domain/services/circuit-group-renumbering.ts` plant explizite
Gruppennummerierungen deterministisch. Der reine Plan erhält Gruppennummerierungen deterministisch. Der reine Plan erhält
Stromkreis-Endnummern, leitet Gruppenpräfixe und optionale `.0`-Komponenten-BMKs Stromkreis-Endnummern, leitet Gruppenpräfixe und optionale `.0`-Komponenten-BMKs
neu ab und prüft Nummerntausch sowie Kollisionen mit unveränderten Gruppen. Die neu ab und prüft Nummerntausch sowie Kollisionen mit unveränderten Gruppen.
persistente Ausführung dieses Plans folgt als eigener Command. `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 `distribution-board.update` versioniert Etage, Netzart und den
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
+1 -1
View File
@@ -26,7 +26,7 @@ requirements and intended sequencing, not proof of implementation.
- [x] Phase C2b1: empty-group insert/update/delete commands. - [x] Phase C2b1: empty-group insert/update/delete commands.
- [x] Phase C2b2: complete group reorder command. - [x] Phase C2b2: complete group reorder command.
- [x] Phase D1a: deterministic collision-aware group-renumber plan. - [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 D2: same-category cross-group circuit moves.
- [ ] Phase D3: confirmed populated-group deletion. - [ ] Phase D3: confirmed populated-group deletion.
- [ ] Phase E: editor projection and editing. - [ ] Phase E: editor projection and editing.
@@ -662,9 +662,8 @@ Acceptance:
### D. Group Numbering and Circuit Moves ### D. Group Numbering and Circuit Moves
Status: In progress. The deterministic renumber planner D1a is complete; Status: In progress. Deterministic planning D1a and persistent execution D1b
persistent execution D1b, cross-group moves D2 and populated deletion D3 are complete; cross-group moves D2 and populated deletion D3 remain pending.
remain pending.
- implement nested identifier generation - implement nested identifier generation
- support same-category cross-group circuit moves - 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 - collisions with unchanged groups, no-op mappings and mismatched current BMKs
are rejected before persistence 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: Acceptance:
- target identifiers use highest suffix plus one - target identifiers use highest suffix plus one
@@ -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,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,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;
};
}
@@ -99,6 +99,7 @@ import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-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 { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
import type { CircuitGroupStructureProjectCommandStore } from "../ports/circuit-group-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 { 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 { DistributionBoardComponentStructureProjectCommandStore } from "../ports/distribution-board-component-structure-project-command.store.js";
import type { import type {
@@ -121,6 +122,10 @@ import {
circuitGroupReorderCommandType, circuitGroupReorderCommandType,
circuitGroupUpdateCommandType, circuitGroupUpdateCommandType,
} from "../models/circuit-group-structure-project-command.model.js"; } 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 { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js";
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-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"; 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 projectStateRestoreStore: ProjectStateRestoreCommandStore,
private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore, private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore,
private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore, private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore,
private readonly circuitGroupRenumberStore: CircuitGroupRenumberProjectCommandStore,
private readonly historyStore: ProjectHistoryStore private readonly historyStore: ProjectHistoryStore
) {} ) {}
@@ -373,6 +379,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case circuitGroupRenumberCommandType: {
assertCircuitGroupRenumberProjectCommand(input.command);
return this.circuitGroupRenumberStore.execute({
...input,
command: input.command,
}).revision;
}
case projectFloorInsertCommandType: { case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command); assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ return this.projectLocationStructureStore.execute({
@@ -40,6 +40,7 @@ const commandTypeLabels: Record<string, string> = {
"circuit-group.delete": "Stromkreisgruppe entfernt", "circuit-group.delete": "Stromkreisgruppe entfernt",
"circuit-group.update": "Stromkreisgruppe bearbeitet", "circuit-group.update": "Stromkreisgruppe bearbeitet",
"circuit-group.reorder": "Stromkreisgruppen sortiert", "circuit-group.reorder": "Stromkreisgruppen sortiert",
"circuit-group.renumber": "Stromkreisgruppen neu nummeriert",
"project-floor.insert": "Geschoss angelegt", "project-floor.insert": "Geschoss angelegt",
"project-floor.delete": "Geschoss entfernt", "project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt", "project-room.insert": "Raum angelegt",
@@ -7,6 +7,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../../db/reposito
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-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 { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-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 { 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 { DistributionBoardComponentStructureProjectCommandRepository } from "../../db/repositories/distribution-board-component-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
@@ -29,6 +30,8 @@ export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db); new CircuitStructureProjectCommandRepository(db);
export const circuitGroupStructureProjectCommandStore = export const circuitGroupStructureProjectCommandStore =
new CircuitGroupStructureProjectCommandRepository(db); new CircuitGroupStructureProjectCommandRepository(db);
export const circuitGroupRenumberProjectCommandStore =
new CircuitGroupRenumberProjectCommandRepository(db);
export const distributionBoardStructureProjectCommandStore = export const distributionBoardStructureProjectCommandStore =
new DistributionBoardStructureProjectCommandRepository(db); new DistributionBoardStructureProjectCommandRepository(db);
export const distributionBoardComponentStructureProjectCommandStore = export const distributionBoardComponentStructureProjectCommandStore =
@@ -67,5 +70,6 @@ export const projectCommandService = new ProjectCommandService(
projectStateRestoreCommandStore, projectStateRestoreCommandStore,
distributionBoardComponentStructureProjectCommandStore, distributionBoardComponentStructureProjectCommandStore,
circuitGroupStructureProjectCommandStore, circuitGroupStructureProjectCommandStore,
circuitGroupRenumberProjectCommandStore,
projectHistoryStore projectHistoryStore
); );
+1
View File
@@ -10,6 +10,7 @@ import {
} from "../src/domain/services/circuit-group-numbering.js"; } from "../src/domain/services/circuit-group-numbering.js";
import "./circuit-group-structure-project-command.repository.test.js"; import "./circuit-group-structure-project-command.repository.test.js";
import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js"; import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js";
import "./circuit-group-renumber-project-command.repository.test.js";
describe("circuit group numbering", () => { describe("circuit group numbering", () => {
it("formats the agreed identifiers including the leading hyphen", () => { it("formats the agreed identifiers including the leading hyphen", () => {
@@ -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();
}
});
});
+74
View File
@@ -15,6 +15,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../src/db/reposit
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-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 { 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 { 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 { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.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"; import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
@@ -53,6 +54,8 @@ import {
createCircuitGroupReorderProjectCommand, createCircuitGroupReorderProjectCommand,
createCircuitGroupUpdateProjectCommand, createCircuitGroupUpdateProjectCommand,
} from "../src/domain/models/circuit-group-structure-project-command.model.js"; } 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 { import {
createDistributionBoardInsertProjectCommand, createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot, createDistributionBoardStructureSnapshot,
@@ -138,6 +141,7 @@ function createService(context: DatabaseContext) {
context.db context.db
), ),
new CircuitGroupStructureProjectCommandRepository(context.db), new CircuitGroupStructureProjectCommandRepository(context.db),
new CircuitGroupRenumberProjectCommandRepository(context.db),
new ProjectHistoryRepository(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", () => { it("dispatches floor and room setup with their persisted inverses", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
@@ -15,6 +15,7 @@ import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/reposi
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-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 { 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 { 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 { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.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"; import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
@@ -197,6 +198,7 @@ function createService(context: DatabaseContext) {
context.db context.db
), ),
new CircuitGroupStructureProjectCommandRepository(context.db), new CircuitGroupStructureProjectCommandRepository(context.db),
new CircuitGroupRenumberProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db) new ProjectHistoryRepository(context.db)
); );
} }
+6
View File
@@ -163,6 +163,12 @@ describe("project version history presentation", () => {
), ),
"Stromkreisgruppen sortiert" "Stromkreisgruppen sortiert"
); );
assert.equal(
getProjectRevisionDescription(
revision(12, { commandType: "circuit-group.renumber" })
),
"Stromkreisgruppen neu nummeriert"
);
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt"); assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
assert.equal( assert.equal(
getProjectSnapshotKindLabel("automatic"), getProjectSnapshotKindLabel("automatic"),