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; 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 = { 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>): 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): boolean { return item.action === 'REVIEW' && item.reviewCodes.length > 0 && item.reviewCodes.every((code) => PLAN_DEPENDENCY_REVIEW_CODES.has(code)); } export function planDependencyKeys(item: Pick): string[] { const keys = new Set(); 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>(items: T[]): T[] { const byKey = new Map(items.map((item) => [item.entityKey, item])); const memo = new Map(); const visiting = new Set(); 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>): AssetImportPlanStatus { return items.some((item) => item.action === 'REVIEW') ? 'REVIEW_REQUIRED' : 'READY'; } export function summarizePlanItems(items: AssetImportPlanDraftItem[]): Record { 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 = {}; 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, }; }