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();
}