forked from jappel/leistungsbilanz-ts
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import crypto from "node:crypto";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { db } from "../../db/client.js";
|
|
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
|
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
|
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
|
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
|
import { ConsumerRepository } from "../../db/repositories/consumer.repository.js";
|
|
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
|
import { circuitDeviceRows } from "../../db/schema/circuit-device-rows.js";
|
|
import { legacyConsumerCircuitMigrations } from "../../db/schema/legacy-consumer-circuit-migrations.js";
|
|
import { legacyConsumerMigrationReports } from "../../db/schema/legacy-consumer-migration-report.js";
|
|
import type { LegacyMigrationReport } from "../models/circuit-tree.model.js";
|
|
import {
|
|
inferSectionKeyFromEquipmentIdentifier,
|
|
inferSectionKeyFromLegacyInput,
|
|
normalizeCircuitNumber,
|
|
} from "./legacy-consumer-migration-planner.js";
|
|
|
|
type LegacyConsumerRow = Awaited<ReturnType<ConsumerRepository["listByCircuitList"]>>[number];
|
|
|
|
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 {
|
|
private readonly circuitListRepository = new CircuitListRepository();
|
|
private readonly sectionRepository = new CircuitSectionRepository();
|
|
private readonly circuitRepository = new CircuitRepository();
|
|
private readonly circuitDeviceRowRepository = new CircuitDeviceRowRepository();
|
|
private readonly consumerRepository = new ConsumerRepository();
|
|
private readonly roomRepository = new RoomRepository();
|
|
|
|
async migrateCircuitList(projectId: string, circuitListId: string): Promise<LegacyMigrationReport> {
|
|
const list = await this.circuitListRepository.findById(projectId, circuitListId);
|
|
if (!list) {
|
|
throw new Error("Circuit list not found in project.");
|
|
}
|
|
|
|
await this.sectionRepository.createDefaults(circuitListId);
|
|
const sections = await this.sectionRepository.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.circuitRepository.listByCircuitList(circuitListId);
|
|
const usedEquipmentIdentifiers = new Set(
|
|
existingCircuits.map((circuit) => circuit.equipmentIdentifier.toUpperCase())
|
|
);
|
|
|
|
const legacyConsumers = await this.consumerRepository.listByCircuitList(circuitListId);
|
|
const rooms = await this.roomRepository.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: [],
|
|
};
|
|
|
|
const migratedRows = await db
|
|
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
|
|
.from(legacyConsumerCircuitMigrations)
|
|
.where(eq(legacyConsumerCircuitMigrations.circuitListId, circuitListId));
|
|
const migratedConsumerIds = new Set(migratedRows.map((row) => row.consumerId));
|
|
const consumersToMigrate = legacyConsumers.filter((consumer) => !migratedConsumerIds.has(consumer.id));
|
|
|
|
const byNormalizedCircuitNumber = new Map<string, LegacyConsumerRow[]>();
|
|
const withoutNormalizedCircuitNumber: LegacyConsumerRow[] = [];
|
|
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);
|
|
}
|
|
|
|
report.groupedDuplicateCircuitNumbers = [...byNormalizedCircuitNumber.entries()]
|
|
.filter(([, grouped]) => grouped.length > 1)
|
|
.map(([normalizedCircuitNumber, grouped]) => ({ normalizedCircuitNumber, count: grouped.length }));
|
|
|
|
const groups: Array<{
|
|
equipmentIdentifier: string | null;
|
|
consumers: LegacyConsumerRow[];
|
|
inferredSectionKey: string | null;
|
|
isGeneratedIdentifier: boolean;
|
|
}> = [];
|
|
|
|
for (const [normalizedCircuitNumber, grouped] of byNormalizedCircuitNumber.entries()) {
|
|
groups.push({
|
|
equipmentIdentifier: normalizedCircuitNumber,
|
|
consumers: grouped,
|
|
inferredSectionKey: inferSectionKeyFromEquipmentIdentifier(normalizedCircuitNumber),
|
|
isGeneratedIdentifier: false,
|
|
});
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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 circuitId = await this.circuitRepository.create({
|
|
circuitListId,
|
|
sectionId: section.id,
|
|
equipmentIdentifier,
|
|
displayName: representative.description ?? representative.name,
|
|
sortOrder: nextSortOrder,
|
|
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,
|
|
});
|
|
report.createdCircuitCount += 1;
|
|
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;
|
|
}
|
|
|
|
await this.circuitDeviceRowRepository.create({
|
|
circuitId,
|
|
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,
|
|
});
|
|
report.createdDeviceRowCount += 1;
|
|
rowSortOrder += 10;
|
|
|
|
const [createdRow] = await db
|
|
.select({ id: circuitDeviceRows.id })
|
|
.from(circuitDeviceRows)
|
|
.where(and(eq(circuitDeviceRows.circuitId, circuitId), eq(circuitDeviceRows.legacyConsumerId, consumer.id)))
|
|
.orderBy(circuitDeviceRows.sortOrder)
|
|
.limit(1);
|
|
if (!createdRow) {
|
|
throw new Error("Failed to resolve created circuit device row.");
|
|
}
|
|
|
|
await db.insert(legacyConsumerCircuitMigrations).values({
|
|
consumerId: consumer.id,
|
|
circuitId,
|
|
circuitDeviceRowId: createdRow.id,
|
|
circuitListId,
|
|
createdAtIso: new Date().toISOString(),
|
|
});
|
|
}
|
|
}
|
|
|
|
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.`);
|
|
}
|
|
|
|
const existingReport = await db
|
|
.select({ id: legacyConsumerMigrationReports.id })
|
|
.from(legacyConsumerMigrationReports)
|
|
.where(eq(legacyConsumerMigrationReports.circuitListId, circuitListId))
|
|
.limit(1);
|
|
if (existingReport.length) {
|
|
await db
|
|
.update(legacyConsumerMigrationReports)
|
|
.set({
|
|
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),
|
|
createdAtIso: new Date().toISOString(),
|
|
})
|
|
.where(eq(legacyConsumerMigrationReports.id, existingReport[0].id));
|
|
} else {
|
|
await db.insert(legacyConsumerMigrationReports).values({
|
|
id: crypto.randomUUID(),
|
|
circuitListId,
|
|
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),
|
|
createdAtIso: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
return report;
|
|
}
|
|
}
|