Files
dh-inspeccion-v2/api-v3/src/asset-imports/asset-import-profiles.ts
T

448 lines
22 KiB
TypeScript

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<string, number>;
detectedHeaders: string[];
}
export interface ImportNormalizedRow {
profileCode: AssetImportProfileCode;
rowNumber: number;
sheetName: string;
raw: Record<string, string>;
normalized: Record<string, unknown>;
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<string, number>;
};
}
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<string, string[]> = {
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<string, string[]> = {
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<string, string[]>): { map: Record<string, number>; 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<string>();
const usedIndexes = new Set<number>();
const map: Record<string, number> = {};
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<string, string[]>): { row: number; map: Record<string, number>; score: number; headers: string[] } {
let best = { row: 0, map: {} as Record<string, number>, 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<string, string> {
const result: Record<string, string> = {};
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, unknown>): 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<string, unknown> = {
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<string, ImportNormalizedRow[]>();
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<string, unknown> = {
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<string, number> = {};
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<typeof bestHeader> } | 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';
}