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

329 lines
14 KiB
TypeScript

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,
};
}