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
@@ -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 { 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({
@@ -40,6 +40,7 @@ const commandTypeLabels: Record<string, string> = {
"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",
@@ -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
);