Documentation

This commit is contained in:
2026-05-07 22:55:15 +02:00
parent b1e19a88d5
commit 7580ad0ade
11 changed files with 584 additions and 1 deletions
@@ -39,6 +39,7 @@ export class CircuitWriteService {
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
}
// Ensures writes never connect a section to the wrong circuit list.
private async assertSectionInList(sectionId: string, circuitListId: string) {
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
@@ -50,6 +51,7 @@ export class CircuitWriteService {
return section;
}
// Enforces BMK uniqueness inside one circuit list.
private async assertUniqueEquipmentIdentifier(
circuitListId: string,
equipmentIdentifier: string,
@@ -65,6 +67,7 @@ export class CircuitWriteService {
}
}
// Validates linked project-device id against owning project of the circuit list.
private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) {
if (!linkedProjectDeviceId) {
return;
@@ -184,6 +187,7 @@ export class CircuitWriteService {
overriddenFields: input.overriddenFields,
});
// Reserve circuits become active as soon as at least one device row exists.
if (Boolean(circuit.isReserve)) {
await this.circuitRepository.update(circuit.id, {
sectionId: circuit.sectionId,
@@ -247,6 +251,7 @@ export class CircuitWriteService {
}
await this.deviceRowRepository.delete(rowId);
const remaining = await this.deviceRowRepository.countByCircuit(current.circuitId);
// When last row is removed, keep circuit and mark it reserve instead of deleting it.
if (remaining === 0) {
await this.circuitRepository.update(circuit.id, {
sectionId: circuit.sectionId,
@@ -287,6 +292,7 @@ export class CircuitWriteService {
throw new Error("Invalid target circuit id.");
}
// Placeholder-target move creates a new circuit explicitly; no implicit renumbering of others.
if (!targetCircuit) {
if (!input.targetSectionId || !input.createNewCircuit) {
throw new Error("Invalid move target.");
@@ -321,6 +327,7 @@ export class CircuitWriteService {
await this.deviceRowRepository.moveToCircuit(rowId, targetCircuit.id, (targetCount + 1) * 10);
const sourceRemaining = await this.deviceRowRepository.countByCircuit(sourceCircuit.id);
// Source circuit becomes reserve when all rows are moved away.
if (sourceRemaining === 0) {
await this.circuitRepository.update(sourceCircuit.id, {
sectionId: sourceCircuit.sectionId,
@@ -342,6 +349,7 @@ export class CircuitWriteService {
});
}
// Target circuit is no longer reserve once it receives moved rows.
if (Boolean(targetCircuit.isReserve)) {
await this.circuitRepository.update(targetCircuit.id, {
sectionId: targetCircuit.sectionId,
@@ -367,6 +375,8 @@ export class CircuitWriteService {
}
async moveDeviceRowsBulk(input: MoveCircuitDeviceRowsBulkInput) {
// Bulk move keeps input order and resolves all source circuits first so undo can
// restore per-source assignment deterministically.
const uniqueRowIds = [...new Set(input.rowIds)];
if (uniqueRowIds.length === 0) {
throw new Error("No device rows provided.");
@@ -400,6 +410,7 @@ export class CircuitWriteService {
throw new Error("Invalid target circuit id.");
}
// Bulk placeholder move creates exactly one new circuit as common target.
if (!targetCircuit) {
if (!input.targetSectionId || !input.createNewCircuit) {
throw new Error("Invalid move target.");
@@ -423,6 +434,7 @@ export class CircuitWriteService {
}
}
// Any source circuit emptied by bulk move is preserved as reserve circuit.
for (const sourceCircuit of sourceCircuits.values()) {
if (sourceCircuit.circuitListId !== targetCircuit.circuitListId) {
throw new Error("All moved rows must belong to same circuit list as target.");
@@ -493,6 +505,8 @@ export class CircuitWriteService {
}
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.");
@@ -514,6 +528,7 @@ export class CircuitWriteService {
finalAssignments.push({ id: circuit.id, equipmentIdentifier: candidate });
index += 1;
}
// Uses safe two-phase identifier update to avoid UNIQUE collisions during swaps.
await this.circuitRepository.updateEquipmentIdentifiersSafely(
section.circuitListId,
finalAssignments,
@@ -551,6 +566,7 @@ export class CircuitWriteService {
}
async reorderCircuitsInSection(sectionId: string, input: ReorderSectionCircuitsInput) {
// Reorder updates sortOrder only. BMKs remain unchanged; users may renumber explicitly later.
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
throw new Error("Invalid section id.");
@@ -6,6 +6,7 @@ export interface LegacyConsumerForPlanning {
phaseCount: number | null;
}
// Accepts only normalized BMK-like legacy circuit numbers used for grouping.
export function normalizeCircuitNumber(value: string | null): string | null {
if (!value) {
return null;
@@ -20,6 +21,8 @@ export function normalizeCircuitNumber(value: string | null): string | null {
return trimmed;
}
// Best-effort fallback when no valid circuit number exists. Keeps migration deterministic
// by preferring explicit category/phase cues over random assignment.
export function inferSectionKeyFromLegacyInput(consumer: LegacyConsumerForPlanning): string | null {
const category = (consumer.category ?? "").toLowerCase();
if (category.includes("light") || category.includes("beleuchtung")) {
@@ -42,6 +45,7 @@ export function inferSectionKeyFromLegacyInput(consumer: LegacyConsumerForPlanni
return null;
}
// Prefix-based section inference for already normalized equipment identifiers.
export function inferSectionKeyFromEquipmentIdentifier(equipmentIdentifier: string): string | null {
if (equipmentIdentifier.startsWith("-1F")) {
return "lighting";
@@ -54,4 +58,3 @@ export function inferSectionKeyFromEquipmentIdentifier(equipmentIdentifier: stri
}
return null;
}
@@ -40,6 +40,8 @@ export class LegacyConsumerMigrationService {
private readonly roomRepository = new RoomRepository();
async migrateCircuitList(projectId: string, circuitListId: string): Promise<LegacyMigrationReport> {
// Migration is additive: legacy consumers are preserved, and circuit-first entities are created
// with mapping records so transition remains auditable and reversible.
const list = await this.circuitListRepository.findById(projectId, circuitListId);
if (!list) {
throw new Error("Circuit list not found in project.");
@@ -73,6 +75,7 @@ export class LegacyConsumerMigrationService {
warnings: [],
};
// Idempotency guard: skip consumers already mapped in previous migration run.
const migratedRows = await db
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
.from(legacyConsumerCircuitMigrations)
@@ -80,6 +83,8 @@ export class LegacyConsumerMigrationService {
const migratedConsumerIds = new Set(migratedRows.map((row) => row.consumerId));
const consumersToMigrate = legacyConsumers.filter((consumer) => !migratedConsumerIds.has(consumer.id));
// Legacy rows are grouped by normalized circuit number so duplicates become
// multiple device rows within one circuit instead of duplicate circuits.
const byNormalizedCircuitNumber = new Map<string, LegacyConsumerRow[]>();
const withoutNormalizedCircuitNumber: LegacyConsumerRow[] = [];
for (const consumer of consumersToMigrate) {
@@ -94,6 +99,8 @@ export class LegacyConsumerMigrationService {
byNormalizedCircuitNumber.get(normalized)!.push(consumer);
}
// Duplicate normalized circuit numbers are expected and represented as one circuit
// with multiple circuit_device_rows.
report.groupedDuplicateCircuitNumbers = [...byNormalizedCircuitNumber.entries()]
.filter(([, grouped]) => grouped.length > 1)
.map(([normalizedCircuitNumber, grouped]) => ({ normalizedCircuitNumber, count: grouped.length }));
@@ -105,6 +112,7 @@ export class LegacyConsumerMigrationService {
isGeneratedIdentifier: boolean;
}> = [];
// Stable normalized circuit numbers keep existing intent where available.
for (const [normalizedCircuitNumber, grouped] of byNormalizedCircuitNumber.entries()) {
groups.push({
equipmentIdentifier: normalizedCircuitNumber,
@@ -114,6 +122,8 @@ export class LegacyConsumerMigrationService {
});
}
// Missing/invalid circuit numbers are migrated as single-row groups with
// generated identifiers and best-effort section inference.
for (const consumer of withoutNormalizedCircuitNumber) {
groups.push({
equipmentIdentifier: null,