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