Files
leistungsbilanz-ts/src/domain/services/legacy-consumer-migration.service.ts
T

270 lines
10 KiB
TypeScript

import type { LegacyMigrationReport } from "../models/circuit-tree.model.js";
import type {
LegacyConsumerMigrationDependencies,
LegacyConsumerSource,
LegacyMigrationCircuitInput,
} from "../ports/legacy-consumer-migration.store.js";
import {
inferSectionKeyFromEquipmentIdentifier,
inferSectionKeyFromLegacyInput,
normalizeCircuitNumber,
} from "./legacy-consumer-migration-planner.js";
function parseEquipmentSequence(
equipmentIdentifier: string,
prefix: string
): number | null {
if (!equipmentIdentifier.startsWith(prefix)) {
return null;
}
const suffix = equipmentIdentifier.slice(prefix.length);
if (!/^\d+$/.test(suffix)) {
return null;
}
return Number(suffix);
}
export class LegacyConsumerMigrationService {
constructor(
private readonly dependencies: LegacyConsumerMigrationDependencies
) {}
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.dependencies.circuitListReader.findById(
projectId,
circuitListId
);
if (!list) {
throw new Error("Circuit list not found in project.");
}
await this.dependencies.sectionStore.createDefaults(circuitListId);
const sections =
await this.dependencies.sectionStore.listByCircuitList(circuitListId);
const sectionByKey = new Map(sections.map((section) => [section.key, section]));
const unassignedSection = sectionByKey.get("unassigned");
if (!unassignedSection) {
throw new Error("Unassigned section is required.");
}
const existingCircuits =
await this.dependencies.circuitReader.listByCircuitList(circuitListId);
const usedEquipmentIdentifiers = new Set(
existingCircuits.map((circuit) => circuit.equipmentIdentifier.toUpperCase())
);
const legacyConsumers =
await this.dependencies.migrationStore.listSourceConsumersByCircuitList(
circuitListId
);
const rooms = await this.dependencies.roomReader.listByProject(projectId);
const roomById = new Map(rooms.map((room) => [room.id, room]));
const report: LegacyMigrationReport = {
circuitListId,
legacyConsumerCount: legacyConsumers.length,
createdCircuitCount: 0,
createdDeviceRowCount: 0,
groupedDuplicateCircuitNumbers: [],
generatedIdentifiers: [],
unassignedRows: [],
warnings: [],
};
// Idempotency guard: skip consumers already mapped in previous migration run.
const migratedConsumerIds = new Set(
await this.dependencies.migrationStore.listMigratedConsumerIds(
circuitListId
)
);
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, LegacyConsumerSource[]>();
const withoutNormalizedCircuitNumber: LegacyConsumerSource[] = [];
for (const consumer of consumersToMigrate) {
const normalized = normalizeCircuitNumber(consumer.circuitNumber ?? null);
if (!normalized) {
withoutNormalizedCircuitNumber.push(consumer);
continue;
}
if (!byNormalizedCircuitNumber.has(normalized)) {
byNormalizedCircuitNumber.set(normalized, []);
}
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 }));
const groups: Array<{
equipmentIdentifier: string | null;
consumers: LegacyConsumerSource[];
inferredSectionKey: string | null;
isGeneratedIdentifier: boolean;
}> = [];
// Stable normalized circuit numbers keep existing intent where available.
for (const [normalizedCircuitNumber, grouped] of byNormalizedCircuitNumber.entries()) {
groups.push({
equipmentIdentifier: normalizedCircuitNumber,
consumers: grouped,
inferredSectionKey: inferSectionKeyFromEquipmentIdentifier(normalizedCircuitNumber),
isGeneratedIdentifier: false,
});
}
// 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,
consumers: [consumer],
inferredSectionKey: inferSectionKeyFromLegacyInput(consumer),
isGeneratedIdentifier: true,
});
}
let nextSortOrder = existingCircuits.length ? Math.max(...existingCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
const maxBySectionPrefix = new Map<string, number>();
for (const circuit of existingCircuits) {
const section = sections.find((entry) => entry.id === circuit.sectionId);
if (!section) {
continue;
}
const sequence = parseEquipmentSequence(circuit.equipmentIdentifier.toUpperCase(), section.prefix.toUpperCase());
if (sequence === null) {
continue;
}
const current = maxBySectionPrefix.get(section.prefix.toUpperCase()) ?? 0;
maxBySectionPrefix.set(section.prefix.toUpperCase(), Math.max(current, sequence));
}
const migrationCircuits: LegacyMigrationCircuitInput[] = [];
for (const group of groups) {
const representative = group.consumers[0];
let section = group.inferredSectionKey ? sectionByKey.get(group.inferredSectionKey) : null;
if (!section) {
section = unassignedSection;
}
let equipmentIdentifier = group.equipmentIdentifier;
if (!equipmentIdentifier || usedEquipmentIdentifiers.has(equipmentIdentifier.toUpperCase())) {
const prefix = section.prefix.toUpperCase();
const current = maxBySectionPrefix.get(prefix) ?? 0;
const generatedSequence = current + 1;
maxBySectionPrefix.set(prefix, generatedSequence);
equipmentIdentifier = `${section.prefix}${generatedSequence}`;
report.generatedIdentifiers.push(equipmentIdentifier);
}
if (!group.inferredSectionKey && group.isGeneratedIdentifier) {
for (const consumer of group.consumers) {
report.unassignedRows.push({
consumerId: consumer.id,
reason: "Missing or invalid circuit number and no section inferred from phase/category.",
});
}
}
usedEquipmentIdentifiers.add(equipmentIdentifier.toUpperCase());
const deviceRows: LegacyMigrationCircuitInput["deviceRows"] = [];
const circuitSortOrder = nextSortOrder;
nextSortOrder += 10;
let rowSortOrder = 10;
for (const consumer of group.consumers) {
const room = consumer.roomId ? roomById.get(consumer.roomId) : undefined;
let noteRemark = consumer.note?.trim() || consumer.comment?.trim() || undefined;
if (consumer.deviceType && consumer.deviceType.trim()) {
const legacyDeviceTypeNote = `Legacy deviceType: ${consumer.deviceType.trim()}`;
noteRemark = noteRemark ? `${noteRemark} | ${legacyDeviceTypeNote}` : legacyDeviceTypeNote;
}
deviceRows.push({
linkedProjectDeviceId: consumer.projectDeviceId ?? undefined,
legacyConsumerId: consumer.id,
sortOrder: rowSortOrder,
name: consumer.name,
displayName: consumer.description ?? consumer.name,
phaseType: consumer.phaseType ?? undefined,
connectionKind: undefined,
costGroup: consumer.tradeOrCostGroup ?? undefined,
category: consumer.category ?? undefined,
roomId: consumer.roomId ?? undefined,
roomNumberSnapshot: room?.roomNumber,
roomNameSnapshot: room?.roomName,
quantity: consumer.quantity,
powerPerUnit: consumer.installedPowerPerUnitKw,
simultaneityFactor: consumer.demandFactor,
cosPhi: consumer.powerFactor ?? undefined,
remark: noteRemark,
});
rowSortOrder += 10;
}
migrationCircuits.push({
circuit: {
circuitListId,
sectionId: section.id,
equipmentIdentifier,
displayName: representative.description ?? representative.name,
sortOrder: circuitSortOrder,
protectionType: representative.protectionType ?? undefined,
protectionRatedCurrent: representative.protectionRatedCurrent ?? undefined,
protectionCharacteristic: representative.protectionCharacteristic ?? undefined,
cableType: representative.cableType ?? undefined,
cableCrossSection: representative.cableCrossSection ?? undefined,
voltage: representative.voltageV ?? undefined,
remark: undefined,
},
deviceRows,
});
}
report.createdCircuitCount = migrationCircuits.length;
report.createdDeviceRowCount = migrationCircuits.reduce(
(count, entry) => count + entry.deviceRows.length,
0
);
if (consumersToMigrate.some((consumer) => Boolean(consumer.comment?.trim()))) {
report.warnings.push(
"Legacy comment field was mapped to circuit_device_rows.remark because circuit-level vs row-level intent is ambiguous."
);
}
for (const row of report.unassignedRows) {
report.warnings.push(`Consumer ${row.consumerId} was migrated into unassigned section.`);
}
this.dependencies.migrationStore.persistCircuitListMigration({
circuitListId,
circuits: migrationCircuits,
report: {
legacyConsumerCount: report.legacyConsumerCount,
createdCircuitCount: report.createdCircuitCount,
createdDeviceRowCount: report.createdDeviceRowCount,
duplicateGroupedCount: report.groupedDuplicateCircuitNumbers.length,
generatedIdentifierCount: report.generatedIdentifiers.length,
unassignedRowCount: report.unassignedRows.length,
warningsJson: JSON.stringify(report.warnings),
generatedIdentifiersJson: JSON.stringify(report.generatedIdentifiers),
duplicateGroupsJson: JSON.stringify(report.groupedDuplicateCircuitNumbers),
},
});
return report;
}
}