import { createHash } from 'node:crypto'; import type { ParsedSheet, ParsedWorkbook } from './asset-import-parser'; export type AssetImportProfileCode = 'MENDOZA_INVENTORY_V1' | 'MENDOZA_YACIMIENTOS_V1' | 'UNKNOWN'; export type AssetImportRowStatus = 'READY' | 'WARNING' | 'CONFLICT' | 'IGNORED'; export type AssetImportSuggestedAction = 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE'; export interface ImportProfileDetection { profileCode: AssetImportProfileCode; confidence: number; sheetName: string; headerRow: number; columnMap: Record; detectedHeaders: string[]; } export interface ImportNormalizedRow { profileCode: AssetImportProfileCode; rowNumber: number; sheetName: string; raw: Record; normalized: Record; status: AssetImportRowStatus; suggestedAction: AssetImportSuggestedAction; issues: string[]; fingerprint: string; } export interface ImportAnalysis { detection: ImportProfileDetection; rows: ImportNormalizedRow[]; summary: { totalRows: number; readyRows: number; warningRows: number; conflictRows: number; ignoredRows: number; issueCounts: Record; }; } function plain(value: string): string { return value .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .trim() .replace(/\s+/g, ' '); } function compact(value: string): string { return plain(value).replace(/\s+/g, ''); } const inventoryAliases: Record = { item: ['item', 'n item', 'numero item'], areaOrField: ['area yacimiento', 'area/yacimiento', 'area y yacimiento', 'area yacimiento '], installation: ['instalacion', 'intalacion'], subInstallation: ['sub instalacion', 'subinstalacion'], equipment: ['equipo'], equipmentDenomination: ['denominacion del equipo', 'denominacion equipo'], location: ['ubicacion del equipo o instalacion', 'ubicacion equipo o instalacion', 'ubicacion'], inventoryId: ['n id inventario', 'n° id inventario', 'nº id inventario', 'id inventario', 'numero id inventario'], quantity: ['cantidad'], technicalSpecs: ['especificaciones tecnicas', 'especificacion tecnica'], sourceStatus: ['estado en servicio fuera de servicio', 'estado', 'estado servicio'], }; const fieldAliases: Record = { field: ['yacimiento', 'nombre yacimiento'], area: ['area', 'area hidrocarburifera'], department: ['departamento'], rightType: ['tipo concesion', 'tipo de concesion', 'tipo permiso', 'tipo'], operator: ['operadora', 'operador', 'empresa operadora'], }; function aliasScore(header: string, aliases: string[]): number { const normalized = plain(header); const normalizedCompact = compact(header); let best = 0; for (const alias of aliases) { const a = plain(alias); const ac = compact(alias); if (normalized === a || normalizedCompact === ac) best = Math.max(best, 10 + a.length / 100); else if (normalized.includes(a) || a.includes(normalized)) best = Math.max(best, 6 + Math.min(a.length, normalized.length) / 100); } return best; } function mapHeaders(headers: string[], aliases: Record): { map: Record; score: number } { const candidates: Array<{ field: string; index: number; score: number }> = []; headers.forEach((header, index) => { Object.entries(aliases).forEach(([field, values]) => { const score = aliasScore(header, values); if (score > 0) candidates.push({ field, index, score }); }); }); candidates.sort((a, b) => b.score - a.score); const usedFields = new Set(); const usedIndexes = new Set(); const map: Record = {}; let score = 0; for (const candidate of candidates) { if (usedFields.has(candidate.field) || usedIndexes.has(candidate.index)) continue; usedFields.add(candidate.field); usedIndexes.add(candidate.index); map[candidate.field] = candidate.index; score += candidate.score; } return { map, score }; } function bestHeader(sheet: ParsedSheet, aliases: Record): { row: number; map: Record; score: number; headers: string[] } { let best = { row: 0, map: {} as Record, score: -1, headers: [] as string[] }; for (let index = 0; index < Math.min(sheet.rows.length, 30); index += 1) { const headers = sheet.rows[index] ?? []; const mapped = mapHeaders(headers, aliases); if (mapped.score > best.score) best = { row: index + 1, map: mapped.map, score: mapped.score, headers }; } return best; } export function detectImportProfile(workbook: ParsedWorkbook): ImportProfileDetection { let best: ImportProfileDetection = { profileCode: 'UNKNOWN', confidence: 0, sheetName: workbook.sheets[0]?.name ?? 'Hoja', headerRow: 1, columnMap: {}, detectedHeaders: [] }; for (const sheet of workbook.sheets) { const inventory = bestHeader(sheet, inventoryAliases); const inventoryRequired = ['areaOrField', 'installation', 'equipment', 'inventoryId']; const inventoryHits = inventoryRequired.filter((field) => inventory.map[field] !== undefined).length; const inventoryConfidence = Math.min(100, Math.round((inventory.score / 95) * 100)); if (inventoryHits >= 3 && inventoryConfidence > best.confidence) { best = { profileCode: 'MENDOZA_INVENTORY_V1', confidence: inventoryConfidence, sheetName: sheet.name, headerRow: inventory.row, columnMap: inventory.map, detectedHeaders: inventory.headers }; } const fields = bestHeader(sheet, fieldAliases); const fieldRequired = ['field', 'area', 'operator']; const fieldHits = fieldRequired.filter((field) => fields.map[field] !== undefined).length; const fieldConfidence = Math.min(100, Math.round((fields.score / 55) * 100)); if (fieldHits >= 2 && fieldConfidence > best.confidence) { best = { profileCode: 'MENDOZA_YACIMIENTOS_V1', confidence: fieldConfidence, sheetName: sheet.name, headerRow: fields.row, columnMap: fields.map, detectedHeaders: fields.headers }; } } return best; } function text(row: string[], index: number | undefined): string { return index === undefined ? '' : (row[index] ?? '').trim(); } function asNumber(value: string): number | null { if (!value.trim()) return null; const normalized = value.trim().replace(/\s+/g, '').replace(',', '.'); const number = Number(normalized); return Number.isFinite(number) ? number : null; } function inventoryStatusSuggestion(value: string): { operationalStatus?: string; conditionStatus?: string; ambiguous: boolean } { const normalized = plain(value); if (!normalized) return { ambiguous: false }; if (['en servicio', 'servicio', 'operativo', 'operativa'].includes(normalized)) return { operationalStatus: 'IN_SERVICE', ambiguous: false }; if (['fuera de servicio', 'f servicio', 'f serv', 'fs', 'f s'].includes(normalized)) return { operationalStatus: 'OUT_OF_SERVICE', ambiguous: false }; if (['bueno', 'buena'].includes(normalized)) return { conditionStatus: 'GOOD', ambiguous: true }; if (['regular'].includes(normalized)) return { conditionStatus: 'FAIR', ambiguous: true }; if (['malo', 'mala'].includes(normalized)) return { conditionStatus: 'POOR', ambiguous: true }; if (['si', 'no', 's', 'n'].includes(normalized)) return { ambiguous: true }; return { ambiguous: true }; } function splitClassType(value: string): { sourceClass: string | null; sourceSubtype: string | null } { const classMatch = /clase\s*:\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null; const typeMatch = /tipo\s*:\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null; return { sourceClass: classMatch, sourceSubtype: typeMatch }; } function splitManufacturerModel(value: string): { manufacturer: string | null; model: string | null } { const manufacturer = /fabricante\s*[;:]\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null; const model = /modelo\s*[;:]\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null; if (manufacturer || model) return { manufacturer, model }; const parts = value.split('|').map((part) => part.trim()).filter(Boolean); if (parts.length >= 3) return { manufacturer: parts[1] ?? null, model: parts[2] ?? null }; return { manufacturer: null, model: null }; } export interface TechnicalNormalizationSuggestion { family: string | null; subtype: string | null; } function technicalNormalizationSuggestion( equipment: string, denomination: string, sourceClass: string | null, sourceSubtype: string | null, ): TechnicalNormalizationSuggestion { const source = plain(`${sourceClass ?? ''} ${sourceSubtype ?? ''} ${equipment} ${denomination}`); const classCode = plain(sourceClass ?? ''); const subtypeCode = plain(sourceSubtype ?? ''); if (/rectificador/.test(plain(equipment))) return { family: 'equipo_electrico', subtype: 'rectificador' }; const equipmentOnly = plain(equipment); if (/^psv$/.test(equipmentOnly)) return { family: 'valvula', subtype: 'seguridad' }; if (/^vpsv$|^vpv$/.test(equipmentOnly)) return { family: 'valvula', subtype: 'presion_vacio' }; if (/^aib$/.test(equipmentOnly)) return { family: 'sistema_extraccion', subtype: 'aib' }; if (/seccionador/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'seccionador' }; if (/interruptor/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'interruptor' }; if (/reconectador/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'reconectador' }; if (/^colector$/.test(equipmentOnly)) return { family: 'colector', subtype: null }; if (/punto de medicion/.test(equipmentOnly)) return { family: 'instrumentacion', subtype: 'punto_medicion' }; if (/sistema rci|^rci$/.test(equipmentOnly)) return { family: 'defensa_incendios', subtype: null }; if (/aeroenfriador/.test(equipmentOnly)) return { family: 'aeroenfriador', subtype: null }; if (/^bateria$/.test(equipmentOnly)) return { family: 'instalacion', subtype: 'bateria' }; if (/^planta$/.test(equipmentOnly)) return { family: 'instalacion', subtype: 'planta' }; if (classCode === 'bba' || /\bbomba\b/.test(source)) { if (/\bcen\b|centrifug/.test(source)) return { family: 'bomba', subtype: 'centrifuga' }; if (/\btx\b|triplex|quintuplex|alternativa/.test(source)) return { family: 'bomba', subtype: 'alternativa' }; if (/tornillo/.test(source)) return { family: 'bomba', subtype: 'tornillo' }; if (/diafragma/.test(source)) return { family: 'bomba', subtype: 'diafragma' }; return { family: 'bomba', subtype: null }; } if (classCode === 'tk' || /\btanque\b|\btk\b/.test(source)) return { family: 'tanque', subtype: null }; if (classCode === 'sep' || /separador/.test(source)) { if (/sep b|bifasic/.test(source)) return { family: 'separador', subtype: 'bifasico' }; if (/sep g|gas/.test(source)) return { family: 'separador', subtype: 'gas' }; return { family: 'separador', subtype: null }; } if (classCode === 'cald' || /\bcaldera\b/.test(source)) return { family: 'caldera', subtype: null }; if (classCode === 'cal' || /calentador|hot oil/.test(source)) return { family: 'calentador', subtype: null }; if (classCode === 'ant' || /antorcha|flare/.test(source)) return { family: 'antorcha', subtype: /frio/.test(source) ? 'venteo_frio' : null }; if (classCode === 'fil' || /\bfiltro\b/.test(source)) return { family: 'filtro', subtype: /arena/.test(source) ? 'arena' : null }; if (classCode === 'tra' || /transformador/.test(source)) return { family: 'equipo_electrico', subtype: 'transformador' }; if (classCode === 'moe' || /motor electr/.test(source)) return { family: 'motor', subtype: 'electrico' }; if (classCode === 'moex' || /motor de combustion|motor a explosion/.test(source)) return { family: 'motor', subtype: 'combustion' }; if (classCode === 'com' || /compresor|soplador/.test(source)) return { family: 'compresor', subtype: null }; if (classCode === 'va' || /valvula/.test(source)) { if (/vpv|presion y vacio/.test(source)) return { family: 'valvula', subtype: 'presion_vacio' }; if (/\bvs\b|seguridad/.test(source)) return { family: 'valvula', subtype: 'seguridad' }; if (/\bvr\b|reguladora/.test(source)) return { family: 'valvula', subtype: 'reguladora' }; return { family: 'valvula', subtype: null }; } if (classCode === 'caud' || /caudalimetro/.test(source)) return { family: 'instrumentacion', subtype: 'caudalimetro' }; if (classCode === 'eg' || /generador|motogenerador/.test(source)) return { family: 'generador', subtype: null }; if (classCode === 'cel' || /\bcelda\b/.test(source)) return { family: 'equipo_electrico', subtype: 'celda' }; if (classCode === 'pil' || /pileta/.test(source)) return { family: 'pileta', subtype: null }; if (classCode === 'aib' || /aparato individual de bombeo|rotaflex/.test(source)) { const subtype = /rotaflex/.test(source) ? 'rotaflex' : /mark ii/.test(source) ? 'mark_ii' : /convencional/.test(source) ? 'convencional' : null; return { family: 'sistema_extraccion', subtype }; } if (classCode === 'pcp' || /\bpcp\b/.test(source)) return { family: 'sistema_extraccion', subtype: 'pcp' }; if (/\bbes\b|electrosumerg/.test(source)) return { family: 'sistema_extraccion', subtype: 'bes' }; if (/\bpozo\b/.test(source)) return { family: 'pozo', subtype: null }; if (/oleoducto/.test(source)) return { family: 'ducto', subtype: 'oleoducto' }; if (/gasoducto/.test(source)) return { family: 'ducto', subtype: 'gasoducto' }; if (/acueducto/.test(source)) return { family: 'ducto', subtype: 'acueducto' }; if (/caneria/.test(source)) return { family: 'ducto', subtype: 'caneria' }; return { family: null, subtype: null }; } function rawObject(headers: string[], row: string[]): Record { const result: Record = {}; headers.forEach((header, index) => { if (!header.trim() && !row[index]?.trim()) return; result[header.trim() || `Columna ${index + 1}`] = row[index]?.trim() ?? ''; }); return result; } function fingerprint(value: Record): string { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); } function normalizeInventory(sheet: ParsedSheet, detection: ImportProfileDetection): ImportNormalizedRow[] { const headers = sheet.rows[detection.headerRow - 1] ?? []; const result: ImportNormalizedRow[] = []; const map = detection.columnMap; for (let index = detection.headerRow; index < sheet.rows.length; index += 1) { const row = sheet.rows[index] ?? []; if (!row.some((value) => value.trim())) continue; const areaOrField = text(row, map.areaOrField); const installation = text(row, map.installation); const subInstallation = text(row, map.subInstallation); const equipment = text(row, map.equipment); const denomination = text(row, map.equipmentDenomination); const location = text(row, map.location); const inventoryId = text(row, map.inventoryId); const quantityRaw = text(row, map.quantity); const technicalSpecs = text(row, map.technicalSpecs); const sourceStatus = text(row, map.sourceStatus); const item = text(row, map.item); const isHeaderRepeat = compact(areaOrField) === compact(headers[map.areaOrField ?? -1] ?? '') && compact(equipment) === compact(headers[map.equipment ?? -1] ?? ''); if (isHeaderRepeat) continue; const issues: string[] = []; if (!areaOrField) issues.push('MISSING_AREA_OR_YACIMIENTO'); if (!equipment && !denomination) issues.push('MISSING_EQUIPMENT_DESCRIPTION'); if (!inventoryId) issues.push('MISSING_INVENTORY_ID'); const quantity = asNumber(quantityRaw); if (quantity !== null && (!Number.isInteger(quantity) || quantity <= 0)) issues.push('INVALID_QUANTITY'); if (quantity !== null && quantity > 1) issues.push('GROUPED_QUANTITY'); const sourceState = inventoryStatusSuggestion(sourceStatus); if (sourceState.ambiguous && sourceStatus) issues.push('SOURCE_STATUS_REQUIRES_MAPPING'); const classType = splitClassType(denomination); const manufacturerModel = splitManufacturerModel(technicalSpecs); const technical = technicalNormalizationSuggestion(equipment, denomination, classType.sourceClass, classType.sourceSubtype); const normalized: Record = { item: item || null, areaOrField: areaOrField || null, installation: installation || null, subInstallation: subInstallation || null, equipment: equipment || null, sourceClassification: denomination || null, location: location || null, inventoryId: inventoryId || null, quantity: quantity ?? (quantityRaw || null), technicalSpecs: technicalSpecs || null, sourceStatus: sourceStatus || null, operationalStatusSuggestion: sourceState.operationalStatus ?? null, conditionStatusSuggestion: sourceState.conditionStatus ?? null, sourceClass: classType.sourceClass, sourceSubtype: classType.sourceSubtype, manufacturer: manufacturerModel.manufacturer, model: manufacturerModel.model, familySuggestion: technical.family, normalizedFamily: technical.family, normalizedSubtype: technical.subtype, }; const conflict = issues.includes('MISSING_EQUIPMENT_DESCRIPTION') || issues.includes('INVALID_QUANTITY'); const status: AssetImportRowStatus = conflict ? 'CONFLICT' : issues.length ? 'WARNING' : 'READY'; result.push({ profileCode: 'MENDOZA_INVENTORY_V1', rowNumber: index + 1, sheetName: sheet.name, raw: rawObject(headers, row), normalized, status, suggestedAction: conflict ? 'REVIEW' : 'CREATE', issues, fingerprint: fingerprint(normalized), }); } return applyBatchDuplicateRules(result); } function applyBatchDuplicateRules(rows: ImportNormalizedRow[]): ImportNormalizedRow[] { const groups = new Map(); for (const row of rows) { const id = String(row.normalized.inventoryId ?? '').trim().toLowerCase(); if (!id) continue; const area = plain(String(row.normalized.areaOrField ?? '')); const key = `${area}|${id}`; const current = groups.get(key) ?? []; current.push(row); groups.set(key, current); } for (const group of groups.values()) { if (group.length < 2) continue; const locations = new Set(group.map((row) => plain(`${row.normalized.installation ?? ''}|${row.normalized.subInstallation ?? ''}|${row.normalized.location ?? ''}`))); const issue = locations.size > 1 ? 'INVENTORY_ID_MULTIPLE_LOCATIONS' : 'DUPLICATE_INVENTORY_ID_IN_BATCH'; for (const row of group) { if (!row.issues.includes(issue)) row.issues.push(issue); if (issue === 'INVENTORY_ID_MULTIPLE_LOCATIONS') { row.status = 'CONFLICT'; row.suggestedAction = 'REVIEW'; } else if (row.status === 'READY') { row.status = 'WARNING'; } } } return rows; } function normalizeFields(sheet: ParsedSheet, detection: ImportProfileDetection): ImportNormalizedRow[] { const headers = sheet.rows[detection.headerRow - 1] ?? []; const result: ImportNormalizedRow[] = []; const map = detection.columnMap; for (let index = detection.headerRow; index < sheet.rows.length; index += 1) { const row = sheet.rows[index] ?? []; if (!row.some((value) => value.trim())) continue; const field = text(row, map.field); const area = text(row, map.area); const department = text(row, map.department); const rightType = text(row, map.rightType); const operator = text(row, map.operator); const issues: string[] = []; if (!field) issues.push('MISSING_FIELD'); if (!area) issues.push('MISSING_AREA'); if (!operator) issues.push('MISSING_OPERATOR'); const normalized: Record = { field: field || null, area: area || null, department: department || null, rightType: rightType || null, operator: operator || null, }; const conflict = !field || !area; result.push({ profileCode: 'MENDOZA_YACIMIENTOS_V1', rowNumber: index + 1, sheetName: sheet.name, raw: rawObject(headers, row), normalized, status: conflict ? 'CONFLICT' : issues.length ? 'WARNING' : 'READY', suggestedAction: conflict ? 'REVIEW' : 'CREATE', issues, fingerprint: fingerprint(normalized), }); } return result; } function summarize(rows: ImportNormalizedRow[]): ImportAnalysis['summary'] { const issueCounts: Record = {}; rows.forEach((row) => row.issues.forEach((issue) => { issueCounts[issue] = (issueCounts[issue] ?? 0) + 1; })); return { totalRows: rows.length, readyRows: rows.filter((row) => row.status === 'READY').length, warningRows: rows.filter((row) => row.status === 'WARNING').length, conflictRows: rows.filter((row) => row.status === 'CONFLICT').length, ignoredRows: rows.filter((row) => row.status === 'IGNORED').length, issueCounts, }; } export function analyzeImportWorkbook(workbook: ParsedWorkbook, forcedProfile?: AssetImportProfileCode): ImportAnalysis { const detected = detectImportProfile(workbook); const profileCode = forcedProfile && forcedProfile !== 'UNKNOWN' ? forcedProfile : detected.profileCode; if (profileCode === 'UNKNOWN') { return { detection: detected, rows: [], summary: { totalRows: 0, readyRows: 0, warningRows: 0, conflictRows: 0, ignoredRows: 0, issueCounts: { PROFILE_NOT_RECOGNIZED: 1 } } }; } const aliases = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? fieldAliases : inventoryAliases; let selected: { sheet: ParsedSheet; header: ReturnType } | null = null; for (const candidate of workbook.sheets) { const header = bestHeader(candidate, aliases); if (!selected || header.score > selected.header.score) selected = { sheet: candidate, header }; } if (!selected) { return { detection: detected, rows: [], summary: { totalRows: 0, readyRows: 0, warningRows: 0, conflictRows: 0, ignoredRows: 0, issueCounts: { PROFILE_NOT_RECOGNIZED: 1 } } }; } const denominator = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? 55 : 95; const confidence = Math.min(100, Math.max(0, Math.round((selected.header.score / denominator) * 100))); const detection: ImportProfileDetection = { profileCode, confidence, sheetName: selected.sheet.name, headerRow: selected.header.row, columnMap: selected.header.map, detectedHeaders: selected.header.headers }; const rows = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? normalizeFields(selected.sheet, detection) : normalizeInventory(selected.sheet, detection); return { detection, rows, summary: summarize(rows) }; } export function profileLabel(code: AssetImportProfileCode): string { if (code === 'MENDOZA_INVENTORY_V1') return 'Inventario de instalaciones · Mendoza'; if (code === 'MENDOZA_YACIMIENTOS_V1') return 'Tabla Área / Yacimiento · Mendoza'; return 'Formato no reconocido'; }