329 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|