chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
@@ -0,0 +1,250 @@
import { execFile as execFileCallback } from 'node:child_process';
import { promisify } from 'node:util';
import { BadRequestException } from '@nestjs/common';
const execFile = promisify(execFileCallback);
export const MAX_ASSET_IMPORT_BYTES = 25 * 1024 * 1024;
const MAX_XLSX_UNCOMPRESSED_BYTES = 120 * 1024 * 1024;
const MAX_XLSX_ENTRIES = 2500;
const MAX_ROWS_PER_SHEET = 50_000;
const MAX_COLUMNS = 120;
export interface UploadedImportFile {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
}
export interface ParsedSheet {
name: string;
rows: string[][];
}
export interface ParsedWorkbook {
kind: 'XLSX' | 'CSV';
sheets: ParsedSheet[];
}
function importFileError(code: string, message: string): BadRequestException {
return new BadRequestException({ code, message });
}
export function inspectImportFile(file: UploadedImportFile | undefined): { extension: '.xlsx' | '.csv'; mimeType: string } {
if (!file?.buffer?.length) throw importFileError('IMPORT_FILE_REQUIRED', 'Seleccioná un archivo XLSX o CSV');
if (file.buffer.length > MAX_ASSET_IMPORT_BYTES) throw importFileError('IMPORT_FILE_TOO_LARGE', 'El archivo supera el límite de 25 MB');
const lower = file.originalname.toLowerCase();
if (lower.endsWith('.xlsx')) {
if (!(file.buffer[0] === 0x50 && file.buffer[1] === 0x4b)) {
throw importFileError('INVALID_XLSX_FILE', 'El contenido no corresponde a un archivo XLSX válido');
}
return { extension: '.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' };
}
if (lower.endsWith('.csv')) {
if (file.buffer.includes(0)) throw importFileError('INVALID_CSV_FILE', 'El CSV contiene datos binarios no admitidos');
return { extension: '.csv', mimeType: 'text/csv' };
}
throw importFileError('UNSUPPORTED_IMPORT_FILE', 'Sólo se admiten archivos .xlsx y .csv');
}
function decodeXml(value: string): string {
return value
.replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)))
.replace(/&#([0-9]+);/g, (_match, decimal: string) => String.fromCodePoint(Number.parseInt(decimal, 10)))
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
function stripXmlText(xml: string): string {
const parts: string[] = [];
for (const match of xml.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)) parts.push(decodeXml(match[1] ?? ''));
return parts.join('');
}
function columnIndex(reference: string): number {
const match = /^([A-Z]+)\d+$/i.exec(reference);
if (!match) return -1;
let result = 0;
for (const char of match[1]!.toUpperCase()) result = result * 26 + (char.charCodeAt(0) - 64);
return result - 1;
}
async function zipList(filePath: string): Promise<string[]> {
let stdout: string;
try {
({ stdout } = await execFile('unzip', ['-Z1', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }));
} catch {
throw importFileError('XLSX_UNZIP_UNAVAILABLE', 'No se pudo inspeccionar el XLSX. Verificá que el archivo no esté dañado');
}
const entries = stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean);
if (entries.length > MAX_XLSX_ENTRIES) throw importFileError('XLSX_TOO_COMPLEX', 'El XLSX contiene demasiados archivos internos');
if (entries.some((entry) => entry.startsWith('/') || entry.split('/').includes('..'))) {
throw importFileError('INVALID_XLSX_PATH', 'El XLSX contiene rutas internas inválidas');
}
return entries;
}
async function assertZipSize(filePath: string): Promise<void> {
try {
const { stdout } = await execFile('unzip', ['-l', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 });
const summary = stdout.split(/\r?\n/).reverse().find((line) => /\bfiles?\b/.test(line));
const bytes = summary ? Number(/^\s*(\d+)/.exec(summary)?.[1] ?? 0) : 0;
if (Number.isFinite(bytes) && bytes > MAX_XLSX_UNCOMPRESSED_BYTES) {
throw importFileError('XLSX_UNCOMPRESSED_TOO_LARGE', 'El contenido descomprimido del XLSX supera el límite de seguridad');
}
} catch (error) {
if (error instanceof BadRequestException) throw error;
throw importFileError('INVALID_XLSX_FILE', 'No se pudo leer la estructura interna del XLSX');
}
}
async function zipEntry(filePath: string, entry: string): Promise<string> {
try {
const { stdout } = await execFile('unzip', ['-p', filePath, entry], {
encoding: 'utf8',
maxBuffer: MAX_XLSX_UNCOMPRESSED_BYTES,
});
return stdout;
} catch {
throw importFileError('INVALID_XLSX_FILE', `No se pudo leer ${entry} dentro del XLSX`);
}
}
function workbookSheets(workbookXml: string, relationshipsXml: string): Array<{ name: string; path: string }> {
const relationTargets = new Map<string, string>();
for (const relation of relationshipsXml.matchAll(/<Relationship\b([^>]*)\/?\s*>/g)) {
const attributes = relation[1] ?? '';
const id = /\bId="([^"]+)"/.exec(attributes)?.[1];
const target = /\bTarget="([^"]+)"/.exec(attributes)?.[1];
if (id && target) relationTargets.set(id, target);
}
const result: Array<{ name: string; path: string }> = [];
for (const sheet of workbookXml.matchAll(/<sheet\b([^>]*)\/?\s*>/g)) {
const attributes = sheet[1] ?? '';
const name = decodeXml(/\bname="([^"]+)"/.exec(attributes)?.[1] ?? 'Hoja');
const relationId = /\br:id="([^"]+)"/.exec(attributes)?.[1];
if (!relationId) continue;
const target = relationTargets.get(relationId);
if (!target) continue;
const clean = target.replace(/^\//, '');
const path = clean.startsWith('xl/') ? clean : `xl/${clean.replace(/^\.\//, '')}`;
result.push({ name, path });
}
return result;
}
function parseSharedStrings(xml: string): string[] {
const result: string[] = [];
for (const match of xml.matchAll(/<si(?:\s[^>]*)?>([\s\S]*?)<\/si>/g)) result.push(stripXmlText(match[1] ?? ''));
return result;
}
function cellValue(cellXml: string, cellType: string | undefined, sharedStrings: string[]): string {
if (cellType === 'inlineStr') return stripXmlText(cellXml).trim();
const raw = /<v(?:\s[^>]*)?>([\s\S]*?)<\/v>/.exec(cellXml)?.[1] ?? '';
const value = decodeXml(raw);
if (cellType === 's') {
const index = Number.parseInt(value, 10);
return Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';
}
if (cellType === 'b') return value === '1' ? 'TRUE' : 'FALSE';
if (cellType === 'str') return value;
return value;
}
function parseSheetXml(xml: string, sharedStrings: string[]): string[][] {
const rows: string[][] = [];
let count = 0;
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
if (++count > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `La hoja supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`);
const values: string[] = [];
const rowXml = rowMatch[1] ?? '';
for (const cellMatch of rowXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
const attributes = cellMatch[1] ?? '';
const reference = /\br="([A-Z]+\d+)"/i.exec(attributes)?.[1];
if (!reference) continue;
const index = columnIndex(reference);
if (index < 0 || index >= MAX_COLUMNS) continue;
const type = /\bt="([^"]+)"/.exec(attributes)?.[1];
values[index] = cellValue(cellMatch[2] ?? '', type, sharedStrings).trim();
}
while (values.length && !values[values.length - 1]) values.pop();
rows.push(values.map((value) => value ?? ''));
}
return rows;
}
function detectDelimiter(firstLines: string[]): ',' | ';' | '\t' {
const candidates: Array<',' | ';' | '\t'> = [',', ';', '\t'];
let best: ',' | ';' | '\t' = ',';
let score = -1;
for (const candidate of candidates) {
const current = firstLines.reduce((sum, line) => sum + line.split(candidate).length - 1, 0);
if (current > score) { score = current; best = candidate; }
}
return best;
}
export function parseCsv(text: string): string[][] {
const normalized = text.replace(/^\uFEFF/, '');
const sample = normalized.split(/\r?\n/).slice(0, 8);
const delimiter = detectDelimiter(sample);
const rows: string[][] = [];
let row: string[] = [];
let value = '';
let quoted = false;
for (let i = 0; i < normalized.length; i += 1) {
const char = normalized[i]!;
if (char === '"') {
if (quoted && normalized[i + 1] === '"') { value += '"'; i += 1; }
else quoted = !quoted;
continue;
}
if (!quoted && char === delimiter) { row.push(value.trim()); value = ''; continue; }
if (!quoted && (char === '\n' || char === '\r')) {
if (char === '\r' && normalized[i + 1] === '\n') i += 1;
row.push(value.trim()); value = '';
if (row.some(Boolean)) rows.push(row);
row = [];
if (rows.length > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `El archivo supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`);
continue;
}
value += char;
}
if (value.length || row.length) { row.push(value.trim()); if (row.some(Boolean)) rows.push(row); }
return rows;
}
export async function parseImportWorkbook(filePath: string, extension: '.xlsx' | '.csv'): Promise<ParsedWorkbook> {
if (extension === '.csv') {
const { readFile } = await import('node:fs/promises');
const text = await readFile(filePath, 'utf8');
return { kind: 'CSV', sheets: [{ name: 'CSV', rows: parseCsv(text) }] };
}
await assertZipSize(filePath);
const entries = await zipList(filePath);
if (!entries.includes('xl/workbook.xml') || !entries.includes('xl/_rels/workbook.xml.rels')) {
throw importFileError('INVALID_XLSX_FILE', 'El archivo no contiene una estructura XLSX compatible');
}
const [workbookXml, relationshipsXml] = await Promise.all([
zipEntry(filePath, 'xl/workbook.xml'),
zipEntry(filePath, 'xl/_rels/workbook.xml.rels'),
]);
const sharedStrings = entries.includes('xl/sharedStrings.xml')
? parseSharedStrings(await zipEntry(filePath, 'xl/sharedStrings.xml'))
: [];
const sheets = workbookSheets(workbookXml, relationshipsXml);
if (!sheets.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'El XLSX no contiene hojas legibles');
const parsed: ParsedSheet[] = [];
for (const sheet of sheets.slice(0, 30)) {
if (!entries.includes(sheet.path)) continue;
const xml = await zipEntry(filePath, sheet.path);
parsed.push({ name: sheet.name, rows: parseSheetXml(xml, sharedStrings) });
}
if (!parsed.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'No se pudo leer ninguna hoja del XLSX');
return { kind: 'XLSX', sheets: parsed };
}
@@ -0,0 +1,328 @@
import { createHash } from 'node:crypto';
export type AssetImportPlanEntityKind = 'DEPARTMENT' | 'ORGANIZATION' | 'AREA' | 'AREA_DEPARTMENT_RELATION' | 'FIELD' | 'OPERATOR_RELATION' | 'LEGAL_RIGHT' | 'LEGAL_RIGHT_ORGANIZATION' | 'INSTALLATION' | 'LOCAL_STRUCTURE' | 'TECHNICAL_ASSET';
export type AssetImportPlanAction = 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
export type AssetImportPlanItemStatus = 'PLANNED' | 'MATCHED' | 'REVIEW' | 'IGNORED' | 'APPLIED' | 'ROLLED_BACK' | 'FAILED';
export type AssetImportPlanStatus = 'REVIEW_REQUIRED' | 'READY' | 'APPLIED' | 'ROLLED_BACK' | 'SUPERSEDED' | 'FAILED';
export interface AssetImportPlanDraftItem {
entityKey: string;
entityKind: AssetImportPlanEntityKind;
action: AssetImportPlanAction;
status: AssetImportPlanItemStatus;
assetTypeCode: string | null;
displayName: string;
generatedCode: string | null;
parentEntityKey: string | null;
matchedAssetId: string | null;
payload: Record<string, unknown>;
sourceRowNumbers: number[];
reviewCodes: string[];
}
export function plainImportKey(value: unknown): string {
return String(value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
export function organizationImportKey(value: unknown): string {
const tokens = plainImportKey(value).split(' ').filter(Boolean);
const output: string[] = [];
for (let index = 0; index < tokens.length;) {
if (tokens[index]!.length !== 1) { output.push(tokens[index]!); index += 1; continue; }
const letters: string[] = [];
let cursor = index;
while (cursor < tokens.length && tokens[cursor]!.length === 1) { letters.push(tokens[cursor]!); cursor += 1; }
output.push(letters.length >= 2 ? letters.join('') : letters[0]!);
index = cursor;
}
return output.join(' ');
}
export function isExplicitlyUnassignedOperator(value: unknown): boolean {
return plainImportKey(value) === 'sin empresa operadora';
}
export type NormalizedLegalRightType = 'EXPLOITATION_CONCESSION' | 'EXPLORATION_PERMIT' | 'TRANSPORT_CONCESSION' | 'OTHER';
export function normalizedLegalRightType(value: unknown): NormalizedLegalRightType | null {
const normalized = plainImportKey(value);
if (normalized === 'explotacion') return 'EXPLOITATION_CONCESSION';
if (normalized === 'exploracion') return 'EXPLORATION_PERMIT';
if (normalized === 'transporte' || normalized === 'concesion de transporte') return 'TRANSPORT_CONCESSION';
if (!normalized) return null;
return 'OTHER';
}
export function legalRightTypeLabel(value: NormalizedLegalRightType): string {
if (value === 'EXPLOITATION_CONCESSION') return 'Concesión de explotación';
if (value === 'EXPLORATION_PERMIT') return 'Permiso de exploración';
if (value === 'TRANSPORT_CONCESSION') return 'Concesión de transporte';
return 'Otro derecho';
}
export function departmentCode(value: unknown): string {
const normalized = String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase().replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 70);
return normalized || 'SIN-DEPARTAMENTO';
}
export function externalIdNamespace(input: string | null | undefined, originalName: string): string {
const seed = (input?.trim() || originalName.replace(/\.[^.]+$/, '').split(/[-_]/)[0] || 'IMPORT').normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const normalized = seed.toUpperCase().replace(/[^A-Z0-9._/-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
if (normalized.length >= 2 && /^[A-Z0-9]/.test(normalized)) return normalized;
return 'IMPORT';
}
export function generatedImportCode(planId: string, typeCode: string, entityKey: string): string {
const prefix = typeCode.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 10) || 'ACT';
const digest = createHash('sha256').update(`${planId}\u001f${entityKey}`).digest('hex').slice(0, 12).toUpperCase();
return `IMP-${prefix}-${digest}`;
}
export function technicalFamilyTypeCode(family: unknown, subtype: unknown): string {
const normalizedFamily = plainImportKey(family).replace(/ /g, '_');
const normalizedSubtype = plainImportKey(subtype).replace(/ /g, '_');
const direct = new Set(['tanque','separador','bomba','caldera','antorcha','colector','filtro','calentador','ducto','pozo']);
if (direct.has(normalizedFamily)) return normalizedFamily;
if (normalizedFamily === 'pileta') return 'pileta_api';
if (normalizedFamily === 'defensa_incendios') return 'sistema_defensa_incendios';
if (normalizedFamily === 'instalacion' && normalizedSubtype === 'planta') return 'planta';
if (normalizedFamily === 'instalacion' && normalizedSubtype === 'bateria') return 'bateria';
return 'equipo';
}
export interface SourceLocalStructureSuggestion {
displayName: string;
sourcePath: string[];
sourceInstallation: string | null;
sourceSubInstallation: string | null;
concreteFromLocation: boolean;
sourceGroupOnly: boolean;
}
export function sourceLocalStructureSuggestion(
areaOrField: unknown,
installation: unknown,
subInstallation: unknown,
location: unknown,
): SourceLocalStructureSuggestion | null {
const areaText = String(areaOrField ?? '').trim();
const installationText = String(installation ?? '').trim();
const subInstallationText = String(subInstallation ?? '').trim();
const locationText = String(location ?? '').trim();
const unusable = (value: string) => !value || ['-', 'n/a', 'na', 's/d', 'sd', 'sin dato', 'sin datos'].includes(plainImportKey(value));
const sourcePath = locationText.split('/').map((segment) => segment.trim()).filter((segment) => !unusable(segment));
const areaKey = plainImportKey(areaText);
const installationKey = plainImportKey(installationText);
const subInstallationKey = plainImportKey(subInstallationText);
const genericProvinceKeys = new Set(['mendoza', 'provincia de mendoza']);
const genericLocationKeys = new Set([
'yacimiento','planta','energia','edilicio','transporte','repositorio','repositorios','op digitales',
'bateria','set','pta','ptc','em','pcg','et','oficina','estacion de servicio','taller','almacen',
]);
const contextualSegments = sourcePath.filter((segment, index) => {
const key = plainImportKey(segment);
if (!key) return false;
if (index === 0 && genericProvinceKeys.has(key)) return false;
if (areaKey && key === areaKey) return false;
return true;
});
const concrete = [...contextualSegments].reverse().find((segment) => {
const key = plainImportKey(segment);
if (!key) return false;
if (installationKey && key === installationKey) return false;
if (subInstallationKey && key === subInstallationKey) return false;
if (/^pozo\b/.test(key)) return false;
return !genericLocationKeys.has(key);
}) ?? null;
if (concrete) {
return {
displayName: concrete.slice(0, 200),
sourcePath,
sourceInstallation: unusable(installationText) ? null : installationText,
sourceSubInstallation: unusable(subInstallationText) ? null : subInstallationText,
concreteFromLocation: true,
sourceGroupOnly: false,
};
}
const categoryParts = [installationText, subInstallationText]
.filter((value) => !unusable(value))
.filter((value, index, values) => values.findIndex((candidate) => plainImportKey(candidate) === plainImportKey(value)) === index);
if (!categoryParts.length) return null;
return {
displayName: categoryParts.join(' / ').slice(0, 200),
sourcePath,
sourceInstallation: unusable(installationText) ? null : installationText,
sourceSubInstallation: unusable(subInstallationText) ? null : subInstallationText,
concreteFromLocation: false,
sourceGroupOnly: true,
};
}
export function sourceContainerTypeCode(installation: unknown, subInstallation?: unknown): string {
const parent = plainImportKey(installation);
const sub = plainImportKey(subInstallation);
if (sub === 'bateria') return 'bateria';
if (['pta', 'ptc', 'pcg'].includes(sub)) return 'planta';
if (sub === 'estacion de servicio') return 'estacion';
if (/\bplanta\b/.test(parent)) return 'planta';
if (/\bbateria\b/.test(parent)) return 'bateria';
if (/\bsubestacion\b/.test(parent)) return 'subestacion';
if (/\bestacion\b/.test(parent)) return 'estacion';
if (/\blocacion\b/.test(parent)) return 'locacion';
return 'instalacion';
}
export function sourceContainerName(installation: unknown, subInstallation: unknown, location?: unknown): string | null {
const sub = String(subInstallation ?? '').trim();
const parent = String(installation ?? '').trim();
const locationText = String(location ?? '').trim();
const unusable = (value: string) => !value || ['-', 'n/a', 'na', 's/d', 'sd', 'sin dato', 'sin datos'].includes(plainImportKey(value));
const genericSub = new Set(['bateria','set','pta','ptc','em','pcg','et','transporte','oficina','repositorio','repositorios','estacion de servicio','taller','op digitales','almacen']);
const subKey = plainImportKey(sub);
if (!unusable(sub) && !genericSub.has(subKey)) return sub;
const segments = locationText.split('/').map((segment) => segment.trim()).filter(Boolean);
const prefixes: Record<string, RegExp> = {
bateria: /^BAT[A-Z0-9-]/i,
pta: /^PTA[A-Z0-9-]/i,
ptc: /^(PTC[A-Z0-9-]|ESTACION DE BOMBEO)/i,
pcg: /^PCG[A-Z0-9-]/i,
};
const prefix = prefixes[subKey];
if (prefix) {
const candidate = segments.find((segment) => plainImportKey(segment) !== subKey && prefix.test(segment));
if (candidate) return candidate;
}
if (!unusable(parent) && !['planta','bateria','estacion','subestacion','yacimiento','locacion','energia','edilicio'].includes(plainImportKey(parent))) return parent;
return null;
}
export function planItemHash(items: Array<Pick<AssetImportPlanDraftItem, 'entityKey' | 'entityKind' | 'action' | 'status' | 'assetTypeCode' | 'displayName' | 'generatedCode' | 'parentEntityKey' | 'matchedAssetId' | 'payload' | 'sourceRowNumbers' | 'reviewCodes'>>): string {
const canonical = items
.map((item) => ({
entityKey: item.entityKey,
entityKind: item.entityKind,
action: item.action,
status: item.status,
assetTypeCode: item.assetTypeCode,
displayName: item.displayName,
generatedCode: item.generatedCode,
parentEntityKey: item.parentEntityKey,
matchedAssetId: item.matchedAssetId,
payload: item.payload,
sourceRowNumbers: [...item.sourceRowNumbers].sort((a, b) => a - b),
reviewCodes: [...item.reviewCodes].sort(),
}))
.sort((a, b) => a.entityKey.localeCompare(b.entityKey));
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
}
export const PLAN_DEPENDENCY_REVIEW_CODES = new Set([
'PLAN_DEPARTMENT_REVIEW_REQUIRED',
'PLAN_LEGAL_RIGHT_REVIEW_REQUIRED',
'PLAN_AREA_REVIEW_REQUIRED',
'PLAN_ORGANIZATION_REVIEW_REQUIRED',
'PLAN_TERRITORY_CONTEXT_REQUIRED',
'PLAN_CONTEXT_DECISION_REQUIRED',
'PLAN_CONTAINER_REVIEW_REQUIRED',
'PLAN_PARENT_NOT_RESOLVED',
]);
export function isPlanDependencyReview(item: Pick<AssetImportPlanDraftItem, 'action' | 'reviewCodes'>): boolean {
return item.action === 'REVIEW'
&& item.reviewCodes.length > 0
&& item.reviewCodes.every((code) => PLAN_DEPENDENCY_REVIEW_CODES.has(code));
}
export function planDependencyKeys(item: Pick<AssetImportPlanDraftItem, 'entityKey' | 'parentEntityKey' | 'payload'>): string[] {
const keys = new Set<string>();
if (item.parentEntityKey) keys.add(item.parentEntityKey);
for (const key of ['areaEntityKey','organizationEntityKey','departmentEntityKey','legalRightEntityKey','operationalAreaEntityKey','operatorEntityKey','localStructureEntityKey']) {
const value = item.payload[key];
if (typeof value === 'string' && value.trim()) keys.add(value.trim());
}
keys.delete(item.entityKey);
return [...keys];
}
export function safePlanItems<T extends Pick<AssetImportPlanDraftItem, 'entityKey' | 'parentEntityKey' | 'payload' | 'action' | 'status'>>(items: T[]): T[] {
const byKey = new Map(items.map((item) => [item.entityKey, item]));
const memo = new Map<string, boolean>();
const visiting = new Set<string>();
const isSafe = (item: T): boolean => {
if (item.status === 'APPLIED') return true;
if (item.action === 'REVIEW') return false;
const cached = memo.get(item.entityKey);
if (cached !== undefined) return cached;
if (visiting.has(item.entityKey)) return false;
visiting.add(item.entityKey);
const safe = planDependencyKeys(item).every((key) => {
const dependency = byKey.get(key);
return !dependency || isSafe(dependency);
});
visiting.delete(item.entityKey);
memo.set(item.entityKey, safe);
return safe;
};
return items.filter((item) => isSafe(item));
}
export function planStatusForItems(items: Array<Pick<AssetImportPlanDraftItem, 'action'>>): AssetImportPlanStatus {
return items.some((item) => item.action === 'REVIEW') ? 'REVIEW_REQUIRED' : 'READY';
}
export function summarizePlanItems(items: AssetImportPlanDraftItem[]): Record<string, unknown> {
const actionCounts = { create: 0, match: 0, review: 0, ignore: 0 };
let directReviewItems = 0;
let dependencyReviewItems = 0;
let appliedCreateItems = 0;
let pendingCreateItems = 0;
const byKind: Record<string, { create: number; match: number; review: number; ignore: number; total: number }> = {};
for (const item of items) {
const action = item.action.toLowerCase() as keyof typeof actionCounts;
actionCounts[action] += 1;
if (item.action === 'CREATE') {
if (item.status === 'APPLIED') appliedCreateItems += 1;
else pendingCreateItems += 1;
}
if (item.action === 'REVIEW') {
if (isPlanDependencyReview(item)) dependencyReviewItems += 1;
else directReviewItems += 1;
}
const current = byKind[item.entityKind] ?? { create: 0, match: 0, review: 0, ignore: 0, total: 0 };
current[action] += 1;
current.total += 1;
byKind[item.entityKind] = current;
}
const safeCreateItems = safePlanItems(items).filter((item) => item.action === 'CREATE' && item.status !== 'APPLIED').length;
return {
totalItems: items.length,
createItems: actionCounts.create,
matchItems: actionCounts.match,
reviewItems: actionCounts.review,
directReviewItems,
dependencyReviewItems,
ignoreItems: actionCounts.ignore,
appliedCreateItems,
pendingCreateItems,
safeCreateItems,
blockedCreateItems: Math.max(0, pendingCreateItems - safeCreateItems),
partialApplied: appliedCreateItems > 0 && actionCounts.review > 0,
byKind,
blocked: actionCounts.review > 0,
};
}
@@ -0,0 +1,447 @@
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';
}
@@ -0,0 +1,158 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Req, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AssetImportsService, MAX_ASSET_IMPORT_BYTES, type UploadedImportFile } from './asset-imports.service';
import { ApplyAssetImportPlanDto } from './dto/apply-asset-import-plan.dto';
import { CancelAssetImportDto } from './dto/cancel-asset-import.dto';
import { CreateAssetImportPlanDto } from './dto/create-asset-import-plan.dto';
import { ResolveAssetImportPlanItemDto } from './dto/resolve-asset-import-plan-item.dto';
import { RollbackAssetImportPlanDto } from './dto/rollback-asset-import-plan.dto';
import { ListAssetImportPlanItemsQueryDto } from './dto/list-asset-import-plan-items-query.dto';
import { ListAssetImportReviewsQueryDto } from './dto/list-asset-import-reviews-query.dto';
import { ListAssetImportRowsQueryDto } from './dto/list-asset-import-rows-query.dto';
import { ListAssetImportsQueryDto } from './dto/list-asset-imports-query.dto';
import { UploadAssetImportDto } from './dto/upload-asset-import.dto';
@Controller('asset-imports')
export class AssetImportsController {
constructor(private readonly imports: AssetImportsService) {}
@Get()
@RequirePermissions('asset_imports.read')
list(@Query() query: ListAssetImportsQueryDto) {
return this.imports.list(query);
}
@Post()
@RequirePermissions('asset_imports.manage')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ASSET_IMPORT_BYTES, files: 1 } }))
upload(
@Body() dto: UploadAssetImportDto,
@UploadedFile() file: UploadedImportFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.upload(dto, file, principal, request);
}
@Get('context/organizations')
@RequirePermissions('asset_imports.read')
organizations(@Query('search') search?: string) {
return this.imports.organizationOptions(search);
}
@Get('reviews')
@RequirePermissions('asset_imports.read')
reviews(@Query() query: ListAssetImportReviewsQueryDto) {
return this.imports.reviews(query);
}
@Get(':id')
@RequirePermissions('asset_imports.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.imports.get(id);
}
@Get(':id/plan')
@RequirePermissions('asset_imports.read')
plan(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.imports.plan(id);
}
@Get(':id/plan/items')
@RequirePermissions('asset_imports.read')
planItems(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Query() query: ListAssetImportPlanItemsQueryDto,
) {
return this.imports.planItems(id, query);
}
@Post(':id/plan')
@RequirePermissions('asset_imports.manage')
generatePlan(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: CreateAssetImportPlanDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.generatePlan(id, dto, principal, request);
}
@Post(':id/plan/items/:itemId/resolve')
@RequirePermissions('asset_imports.manage')
resolvePlanItem(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Param('itemId', new ParseUUIDPipe({ version: '4' })) itemId: string,
@Body() dto: ResolveAssetImportPlanItemDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.resolvePlanItem(id, itemId, dto, principal, request);
}
@Post(':id/apply-safe')
@RequirePermissions('asset_imports.apply')
applySafePlan(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ApplyAssetImportPlanDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.applySafePlan(id, dto, principal, request);
}
@Post(':id/apply')
@RequirePermissions('asset_imports.apply')
applyPlan(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ApplyAssetImportPlanDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.applyPlan(id, dto, principal, request);
}
@Post(':id/rollback')
@RequirePermissions('asset_imports.apply')
rollbackPlan(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: RollbackAssetImportPlanDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.rollbackPlan(id, dto, principal, request);
}
@Get(':id/rows')
@RequirePermissions('asset_imports.read')
rows(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Query() query: ListAssetImportRowsQueryDto,
) {
return this.imports.rows(id, query);
}
@Post(':id/reconcile')
@RequirePermissions('asset_imports.manage')
reconcile(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.reconcile(id, principal, request);
}
@Post(':id/cancel')
@RequirePermissions('asset_imports.manage')
cancel(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: CancelAssetImportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.imports.cancel(id, dto, principal, request);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { AssetImportsController } from './asset-imports.controller';
import { AssetImportsService } from './asset-imports.service';
@Module({
imports: [AuditModule],
controllers: [AssetImportsController],
providers: [AssetImportsService],
})
export class AssetImportsModule {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
import { IsString, Matches } from 'class-validator';
export class ApplyAssetImportPlanDto {
@IsString()
@Matches(/^[0-9a-f]{64}$/)
planHash!: string;
}
@@ -0,0 +1,5 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class CancelAssetImportDto {
@IsString() @MinLength(5) @MaxLength(500) reason!: string;
}
@@ -0,0 +1,13 @@
import { IsOptional, IsString, IsUUID, Matches, MaxLength } from 'class-validator';
export class CreateAssetImportPlanDto {
@IsOptional()
@IsUUID('4')
operatorAssetId?: string;
@IsOptional()
@IsString()
@MaxLength(80)
@Matches(/^[A-Z0-9][A-Z0-9._/-]{1,79}$/i)
externalIdNamespace?: string;
}
@@ -0,0 +1,14 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
const entityKinds = [
'DEPARTMENT','ORGANIZATION','AREA','AREA_DEPARTMENT_RELATION','FIELD','OPERATOR_RELATION',
'LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET',
] as const;
export class ListAssetImportPlanItemsQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize = 50;
@IsOptional() @IsIn(entityKinds) entityKind?: typeof entityKinds[number];
@IsOptional() @IsIn(['CREATE','MATCH','REVIEW','IGNORE']) action?: 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
}
@@ -0,0 +1,8 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListAssetImportReviewsQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 30;
@IsOptional() @IsIn(['ALL','DIRECT','DEPENDENCY']) kind: 'ALL' | 'DIRECT' | 'DEPENDENCY' = 'ALL';
}
@@ -0,0 +1,9 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class ListAssetImportRowsQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize = 50;
@IsOptional() @IsIn(['READY','WARNING','CONFLICT','IGNORED']) status?: string;
@IsOptional() @IsString() @MaxLength(160) search?: string;
}
@@ -0,0 +1,8 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListAssetImportsQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 20;
@IsOptional() @IsIn(['ANALYZED','REVIEW_REQUIRED','CANCELLED','FAILED']) status?: string;
}
@@ -0,0 +1,15 @@
import { IsIn, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateIf } from 'class-validator';
export class ResolveAssetImportPlanItemDto {
@IsIn(['CREATE', 'MATCH', 'IGNORE'])
action!: 'CREATE' | 'MATCH' | 'IGNORE';
@ValidateIf((value: ResolveAssetImportPlanItemDto) => value.action === 'MATCH')
@IsUUID('4')
matchedAssetId?: string;
@IsString()
@MinLength(5)
@MaxLength(1000)
reason!: string;
}
@@ -0,0 +1,8 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class RollbackAssetImportPlanDto {
@IsString()
@MinLength(8)
@MaxLength(1000)
reason!: string;
}
@@ -0,0 +1,18 @@
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import type { AssetImportProfileCode } from '../asset-import-profiles';
export class UploadAssetImportDto {
@IsOptional()
@IsString()
@MaxLength(240)
sourceLabel?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsIn(['MENDOZA_INVENTORY_V1', 'MENDOZA_YACIMIENTOS_V1'])
profileCode?: Exclude<AssetImportProfileCode, 'UNKNOWN'>;
}