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