Persist section renumbering

This commit is contained in:
2026-07-25 21:30:29 +02:00
parent abcb468807
commit c45afd0981
24 changed files with 317 additions and 638 deletions
@@ -1,68 +0,0 @@
import { and, eq, inArray } from "drizzle-orm";
import type { CircuitSectionTransactionStore } from "../../domain/ports/circuit-section-transaction.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuits } from "../schema/circuits.js";
export class CircuitSectionTransactionRepository
implements CircuitSectionTransactionStore
{
constructor(private readonly database: AppDatabase) {}
updateEquipmentIdentifiers(
circuitListId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>,
tempNamespace: string
) {
if (updates.length === 0) {
return;
}
this.database.transaction((tx) => {
const ids = updates.map((entry) => entry.id);
const existing = tx
.select({ id: circuits.id })
.from(circuits)
.where(
and(
eq(circuits.circuitListId, circuitListId),
inArray(circuits.id, ids)
)
)
.all();
if (existing.length !== ids.length) {
throw new Error(
"One or more circuit ids are invalid for circuit list."
);
}
const stamp = Date.now();
for (let index = 0; index < updates.length; index += 1) {
const entry = updates[index];
const tempIdentifier = `__tmp_renumber_${tempNamespace}_${stamp}_${index}`;
tx
.update(circuits)
.set({ equipmentIdentifier: tempIdentifier })
.where(
and(
eq(circuits.circuitListId, circuitListId),
eq(circuits.id, entry.id)
)
)
.run();
}
for (const entry of updates) {
tx
.update(circuits)
.set({ equipmentIdentifier: entry.equipmentIdentifier })
.where(
and(
eq(circuits.circuitListId, circuitListId),
eq(circuits.id, entry.id)
)
)
.run();
}
});
}
}
@@ -1,7 +0,0 @@
export interface CircuitSectionTransactionStore {
updateEquipmentIdentifiers(
circuitListId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>,
tempNamespace: string
): void;
}
@@ -1,99 +0,0 @@
import type { CircuitSectionTransactionStore } from "../ports/circuit-section-transaction.store.js";
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import type {
UpdateSectionEquipmentIdentifiersInput,
} from "../../shared/validation/circuit.schemas.js";
import { CircuitNumberingService } from "./circuit-numbering.service.js";
export class CircuitWriteService {
private readonly circuitRepository: CircuitRepository;
private readonly circuitSectionRepository: CircuitSectionRepository;
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
private readonly numberingService: CircuitNumberingService;
constructor(deps?: {
circuitRepository?: CircuitRepository;
circuitSectionRepository?: CircuitSectionRepository;
circuitSectionTransactionStore?: CircuitSectionTransactionStore;
numberingService?: CircuitNumberingService;
}) {
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository();
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
}
private getCircuitSectionTransactionStore() {
if (!this.circuitSectionTransactionStore) {
throw new Error("Circuit-section transactions are not configured.");
}
return this.circuitSectionTransactionStore;
}
async getNextIdentifier(sectionId: string) {
return this.numberingService.getNextIdentifier(sectionId);
}
async renumberSection(sectionId: string) {
// Explicit renumber operation for one section only.
// Never renumbers other sections and never runs implicitly during move/sort operations.
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
throw new Error("Invalid section id.");
}
const sectionCircuits = await this.circuitRepository.listBySection(sectionId);
const otherCircuits = (await this.circuitRepository.listByCircuitList(section.circuitListId)).filter(
(circuit) => circuit.sectionId !== sectionId
);
const otherIdentifiers = new Set(otherCircuits.map((circuit) => circuit.equipmentIdentifier));
const finalAssignments: Array<{ id: string; equipmentIdentifier: string }> = [];
let index = 1;
for (const circuit of sectionCircuits) {
let candidate = `${section.prefix}${index}`;
while (otherIdentifiers.has(candidate)) {
index += 1;
candidate = `${section.prefix}${index}`;
}
finalAssignments.push({ id: circuit.id, equipmentIdentifier: candidate });
index += 1;
}
// Uses safe two-phase identifier update to avoid UNIQUE collisions during swaps.
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
section.circuitListId,
finalAssignments,
sectionId
);
return this.circuitRepository.listBySection(sectionId);
}
async updateSectionEquipmentIdentifiers(
sectionId: string,
input: UpdateSectionEquipmentIdentifiersInput
) {
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
throw new Error("Invalid section id.");
}
const sectionCircuits = await this.circuitRepository.listBySection(sectionId);
const sectionIds = new Set(sectionCircuits.map((circuit) => circuit.id));
if (input.identifiers.length !== sectionCircuits.length) {
throw new Error("identifiers must include all circuits in the section.");
}
for (const entry of input.identifiers) {
if (!sectionIds.has(entry.circuitId)) {
throw new Error("Circuit id does not belong to section.");
}
}
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
section.circuitListId,
input.identifiers.map((entry) => ({ id: entry.circuitId, equipmentIdentifier: entry.equipmentIdentifier })),
sectionId
);
return this.circuitRepository.listBySection(sectionId);
}
}
+27 -19
View File
@@ -33,6 +33,9 @@ import {
import {
buildCircuitSectionReorderAssignments,
} from "../utils/circuit-section-reorder-command";
import {
buildCircuitSectionRenumberAssignments,
} from "../utils/circuit-section-renumber-command";
import type {
CellKey,
CellKind,
@@ -57,9 +60,8 @@ import {
moveCircuitDeviceRowsToNewCircuitCommand,
reorderCircuitSectionCommand,
reorderCircuitSectionsCommand,
renumberCircuitSection,
renumberCircuitSectionCommand,
redoProjectCommand,
updateSectionEquipmentIdentifiers,
updateCircuitById,
updateCircuitDeviceRowById,
undoProjectCommand,
@@ -2592,7 +2594,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await handleDeleteCircuit(intent.circuitId);
}
// Explicit renumber action. Undo restores previous BMKs via safe section identifier update.
// Explicit renumber action through project history. Sorting and device rows
// remain unchanged; the server handles collision-safe identifier swaps.
async function handleRenumberSection(sectionId: string) {
if (!confirm("Diesen Bereich neu nummerieren? Nur die Stromkreise in diesem Bereich werden geändert.")) {
return;
@@ -2601,26 +2604,31 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!section) {
return;
}
const before = section.circuits.map((circuit) => ({
id: circuit.id,
equipmentIdentifier: circuit.equipmentIdentifier,
}));
const assignments = buildCircuitSectionRenumberAssignments(
data?.sections ?? [],
sectionId
);
if (assignments.length === 0) {
return;
}
const label = "Bereich neu nummerieren";
await runCommand({
label: "Bereich neu nummerieren",
label,
redo: async () => {
await renumberCircuitSection(sectionId);
const result = await renumberCircuitSectionCommand(
projectId,
getExpectedProjectRevision(),
sectionId,
assignments,
label
);
applyProjectCommandResult(result);
return null;
},
undo: async () => {
// Restore prior BMKs through safe bulk endpoint to avoid transient unique collisions.
await updateSectionEquipmentIdentifiers(
sectionId,
before.map((entry) => ({
circuitId: entry.id,
equipmentIdentifier: entry.equipmentIdentifier,
}))
);
return null;
undo: async () => null,
persistent: {
undoIntent: () => null,
redoIntent: () => null,
},
});
}
+18 -12
View File
@@ -42,6 +42,9 @@ import type {
import type {
CircuitSectionsReorderSection,
} from "../../domain/models/circuit-sections-reorder-project-command.model";
import type {
CircuitSectionRenumberAssignment,
} from "../../domain/models/circuit-section-renumber-project-command.model";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -319,12 +322,6 @@ export function moveCircuitDeviceRowsToNewCircuitCommand(
);
}
export function renumberCircuitSection(sectionId: string) {
return request(`/api/circuit-sections/${sectionId}/renumber`, {
method: "POST",
});
}
export function reorderCircuitSectionCommand(
projectId: string,
expectedRevision: number,
@@ -362,14 +359,23 @@ export function reorderCircuitSectionsCommand(
);
}
export function updateSectionEquipmentIdentifiers(
export function renumberCircuitSectionCommand(
projectId: string,
expectedRevision: number,
sectionId: string,
identifiers: Array<{ circuitId: string; equipmentIdentifier: string }>
assignments: CircuitSectionRenumberAssignment[],
description: string
) {
return request(`/api/circuit-sections/${sectionId}/equipment-identifiers`, {
method: "PATCH",
body: JSON.stringify({ identifiers }),
});
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit.renumber-section",
payload: { sectionId, assignments },
},
description
);
}
export function listFloors(projectId: string) {
@@ -0,0 +1,54 @@
import type {
CircuitSectionRenumberAssignment,
} from "../../domain/models/circuit-section-renumber-project-command.model.js";
import type { CircuitTreeSectionDto } from "../types.js";
export function buildCircuitSectionRenumberAssignments(
sections: CircuitTreeSectionDto[],
sectionId: string
): CircuitSectionRenumberAssignment[] {
const targetSection = sections.find(
(section) => section.id === sectionId
);
if (!targetSection) {
throw new Error("Der Bereich wurde nicht gefunden.");
}
const identifiersOutsideSection = new Set(
sections
.filter((section) => section.id !== sectionId)
.flatMap((section) =>
section.circuits.map(
(circuit) => circuit.equipmentIdentifier
)
)
);
const assignments: CircuitSectionRenumberAssignment[] = [];
let suffix = 1;
for (const circuit of targetSection.circuits) {
let targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
while (
identifiersOutsideSection.has(targetEquipmentIdentifier)
) {
suffix += 1;
targetEquipmentIdentifier = `${targetSection.prefix}${suffix}`;
}
assignments.push({
circuitId: circuit.id,
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
targetEquipmentIdentifier,
});
suffix += 1;
}
if (
assignments.every(
(assignment) =>
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
)
) {
return [];
}
return assignments;
}
@@ -0,0 +1,3 @@
import { CircuitNumberingService } from "../../domain/services/circuit-numbering.service.js";
export const circuitNumberingService = new CircuitNumberingService();
@@ -1,7 +0,0 @@
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
import { db } from "../../db/client.js";
import { CircuitSectionTransactionRepository } from "../../db/repositories/circuit-section-transaction.repository.js";
export const circuitWriteService = new CircuitWriteService({
circuitSectionTransactionStore: new CircuitSectionTransactionRepository(db),
});
@@ -1,38 +0,0 @@
import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js";
import {
updateSectionEquipmentIdentifiersSchema,
} from "../../shared/validation/circuit.schemas.js";
export async function renumberCircuitSection(req: Request, res: Response) {
const { sectionId } = req.params;
if (typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid sectionId" });
}
try {
const updatedCircuits = await circuitWriteService.renumberSection(sectionId);
return res.json({ sectionId, circuits: updatedCircuits });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to renumber section." });
}
}
export async function updateSectionEquipmentIdentifiers(req: Request, res: Response) {
const { sectionId } = req.params;
if (typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid sectionId" });
}
const parsed = updateSectionEquipmentIdentifiersSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const updatedCircuits = await circuitWriteService.updateSectionEquipmentIdentifiers(sectionId, parsed.data);
return res.json({ sectionId, circuits: updatedCircuits });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update section identifiers." });
}
}
+3 -2
View File
@@ -1,5 +1,5 @@
import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js";
import { circuitNumberingService } from "../composition/circuit-numbering-service.js";
export async function getNextCircuitIdentifier(req: Request, res: Response) {
const { sectionId } = req.params;
@@ -7,7 +7,8 @@ export async function getNextCircuitIdentifier(req: Request, res: Response) {
return res.status(400).json({ error: "Invalid sectionId" });
}
try {
const nextIdentifier = await circuitWriteService.getNextIdentifier(sectionId);
const nextIdentifier =
await circuitNumberingService.getNextIdentifier(sectionId);
return res.json({ sectionId, nextIdentifier });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to get identifier." });
-2
View File
@@ -1,6 +1,5 @@
import express from "express";
import { circuitRouter } from "./routes/circuit.routes.js";
import { circuitSectionRouter } from "./routes/circuit-section.routes.js";
import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { projectRouter } from "./routes/project.routes.js";
@@ -17,7 +16,6 @@ app.get("/health", (_req, res) => {
app.use("/api/projects", projectRouter);
app.use("/api", circuitRouter);
app.use("/api", circuitSectionRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);
@@ -1,10 +0,0 @@
import { Router } from "express";
import {
renumberCircuitSection,
updateSectionEquipmentIdentifiers,
} from "../controllers/circuit-section.controller.js";
export const circuitSectionRouter = Router();
circuitSectionRouter.post("/circuit-sections/:sectionId/renumber", renumberCircuitSection);
circuitSectionRouter.patch("/circuit-sections/:sectionId/equipment-identifiers", updateSectionEquipmentIdentifiers);
-14
View File
@@ -1,14 +0,0 @@
import { z } from "zod";
export const updateSectionEquipmentIdentifiersSchema = z.object({
identifiers: z
.array(
z.object({
circuitId: z.string().min(1),
equipmentIdentifier: z.string().min(1),
})
)
.min(1),
});
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;