import { createHash, randomUUID } from 'node:crypto'; import { mkdir, unlink, writeFile } from 'node:fs/promises'; import { isAbsolute, parse, resolve } from 'node:path'; import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DataSource, EntityManager } from 'typeorm'; import { administrationAuditContext } from '../administration/common/administration-audit'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { AuditAction, SourceDocumentType } from '../database/entities'; import { analyzeImportWorkbook, profileLabel, type AssetImportProfileCode, type ImportNormalizedRow } from './asset-import-profiles'; import { inspectImportFile, MAX_ASSET_IMPORT_BYTES, parseImportWorkbook, type UploadedImportFile } from './asset-import-parser'; import type { ApplyAssetImportPlanDto } from './dto/apply-asset-import-plan.dto'; import type { CancelAssetImportDto } from './dto/cancel-asset-import.dto'; import type { CreateAssetImportPlanDto } from './dto/create-asset-import-plan.dto'; import type { ResolveAssetImportPlanItemDto } from './dto/resolve-asset-import-plan-item.dto'; import type { RollbackAssetImportPlanDto } from './dto/rollback-asset-import-plan.dto'; import type { ListAssetImportPlanItemsQueryDto } from './dto/list-asset-import-plan-items-query.dto'; import type { ListAssetImportReviewsQueryDto } from './dto/list-asset-import-reviews-query.dto'; import type { ListAssetImportRowsQueryDto } from './dto/list-asset-import-rows-query.dto'; import type { ListAssetImportsQueryDto } from './dto/list-asset-imports-query.dto'; import type { UploadAssetImportDto } from './dto/upload-asset-import.dto'; import { departmentCode, externalIdNamespace, generatedImportCode, isExplicitlyUnassignedOperator, legalRightTypeLabel, normalizedLegalRightType, organizationImportKey, planItemHash, planStatusForItems, safePlanItems, plainImportKey, sourceContainerName, sourceContainerTypeCode, sourceLocalStructureSuggestion, summarizePlanItems, technicalFamilyTypeCode, type AssetImportPlanAction, type AssetImportPlanDraftItem, type AssetImportPlanEntityKind, type AssetImportPlanItemStatus, } from './asset-import-plan'; export { MAX_ASSET_IMPORT_BYTES, type UploadedImportFile } from './asset-import-parser'; export interface ImportBatchRow { id: string; originalName: string; mimeType: string; sizeBytes: number; sha256: string; profileCode: string; profileConfidence: number; worksheetName: string | null; headerRow: number | null; totalRows: number; readyRows: number; warningRows: number; conflictRows: number; ignoredRows: number; status: string; sourceDocumentId: string | null; sourceLabel: string | null; notes: string | null; analysis: Record; uploadedBy: string | null; uploadedByUsername: string | null; analyzedAt: Date; createdAt: Date; updatedAt: Date; } export interface ImportReconciliationSummary { totalRows: number; createRows: number; matchRows: number; reviewRows: number; ignoreRows: number; exactExternalIdMatches: number; exactCodeMatches: number; exactTerritoryMatches: number; exactOrganizationMatches: number; multipleMatches: number; reconciledAt: string; } export interface ImportPlanRow { id: string; batchId: string; revision: number; status: string; externalIdNamespace: string | null; operatorAssetId: string | null; summary: Record; planHash: string; generatedBy: string | null; generatedAt: Date; appliedBy: string | null; appliedAt: Date | null; applicationSummary: Record | null; rolledBackBy: string | null; rolledBackAt: Date | null; rollbackSummary: Record | null; supersededAt: Date | null; createdAt: Date; updatedAt: Date; } export interface PlanItemRow { id: string; planId: string; itemOrder: number; entityKey: string; entityKind: AssetImportPlanEntityKind; action: AssetImportPlanAction; status: AssetImportPlanItemStatus; assetTypeCode: string | null; displayName: string; generatedCode: string | null; parentEntityKey: string | null; matchedAssetId: string | null; appliedAssetId: string | null; appliedObjectId: string | null; payload: Record; sourceRowNumbers: number[]; reviewCodes: string[]; resolutionNote: string | null; resolvedBy: string | null; resolvedAt: Date | null; } interface ImportSourceRow { id: string; rowNumber: number; status: string; suggestedAction: string; normalizedData: Record; issues: string[]; matchedAssetId: string | null; } interface AssetMatch { id: string; code: string; name: string; typeCode: string; parentId: string | null; operationalAreaId: string | null; operatorCompanyId: string | null; } function importNotFound(): NotFoundException { return new NotFoundException({ code: 'ASSET_IMPORT_NOT_FOUND', message: 'Lote de importación no encontrado' }); } @Injectable() export class AssetImportsService { private readonly storageRoot: string; constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, config: ConfigService, ) { const configured = config.get('ASSET_IMPORT_ROOT') ?? '/app/storage/asset-media/imports'; if (!isAbsolute(configured)) throw new Error('ASSET_IMPORT_ROOT must be an absolute path'); this.storageRoot = resolve(configured); if (this.storageRoot === parse(this.storageRoot).root) throw new Error('ASSET_IMPORT_ROOT cannot be the filesystem root'); } async list(query: ListAssetImportsQueryDto) { const parameters: unknown[] = []; const conditions: string[] = []; if (query.status) { parameters.push(query.status); conditions.push(`batch.status = $${parameters.length}::asset_import_batch_status`); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const [count] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_import_batches batch ${where}`, parameters)) as Array<{ total: number }>; parameters.push(query.pageSize); const limit = `$${parameters.length}`; parameters.push((query.page - 1) * query.pageSize); const offset = `$${parameters.length}`; const data = (await this.dataSource.query(`${this.batchSelect()} ${where} ORDER BY batch.created_at DESC LIMIT ${limit} OFFSET ${offset}`, parameters)) as ImportBatchRow[]; const total = Number(count?.total ?? 0); return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total ? Math.ceil(total / query.pageSize) : 0 } }; } async reviews(query: ListAssetImportReviewsQueryDto) { const dependencyCodes = '["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"]'; const params: unknown[] = []; const conditions = ["plan.superseded_at IS NULL", "plan.status='REVIEW_REQUIRED'", "item.action='REVIEW'", "batch.status<>'CANCELLED'"]; if (query.kind === 'DIRECT') conditions.push(`NOT (item.review_codes <@ '${dependencyCodes}'::jsonb)`); if (query.kind === 'DEPENDENCY') conditions.push(`item.review_codes <@ '${dependencyCodes}'::jsonb`); const where = `WHERE ${conditions.join(' AND ')}`; const [count] = (await this.dataSource.query(` SELECT COUNT(*)::integer AS total FROM asset_import_plan_items item JOIN asset_import_plans plan ON plan.id=item.plan_id JOIN asset_import_batches batch ON batch.id=plan.batch_id ${where} `, params)) as Array<{ total: number }>; params.push(query.pageSize); const limit = `$${params.length}`; params.push((query.page - 1) * query.pageSize); const offset = `$${params.length}`; const data = await this.dataSource.query(` SELECT item.id,item.plan_id AS "planId",item.item_order AS "itemOrder",item.entity_key AS "entityKey",item.entity_kind AS "entityKind", item.action,item.status,item.asset_type_code AS "assetTypeCode",item.display_name AS "displayName",item.generated_code AS "generatedCode", item.parent_entity_key AS "parentEntityKey",item.matched_asset_id AS "matchedAssetId",item.applied_asset_id AS "appliedAssetId", item.applied_object_id AS "appliedObjectId",item.payload,item.source_row_numbers AS "sourceRowNumbers",item.review_codes AS "reviewCodes", item.resolution_note AS "resolutionNote",item.resolved_by AS "resolvedBy",item.resolved_at AS "resolvedAt", batch.id AS "batchId",batch.original_name AS "batchName",batch.source_label AS "sourceLabel",batch.profile_code AS "profileCode", plan.revision AS "planRevision",plan.status AS "planStatus", (item.review_codes <@ '${dependencyCodes}'::jsonb) AS dependency FROM asset_import_plan_items item JOIN asset_import_plans plan ON plan.id=item.plan_id JOIN asset_import_batches batch ON batch.id=plan.batch_id ${where} ORDER BY dependency ASC,batch.created_at DESC,item.item_order LIMIT ${limit} OFFSET ${offset} `, params); const total = Number(count?.total ?? 0); return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total ? Math.ceil(total / query.pageSize) : 0 } }; } async get(id: string) { const batch = await this.loadBatch(this.dataSource.manager, id); const topIssues = await this.dataSource.query(` SELECT issue AS code, COUNT(*)::integer AS count FROM asset_import_rows row, jsonb_array_elements_text(row.issue_codes) issue WHERE row.batch_id=$1 GROUP BY issue ORDER BY COUNT(*) DESC, issue LIMIT 20 `, [id]); const duplicateFiles = await this.dataSource.query(` SELECT id, original_name AS "originalName", created_at AS "createdAt", status FROM asset_import_batches WHERE sha256=$1 AND id<>$2 ORDER BY created_at DESC LIMIT 10 `, [batch.sha256, id]); return { ...batch, topIssues, duplicateFiles }; } async rows(id: string, query: ListAssetImportRowsQueryDto) { await this.loadBatch(this.dataSource.manager, id); const parameters: unknown[] = [id]; const conditions = ['row.batch_id=$1']; if (query.status) { parameters.push(query.status); conditions.push(`row.status=$${parameters.length}::asset_import_row_status`); } if (query.search?.trim()) { parameters.push(`%${query.search.trim()}%`); const p = `$${parameters.length}`; conditions.push(`(row.normalized_data::text ILIKE ${p} OR row.raw_data::text ILIKE ${p})`); } const where = `WHERE ${conditions.join(' AND ')}`; const [count] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_import_rows row ${where}`, parameters)) as Array<{ total: number }>; parameters.push(query.pageSize); const limit = `$${parameters.length}`; parameters.push((query.page - 1) * query.pageSize); const offset = `$${parameters.length}`; const data = await this.dataSource.query(` SELECT row.id,row.worksheet_name AS "worksheetName",row.row_number AS "rowNumber",row.status, row.suggested_action AS "suggestedAction",row.raw_data AS "rawData",row.normalized_data AS "normalizedData", row.issue_codes AS issues,row.fingerprint,row.matched_asset_id AS "matchedAssetId",row.imported_asset_id AS "importedAssetId", row.created_at AS "createdAt",row.updated_at AS "updatedAt" FROM asset_import_rows row ${where} ORDER BY row.row_number LIMIT ${limit} OFFSET ${offset} `, parameters); const total = Number(count?.total ?? 0); return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total ? Math.ceil(total / query.pageSize) : 0 } }; } async organizationOptions(search?: string): Promise<{ data: Array<{ id: string; code: string; name: string; legalName: string | null }> }> { const parameters: unknown[] = []; let filter = ''; if (search?.trim()) { parameters.push(`%${search.trim()}%`); filter = `AND (asset.code ILIKE $1 OR asset.name ILIKE $1 OR profile.legal_name ILIKE $1)`; } const data = (await this.dataSource.query(` SELECT asset.id,asset.code,asset.name,profile.legal_name AS "legalName" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id LEFT JOIN organization_profiles profile ON profile.asset_id=asset.id WHERE type.operational_role='COMPANY' AND type.is_active=true AND asset.information_status<>'INACTIVE' ${filter} ORDER BY asset.name,asset.code LIMIT 500 `, parameters)) as Array<{ id: string; code: string; name: string; legalName: string | null }>; return { data }; } async plan(id: string): Promise<(ImportPlanRow & { previewItems: PlanItemRow[]; reviewItems: PlanItemRow[]; masterStateStale: boolean; masterStateHashAvailable: boolean; masterStateCheckedAt: string }) | null> { await this.loadBatch(this.dataSource.manager, id); const plan = await this.loadActivePlan(this.dataSource.manager, id); if (!plan) return null; const previewItems = await this.loadPlanItems(this.dataSource.manager, plan.id, `ORDER BY item_order LIMIT 80`); const reviewItems = await this.loadPlanItems(this.dataSource.manager, plan.id, `AND action='REVIEW' ORDER BY CASE WHEN review_codes <@ '["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"]'::jsonb THEN 1 ELSE 0 END, item_order LIMIT 80`); const masterState = await this.planMasterState(this.dataSource.manager, plan); return { ...plan, previewItems, reviewItems, ...masterState }; } async planItems(id: string, query: ListAssetImportPlanItemsQueryDto) { await this.loadBatch(this.dataSource.manager, id); const plan = await this.loadActivePlan(this.dataSource.manager, id); if (!plan) throw new NotFoundException({ code: 'ASSET_IMPORT_PLAN_NOT_FOUND', message: 'Plan de importación no encontrado' }); const params: unknown[] = [plan.id]; const conditions = ['plan_id=$1']; if (query.entityKind) { params.push(query.entityKind); conditions.push(`entity_kind=$${params.length}`); } if (query.action) { params.push(query.action); conditions.push(`action=$${params.length}`); } const where = `WHERE ${conditions.join(' AND ')}`; const [count] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_import_plan_items ${where}`, params)) as Array<{ total: number }>; params.push(query.pageSize); const limit = `$${params.length}`; params.push((query.page - 1) * query.pageSize); const offset = `$${params.length}`; const data = (await this.dataSource.query(` SELECT id,plan_id AS "planId",item_order AS "itemOrder",entity_key AS "entityKey",entity_kind AS "entityKind",action,status, asset_type_code AS "assetTypeCode",display_name AS "displayName",generated_code AS "generatedCode",parent_entity_key AS "parentEntityKey", matched_asset_id AS "matchedAssetId",applied_asset_id AS "appliedAssetId",applied_object_id AS "appliedObjectId",payload, source_row_numbers AS "sourceRowNumbers",review_codes AS "reviewCodes",resolution_note AS "resolutionNote",resolved_by AS "resolvedBy",resolved_at AS "resolvedAt" FROM asset_import_plan_items ${where} ORDER BY item_order LIMIT ${limit} OFFSET ${offset} `, params)) as PlanItemRow[]; const total = Number(count?.total ?? 0); return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total ? Math.ceil(total / query.pageSize) : 0 } }; } async generatePlan(id: string, dto: CreateAssetImportPlanDto, principal: AuthPrincipal, request: RequestWithContext) { return this.dataSource.transaction(async (manager) => { const batch = await this.loadBatch(manager, id, true); if (batch.status === 'CANCELLED') throw new ConflictException({ code: 'ASSET_IMPORT_CANCELLED', message: 'El lote está cancelado' }); if (!batch.analysis?.reconciliation) throw new ConflictException({ code: 'ASSET_IMPORT_RECONCILIATION_REQUIRED', message: 'Conciliá el lote con el Maestro antes de generar el plan' }); const current = await this.loadActivePlan(manager, id, true); if (current?.status === 'APPLIED') throw new ConflictException({ code: 'ASSET_IMPORT_ALREADY_APPLIED', message: 'El lote ya tiene un plan aplicado. Revertí el lote antes de generar otro plan.' }); if (current) { await manager.query(`UPDATE asset_import_plans SET status='SUPERSEDED',superseded_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [current.id]); } const [revisionRow] = (await manager.query(`SELECT COALESCE(MAX(revision),0)::integer+1 AS revision FROM asset_import_plans WHERE batch_id=$1`, [id])) as Array<{ revision: number }>; const revision = Number(revisionRow?.revision ?? 1); const planId = randomUUID(); const namespace = batch.profileCode === 'MENDOZA_INVENTORY_V1' ? externalIdNamespace(dto.externalIdNamespace, batch.originalName) : null; let operatorAssetId: string | null = null; if (batch.profileCode === 'MENDOZA_INVENTORY_V1') { if (!dto.operatorAssetId) throw new BadRequestException({ code: 'ASSET_IMPORT_OPERATOR_REQUIRED', message: 'Seleccioná la organización operadora para planificar este inventario' }); await this.requireActiveOrganization(manager, dto.operatorAssetId); operatorAssetId = dto.operatorAssetId; } const masterStateBefore = await this.masterStateFingerprint(manager); const rows = await this.loadSourceRows(manager, id); const items = batch.profileCode === 'MENDOZA_YACIMIENTOS_V1' ? await this.buildTerritoryPlan(manager, planId, batch, rows) : batch.profileCode === 'MENDOZA_INVENTORY_V1' ? await this.buildInventoryPlan(manager, planId, batch, rows, operatorAssetId!, namespace!) : [this.reviewPlanItem(planId, 'unsupported-profile', 'TECHNICAL_ASSET', 'Formato no soportado', rows.map((row) => row.rowNumber), ['PLAN_PROFILE_UNSUPPORTED'])]; const masterStateHash = await this.masterStateFingerprint(manager); if (masterStateBefore !== masterStateHash) throw new ConflictException({ code: 'ASSET_IMPORT_MASTER_CHANGED_DURING_PLAN', message: 'El Maestro cambió mientras se generaba el plan. Volvé a generarlo para trabajar sobre un estado consistente.' }); const summary = { ...summarizePlanItems(items), masterStateHash, sourceRows: rows.length, sourceReadyRows: batch.readyRows, sourceWarningRows: batch.warningRows, sourceConflictRows: batch.conflictRows, relationalTerritoryPlan: batch.profileCode === 'MENDOZA_YACIMIENTOS_V1', normalizedDepartments: items.filter((item) => item.entityKind === 'DEPARTMENT').length, normalizedLegalRightTypes: [...new Set(items.filter((item) => item.entityKind === 'LEGAL_RIGHT').map((item) => String(item.payload.rightType ?? '')).filter(Boolean))].length, areaDepartmentRelations: items.filter((item) => item.entityKind === 'AREA_DEPARTMENT_RELATION').length, legalRights: items.filter((item) => item.entityKind === 'LEGAL_RIGHT').length, legalRightOrganizationRelations: items.filter((item) => item.entityKind === 'LEGAL_RIGHT_ORGANIZATION').length, explicitUnassignedOperatorFields: items.filter((item) => item.entityKind === 'FIELD' && item.payload.operatorAssignmentStatus === 'UNASSIGNED_SOURCE').length, externalIdNamespace: namespace, operatorAssetId, generatedAt: new Date().toISOString(), }; const status = planStatusForItems(items); const planHash = planItemHash(items); await manager.query(` INSERT INTO asset_import_plans (id,batch_id,revision,status,external_id_namespace,operator_asset_id,summary,plan_hash,generated_by) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9) `, [planId,id,revision,status,namespace,operatorAssetId,JSON.stringify(summary),planHash,principal.userId]); await this.insertPlanItems(manager, planId, items); await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importPlan}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, JSON.stringify({ planId, revision, status, planHash, ...summary })]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_IMPORT_PLAN_GENERATED, entityType: 'asset_import_plan', entityId: planId, afterData: { revision, status, planHash, ...summary }, metadata: { batchId: id, masterModified: false }, }, manager); return this.planView(manager, planId); }); } async resolvePlanItem(id: string, itemId: string, dto: ResolveAssetImportPlanItemDto, principal: AuthPrincipal, request: RequestWithContext) { return this.dataSource.transaction(async (manager) => { const plan = await this.requireMutableActivePlan(manager, id); const [item] = (await manager.query(` SELECT id,entity_key AS "entityKey",action,status,entity_kind AS "entityKind",asset_type_code AS "assetTypeCode",matched_asset_id AS "matchedAssetId",review_codes AS "reviewCodes",payload FROM asset_import_plan_items WHERE id=$1 AND plan_id=$2 FOR UPDATE `, [itemId, plan.id])) as Array<{ id: string; entityKey: string; action: AssetImportPlanAction; status: AssetImportPlanItemStatus; entityKind: AssetImportPlanEntityKind; assetTypeCode: string | null; matchedAssetId: string | null; reviewCodes: string[]; payload: Record }>; if (!item) throw new NotFoundException({ code: 'ASSET_IMPORT_PLAN_ITEM_NOT_FOUND', message: 'Ítem del plan no encontrado' }); if (item.action !== 'REVIEW') throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_ITEM_NOT_REVIEW', message: 'Sólo se resuelven manualmente los ítems pendientes de revisión' }); let matchedAssetId: string | null = null; let status: AssetImportPlanItemStatus; if (dto.action === 'MATCH') { if (item.payload.manualMatchAllowed === false) throw new ConflictException({ code: 'ASSET_IMPORT_MATCH_CONTEXT_BLOCKED', message: 'La coincidencia encontrada contradice el contexto operativo de la fuente. Corregí el Maestro o la fuente y regenerá el plan.' }); if (!item.assetTypeCode || item.entityKind === 'OPERATOR_RELATION') throw new ConflictException({ code: 'ASSET_IMPORT_MATCH_NOT_SUPPORTED', message: 'Este ítem no admite vinculación manual a un activo. Corregí el contexto y regenerá el plan.' }); if (!dto.matchedAssetId) throw new BadRequestException({ code: 'ASSET_IMPORT_MATCH_REQUIRED', message: 'Seleccioná el activo del Maestro para resolver como coincidencia' }); const [asset] = (await manager.query(`SELECT asset.id,type.code AS "typeCode" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id=$1 AND asset.information_status<>'INACTIVE'`, [dto.matchedAssetId])) as Array<{ id: string; typeCode: string }>; if (!asset) throw new BadRequestException({ code: 'ASSET_IMPORT_MATCH_INVALID', message: 'El activo seleccionado no existe o está inactivo' }); if (item.assetTypeCode && asset.typeCode !== item.assetTypeCode) throw new BadRequestException({ code: 'ASSET_IMPORT_MATCH_TYPE_INVALID', message: `La coincidencia debe ser del tipo ${item.assetTypeCode}` }); if (item.payload.inventoryContextDecision === true) { const options = Array.isArray(item.payload.candidateOptions) ? item.payload.candidateOptions as Array> : []; if (!options.some((option) => String(option.id ?? '') === dto.matchedAssetId)) throw new BadRequestException({ code: 'ASSET_IMPORT_CONTEXT_MATCH_INVALID', message: 'El contexto elegido no pertenece a las alternativas seguras detectadas para este inventario.' }); const contextLookup = String(item.payload.contextLookup ?? '').trim(); if (!contextLookup) throw new ConflictException({ code: 'ASSET_IMPORT_CONTEXT_KEY_REQUIRED', message: 'No se pudo identificar la clave territorial de esta decisión.' }); await manager.query(` UPDATE asset_import_batches SET analysis=jsonb_set( COALESCE(analysis,'{}'::jsonb), '{inventoryContextOverrides}', COALESCE(analysis->'inventoryContextOverrides','{}'::jsonb) || jsonb_build_object($2::text,$3::text), true ),updated_at=CURRENT_TIMESTAMP WHERE id=$1 `,[id,contextLookup,dto.matchedAssetId]); } matchedAssetId = dto.matchedAssetId; status = 'MATCHED'; } else if (dto.action === 'IGNORE') { if (item.entityKind !== 'TECHNICAL_ASSET') throw new ConflictException({ code: 'ASSET_IMPORT_STRUCTURAL_ITEM_REQUIRED', message: 'Las entidades estructurales no se omiten aisladamente. Vinculalas o aceptá su creación para preservar la jerarquía.' }); const [dependent] = (await manager.query(`SELECT 1 FROM asset_import_plan_items WHERE plan_id=$1 AND parent_entity_key=$2 AND action<>'IGNORE' LIMIT 1`, [plan.id, item.entityKey])) as unknown[]; if (dependent) throw new ConflictException({ code: 'ASSET_IMPORT_STRUCTURAL_PARENT_REQUIRED', message: 'Este ítem es padre de otras operaciones del plan. Vinculalo o aceptá su creación antes de continuar.' }); status = 'IGNORED'; } else { if (item.payload.manualCreateAllowed !== true || !item.assetTypeCode) throw new ConflictException({ code: 'ASSET_IMPORT_MANUAL_CREATE_BLOCKED', message: 'Este conflicto no puede resolverse creando automáticamente. Corregí el contexto o elegí una coincidencia.' }); const inventoryId = String(item.payload.inventoryId ?? '').trim().toLowerCase(); if (inventoryId && item.reviewCodes.includes('DUPLICATE_INVENTORY_ID_IN_BATCH')) { const [otherDuplicate] = (await manager.query(` SELECT id FROM asset_import_plan_items WHERE plan_id=$1 AND id<>$2 AND entity_kind='TECHNICAL_ASSET' AND action<>'IGNORE' AND lower(btrim(COALESCE(payload->>'inventoryId','')))=$3 LIMIT 1 `, [plan.id,item.id,inventoryId])) as Array<{ id: string }>; if (otherDuplicate) throw new ConflictException({ code: 'ASSET_IMPORT_DUPLICATE_ID_UNRESOLVED', message: 'Este ID aparece en más de una fila. Primero vinculá o ignorá las otras filas duplicadas y dejá una sola candidata a creación.' }); } status = 'PLANNED'; } const generatedCode = dto.action === 'CREATE' && item.assetTypeCode ? generatedImportCode(plan.id, item.assetTypeCode, item.entityKey) : null; await manager.query(` UPDATE asset_import_plan_items SET action=$3,status=$4,matched_asset_id=$5,generated_code=$6,resolution_note=$7,resolved_by=$8,resolved_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=$1 AND plan_id=$2 `, [itemId,plan.id,dto.action,status,matchedAssetId,generatedCode,dto.reason.trim(),principal.userId]); const updated = await this.refreshPlan(manager, plan.id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_IMPORT_PLAN_ITEM_RESOLVED, entityType: 'asset_import_plan_item', entityId: itemId, beforeData: { action: item.action, status: item.status, reviewCodes: item.reviewCodes }, afterData: { action: dto.action, status, matchedAssetId, reason: dto.reason.trim() }, metadata: { batchId: id, planId: plan.id, masterModified: false }, }, manager); return updated; }); } async applySafePlan(id: string, dto: ApplyAssetImportPlanDto, principal: AuthPrincipal, request: RequestWithContext) { return this.dataSource.transaction(async (manager) => { const batch = await this.loadBatch(manager, id, true); if (batch.status === 'CANCELLED') throw new ConflictException({ code: 'ASSET_IMPORT_CANCELLED', message: 'El lote está cancelado' }); const plan = await this.loadActivePlan(manager, id, true); if (!plan) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_REQUIRED', message: 'Generá el plan de importación antes de aplicar el lote' }); if (plan.status !== 'REVIEW_REQUIRED') throw new ConflictException({ code: 'ASSET_IMPORT_PARTIAL_NOT_REQUIRED', message: 'La aplicación parcial se utiliza cuando existen revisiones pendientes' }); if (plan.planHash !== dto.planHash) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'El plan cambió desde la confirmación. Recargá la vista antes de aplicar.' }); const plannedMasterStateHash = String(plan.summary?.masterStateHash ?? '').trim(); const currentMasterStateHash = await this.masterStateFingerprint(manager); if (!plannedMasterStateHash || plannedMasterStateHash !== currentMasterStateHash) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'El Maestro cambió desde que se generó este plan. Regenerá el plan antes de aplicar.' }); const allItems = await this.loadPlanItems(manager, plan.id, `ORDER BY item_order FOR UPDATE`); const safeItems = safePlanItems(allItems); const pendingWrites = safeItems.filter((item) => item.action === 'CREATE' && item.status !== 'APPLIED'); if (!pendingWrites.length) throw new ConflictException({ code: 'ASSET_IMPORT_NO_SAFE_ITEMS', message: 'No quedan registros independientes listos para importar. Resolvé alguna revisión para continuar.' }); await this.revalidateMatchedAssets(manager, safeItems); await this.revalidateMatchedRelationalObjects(manager, safeItems); if (plan.operatorAssetId) await this.requireActiveOrganization(manager, plan.operatorAssetId); await this.revalidateExternalIdentifiers(manager, plan, safeItems); const result = await this.applyPlanItems(manager, batch, plan, safeItems, principal, request); const nextMasterStateHash = await this.masterStateFingerprint(manager); const partialAt = new Date().toISOString(); const refreshed = await this.refreshPlan(manager, plan.id, { masterStateHash: nextMasterStateHash, lastPartialAppliedAt: partialAt }); const progress = await this.batchApplicationProgress(manager, id); const partialSummary = { status: 'PARTIAL', planId: plan.id, planHash: refreshed.planHash, appliedCreateItems: progress.appliedCreateItems, importedRows: progress.importedRows, pendingCreateItems: Number(refreshed.summary.pendingCreateItems ?? 0), reviewItems: Number(refreshed.summary.reviewItems ?? 0), directReviewItems: Number(refreshed.summary.directReviewItems ?? 0), dependencyReviewItems: Number(refreshed.summary.dependencyReviewItems ?? 0), lastResult: result, appliedAt: partialAt, }; await manager.query(`UPDATE asset_import_plans SET application_summary=$2::jsonb,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [plan.id, JSON.stringify(partialSummary)]); await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importExecution}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, JSON.stringify(partialSummary)]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_IMPORT_PLAN_APPLIED, entityType: 'asset_import_plan', entityId: plan.id, afterData: partialSummary, metadata: { batchId: id, planHash: refreshed.planHash, transactional: true, partial: true }, }, manager); return this.planView(manager, plan.id); }); } async applyPlan(id: string, dto: ApplyAssetImportPlanDto, principal: AuthPrincipal, request: RequestWithContext) { return this.dataSource.transaction(async (manager) => { const batch = await this.loadBatch(manager, id, true); if (batch.status === 'CANCELLED') throw new ConflictException({ code: 'ASSET_IMPORT_CANCELLED', message: 'El lote está cancelado' }); const plan = await this.loadActivePlan(manager, id, true); if (!plan) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_REQUIRED', message: 'Generá el plan de importación antes de aplicar el lote' }); if (plan.status === 'APPLIED') return this.planView(manager, plan.id); if (plan.status !== 'READY') throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_BLOCKED', message: 'El plan tiene revisiones pendientes o no está listo para aplicarse' }); if (plan.planHash !== dto.planHash) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'El plan cambió desde la confirmación. Recargá la vista antes de aplicar.' }); const plannedMasterStateHash = String(plan.summary?.masterStateHash ?? '').trim(); const currentMasterStateHash = await this.masterStateFingerprint(manager); if (!plannedMasterStateHash || plannedMasterStateHash !== currentMasterStateHash) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'El Maestro cambió desde que se generó este plan. Conciliá y regenerá el plan antes de aplicar para evitar duplicados.' }); const items = await this.loadPlanItems(manager, plan.id, `ORDER BY item_order FOR UPDATE`); if (items.some((item) => item.action === 'REVIEW')) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_BLOCKED', message: 'Existen ítems pendientes de revisión' }); await this.revalidateMatchedAssets(manager, items); await this.revalidateMatchedRelationalObjects(manager, items); if (plan.operatorAssetId) await this.requireActiveOrganization(manager, plan.operatorAssetId); await this.revalidateExternalIdentifiers(manager, plan, items); const result = await this.applyPlanItems(manager, batch, plan, items, principal, request); const progress = await this.batchApplicationProgress(manager, id); const appliedAt = new Date().toISOString(); const finalSummary = { status: 'APPLIED', planId: plan.id, planHash: plan.planHash, appliedCreateItems: progress.appliedCreateItems, importedRows: progress.importedRows, lastResult: result, appliedAt }; await manager.query(` UPDATE asset_import_plans SET status='APPLIED',applied_by=$2,applied_at=CURRENT_TIMESTAMP,application_summary=$3::jsonb,updated_at=CURRENT_TIMESTAMP WHERE id=$1 `, [plan.id,principal.userId,JSON.stringify(finalSummary)]); await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importExecution}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, JSON.stringify(finalSummary)]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_IMPORT_PLAN_APPLIED, entityType: 'asset_import_plan', entityId: plan.id, afterData: finalSummary, metadata: { batchId: id, planHash: plan.planHash, transactional: true }, }, manager); return this.planView(manager, plan.id); }); } async rollbackPlan(id: string, dto: RollbackAssetImportPlanDto, principal: AuthPrincipal, request: RequestWithContext) { return this.dataSource.transaction(async (manager) => { const plan = await this.loadActivePlan(manager, id, true); if (!plan) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_NOT_APPLIED', message: 'No existe una importación activa para revertir' }); const items = (await manager.query(` SELECT item.id,item.plan_id AS "planId",item.item_order AS "itemOrder",item.entity_key AS "entityKey",item.entity_kind AS "entityKind",item.action,item.status, item.asset_type_code AS "assetTypeCode",item.display_name AS "displayName",item.generated_code AS "generatedCode",item.parent_entity_key AS "parentEntityKey", item.matched_asset_id AS "matchedAssetId",item.applied_asset_id AS "appliedAssetId",item.applied_object_id AS "appliedObjectId",item.payload, item.source_row_numbers AS "sourceRowNumbers",item.review_codes AS "reviewCodes",item.resolution_note AS "resolutionNote",item.resolved_by AS "resolvedBy",item.resolved_at AS "resolvedAt" FROM asset_import_plan_items item JOIN asset_import_plans history ON history.id=item.plan_id WHERE history.batch_id=$1 AND item.action='CREATE' AND item.status='APPLIED' ORDER BY history.revision DESC,item.item_order DESC FOR UPDATE OF item `,[id])) as PlanItemRow[]; if (!items.length) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_NOT_APPLIED', message: 'Este lote todavía no incorporó registros al Maestro' }); const createdAssetIds = [...new Set(items.filter((item) => item.appliedAssetId).map((item) => item.appliedAssetId!))]; await this.assertRollbackSafe(manager, items, createdAssetIds); const reason = dto.reason.trim(); let endedOperatorRelations = 0; let endedAreaDepartmentRelations = 0; let endedLegalRightOrganizationRelations = 0; let revokedLegalRights = 0; let deactivatedDepartments = 0; let endedIdentifiers = 0; const operatorRelationIds = [...new Set(items.filter((item) => item.entityKind === 'OPERATOR_RELATION' && item.appliedObjectId).map((item) => item.appliedObjectId!))]; const areaDepartmentRelationIds = [...new Set(items.filter((item) => item.entityKind === 'AREA_DEPARTMENT_RELATION' && item.appliedObjectId).map((item) => item.appliedObjectId!))]; const legalRightIds = [...new Set(items.filter((item) => item.entityKind === 'LEGAL_RIGHT' && item.appliedObjectId).map((item) => item.appliedObjectId!))]; const legalRightOrganizationIds = [...new Set(items.filter((item) => item.entityKind === 'LEGAL_RIGHT_ORGANIZATION' && item.appliedObjectId).map((item) => item.appliedObjectId!))]; const departmentIds = [...new Set(items.filter((item) => item.entityKind === 'DEPARTMENT' && item.appliedObjectId).map((item) => item.appliedObjectId!))]; const roleRows = createdAssetIds.length ? (await manager.query(` SELECT asset.id,type.operational_role AS role FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id=ANY($1::uuid[]) `, [createdAssetIds])) as Array<{ id: string; role: 'AREA' | 'COMPANY' | 'GENERIC' }> : []; const genericIds = roleRows.filter((row) => row.role === 'GENERIC').map((row) => row.id); const anchorIds = roleRows.filter((row) => row.role !== 'GENERIC').map((row) => row.id); if (createdAssetIds.length) { const result = await manager.query(`UPDATE asset_external_identifiers SET valid_until=CURRENT_TIMESTAMP,end_reason=$2,ended_by=$3,updated_at=CURRENT_TIMESTAMP WHERE asset_id=ANY($1::uuid[]) AND valid_until IS NULL RETURNING id`, [createdAssetIds,`Reversión de importación: ${reason}`,principal.userId]); endedIdentifiers = result.length; } if (genericIds.length) await manager.query(`UPDATE assets SET information_status='INACTIVE',operational_area_id=NULL,operator_company_id=NULL,updated_by=$2,updated_at=CURRENT_TIMESTAMP,source_notes=concat_ws(E'\n',source_notes,$3),current_version=current_version+1 WHERE id=ANY($1::uuid[])`, [genericIds,principal.userId,`Revertido por lote ${id}: ${reason}`]); if (legalRightOrganizationIds.length) { const result = await manager.query(`UPDATE area_legal_right_organizations SET valid_until=CURRENT_DATE,end_reason=$2,ended_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND valid_until IS NULL RETURNING id`, [legalRightOrganizationIds,`Reversión de importación: ${reason}`,principal.userId]); endedLegalRightOrganizationRelations = result.length; } if (legalRightIds.length) { const result = await manager.query(`UPDATE area_legal_rights SET status='REVOKED',valid_until=COALESCE(valid_until,CURRENT_DATE),notes=concat_ws(E'\n',notes,$2),updated_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND status IN ('ACTIVE','PENDING') RETURNING id`, [legalRightIds,`Revertido por lote ${id}: ${reason}`,principal.userId]); revokedLegalRights = result.length; } if (areaDepartmentRelationIds.length) { const result = await manager.query(`UPDATE area_department_relations SET valid_until=CURRENT_DATE,notes=concat_ws(E'\n',notes,$2),ended_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND valid_until IS NULL RETURNING id`, [areaDepartmentRelationIds,`Reversión de importación: ${reason}`,principal.userId]); endedAreaDepartmentRelations = result.length; } if (operatorRelationIds.length) { const result = await manager.query(`UPDATE area_company_relations SET valid_until=CURRENT_TIMESTAMP,end_reason=$2,ended_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND valid_until IS NULL RETURNING id`, [operatorRelationIds,`Reversión de importación: ${reason}`,principal.userId]); endedOperatorRelations = result.length; } if (departmentIds.length) { const result = await manager.query(`UPDATE administrative_departments SET is_active=false,updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND is_active=true RETURNING id`, [departmentIds,principal.userId]); deactivatedDepartments = result.length; } if (anchorIds.length) await manager.query(`UPDATE assets SET information_status='INACTIVE',updated_by=$2,updated_at=CURRENT_TIMESTAMP,source_notes=concat_ws(E'\n',source_notes,$3),current_version=current_version+1 WHERE id=ANY($1::uuid[])`, [anchorIds,principal.userId,`Revertido por lote ${id}: ${reason}`]); if (createdAssetIds.length) await this.insertRollbackVersions(manager, createdAssetIds, principal, request); await manager.query(`UPDATE asset_import_rows SET imported_asset_id=NULL,updated_at=CURRENT_TIMESTAMP WHERE batch_id=$1`, [id]); const itemIds = items.map((item) => item.id); await manager.query(`UPDATE asset_import_plan_items SET status='ROLLED_BACK',updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[])`, [itemIds]); const historyPlanIds = [...new Set([...items.map((item) => item.planId), plan.id])]; const summary = { assetsInactivated: createdAssetIds.length, operatorRelationsEnded: endedOperatorRelations, areaDepartmentRelationsEnded: endedAreaDepartmentRelations, legalRightOrganizationRelationsEnded: endedLegalRightOrganizationRelations, legalRightsRevoked: revokedLegalRights, departmentsDeactivated: deactivatedDepartments, identifiersEnded: endedIdentifiers, reason, rolledBackAt: new Date().toISOString() }; await manager.query(`UPDATE asset_import_plans SET status='ROLLED_BACK',rolled_back_by=$2,rolled_back_at=CURRENT_TIMESTAMP,rollback_summary=$3::jsonb,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[])`, [historyPlanIds,principal.userId,JSON.stringify(summary)]); await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importExecution}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id,JSON.stringify({ status: 'ROLLED_BACK',planId:plan.id,...summary })]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_IMPORT_PLAN_ROLLED_BACK, entityType: 'asset_import_plan', entityId: plan.id, beforeData: { status: plan.status }, afterData: { status: 'ROLLED_BACK', ...summary }, metadata: { batchId: id, logicalRollback: true, relationalTerritory: true, planRevisions: historyPlanIds.length }, }, manager); return this.planView(manager, plan.id); }); } async upload(dto: UploadAssetImportDto, file: UploadedImportFile | undefined, principal: AuthPrincipal, request: RequestWithContext) { const inspected = inspectImportFile(file); const id = randomUUID(); const storedName = `${id}${inspected.extension}`; const filePath = resolve(this.storageRoot, storedName); const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); await mkdir(this.storageRoot, { recursive: true, mode: 0o700 }); await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); try { const workbook = await parseImportWorkbook(filePath, inspected.extension); const analysis = analyzeImportWorkbook(workbook, dto.profileCode as AssetImportProfileCode | undefined); const status = analysis.detection.profileCode === 'UNKNOWN' || analysis.summary.conflictRows > 0 ? 'REVIEW_REQUIRED' : 'ANALYZED'; return await this.dataSource.transaction(async (manager) => { const [document] = await manager.query(` INSERT INTO source_documents (document_type,title,issuer,external_reference,notes,created_by,updated_by) VALUES ($1,$2,$3,$4,$5,$6,$6) RETURNING id `, [SourceDocumentType.SPREADSHEET, `Archivo de inventario: ${file!.originalname}`, dto.sourceLabel?.trim() || null, `sha256:${sha256}`, dto.notes?.trim() || null, principal.userId]); const sourceDocumentId = String(document.id); await manager.query(` INSERT INTO asset_import_batches ( id,original_name,stored_name,mime_type,size_bytes,sha256,profile_code,profile_confidence,worksheet_name,header_row, total_rows,ready_rows,warning_rows,conflict_rows,ignored_rows,status,source_document_id,source_label,notes,analysis,uploaded_by ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20::jsonb,$21) `, [ id,file!.originalname,storedName,inspected.mimeType,file!.buffer.length,sha256,analysis.detection.profileCode,analysis.detection.confidence, analysis.detection.sheetName,analysis.detection.headerRow,analysis.summary.totalRows,analysis.summary.readyRows,analysis.summary.warningRows, analysis.summary.conflictRows,analysis.summary.ignoredRows,status,sourceDocumentId,dto.sourceLabel?.trim() || null,dto.notes?.trim() || null, JSON.stringify({ profileLabel: profileLabel(analysis.detection.profileCode), detectedHeaders: analysis.detection.detectedHeaders, columnMap: analysis.detection.columnMap, issueCounts: analysis.summary.issueCounts, workbookKind: workbook.kind, sheetCount: workbook.sheets.length }), principal.userId, ]); await this.insertRows(manager, id, analysis.rows); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_IMPORT_BATCH_ANALYZED, entityType: 'asset_import_batch', entityId: id, afterData: { originalName: file!.originalname, profileCode: analysis.detection.profileCode, status, ...analysis.summary }, metadata: { sha256, sourceDocumentId }, }, manager); return this.loadBatch(manager, id); }); } catch (error) { await unlink(filePath).catch(() => undefined); throw error; } } async reconcile(id: string, principal: AuthPrincipal, request: RequestWithContext): Promise { return this.dataSource.transaction(async (manager) => { const batch = await this.loadBatch(manager, id, true); if (batch.status === 'CANCELLED') throw new ConflictException({ code: 'ASSET_IMPORT_CANCELLED', message: 'El lote está cancelado' }); const activePlan = await this.loadActivePlan(manager, id, true); if (activePlan?.status === 'APPLIED') throw new ConflictException({ code: 'ASSET_IMPORT_ALREADY_APPLIED', message: 'El lote ya fue aplicado. Revertí la importación antes de volver a conciliar.' }); if (await this.batchHasAppliedItems(manager, id)) throw new ConflictException({ code: 'ASSET_IMPORT_PARTIAL_RECONCILE_BLOCKED', message: 'El lote ya tiene registros importados parcialmente. Regenerá el plan directamente o revertí la importación antes de volver a conciliar.' }); if (activePlan) await manager.query(`UPDATE asset_import_plans SET status='SUPERSEDED',superseded_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [activePlan.id]); const rows = (await manager.query(` SELECT id,row_number AS "rowNumber",status,suggested_action AS "suggestedAction", normalized_data AS "normalizedData",issue_codes AS issues FROM asset_import_rows WHERE batch_id=$1 ORDER BY row_number `, [id])) as Array<{ id: string; rowNumber: number; status: string; suggestedAction: string; normalizedData: Record; issues: string[]; }>; const inventoryIds = [...new Set(rows.map((row) => String(row.normalizedData.inventoryId ?? '').trim().toLowerCase()).filter(Boolean))]; const exactByInventory = new Map>(); let exactExternalIdMatches = 0; let exactCodeMatches = 0; if (inventoryIds.length) { const external = (await manager.query(` SELECT lower(value) AS lookup,asset_id AS "assetId" FROM asset_external_identifiers WHERE valid_until IS NULL AND lower(value)=ANY($1::text[]) `, [inventoryIds])) as Array<{ lookup: string; assetId: string }>; for (const item of external) { const set = exactByInventory.get(item.lookup) ?? new Set(); set.add(item.assetId); exactByInventory.set(item.lookup, set); exactExternalIdMatches += 1; } const codes = (await manager.query(`SELECT lower(code) AS lookup,id AS "assetId" FROM assets WHERE lower(code)=ANY($1::text[])`, [inventoryIds])) as Array<{ lookup: string; assetId: string }>; for (const item of codes) { const set = exactByInventory.get(item.lookup) ?? new Set(); set.add(item.assetId); exactByInventory.set(item.lookup, set); exactCodeMatches += 1; } } const areaNames = [...new Set(rows.map((row) => String(row.normalizedData.area ?? '').trim().toLowerCase()).filter(Boolean))]; const fieldNames = [...new Set(rows.map((row) => String(row.normalizedData.field ?? '').trim().toLowerCase()).filter(Boolean))]; const territoryMatches = new Map>(); let exactTerritoryMatches = 0; if (areaNames.length && fieldNames.length) { const territories = (await manager.query(` SELECT lower(area.name) AS area,lower(field.name) AS field,field.id AS "assetId" FROM assets field JOIN assets area ON area.id=field.parent_id JOIN asset_types area_type ON area_type.id=area.asset_type_id WHERE area_type.operational_role='AREA' AND lower(area.name)=ANY($1::text[]) AND lower(field.name)=ANY($2::text[]) `, [areaNames, fieldNames])) as Array<{ area: string; field: string; assetId: string }>; for (const item of territories) { const key = `${item.area}\u001f${item.field}`; const set = territoryMatches.get(key) ?? new Set(); set.add(item.assetId); territoryMatches.set(key, set); exactTerritoryMatches += 1; } } const operatorNames = [...new Set(rows.map((row) => String(row.normalizedData.operator ?? '').trim().toLowerCase()).filter(Boolean))]; const organizationMatches = new Map>(); let exactOrganizationMatches = 0; if (operatorNames.length) { const organizations = (await manager.query(` SELECT lookup,"assetId" FROM ( SELECT lower(asset.name) AS lookup,asset.id AS "assetId" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE type.operational_role='COMPANY' AND lower(asset.name)=ANY($1::text[]) UNION SELECT lower(profile.legal_name) AS lookup,profile.asset_id AS "assetId" FROM organization_profiles profile WHERE lower(profile.legal_name)=ANY($1::text[]) ) candidates `, [operatorNames])) as Array<{ lookup: string; assetId: string }>; for (const item of organizations) { const set = organizationMatches.get(item.lookup) ?? new Set(); set.add(item.assetId); organizationMatches.set(item.lookup, set); exactOrganizationMatches += 1; } } let createRows = 0; let matchRows = 0; let reviewRows = 0; let ignoreRows = 0; let multipleMatches = 0; const updates: Array<{ id: string; matchedAssetId: string | null; action: string; status: string; issues: string[]; reconciliation: Record }> = []; for (const row of rows) { const existingIssues = Array.isArray(row.issues) ? [...row.issues] : []; let candidates = new Set(); let matchMethod: string | null = null; const inventoryId = String(row.normalizedData.inventoryId ?? '').trim().toLowerCase(); if (inventoryId) { candidates = exactByInventory.get(inventoryId) ?? new Set(); if (candidates.size) matchMethod = 'INVENTORY_ID_OR_CODE_EXACT'; } if (!candidates.size && batch.profileCode === 'MENDOZA_YACIMIENTOS_V1') { const key = `${String(row.normalizedData.area ?? '').trim().toLowerCase()}\u001f${String(row.normalizedData.field ?? '').trim().toLowerCase()}`; candidates = territoryMatches.get(key) ?? new Set(); if (candidates.size) matchMethod = 'AREA_FIELD_EXACT'; } const operatorKey = String(row.normalizedData.operator ?? '').trim().toLowerCase(); const operatorCandidates = operatorKey ? (organizationMatches.get(operatorKey) ?? new Set()) : new Set(); const operatorMatchedAssetId = operatorCandidates.size === 1 ? ([...operatorCandidates][0] ?? null) : null; if (operatorCandidates.size > 1 && !existingIssues.includes('MATCH_MULTIPLE_ORGANIZATIONS')) existingIssues.push('MATCH_MULTIPLE_ORGANIZATIONS'); let action = row.suggestedAction; let status = row.status; let matchedAssetId: string | null = null; if (status === 'IGNORED') { action = 'IGNORE'; ignoreRows += 1; } else if (status === 'CONFLICT' || status === 'WARNING') { action = 'REVIEW'; reviewRows += 1; if (candidates.size > 1) multipleMatches += 1; } else if (candidates.size === 1) { matchedAssetId = [...candidates][0] ?? null; action = 'MATCH'; matchRows += 1; } else if (candidates.size > 1) { action = 'REVIEW'; status = 'CONFLICT'; reviewRows += 1; multipleMatches += 1; if (!existingIssues.includes('MATCH_MULTIPLE_ASSETS')) existingIssues.push('MATCH_MULTIPLE_ASSETS'); } else if (operatorCandidates.size > 1) { action = 'REVIEW'; status = 'CONFLICT'; reviewRows += 1; multipleMatches += 1; } else { action = 'CREATE'; createRows += 1; } updates.push({ id: row.id, matchedAssetId, action, status, issues: existingIssues, reconciliation: { matchMethod, candidateCount: candidates.size, matchedAssetId, operatorMatchedAssetId, operatorCandidateCount: operatorCandidates.size, operatorMatchMethod: operatorCandidates.size ? 'ORGANIZATION_NAME_EXACT' : null } }); } for (let start = 0; start < updates.length; start += 250) { const chunk = updates.slice(start, start + 250); const params: unknown[] = []; const values = chunk.map((item) => { const base = params.length; params.push(item.id,item.matchedAssetId,item.action,item.status,JSON.stringify(item.issues),JSON.stringify(item.reconciliation)); return `($${base+1}::uuid,$${base+2}::uuid,$${base+3}::text,$${base+4}::text,$${base+5}::jsonb,$${base+6}::jsonb)`; }); await manager.query(` UPDATE asset_import_rows row SET matched_asset_id=v.matched_asset_id, suggested_action=v.action::asset_import_suggested_action, status=v.status::asset_import_row_status, issue_codes=v.issues, normalized_data=jsonb_set(row.normalized_data,'{reconciliation}',v.reconciliation,true), updated_at=CURRENT_TIMESTAMP FROM (VALUES ${values.join(',')}) AS v(id,matched_asset_id,action,status,issues,reconciliation) WHERE row.id=v.id `, params); } const summary: ImportReconciliationSummary = { totalRows: rows.length, createRows, matchRows, reviewRows, ignoreRows, exactExternalIdMatches, exactCodeMatches, exactTerritoryMatches, exactOrganizationMatches, multipleMatches, reconciledAt: new Date().toISOString(), }; await manager.query(` UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{reconciliation}',$2::jsonb,true), status=CASE WHEN $3::integer>0 THEN 'REVIEW_REQUIRED'::asset_import_batch_status ELSE 'ANALYZED'::asset_import_batch_status END, updated_at=CURRENT_TIMESTAMP WHERE id=$1 `, [id, JSON.stringify(summary), reviewRows]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_IMPORT_BATCH_RECONCILED, entityType: 'asset_import_batch', entityId: id, afterData: { ...summary }, metadata: { conservativeMatching: true, masterModified: false }, }, manager); return this.loadBatch(manager, id); }); } async cancel(id: string, dto: CancelAssetImportDto, principal: AuthPrincipal, request: RequestWithContext) { return this.dataSource.transaction(async (manager) => { const batch = await this.loadBatch(manager, id, true); if (batch.status === 'CANCELLED') return batch; const activePlan = await this.loadActivePlan(manager, id, true); if (activePlan?.status === 'APPLIED' || await this.batchHasAppliedItems(manager, id)) throw new ConflictException({ code: 'ASSET_IMPORT_APPLIED_CANNOT_CANCEL', message: 'El lote ya incorporó registros al Maestro. Revertí primero la importación antes de cancelarlo.' }); if (activePlan) await manager.query(`UPDATE asset_import_plans SET status='SUPERSEDED',superseded_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [activePlan.id]); await manager.query(`UPDATE asset_import_batches SET status='CANCELLED', notes=concat_ws(E'\n',notes,$2),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, `Cancelado: ${dto.reason.trim()}`]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_IMPORT_BATCH_CANCELLED, entityType: 'asset_import_batch', entityId: id, beforeData: { status: batch.status }, afterData: { status: 'CANCELLED' }, metadata: { reason: dto.reason.trim() }, }, manager); return this.loadBatch(manager, id); }); } private async loadSourceRows(manager: EntityManager, batchId: string): Promise { return (await manager.query(` SELECT id,row_number AS "rowNumber",status,suggested_action AS "suggestedAction", normalized_data AS "normalizedData",issue_codes AS issues,matched_asset_id AS "matchedAssetId" FROM asset_import_rows WHERE batch_id=$1 ORDER BY row_number `, [batchId])) as ImportSourceRow[]; } private async requireActiveOrganization(manager: EntityManager, assetId: string): Promise { const [row] = (await manager.query(` SELECT 1 FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id=$1 AND type.operational_role='COMPANY' AND type.is_active=true AND asset.information_status<>'INACTIVE' `, [assetId])) as unknown[]; if (!row) throw new BadRequestException({ code: 'ASSET_IMPORT_OPERATOR_INVALID', message: 'La organización operadora seleccionada no existe o está inactiva' }); } private async loadActivePlan(manager: EntityManager, batchId: string, lock = false): Promise { const rows = (await manager.query(` SELECT id,batch_id AS "batchId",revision,status,external_id_namespace AS "externalIdNamespace",operator_asset_id AS "operatorAssetId", summary,plan_hash AS "planHash",generated_by AS "generatedBy",generated_at AS "generatedAt",applied_by AS "appliedBy",applied_at AS "appliedAt", application_summary AS "applicationSummary",rolled_back_by AS "rolledBackBy",rolled_back_at AS "rolledBackAt",rollback_summary AS "rollbackSummary", superseded_at AS "supersededAt",created_at AS "createdAt",updated_at AS "updatedAt" FROM asset_import_plans WHERE batch_id=$1 AND superseded_at IS NULL ORDER BY revision DESC LIMIT 1${lock ? ' FOR UPDATE' : ''} `, [batchId])) as ImportPlanRow[]; return rows[0] ?? null; } private async loadPlanById(manager: EntityManager, planId: string): Promise { const rows = (await manager.query(` SELECT id,batch_id AS "batchId",revision,status,external_id_namespace AS "externalIdNamespace",operator_asset_id AS "operatorAssetId", summary,plan_hash AS "planHash",generated_by AS "generatedBy",generated_at AS "generatedAt",applied_by AS "appliedBy",applied_at AS "appliedAt", application_summary AS "applicationSummary",rolled_back_by AS "rolledBackBy",rolled_back_at AS "rolledBackAt",rollback_summary AS "rollbackSummary", superseded_at AS "supersededAt",created_at AS "createdAt",updated_at AS "updatedAt" FROM asset_import_plans WHERE id=$1 `, [planId])) as ImportPlanRow[]; if (!rows[0]) throw new NotFoundException({ code: 'ASSET_IMPORT_PLAN_NOT_FOUND', message: 'Plan de importación no encontrado' }); return rows[0]; } private async loadPlanItems(manager: EntityManager, planId: string, suffix = 'ORDER BY item_order'): Promise { return (await manager.query(` SELECT id,plan_id AS "planId",item_order AS "itemOrder",entity_key AS "entityKey",entity_kind AS "entityKind",action,status, asset_type_code AS "assetTypeCode",display_name AS "displayName",generated_code AS "generatedCode",parent_entity_key AS "parentEntityKey", matched_asset_id AS "matchedAssetId",applied_asset_id AS "appliedAssetId",applied_object_id AS "appliedObjectId",payload, source_row_numbers AS "sourceRowNumbers",review_codes AS "reviewCodes",resolution_note AS "resolutionNote",resolved_by AS "resolvedBy",resolved_at AS "resolvedAt" FROM asset_import_plan_items WHERE plan_id=$1 ${suffix} `, [planId])) as PlanItemRow[]; } private async planView(manager: EntityManager, planId: string) { const plan = await this.loadPlanById(manager, planId); const previewItems = await this.loadPlanItems(manager, plan.id, `ORDER BY item_order LIMIT 80`); const reviewItems = await this.loadPlanItems(manager, plan.id, `AND action='REVIEW' ORDER BY CASE WHEN review_codes <@ '["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"]'::jsonb THEN 1 ELSE 0 END, item_order LIMIT 80`); const masterState = await this.planMasterState(manager, plan); return { ...plan, previewItems, reviewItems, ...masterState }; } private async planMasterState(manager: EntityManager, plan: ImportPlanRow): Promise<{ masterStateStale: boolean; masterStateHashAvailable: boolean; masterStateCheckedAt: string }> { const planned = String(plan.summary?.masterStateHash ?? '').trim(); if (!['READY','REVIEW_REQUIRED'].includes(plan.status)) return { masterStateStale: false, masterStateHashAvailable: Boolean(planned), masterStateCheckedAt: new Date().toISOString() }; const current = await this.masterStateFingerprint(manager); return { masterStateStale: !planned || planned !== current, masterStateHashAvailable: Boolean(planned), masterStateCheckedAt: new Date().toISOString() }; } private async masterStateFingerprint(manager: EntityManager): Promise { const [state] = (await manager.query(` SELECT (SELECT COUNT(*)::text FROM assets WHERE information_status<>'INACTIVE') AS "assetsCount", COALESCE((SELECT MAX(updated_at)::text FROM assets),'') AS "assetsUpdatedAt", (SELECT COUNT(*)::text FROM organization_profiles) AS "organizationsCount", COALESCE((SELECT MAX(updated_at)::text FROM organization_profiles),'') AS "organizationsUpdatedAt", (SELECT COUNT(*)::text FROM administrative_departments WHERE is_active=true) AS "departmentsCount", COALESCE((SELECT MAX(updated_at)::text FROM administrative_departments),'') AS "departmentsUpdatedAt", (SELECT COUNT(*)::text FROM area_department_relations WHERE valid_until IS NULL) AS "areaDepartmentsCount", COALESCE((SELECT MAX(updated_at)::text FROM area_department_relations),'') AS "areaDepartmentsUpdatedAt", (SELECT COUNT(*)::text FROM area_company_relations WHERE valid_until IS NULL) AS "areaOrganizationsCount", COALESCE((SELECT MAX(updated_at)::text FROM area_company_relations),'') AS "areaOrganizationsUpdatedAt", (SELECT COUNT(*)::text FROM area_legal_rights WHERE status IN ('ACTIVE','PENDING')) AS "legalRightsCount", COALESCE((SELECT MAX(updated_at)::text FROM area_legal_rights),'') AS "legalRightsUpdatedAt", (SELECT COUNT(*)::text FROM area_legal_right_organizations WHERE valid_until IS NULL) AS "legalRightOrganizationsCount", COALESCE((SELECT MAX(updated_at)::text FROM area_legal_right_organizations),'') AS "legalRightOrganizationsUpdatedAt", (SELECT COUNT(*)::text FROM asset_external_identifiers WHERE valid_until IS NULL) AS "externalIdentifiersCount", COALESCE((SELECT MAX(updated_at)::text FROM asset_external_identifiers),'') AS "externalIdentifiersUpdatedAt" `)) as Array>; return createHash('sha256').update(JSON.stringify(state ?? {})).digest('hex'); } private async batchApplicationProgress(manager: EntityManager, batchId: string): Promise<{ appliedCreateItems: number; importedRows: number }> { const [row] = (await manager.query(` SELECT (SELECT COUNT(*)::integer FROM asset_import_plan_items item JOIN asset_import_plans plan ON plan.id=item.plan_id WHERE plan.batch_id=$1 AND item.action='CREATE' AND item.status='APPLIED') AS "appliedCreateItems", (SELECT COUNT(*)::integer FROM asset_import_rows WHERE batch_id=$1 AND imported_asset_id IS NOT NULL) AS "importedRows" `,[batchId])) as Array<{ appliedCreateItems: number; importedRows: number }>; return { appliedCreateItems: Number(row?.appliedCreateItems ?? 0), importedRows: Number(row?.importedRows ?? 0) }; } private async batchHasAppliedItems(manager: EntityManager, batchId: string): Promise { const progress = await this.batchApplicationProgress(manager, batchId); return progress.appliedCreateItems > 0; } private async requireMutableActivePlan(manager: EntityManager, batchId: string): Promise { const plan = await this.loadActivePlan(manager, batchId, true); if (!plan) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_REQUIRED', message: 'Generá primero el plan de importación' }); if (!['READY','REVIEW_REQUIRED'].includes(plan.status)) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_LOCKED', message: 'El plan ya no admite resoluciones manuales' }); return plan; } private reviewPlanItem( planId: string, entityKey: string, entityKind: AssetImportPlanEntityKind, displayName: string, sourceRowNumbers: number[], reviewCodes: string[], payload: Record = {}, ): AssetImportPlanDraftItem { return { entityKey,entityKind,action:'REVIEW',status:'REVIEW',assetTypeCode:null,displayName,generatedCode:null,parentEntityKey:null,matchedAssetId:null, payload: { ...payload, manualCreateAllowed: false, planId }, sourceRowNumbers:[...new Set(sourceRowNumbers)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviewCodes)], }; } private async buildTerritoryPlan(manager: EntityManager, planId: string, batch: ImportBatchRow, rows: ImportSourceRow[]): Promise { type Aggregate = { name: string; rows: number[]; issues: Set; values: Set }; type FieldAggregate = Aggregate & { areaKey: string; departmentKeys: Set; rightKeys: Set; operatorKeys: Set; operatorUnassigned: boolean; operatorSourceValues: Set; }; const departments = new Map(); const organizations = new Map(); const areas = new Map(); const fields = new Map(); const areaDepartments = new Map }>(); const operatorRelations = new Map; rightTypes: Set }>(); const legalRights = new Map; rows: number[]; issues: Set }>(); const legalRightOrganizations = new Map }>(); const add = (map: Map, key: string, name: string, row: ImportSourceRow, value?: string) => { const current = map.get(key) ?? { name, rows: [], issues: new Set(), values: new Set() }; current.rows.push(row.rowNumber); for (const issue of row.issues ?? []) current.issues.add(issue); if (row.status !== 'READY') current.issues.add(`SOURCE_ROW_${row.status}`); if (value) current.values.add(value); map.set(key, current); }; const addIssues = (target: Set, row: ImportSourceRow) => { for (const issue of row.issues ?? []) target.add(issue); if (row.status !== 'READY') target.add(`SOURCE_ROW_${row.status}`); }; for (const row of rows) { const area = String(row.normalizedData.area ?? '').trim(); const field = String(row.normalizedData.field ?? '').trim(); const operator = String(row.normalizedData.operator ?? '').trim(); const department = String(row.normalizedData.department ?? '').trim(); const sourceRightType = String(row.normalizedData.rightType ?? '').trim(); const areaKey = plainImportKey(area); const fieldKeyPart = plainImportKey(field); const departmentKey = plainImportKey(department); const operatorUnassigned = isExplicitlyUnassignedOperator(operator); const operatorKey = operatorUnassigned ? '' : organizationImportKey(operator); const normalizedRight = normalizedLegalRightType(sourceRightType); const rightKey = normalizedRight ? `${areaKey}\u001f${normalizedRight}` : ''; if (departmentKey) add(departments, departmentKey, department, row); if (areaKey) add(areas, areaKey, area, row); if (operatorKey) add(organizations, operatorKey, operator, row); if (areaKey && departmentKey) { const key = `${areaKey}\u001f${departmentKey}`; const current = areaDepartments.get(key) ?? { areaKey, departmentKey, rows: [], issues: new Set() }; current.rows.push(row.rowNumber); addIssues(current.issues, row); areaDepartments.set(key, current); } if (areaKey && normalizedRight) { const current = legalRights.get(rightKey) ?? { areaKey, rightType: normalizedRight, sourceRightTypes: new Set(), rows: [], issues: new Set() }; current.rows.push(row.rowNumber); addIssues(current.issues, row); if (sourceRightType) current.sourceRightTypes.add(sourceRightType); if (normalizedRight === 'OTHER') current.issues.add('PLAN_LEGAL_RIGHT_TYPE_UNRECOGNIZED'); legalRights.set(rightKey, current); } else if (areaKey) { const syntheticKey = `${areaKey}\u001fmissing`; const current = legalRights.get(syntheticKey) ?? { areaKey, rightType: 'OTHER', sourceRightTypes: new Set(), rows: [], issues: new Set() }; current.rows.push(row.rowNumber); addIssues(current.issues, row); current.issues.add('PLAN_LEGAL_RIGHT_TYPE_MISSING'); legalRights.set(syntheticKey, current); } if (areaKey && fieldKeyPart) { const key = `${areaKey}\u001f${fieldKeyPart}`; const current = fields.get(key) ?? { name: field, areaKey, rows: [], issues: new Set(), values: new Set(), departmentKeys: new Set(), rightKeys: new Set(), operatorKeys: new Set(), operatorUnassigned: false, operatorSourceValues: new Set(), }; current.rows.push(row.rowNumber); addIssues(current.issues, row); if (departmentKey) current.departmentKeys.add(departmentKey); if (rightKey) current.rightKeys.add(rightKey); if (operatorKey) current.operatorKeys.add(operatorKey); if (operator) current.operatorSourceValues.add(operator); if (operatorUnassigned) current.operatorUnassigned = true; fields.set(key, current); } if (areaKey && operatorKey) { const key = `${areaKey}\u001f${operatorKey}`; const current = operatorRelations.get(key) ?? { areaKey, organizationKey: operatorKey, rows: [], issues: new Set(), rightTypes: new Set() }; current.rows.push(row.rowNumber); addIssues(current.issues, row); if (sourceRightType) current.rightTypes.add(sourceRightType); operatorRelations.set(key, current); if (rightKey) { const legalOrgKey = `${rightKey}\u001f${operatorKey}`; const legalOrg = legalRightOrganizations.get(legalOrgKey) ?? { legalRightKey: rightKey, organizationKey: operatorKey, rows: [], issues: new Set() }; legalOrg.rows.push(row.rowNumber); addIssues(legalOrg.issues, row); legalRightOrganizations.set(legalOrgKey, legalOrg); } } } const areaNames = [...areas.keys()]; const orgNames = [...organizations.keys()]; const fieldNames = [...new Set([...fields.values()].map((item) => plainImportKey(item.name)))]; const existingAreas = await this.assetNameMatches(manager, 'AREA', areaNames); const existingOrganizations = await this.organizationNameMatches(manager, orgNames); const existingDepartments = new Map(); if (departments.size) { const found = (await manager.query(`SELECT id,name,code,normalized_name AS "normalizedName" FROM administrative_departments WHERE province_code='MENDOZA' AND is_active=true AND normalized_name=ANY($1::text[])`, [[...departments.keys()]])) as Array<{id:string;name:string;code:string;normalizedName:string}>; for (const item of found) { const list = existingDepartments.get(item.normalizedName) ?? []; list.push(item); existingDepartments.set(item.normalizedName, list); } } const existingFields = new Map(); if (areaNames.length && fieldNames.length) { const wanted = new Set(fields.keys()); const result = (await manager.query(` SELECT field.id,field.code,field.name,type.code AS "typeCode",field.parent_id AS "parentId",field.operational_area_id AS "operationalAreaId",field.operator_company_id AS "operatorCompanyId",area.name AS "areaName" FROM assets field JOIN asset_types type ON type.id=field.asset_type_id JOIN assets area ON area.id=field.parent_id WHERE type.code='yacimiento' AND field.information_status<>'INACTIVE' AND area.information_status<>'INACTIVE' `)) as Array; for (const item of result) { const key = `${plainImportKey(item.areaName)}\u001f${plainImportKey(item.name)}`; if (!wanted.has(key)) continue; const list = existingFields.get(key) ?? []; list.push(item); existingFields.set(key, list); } } const items: AssetImportPlanDraftItem[] = []; const departmentItemByKey = new Map(); const areaItemByKey = new Map(); const orgItemByKey = new Map(); const legalRightItemByKey = new Map(); for (const [key, aggregate] of [...departments.entries()].sort(([a],[b]) => a.localeCompare(b))) { const matches = existingDepartments.get(key) ?? []; const reviews = [...aggregate.issues]; if (matches.length > 1) reviews.push('PLAN_MULTIPLE_DEPARTMENTS'); const action: AssetImportPlanAction = reviews.length ? 'REVIEW' : matches.length === 1 ? 'MATCH' : 'CREATE'; const entityKey = `department:${key}`; const item: AssetImportPlanDraftItem = { entityKey, entityKind: 'DEPARTMENT', action, status: action === 'REVIEW' ? 'REVIEW' : action === 'MATCH' ? 'MATCHED' : 'PLANNED', assetTypeCode: null, displayName: aggregate.name, generatedCode: null, parentEntityKey: null, matchedAssetId: null, payload: { provinceCode: 'MENDOZA', departmentCode: departmentCode(aggregate.name), normalizedName: key, matchedDepartmentId: matches.length === 1 ? matches[0]!.id : null, sourceDocumentId: batch.sourceDocumentId, manualCreateAllowed: matches.length <= 1 }, sourceRowNumbers: [...new Set(aggregate.rows)].sort((a,b)=>a-b), reviewCodes: [...new Set(reviews)], }; items.push(item); departmentItemByKey.set(key, item); } for (const [key, aggregate] of [...organizations.entries()].sort(([a],[b]) => a.localeCompare(b))) { const matches = existingOrganizations.get(key) ?? []; const reviews = [...aggregate.issues]; if (matches.length > 1) reviews.push('PLAN_MULTIPLE_ORGANIZATIONS'); const action: AssetImportPlanAction = reviews.length ? 'REVIEW' : matches.length === 1 ? 'MATCH' : 'CREATE'; const entityKey = `organization:${key}`; const item: AssetImportPlanDraftItem = { entityKey, entityKind:'ORGANIZATION', action, status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED', assetTypeCode:'empresa', displayName:aggregate.name, generatedCode:action==='CREATE'?generatedImportCode(planId,'empresa',entityKey):null, parentEntityKey:null, matchedAssetId:matches.length===1?matches[0]!.id:null, payload:{legalName:aggregate.name,organizationKind:/^UTE\b/i.test(aggregate.name.trim())?'UTE':'COMPANY',sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:matches.length<=1}, sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b), reviewCodes:[...new Set(reviews)] }; items.push(item); orgItemByKey.set(key,item); } for (const [key, aggregate] of [...areas.entries()].sort(([a],[b]) => a.localeCompare(b))) { const matches = existingAreas.get(key) ?? []; const reviews = [...aggregate.issues]; if (matches.length > 1) reviews.push('PLAN_MULTIPLE_AREAS'); const action: AssetImportPlanAction = reviews.length ? 'REVIEW' : matches.length === 1 ? 'MATCH' : 'CREATE'; const entityKey = `area:${key}`; const item: AssetImportPlanDraftItem = { entityKey,entityKind:'AREA',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:'area',displayName:aggregate.name,generatedCode:action==='CREATE'?generatedImportCode(planId,'area',entityKey):null,parentEntityKey:null,matchedAssetId:matches.length===1?matches[0]!.id:null,payload:{sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:matches.length<=1},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)] }; items.push(item); areaItemByKey.set(key,item); } const matchedAreaIds = [...areaItemByKey.values()].map(i=>i.matchedAssetId).filter((v):v is string=>Boolean(v)); const matchedOrgIds = [...orgItemByKey.values()].map(i=>i.matchedAssetId).filter((v):v is string=>Boolean(v)); const matchedDepartmentIds = [...departmentItemByKey.values()].map(item=>String(item.payload.matchedDepartmentId??'')).filter(Boolean); const existingAreaDepartmentPairs = new Map(); if (matchedAreaIds.length && matchedDepartmentIds.length) { const found = (await manager.query(`SELECT id,area_id AS "areaId",department_id AS "departmentId" FROM area_department_relations WHERE area_id=ANY($1::uuid[]) AND department_id=ANY($2::uuid[]) AND valid_until IS NULL`, [matchedAreaIds, matchedDepartmentIds])) as Array<{id:string;areaId:string;departmentId:string}>; for (const relation of found) existingAreaDepartmentPairs.set(`${relation.areaId}\u001f${relation.departmentId}`, relation.id); } for (const [key, aggregate] of [...areaDepartments.entries()].sort(([a],[b])=>a.localeCompare(b))) { const areaItem = areaItemByKey.get(aggregate.areaKey); const departmentItem = departmentItemByKey.get(aggregate.departmentKey); if (!areaItem || !departmentItem) continue; const reviews = [...aggregate.issues]; if (areaItem.action==='REVIEW') reviews.push('PLAN_AREA_REVIEW_REQUIRED'); if (departmentItem.action==='REVIEW') reviews.push('PLAN_DEPARTMENT_REVIEW_REQUIRED'); const departmentId = String(departmentItem.payload.matchedDepartmentId ?? '') || null; const relationId = areaItem.matchedAssetId && departmentId ? existingAreaDepartmentPairs.get(`${areaItem.matchedAssetId}\u001f${departmentId}`) ?? null : null; const action: AssetImportPlanAction = reviews.length ? 'REVIEW' : relationId ? 'MATCH' : 'CREATE'; items.push({ entityKey:`area-department:${key}`, entityKind:'AREA_DEPARTMENT_RELATION', action, status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED', assetTypeCode:null, displayName:`${areaItem.displayName} ↔ ${departmentItem.displayName}`, generatedCode:null, parentEntityKey:null, matchedAssetId:null, payload:{areaEntityKey:areaItem.entityKey,departmentEntityKey:departmentItem.entityKey,matchedRelationId:relationId,sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:reviews.length===0}, sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b), reviewCodes:[...new Set(reviews)] }); } const existingOperatorPairs = new Map(); if (matchedAreaIds.length && matchedOrgIds.length) { const found = (await manager.query(`SELECT id,area_id AS "areaId",company_id AS "companyId" FROM area_company_relations WHERE area_id=ANY($1::uuid[]) AND company_id=ANY($2::uuid[]) AND relation_role='OPERATOR' AND valid_until IS NULL`,[matchedAreaIds,matchedOrgIds])) as Array<{id:string;areaId:string;companyId:string}>; for(const relation of found) existingOperatorPairs.set(`${relation.areaId}\u001f${relation.companyId}`,relation.id); } for (const [key, aggregate] of [...operatorRelations.entries()].sort(([a],[b])=>a.localeCompare(b))) { const areaItem=areaItemByKey.get(aggregate.areaKey); const orgItem=orgItemByKey.get(aggregate.organizationKey); if(!areaItem||!orgItem) continue; const reviews=[...aggregate.issues]; if(areaItem.action==='REVIEW') reviews.push('PLAN_AREA_REVIEW_REQUIRED'); if(orgItem.action==='REVIEW') reviews.push('PLAN_ORGANIZATION_REVIEW_REQUIRED'); const relationId=areaItem.matchedAssetId&&orgItem.matchedAssetId?existingOperatorPairs.get(`${areaItem.matchedAssetId}\u001f${orgItem.matchedAssetId}`)??null:null; const action:AssetImportPlanAction=reviews.length?'REVIEW':relationId?'MATCH':'CREATE'; items.push({entityKey:`operator-relation:${key}`,entityKind:'OPERATOR_RELATION',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:null,displayName:`${areaItem.displayName} ↔ ${orgItem.displayName}`,generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{areaEntityKey:areaItem.entityKey,organizationEntityKey:orgItem.entityKey,matchedRelationId:relationId,rightTypes:[...aggregate.rightTypes].sort(),sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:reviews.length===0},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]}); } const existingLegalRights = new Map(); if (matchedAreaIds.length) { const found = (await manager.query(`SELECT id,area_id AS "areaId",right_type AS "rightType" FROM area_legal_rights WHERE area_id=ANY($1::uuid[]) AND status IN ('ACTIVE','PENDING') AND (valid_until IS NULL OR valid_until>=CURRENT_DATE)`, [matchedAreaIds])) as Array<{id:string;areaId:string;rightType:string}>; for (const right of found) { const key=`${right.areaId}\u001f${right.rightType}`; const list=existingLegalRights.get(key)??[]; list.push(right); existingLegalRights.set(key,list); } } for (const [key, aggregate] of [...legalRights.entries()].sort(([a],[b])=>a.localeCompare(b))) { const areaItem=areaItemByKey.get(aggregate.areaKey); if(!areaItem) continue; const reviews=[...aggregate.issues]; if(areaItem.action==='REVIEW') reviews.push('PLAN_AREA_REVIEW_REQUIRED'); const matches=areaItem.matchedAssetId ? (existingLegalRights.get(`${areaItem.matchedAssetId}\u001f${aggregate.rightType}`)??[]) : []; if(matches.length>1) reviews.push('PLAN_MULTIPLE_LEGAL_RIGHTS'); const action:AssetImportPlanAction=reviews.length?'REVIEW':matches.length===1?'MATCH':'CREATE'; const entityKey=`legal-right:${key}`; const sourceLabels=[...aggregate.sourceRightTypes].sort(); const displayName=`${legalRightTypeLabel(aggregate.rightType as 'EXPLOITATION_CONCESSION' | 'EXPLORATION_PERMIT' | 'TRANSPORT_CONCESSION' | 'OTHER')} · ${areaItem.displayName}`; const item:AssetImportPlanDraftItem={entityKey,entityKind:'LEGAL_RIGHT',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:null,displayName,generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{areaEntityKey:areaItem.entityKey,rightType:aggregate.rightType,sourceRightTypes:sourceLabels,legalRightName:displayName,status:'PENDING',matchedLegalRightId:matches.length===1?matches[0]!.id:null,sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:reviews.length===0},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]}; items.push(item); legalRightItemByKey.set(key,item); } const matchedLegalRightIds=[...legalRightItemByKey.values()].map(item=>String(item.payload.matchedLegalRightId??'')).filter(Boolean); const existingLegalOrgPairs=new Map(); if(matchedLegalRightIds.length&&matchedOrgIds.length){const found=(await manager.query(`SELECT id,right_id AS "rightId",organization_id AS "organizationId" FROM area_legal_right_organizations WHERE right_id=ANY($1::uuid[]) AND organization_id=ANY($2::uuid[]) AND role='OPERATOR' AND valid_until IS NULL`,[matchedLegalRightIds,matchedOrgIds])) as Array<{id:string;rightId:string;organizationId:string}>;for(const row of found)existingLegalOrgPairs.set(`${row.rightId}\u001f${row.organizationId}`,row.id);} for(const [key, aggregate] of [...legalRightOrganizations.entries()].sort(([a],[b])=>a.localeCompare(b))){const rightItem=legalRightItemByKey.get(aggregate.legalRightKey);const orgItem=orgItemByKey.get(aggregate.organizationKey);if(!rightItem||!orgItem)continue;const reviews=[...aggregate.issues];if(rightItem.action==='REVIEW')reviews.push('PLAN_LEGAL_RIGHT_REVIEW_REQUIRED');if(orgItem.action==='REVIEW')reviews.push('PLAN_ORGANIZATION_REVIEW_REQUIRED');const rightId=String(rightItem.payload.matchedLegalRightId??'')||null;const relationId=rightId&&orgItem.matchedAssetId?existingLegalOrgPairs.get(`${rightId}\u001f${orgItem.matchedAssetId}`)??null:null;const action:AssetImportPlanAction=reviews.length?'REVIEW':relationId?'MATCH':'CREATE';items.push({entityKey:`legal-right-organization:${key}`,entityKind:'LEGAL_RIGHT_ORGANIZATION',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:null,displayName:`${rightItem.displayName} ↔ ${orgItem.displayName}`,generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{legalRightEntityKey:rightItem.entityKey,organizationEntityKey:orgItem.entityKey,role:'OPERATOR',matchedRelationId:relationId,sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:reviews.length===0},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]});} for (const [key,aggregate] of [...fields.entries()].sort(([a],[b])=>a.localeCompare(b))) { const areaItem=areaItemByKey.get(aggregate.areaKey); if(!areaItem) continue; const fieldCandidates=areaItem.action==='MATCH'?(existingFields.get(key)??[]):[]; const reviews=[...aggregate.issues]; if(areaItem.action==='REVIEW') reviews.push('PLAN_AREA_REVIEW_REQUIRED'); const departmentItems=[...aggregate.departmentKeys].map(k=>departmentItemByKey.get(k)).filter((v):v is AssetImportPlanDraftItem=>Boolean(v)); if(departmentItems.some(item=>item.action==='REVIEW')) reviews.push('PLAN_DEPARTMENT_REVIEW_REQUIRED'); const rightItems=[...aggregate.rightKeys].map(k=>legalRightItemByKey.get(k)).filter((v):v is AssetImportPlanDraftItem=>Boolean(v)); if(rightItems.some(item=>item.action==='REVIEW')) reviews.push('PLAN_LEGAL_RIGHT_REVIEW_REQUIRED'); const operatorItems=[...aggregate.operatorKeys].map(operatorKey=>orgItemByKey.get(operatorKey)).filter((value):value is AssetImportPlanDraftItem=>Boolean(value)); const uniqueOperatorItems=[...new Map(operatorItems.map(item=>[item.entityKey,item])).values()]; const explicitlyUnassigned=aggregate.operatorUnassigned&&uniqueOperatorItems.length===0; if(uniqueOperatorItems.length>1||(aggregate.operatorUnassigned&&uniqueOperatorItems.length>0))reviews.push('PLAN_FIELD_OPERATOR_AMBIGUOUS'); if(uniqueOperatorItems.length===0&&!explicitlyUnassigned)reviews.push('PLAN_FIELD_OPERATOR_MISSING'); if(uniqueOperatorItems[0]?.action==='REVIEW')reviews.push('PLAN_ORGANIZATION_REVIEW_REQUIRED'); let matches=fieldCandidates; if(fieldCandidates.length){if(explicitlyUnassigned)matches=fieldCandidates.filter(candidate=>!candidate.operatorCompanyId);else if(uniqueOperatorItems.length===1&&uniqueOperatorItems[0]!.action==='MATCH'&&uniqueOperatorItems[0]!.matchedAssetId)matches=fieldCandidates.filter(candidate=>candidate.operatorCompanyId===uniqueOperatorItems[0]!.matchedAssetId);else matches=[];if(matches.length===0)reviews.push('PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH');} if(matches.length>1)reviews.push('PLAN_MULTIPLE_FIELDS'); const action:AssetImportPlanAction=reviews.length?'REVIEW':matches.length===1?'MATCH':'CREATE'; const entityKey=`field:${key}`;const contextMismatch=reviews.includes('PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH'); items.push({entityKey,entityKind:'FIELD',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:'yacimiento',displayName:aggregate.name,generatedCode:action==='CREATE'?generatedImportCode(planId,'yacimiento',entityKey):null,parentEntityKey:areaItem.entityKey,matchedAssetId:matches.length===1?matches[0]!.id:null,payload:{areaEntityKey:areaItem.entityKey,operationalAreaEntityKey:explicitlyUnassigned?null:areaItem.entityKey,operatorEntityKey:uniqueOperatorItems[0]?.entityKey??null,operatorAssignmentStatus:explicitlyUnassigned?'UNASSIGNED_SOURCE':'ASSIGNED',operatorSourceValues:[...aggregate.operatorSourceValues].sort(),departmentEntityKeys:departmentItems.map(item=>item.entityKey),legalRightEntityKeys:rightItems.map(item=>item.entityKey),sourceDocumentId:batch.sourceDocumentId,manualMatchAllowed:!contextMismatch,manualCreateAllowed:reviews.every(code=>code.startsWith('SOURCE_ROW_'))},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]}); } return items; } private async buildInventoryPlan(manager: EntityManager, planId: string, batch: ImportBatchRow, rows: ImportSourceRow[], operatorAssetId: string, namespace: string): Promise { const operatedAreas=(await manager.query(` SELECT area.id,area.code,area.name,type.code AS "typeCode",area.parent_id AS "parentId",area.operational_area_id AS "operationalAreaId",area.operator_company_id AS "operatorCompanyId" FROM area_company_relations relation JOIN assets area ON area.id=relation.area_id JOIN asset_types type ON type.id=area.asset_type_id WHERE relation.company_id=$1 AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL AND area.information_status<>'INACTIVE' ORDER BY area.name `,[operatorAssetId])) as AssetMatch[]; if(!operatedAreas.length) throw new ConflictException({code:'ASSET_IMPORT_OPERATOR_WITHOUT_AREAS',message:'La organización seleccionada no tiene áreas operadas activas. Importá o configurá primero la relación Área ↔ Operadora.'}); const areaIds=operatedAreas.map(area=>area.id); const areaMap=new Map(); for(const area of operatedAreas){const key=plainImportKey(area.name);const list=areaMap.get(key)??[];list.push(area);areaMap.set(key,list);} const lookups=[...new Set(rows.map(row=>plainImportKey(row.normalizedData.areaOrField)).filter(Boolean))]; const lookupSet=new Set(lookups); type ContextCandidate=AssetMatch & {parentName?:string|null}; const fieldMap=new Map(); if(lookups.length){ const found=(await manager.query(` SELECT field.id,field.code,field.name,type.code AS "typeCode",field.parent_id AS "parentId",field.operational_area_id AS "operationalAreaId",field.operator_company_id AS "operatorCompanyId",area.name AS "parentName" FROM assets field JOIN asset_types type ON type.id=field.asset_type_id LEFT JOIN assets area ON area.id=field.parent_id WHERE type.code='yacimiento' AND field.information_status<>'INACTIVE' AND field.parent_id=ANY($1::uuid[]) `,[areaIds])) as ContextCandidate[]; for(const field of found){const key=plainImportKey(field.name);if(!lookupSet.has(key))continue;const list=fieldMap.get(key)??[];list.push(field);fieldMap.set(key,list);} } const inventoryIds=[...new Set(rows.map(row=>String(row.normalizedData.inventoryId??'').trim().toLowerCase()).filter(Boolean))]; const inventoryIdCounts=new Map(); for(const row of rows){const key=plainImportKey(row.normalizedData.inventoryId);if(key)inventoryIdCounts.set(key,(inventoryIdCounts.get(key)??0)+1);} const namespaceMatches=new Map(); if(inventoryIds.length){ const found=(await manager.query(` SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",asset.parent_id AS "parentId",asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId",lower(identifier.value) AS lookup FROM asset_external_identifiers identifier JOIN assets asset ON asset.id=identifier.asset_id JOIN asset_types type ON type.id=asset.asset_type_id WHERE identifier.namespace=$1 AND identifier.valid_until IS NULL AND lower(identifier.value)=ANY($2::text[]) AND asset.information_status<>'INACTIVE' `,[namespace,inventoryIds])) as Array; for(const asset of found){const list=namespaceMatches.get(asset.lookup)??[];list.push(asset);namespaceMatches.set(asset.lookup,list);} } const rawOverrides=batch.analysis?.inventoryContextOverrides; const contextOverrides=(rawOverrides&&typeof rawOverrides==='object'&&!Array.isArray(rawOverrides)?rawOverrides:{}) as Record; type Context = { row:ImportSourceRow; anchor:ContextCandidate|null; areaId:string|null; contextKey:string|null; contextItem:AssetImportPlanDraftItem|null; reviewCodes:string[] }; const contextItems=new Map(); const contextDecisionItems=new Map(); const contexts:Context[]=[]; for(const row of rows){ const lookup=plainImportKey(row.normalizedData.areaOrField); const fields=fieldMap.get(lookup)??[]; const areas=(areaMap.get(lookup)??[]) as ContextCandidate[]; const candidates:ContextCandidate[]=fields.length?fields:areas; const reviews:string[]=[]; let anchor:ContextCandidate|null=null; const overrideId=String(contextOverrides[lookup]??'').trim(); if(overrideId){ anchor=candidates.find(candidate=>candidate.id===overrideId)??null; if(!anchor)reviews.push('PLAN_TERRITORY_CONTEXT_REQUIRED'); } if(!anchor){ if(!overrideId&&candidates.length===1)anchor=candidates[0]!; else if(candidates.length!==1||Boolean(overrideId)){ reviews.push('PLAN_TERRITORY_CONTEXT_REQUIRED'); const decisionKey=`inventory-context:${lookup}`; const existing=contextDecisionItems.get(decisionKey); const candidateOptions=candidates.map(candidate=>({id:candidate.id,code:candidate.code,name:candidate.name,typeCode:candidate.typeCode,parentName:candidate.parentName??null})); const decisionCode=overrideId?'PLAN_TERRITORY_OVERRIDE_STALE':candidates.length?'PLAN_TERRITORY_AMBIGUOUS':'PLAN_TERRITORY_NOT_FOUND'; const decision=existing??{entityKey:decisionKey,entityKind:fields.length?'FIELD':'AREA',action:'REVIEW',status:'REVIEW',assetTypeCode:fields.length?'yacimiento':'area',displayName:String(row.normalizedData.areaOrField??'Contexto territorial'),generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{inventoryContextDecision:true,contextLookup:lookup,candidateOptions,operatorAssetId,manualMatchAllowed:candidateOptions.length>0,manualCreateAllowed:false},sourceRowNumbers:[],reviewCodes:[decisionCode]}; decision.sourceRowNumbers.push(row.rowNumber);contextDecisionItems.set(decisionKey,decision); } } let contextItem:AssetImportPlanDraftItem|null=null; let contextKey:string|null=null; let areaId:string|null=null; if(anchor){ areaId=anchor.typeCode==='area'?anchor.id:(anchor.operationalAreaId??anchor.parentId); contextKey=`context:${anchor.typeCode}:${anchor.id}`; contextItem=contextItems.get(contextKey)??{entityKey:contextKey,entityKind:anchor.typeCode==='area'?'AREA':'FIELD',action:'MATCH',status:'MATCHED',assetTypeCode:anchor.typeCode,displayName:anchor.name,generatedCode:null,parentEntityKey:null,matchedAssetId:anchor.id,payload:{operationalAreaAssetId:areaId,operatorAssetId,contextOnly:true,sourceContextOverride:Boolean(overrideId)},sourceRowNumbers:[],reviewCodes:[]}; contextItem.sourceRowNumbers.push(row.rowNumber);contextItems.set(contextKey,contextItem); } contexts.push({row,anchor,areaId,contextKey,contextItem,reviewCodes:reviews}); } type LocalStructureSpec={key:string;name:string;parentKey:string;parentAssetId:string;areaId:string;rows:number[];sourceInstallation:string|null;sourceSubInstallation:string|null;sourcePath:string[];concreteFromLocation:boolean;sourceGroupOnly:boolean;reviewCodes:string[]}; const localStructures=new Map(); const rowLocalStructureKeys=new Map(); for(const context of contexts){ if(!context.anchor||!context.contextKey||!context.areaId)continue; const suggestion=sourceLocalStructureSuggestion(context.row.normalizedData.areaOrField,context.row.normalizedData.installation,context.row.normalizedData.subInstallation,context.row.normalizedData.location); if(!suggestion)continue; const hierarchyKey=suggestion.concreteFromLocation ? plainImportKey(suggestion.displayName) : [plainImportKey(suggestion.sourceInstallation),plainImportKey(suggestion.sourceSubInstallation)].filter(Boolean).join(':'); const key=`local-structure:${operatorAssetId}:${context.anchor.id}:${hierarchyKey}`; const spec=localStructures.get(key)??{key,name:suggestion.displayName,parentKey:context.contextKey,parentAssetId:context.anchor.id,areaId:context.areaId,rows:[],sourceInstallation:suggestion.sourceInstallation,sourceSubInstallation:suggestion.sourceSubInstallation,sourcePath:suggestion.sourcePath,concreteFromLocation:suggestion.concreteFromLocation,sourceGroupOnly:suggestion.sourceGroupOnly,reviewCodes:[]}; spec.rows.push(context.row.rowNumber);localStructures.set(key,spec);rowLocalStructureKeys.set(context.row.rowNumber,key); } const localMatches=new Map(); if(localStructures.size){ const parentIds=[...new Set([...localStructures.values()].map(item=>item.parentAssetId))]; const wanted=new Set([...localStructures.values()].map(item=>`local:${item.parentAssetId}:${plainImportKey(item.name)}`)); if(parentIds.length){ const found=(await manager.query(` SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",asset.parent_id AS "parentId",asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.parent_id=ANY($1::uuid[]) AND type.code='estructura_local' AND asset.operator_company_id=$2 AND asset.information_status<>'INACTIVE' `,[parentIds,operatorAssetId])) as AssetMatch[]; for(const asset of found){const key=`local:${asset.parentId}:${plainImportKey(asset.name)}`;if(!wanted.has(key))continue;const list=localMatches.get(key)??[];list.push(asset);localMatches.set(key,list);} } } const items:AssetImportPlanDraftItem[]=[...contextItems.values(),...contextDecisionItems.values()].map(item=>({...item,sourceRowNumbers:[...new Set(item.sourceRowNumbers)].sort((a,b)=>a-b)})); const localItemByKey=new Map(); for(const spec of [...localStructures.values()].sort((a,b)=>a.key.localeCompare(b.key))){ const matchKey=`local:${spec.parentAssetId}:${plainImportKey(spec.name)}`; const matches=localMatches.get(matchKey)??[]; const reviews=[...spec.reviewCodes];if(matches.length>1)reviews.push('PLAN_MULTIPLE_LOCAL_STRUCTURES'); const action:AssetImportPlanAction=reviews.length?'REVIEW':matches.length===1?'MATCH':'CREATE'; const item:AssetImportPlanDraftItem={entityKey:spec.key,entityKind:'LOCAL_STRUCTURE',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:'estructura_local',displayName:spec.name,generatedCode:action==='CREATE'?generatedImportCode(planId,'estructura_local',spec.key):null,parentEntityKey:spec.parentKey,matchedAssetId:matches.length===1?matches[0]!.id:null,payload:{operationalAreaAssetId:spec.areaId,operatorAssetId,sourceInstallation:spec.sourceInstallation,sourceSubInstallation:spec.sourceSubInstallation,sourcePath:spec.sourcePath,sourcePathText:spec.sourcePath.join(' / '),concreteFromLocation:spec.concreteFromLocation,sourceGroupOnly:spec.sourceGroupOnly,sourceDocumentId:batch.sourceDocumentId,localNomenclature:true,classificationStatus:'SOURCE_PRESERVED',manualCreateAllowed:reviews.length===0},sourceRowNumbers:[...new Set(spec.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]}; items.push(item);localItemByKey.set(spec.key,item); } const directAnchorTypes=new Set(['pozo','ducto','colector']); for(const context of contexts){ const row=context.row; const inventoryId=String(row.normalizedData.inventoryId??'').trim(); const typeCode=technicalFamilyTypeCode(row.normalizedData.normalizedFamily,row.normalizedData.normalizedSubtype); const displayName=inventoryId?`${String(row.normalizedData.equipment??'Activo').trim()} · ${inventoryId}`:String(row.normalizedData.equipment??'Activo sin identificar'); const inventoryKey=plainImportKey(inventoryId); const entityKey=inventoryKey&&inventoryIdCounts.get(inventoryKey)===1?`technical:${namespace}:${inventoryKey}`:`technical:${namespace}:${inventoryKey||'sin-id'}:row-${row.rowNumber}`; const reviews=[...context.reviewCodes]; if(inventoryId.length>180)reviews.push('PLAN_INVENTORY_ID_TOO_LONG'); const exact=inventoryId?(namespaceMatches.get(inventoryId.toLowerCase())??[]):[]; if(exact.length>1)reviews.push('PLAN_MULTIPLE_NAMESPACE_MATCHES'); if(exact.length===1&&(exact[0]!.operationalAreaId!==context.areaId||exact[0]!.operatorCompanyId!==operatorAssetId))reviews.push('PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH'); if(!exact.length&&row.matchedAssetId)reviews.push('PLAN_GLOBAL_MATCH_REQUIRES_NAMESPACE'); if(row.status==='CONFLICT'||row.status==='WARNING')reviews.push(...(row.issues??[]),`SOURCE_ROW_${row.status}`); if(row.status==='IGNORED'){ items.push({entityKey,entityKind:'TECHNICAL_ASSET',action:'IGNORE',status:'IGNORED',assetTypeCode:typeCode,displayName,generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{...row.normalizedData,externalIdNamespace:namespace,operatorAssetId,manualCreateAllowed:false},sourceRowNumbers:[row.rowNumber],reviewCodes:[...new Set(reviews)]}); continue; } let parentKey=context.contextKey; if(!directAnchorTypes.has(typeCode)){ const key=rowLocalStructureKeys.get(row.rowNumber); parentKey=key?localItemByKey.get(key)?.entityKey??null:null; if(context.anchor&&context.areaId&&!parentKey)reviews.push('PLAN_LOCAL_STRUCTURE_NOT_IDENTIFIED'); } if(!context.anchor||!context.areaId){ if(reviews.includes('PLAN_TERRITORY_CONTEXT_REQUIRED'))reviews.push('PLAN_CONTEXT_DECISION_REQUIRED'); else reviews.push('PLAN_PARENT_NOT_RESOLVED'); } else if(!parentKey)reviews.push('PLAN_PARENT_NOT_RESOLVED'); const action:AssetImportPlanAction=reviews.length?'REVIEW':exact.length===1?'MATCH':'CREATE'; items.push({entityKey,entityKind:'TECHNICAL_ASSET',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:typeCode,displayName,generatedCode:action==='CREATE'?generatedImportCode(planId,typeCode,entityKey):null,parentEntityKey:parentKey,matchedAssetId:exact.length===1?exact[0]!.id:null,payload:{...row.normalizedData,operationalAreaAssetId:context.areaId,operatorAssetId,externalIdNamespace:namespace,sourceDocumentId:batch.sourceDocumentId,localStructureEntityKey:parentKey&&parentKey.startsWith('local-structure:')?parentKey:null,manualMatchAllowed:!reviews.includes('PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH'),manualCreateAllowed:reviews.every(code=>code.startsWith('SOURCE_ROW_')||code==='GROUPED_QUANTITY'||code==='SOURCE_STATUS_REQUIRES_MAPPING'||code==='DUPLICATE_INVENTORY_ID_IN_BATCH')},sourceRowNumbers:[row.rowNumber],reviewCodes:[...new Set(reviews)]}); } return items; } private async assetNameMatches(manager:EntityManager,role:'AREA'|'COMPANY',keys:string[]):Promise>{ const map=new Map();if(!keys.length)return map;const wanted=new Set(keys); const rows=(await manager.query(`SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",asset.parent_id AS "parentId",asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE type.operational_role=$1 AND type.is_active=true AND asset.information_status<>'INACTIVE'`,[role])) as AssetMatch[]; for(const row of rows){const key=plainImportKey(row.name);if(!wanted.has(key))continue;const list=map.get(key)??[];list.push(row);map.set(key,list);}return map; } private async organizationNameMatches(manager:EntityManager,keys:string[]):Promise>{ const map=new Map();if(!keys.length)return map;const wanted=new Set(keys); const rows=(await manager.query(` SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",asset.parent_id AS "parentId",asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId",profile.legal_name AS "legalName" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id LEFT JOIN organization_profiles profile ON profile.asset_id=asset.id WHERE type.operational_role='COMPANY' AND type.is_active=true AND asset.information_status<>'INACTIVE' `)) as Array; for(const row of rows){ const candidateKeys=[organizationImportKey(row.name),organizationImportKey(row.legalName)].filter(Boolean); for(const key of new Set(candidateKeys)){if(!wanted.has(key))continue;const list=map.get(key)??[];if(!list.some(item=>item.id===row.id))list.push(row);map.set(key,list);} } return map; } private async insertPlanItems(manager: EntityManager, planId: string, items: AssetImportPlanDraftItem[]): Promise { for(let start=0;start{const base=params.length;params.push(planId,start+index+1,item.entityKey,item.entityKind,item.action,item.status,item.assetTypeCode,item.displayName,item.generatedCode,item.parentEntityKey,item.matchedAssetId,JSON.stringify(item.payload),JSON.stringify(item.sourceRowNumbers),JSON.stringify(item.reviewCodes));return `($${base+1}::uuid,$${base+2},$${base+3},$${base+4},$${base+5},$${base+6},$${base+7},$${base+8},$${base+9},$${base+10},$${base+11}::uuid,$${base+12}::jsonb,$${base+13}::jsonb,$${base+14}::jsonb)`;});await manager.query(`INSERT INTO asset_import_plan_items (plan_id,item_order,entity_key,entity_kind,action,status,asset_type_code,display_name,generated_code,parent_entity_key,matched_asset_id,payload,source_row_numbers,review_codes) VALUES ${values.join(',')}`,params);} } private async refreshPlan(manager:EntityManager,planId:string,summaryPatch:Record={}){ const plan=await this.loadPlanById(manager,planId);const items=await this.loadPlanItems(manager,planId); const drafts:AssetImportPlanDraftItem[]=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,reviewCodes:item.reviewCodes})); const summary={...plan.summary,...summarizePlanItems(drafts),...summaryPatch,updatedAt:new Date().toISOString()};const status=planStatusForItems(drafts);const hash=planItemHash(drafts); await manager.query(`UPDATE asset_import_plans SET status=$2,summary=$3::jsonb,plan_hash=$4,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[planId,status,JSON.stringify(summary),hash]); await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importPlan}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[plan.batchId,JSON.stringify({planId,revision:plan.revision,status,planHash:hash,...summary})]); return this.planView(manager,planId); } private async revalidateMatchedAssets(manager:EntityManager,items:PlanItemRow[]):Promise{ const ids=[...new Set(items.filter(item=>item.action==='MATCH'&&item.matchedAssetId).map(item=>item.matchedAssetId!))];if(!ids.length)return; const [row]=(await manager.query(`SELECT COUNT(*)::integer AS total FROM assets WHERE id=ANY($1::uuid[]) AND information_status<>'INACTIVE'`,[ids])) as Array<{total:number}>; if(Number(row?.total??0)!==ids.length)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:'Uno o más activos coincidentes cambiaron o fueron inactivados. Regenerá el plan.'}); } private async revalidateMatchedRelationalObjects(manager: EntityManager, items: PlanItemRow[]): Promise { const departmentIds = [...new Set(items.filter((item) => item.entityKind === 'DEPARTMENT' && item.action === 'MATCH').map((item) => String(item.payload.matchedDepartmentId ?? '')).filter(Boolean))]; if (departmentIds.length) { const [row] = (await manager.query(`SELECT COUNT(*)::integer AS total FROM administrative_departments WHERE id=ANY($1::uuid[]) AND is_active=true`, [departmentIds])) as Array<{ total: number }>; if (Number(row?.total ?? 0) !== departmentIds.length) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'Uno o más Departamentos del plan cambiaron. Regenerá el plan.' }); } const rightIds = [...new Set(items.filter((item) => item.entityKind === 'LEGAL_RIGHT' && item.action === 'MATCH').map((item) => String(item.payload.matchedLegalRightId ?? '')).filter(Boolean))]; if (rightIds.length) { const [row] = (await manager.query(`SELECT COUNT(*)::integer AS total FROM area_legal_rights WHERE id=ANY($1::uuid[]) AND status IN ('ACTIVE','PENDING') AND (valid_until IS NULL OR valid_until>=CURRENT_DATE)`, [rightIds])) as Array<{ total: number }>; if (Number(row?.total ?? 0) !== rightIds.length) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'Uno o más derechos del plan cambiaron. Regenerá el plan.' }); } const relationChecks: Array<{ kind: AssetImportPlanEntityKind; table: string }> = [ { kind: 'AREA_DEPARTMENT_RELATION', table: 'area_department_relations' }, { kind: 'OPERATOR_RELATION', table: 'area_company_relations' }, { kind: 'LEGAL_RIGHT_ORGANIZATION', table: 'area_legal_right_organizations' }, ]; for (const check of relationChecks) { const ids = [...new Set(items.filter((item) => item.entityKind === check.kind && item.action === 'MATCH').map((item) => String(item.payload.matchedRelationId ?? '')).filter(Boolean))]; if (!ids.length) continue; const [row] = (await manager.query(`SELECT COUNT(*)::integer AS total FROM ${check.table} WHERE id=ANY($1::uuid[]) AND valid_until IS NULL`, [ids])) as Array<{ total: number }>; if (Number(row?.total ?? 0) !== ids.length) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: `Una relación ${check.kind} cambió. Regenerá el plan.` }); } } private async revalidateExternalIdentifiers(manager:EntityManager,plan:ImportPlanRow,items:PlanItemRow[]):Promise{ if(!plan.externalIdNamespace)return; const values=[...new Set(items.filter(item=>item.action==='CREATE'&&item.status!=='APPLIED'&&item.entityKind==='TECHNICAL_ASSET').map(item=>String(item.payload.inventoryId??'').trim().toLowerCase()).filter(Boolean))]; if(!values.length)return; const [hit]=(await manager.query(`SELECT identifier.value FROM asset_external_identifiers identifier JOIN assets asset ON asset.id=identifier.asset_id WHERE identifier.namespace=$1 AND identifier.valid_until IS NULL AND lower(identifier.value)=ANY($2::text[]) AND asset.information_status<>'INACTIVE' LIMIT 1`,[plan.externalIdNamespace,values])) as Array<{value:string}>; if(hit)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:`El identificador ${plan.externalIdNamespace}:${hit.value} apareció en el Maestro después de generar el plan. Regenerá el plan.`}); } private async revalidateMatchedOperationalContext(manager:EntityManager,items:PlanItemRow[],getRef:(key:string|null|undefined)=>string|null):Promise{ const matched=items.filter(item=>item.action==='MATCH'&&item.matchedAssetId&&['FIELD','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET'].includes(item.entityKind)); if(!matched.length)return; const ids=[...new Set(matched.map(item=>item.matchedAssetId!))]; const rows=(await manager.query(`SELECT asset.id,asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId" FROM assets asset WHERE asset.id=ANY($1::uuid[]) AND asset.information_status<>'INACTIVE'`,[ids])) as Array<{id:string;operationalAreaId:string|null;operatorCompanyId:string|null}>; const byId=new Map(rows.map(row=>[row.id,row])); for(const item of matched){ const current=byId.get(item.matchedAssetId!);if(!current)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:`La coincidencia ${item.displayName} ya no está disponible`}); const areaKey=String(item.payload.operationalAreaEntityKey??'');const operatorKey=String(item.payload.operatorEntityKey??''); const expectedArea=areaKey?getRef(areaKey):(String(item.payload.operationalAreaAssetId??'')||null); const expectedOperator=operatorKey?getRef(operatorKey):(String(item.payload.operatorAssetId??'')||null); const explicitlyUnassigned=item.payload.operatorAssignmentStatus==='UNASSIGNED_SOURCE'; if(explicitlyUnassigned){if(current.operationalAreaId!==null||current.operatorCompanyId!==null)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:`${item.displayName} recibió contexto operativo después de generar el plan. Regenerá antes de aplicar.`});continue;} if(expectedArea!==null||expectedOperator!==null){if(current.operationalAreaId!==expectedArea||current.operatorCompanyId!==expectedOperator)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:`El contexto operativo de ${item.displayName} cambió. Regenerá el plan.`});} } } private async applyPlanItems(manager:EntityManager,batch:ImportBatchRow,plan:ImportPlanRow,items:PlanItemRow[],principal:AuthPrincipal,request:RequestWithContext):Promise>{ const assetKinds = new Set(['ORGANIZATION','AREA','FIELD','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET']); const resolved=new Map(); for(const item of items){if(item.status==='APPLIED'&&item.appliedAssetId&&assetKinds.has(item.entityKind)){resolved.set(item.entityKey,{id:item.appliedAssetId,typeCode:item.assetTypeCode??''});continue;}if(item.action==='MATCH'&&item.matchedAssetId&&assetKinds.has(item.entityKind)){resolved.set(item.entityKey,{id:item.matchedAssetId,typeCode:item.assetTypeCode??''});}} const createAssetItems=items.filter(item=>item.action==='CREATE'&&item.status!=='APPLIED'&&assetKinds.has(item.entityKind)); for(const item of createAssetItems){resolved.set(item.entityKey,{id:randomUUID(),typeCode:item.assetTypeCode??''});} const resolvedObjects=new Map(); for(const item of items){ if(item.status==='APPLIED'&&item.appliedObjectId&&(item.entityKind==='DEPARTMENT'||item.entityKind==='LEGAL_RIGHT')){resolvedObjects.set(item.entityKey,item.appliedObjectId);continue;} if(item.entityKind==='DEPARTMENT'&&item.action==='MATCH'){const id=String(item.payload.matchedDepartmentId??'');if(id)resolvedObjects.set(item.entityKey,id);} if(item.entityKind==='LEGAL_RIGHT'&&item.action==='MATCH'){const id=String(item.payload.matchedLegalRightId??'');if(id)resolvedObjects.set(item.entityKey,id);} } for(const item of items){if(item.action!=='CREATE'||item.status==='APPLIED')continue;if(item.entityKind==='DEPARTMENT'||item.entityKind==='LEGAL_RIGHT')resolvedObjects.set(item.entityKey,randomUUID());} const typeCodes=[...new Set(createAssetItems.map(item=>item.assetTypeCode).filter((value):value is string=>Boolean(value)))]; const typeRows=typeCodes.length?(await manager.query(`SELECT id,code,operational_role AS "operationalRole",is_active AS "isActive",can_be_root AS "canBeRoot" FROM asset_types WHERE code=ANY($1::text[])`,[typeCodes])) as Array<{id:string;code:string;operationalRole:string;isActive:boolean;canBeRoot:boolean}>:[]; const typeMap=new Map(typeRows.map(row=>[row.code,row])); for(const code of typeCodes){const type=typeMap.get(code);if(!type||!type.isActive)throw new ConflictException({code:'ASSET_IMPORT_TYPE_NOT_AVAILABLE',message:`El tipo ${code} no existe o está inactivo`});} const createdAssetIdSet=new Set(createAssetItems.map(item=>resolved.get(item.entityKey)!.id)); const matchedIds=[...new Set([...resolved.values()].map(value=>value.id).filter(id=>!createdAssetIdSet.has(id)))]; const matchedTypes=new Map();if(matchedIds.length){const rows=(await manager.query(`SELECT asset.id,type.code FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id=ANY($1::uuid[])`,[matchedIds])) as Array<{id:string;code:string}>;for(const row of rows)matchedTypes.set(row.id,row.code);} const getRef=(key:string|null|undefined)=>key?resolved.get(key)?.id??null:null; const getObjectRef=(key:string|null|undefined)=>key?resolvedObjects.get(key)??null:null; const getType=(key:string|null|undefined)=>{if(!key)return null;const ref=resolved.get(key);if(!ref)return null;return ref.typeCode||matchedTypes.get(ref.id)||null;}; await this.revalidateMatchedOperationalContext(manager,items,getRef); const rootItems=createAssetItems.filter(item=>item.entityKind==='ORGANIZATION'||item.entityKind==='AREA'); const fieldItems=createAssetItems.filter(item=>item.entityKind==='FIELD'); const installationItems=createAssetItems.filter(item=>item.entityKind==='INSTALLATION'||item.entityKind==='LOCAL_STRUCTURE'); const technicalItems=createAssetItems.filter(item=>item.entityKind==='TECHNICAL_ASSET'); await this.insertAssetItems(manager,batch,rootItems,resolved,typeMap,getRef,getType,principal); let createdDepartments=0; for(const item of items.filter(item=>item.entityKind==='DEPARTMENT'&&item.action==='CREATE'&&item.status!=='APPLIED')){ const id=getObjectRef(item.entityKey);if(!id)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver Departamento ${item.displayName}`}); await manager.query(`INSERT INTO administrative_departments (id,province_code,code,name,normalized_name,source_document_id,created_by,updated_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)`,[id,String(item.payload.provinceCode??'MENDOZA'),String(item.payload.departmentCode??departmentCode(item.displayName)),item.displayName.slice(0,160),String(item.payload.normalizedName??plainImportKey(item.displayName)),batch.sourceDocumentId,principal.userId]); await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,id]);createdDepartments+=1; } let createdAreaDepartmentRelations=0; for(const item of items.filter(item=>item.entityKind==='AREA_DEPARTMENT_RELATION'&&item.action==='CREATE'&&item.status!=='APPLIED')){ const areaId=getRef(String(item.payload.areaEntityKey??''));const departmentId=getObjectRef(String(item.payload.departmentEntityKey??''));if(!areaId||!departmentId)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver ${item.displayName}`}); const [created]=await manager.query(`INSERT INTO area_department_relations (area_id,department_id,source_document_id,notes,created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id`,[areaId,departmentId,batch.sourceDocumentId,`Importación controlada · lote ${batch.id}`,principal.userId]); await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,String(created.id)]);createdAreaDepartmentRelations+=1; } let createdOperatorRelations=0; for(const item of items.filter(item=>item.entityKind==='OPERATOR_RELATION'&&item.action==='CREATE'&&item.status!=='APPLIED')){const areaId=getRef(String(item.payload.areaEntityKey??''));const organizationId=getRef(String(item.payload.organizationEntityKey??''));if(!areaId||!organizationId)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver la relación ${item.displayName}`});const [created]=await manager.query(`INSERT INTO area_company_relations (area_id,company_id,start_reason,created_by,relation_role,source_document_id) VALUES ($1,$2,$3,$4,'OPERATOR',$5) RETURNING id`,[areaId,organizationId,`Importación controlada · lote ${batch.id}`,principal.userId,batch.sourceDocumentId]);await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,String(created.id)]);createdOperatorRelations+=1;} let createdLegalRights=0; for(const item of items.filter(item=>item.entityKind==='LEGAL_RIGHT'&&item.action==='CREATE'&&item.status!=='APPLIED')){ const id=getObjectRef(item.entityKey);const areaId=getRef(String(item.payload.areaEntityKey??''));if(!id||!areaId)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver ${item.displayName}`}); const rightType=String(item.payload.rightType??'OTHER'); if(!['EXPLOITATION_CONCESSION','EXPLORATION_PERMIT','TRANSPORT_CONCESSION','OTHER'].includes(rightType))throw new ConflictException({code:'ASSET_IMPORT_LEGAL_RIGHT_TYPE_INVALID',message:`Tipo de derecho inválido para ${item.displayName}`}); await manager.query(`INSERT INTO area_legal_rights (id,area_id,right_type,name,status,source_document_id,notes,created_by,updated_by) VALUES ($1,$2,$3::area_legal_right_type,$4,'PENDING',$5,$6,$7,$7)`,[id,areaId,rightType,String(item.payload.legalRightName??item.displayName).slice(0,260),batch.sourceDocumentId,`Importado como PENDING: la fuente define tipo y Área pero no instrumento ni vigencia. Lote ${batch.id}.`,principal.userId]); await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,id]);createdLegalRights+=1; } let createdLegalRightOrganizationRelations=0; for(const item of items.filter(item=>item.entityKind==='LEGAL_RIGHT_ORGANIZATION'&&item.action==='CREATE'&&item.status!=='APPLIED')){ const rightId=getObjectRef(String(item.payload.legalRightEntityKey??''));const organizationId=getRef(String(item.payload.organizationEntityKey??''));if(!rightId||!organizationId)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver ${item.displayName}`}); const [created]=await manager.query(`INSERT INTO area_legal_right_organizations (right_id,organization_id,role,notes,created_by) VALUES ($1,$2,'OPERATOR',$3,$4) RETURNING id`,[rightId,organizationId,`La fuente identifica a la organización como operadora; no se infiere titularidad. Lote ${batch.id}.`,principal.userId]); await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,String(created.id)]);createdLegalRightOrganizationRelations+=1; } await this.insertAssetItems(manager,batch,fieldItems,resolved,typeMap,getRef,getType,principal); await this.insertAssetItems(manager,batch,installationItems,resolved,typeMap,getRef,getType,principal); await this.insertAssetItems(manager,batch,technicalItems,resolved,typeMap,getRef,getType,principal); const createdIds=createAssetItems.map(item=>resolved.get(item.entityKey)!.id); await this.insertSourceLinks(manager,batch,createAssetItems,resolved,principal.userId); const createdIdentifiers=await this.insertExternalIdentifiers(manager,batch,technicalItems,resolved,plan.externalIdNamespace,principal.userId); await this.insertKnownAttributes(manager,createAssetItems,resolved,typeMap,principal.userId); await this.insertImportVersions(manager,createdIds,principal,request); await this.updateImportedRows(manager,batch.id,items,resolved); for(const item of createAssetItems){await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_asset_id=$2::uuid,applied_object_id=($2::uuid)::text,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,resolved.get(item.entityKey)!.id]);} const createdByKind:Record={};for(const item of items.filter(item=>item.action==='CREATE'&&item.status!=='APPLIED'))createdByKind[item.entityKind]=(createdByKind[item.entityKind]??0)+1; return {createdAssets:createdIds.length,createdDepartments,createdAreaDepartmentRelations,createdOperatorRelations,createdLegalRights,createdLegalRightOrganizationRelations,createdExternalIdentifiers:createdIdentifiers,matchedItems:items.filter(item=>item.action==='MATCH').length,ignoredItems:items.filter(item=>item.action==='IGNORE').length,createdByKind,sourceRowsLinked:items.filter(item=>['FIELD','TECHNICAL_ASSET'].includes(item.entityKind)&&item.action!=='IGNORE').reduce((sum,item)=>sum+item.sourceRowNumbers.length,0)}; } private async insertAssetItems( manager:EntityManager,batch:ImportBatchRow,items:PlanItemRow[],resolved:Map,typeMap:Map,getRef:(key:string|null|undefined)=>string|null,getType:(key:string|null|undefined)=>string|null,principal:AuthPrincipal, ):Promise{ if(!items.length)return; const parentRuleRows=(await manager.query(`SELECT child.code AS child,parent.code AS parent FROM asset_type_parent_rules rule JOIN asset_types child ON child.id=rule.child_type_id JOIN asset_types parent ON parent.id=rule.parent_type_id`)) as Array<{child:string;parent:string}>; const rules=new Set(parentRuleRows.map(row=>`${row.child}\u001f${row.parent}`)); const records:Array<{item:PlanItemRow;id:string;typeId:string;parentId:string|null;areaId:string|null;operatorId:string|null;operationalStatus:string;description:string}>=[]; for(const item of items){const typeCode=item.assetTypeCode;if(!typeCode)throw new ConflictException({code:'ASSET_IMPORT_TYPE_REQUIRED',message:`El ítem ${item.displayName} no tiene tipo`});const type=typeMap.get(typeCode);if(!type)throw new ConflictException({code:'ASSET_IMPORT_TYPE_NOT_AVAILABLE',message:`Tipo no disponible: ${typeCode}`});const id=resolved.get(item.entityKey)!.id;const parentId=getRef(item.parentEntityKey);const parentType=getType(item.parentEntityKey);if(!parentId&&!type.canBeRoot)throw new ConflictException({code:'ASSET_IMPORT_ROOT_RULE_INVALID',message:`${typeCode} no admite activos raíz (${item.displayName})`});if(parentId&&parentType&&!rules.has(`${typeCode}\u001f${parentType}`))throw new ConflictException({code:'ASSET_IMPORT_PARENT_RULE_INVALID',message:`${typeCode} no admite ${parentType} como padre (${item.displayName})`});let areaId:string|null=null;let operatorId:string|null=null;if(type.operationalRole==='GENERIC'){const areaKey=String(item.payload.operationalAreaEntityKey??'');const operatorKey=String(item.payload.operatorEntityKey??'');areaId=areaKey?getRef(areaKey):String(item.payload.operationalAreaAssetId??'')||null;operatorId=operatorKey?getRef(operatorKey):String(item.payload.operatorAssetId??'')||null;if((areaId&&!operatorId)||(!areaId&&operatorId))throw new ConflictException({code:'ASSET_IMPORT_OPERATIONAL_CONTEXT_INCOMPLETE',message:`Contexto operativo incompleto para ${item.displayName}`});} const suggested=String(item.payload.operationalStatusSuggestion??'');const operationalStatus=['IN_SERVICE','TEMPORARILY_OUT_OF_SERVICE','OUT_OF_SERVICE','DECOMMISSIONED','ABANDONED'].includes(suggested)?suggested:'UNKNOWN';const sourceClassification=String(item.payload.sourceClassification??'').trim();const family=String(item.payload.normalizedFamily??'').trim();const subtype=String(item.payload.normalizedSubtype??'').trim();const description=[`Importado desde ${batch.originalName}.`,sourceClassification?`Clasificación fuente: ${sourceClassification}.`:'',family?`Familia normalizada: ${family}${subtype?` / ${subtype}`:''}.`:''].filter(Boolean).join(' ');records.push({item,id,typeId:type.id,parentId,areaId,operatorId,operationalStatus,description});} for(let start=0;start{const base=params.length;params.push(record.id,record.typeId,record.parentId,record.areaId,record.operatorId,record.item.generatedCode,record.item.displayName.slice(0,200),record.description,record.operationalStatus,principal.userId,batch.sourceLabel||batch.originalName,`asset-import:${batch.id}`,`Importación planificada ${batch.id}`);return `($${base+1}::uuid,$${base+2}::uuid,$${base+3}::uuid,$${base+4}::uuid,$${base+5}::uuid,$${base+6},$${base+7},$${base+8},'DRAFT',$${base+9}::asset_operational_status,$${base+10},$${base+10},1,'IMPORT',$${base+11},$${base+12},$${base+13},$${base+10})`;});await manager.query(`INSERT INTO assets (id,asset_type_id,parent_id,operational_area_id,operator_company_id,code,name,description,information_status,operational_status,created_by,updated_by,current_version,data_origin,source_name,source_reference,source_notes,provenance_updated_by) VALUES ${values.join(',')}`,params);} const organizations=records.filter(record=>record.item.entityKind==='ORGANIZATION');for(let start=0;start{const base=params.length;const kind=record.item.payload.organizationKind==='UTE'?'UTE':'COMPANY';params.push(record.id,kind,record.item.displayName.slice(0,240),principal.userId);return `($${base+1}::uuid,$${base+2}::organization_kind,$${base+3},$${base+4})`;});await manager.query(`INSERT INTO organization_profiles (asset_id,organization_kind,legal_name,updated_by) VALUES ${values.join(',')} ON CONFLICT (asset_id) DO NOTHING`,params);} } private async insertSourceLinks(manager:EntityManager,batch:ImportBatchRow,items:PlanItemRow[],resolved:Map,userId:string):Promise{if(!batch.sourceDocumentId||!items.length)return;const ids=[...new Set(items.map(item=>resolved.get(item.entityKey)?.id).filter((value):value is string=>Boolean(value)))];for(let start=0;start,namespace:string|null,userId:string):Promise{if(!namespace||!items.length)return 0;const values:Array<{assetId:string;value:string}>=[];for(const item of items){const value=String(item.payload.inventoryId??'').trim();if(value)values.push({assetId:resolved.get(item.entityKey)!.id,value});}let count=0;for(let start=0;start{const base=params.length;params.push(item.assetId,namespace,item.value,batch.sourceDocumentId,userId,`Inventario importado · lote ${batch.id}`);return `($${base+1}::uuid,$${base+2},$${base+3},$${base+4}::uuid,$${base+5},$${base+6})`;});const result=await manager.query(`INSERT INTO asset_external_identifiers (asset_id,namespace,value,source_document_id,created_by,notes) VALUES ${sql.join(',')} RETURNING id`,params);count+=result.length;}return count;} private async insertKnownAttributes(manager:EntityManager,items:PlanItemRow[],resolved:Map,typeMap:Map,userId:string):Promise{ if(!items.length)return;const typeIds=[...new Set(items.map(item=>item.assetTypeCode?typeMap.get(item.assetTypeCode)?.id:null).filter((value):value is string=>Boolean(value)))];const definitions=(await manager.query(`SELECT id,asset_type_id AS "assetTypeId",code FROM asset_attribute_definitions WHERE asset_type_id=ANY($1::uuid[]) AND is_active=true`,[typeIds])) as Array<{id:string;assetTypeId:string;code:string}>;const defs=new Map();for(const def of definitions)defs.set(`${def.assetTypeId}\u001f${def.code}`,def.id);const rows:Array<{assetId:string;definitionId:string;value:unknown}>=[];for(const item of items){const type=item.assetTypeCode?typeMap.get(item.assetTypeCode):null;if(!type)continue;const add=(code:string,value:unknown)=>{if(value===null||value===undefined||String(value).trim()==='')return;const definitionId=defs.get(`${type.id}\u001f${code}`);if(definitionId)rows.push({assetId:resolved.get(item.entityKey)!.id,definitionId,value});};if(item.entityKind==='ORGANIZATION')add('razon_social',item.displayName);add('fabricante',item.payload.manufacturer);add('modelo',item.payload.model);if(item.assetTypeCode==='instalacion')add('tipo_instalacion',item.payload.sourceInstallation);if(item.assetTypeCode==='estructura_local'){add('nivel_fuente',item.payload.sourceInstallation);add('clasificacion_fuente',item.payload.sourceSubInstallation);add('ruta_fuente',item.payload.sourcePathText);}if(item.assetTypeCode==='planta')add('funcion_planta',item.payload.sourceSubInstallation||item.payload.sourceInstallation);if(item.assetTypeCode==='pozo')add('estado_operativo',item.payload.sourceStatus);} for(let start=0;start{const base=params.length;params.push(row.assetId,row.definitionId,JSON.stringify(row.value),userId);return `($${base+1}::uuid,$${base+2}::uuid,$${base+3}::jsonb,$${base+4})`;});await manager.query(`INSERT INTO asset_attribute_values (asset_id,definition_id,value,updated_by) VALUES ${values.join(',')} ON CONFLICT (asset_id,definition_id) DO UPDATE SET value=EXCLUDED.value,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP`,params);} } private async insertImportVersions(manager:EntityManager,assetIds:string[],principal:AuthPrincipal,request:RequestWithContext):Promise{for(let start=0;start):Promise{const pairs:Array<{rowNumber:number;assetId:string}>=[];for(const item of items){if(!['FIELD','TECHNICAL_ASSET'].includes(item.entityKind)||item.action==='IGNORE'||item.action==='REVIEW')continue;const assetId=item.action==='MATCH'?item.matchedAssetId:resolved.get(item.entityKey)?.id;if(!assetId)continue;for(const rowNumber of item.sourceRowNumbers)pairs.push({rowNumber,assetId});}for(let start=0;start{const base=params.length;params.push(pair.rowNumber,pair.assetId);return `($${base+1}::integer,$${base+2}::uuid)`;});await manager.query(`UPDATE asset_import_rows row SET imported_asset_id=v.asset_id,updated_at=CURRENT_TIMESTAMP FROM (VALUES ${values.join(',')}) AS v(row_number,asset_id) WHERE row.batch_id=$1 AND row.row_number=v.row_number`,params);}} private async assertRollbackSafe(manager:EntityManager,items:PlanItemRow[],createdAssetIds:string[]):Promise{ const planObjects=items.filter(item=>item.action==='CREATE'&&item.appliedObjectId).map(item=>({entityKind:item.entityKind,id:item.appliedObjectId!})); const ids=(kind:AssetImportPlanEntityKind)=>planObjects.filter(row=>row.entityKind===kind).map(row=>row.id); const operatorRelationIds=ids('OPERATOR_RELATION'); const areaDepartmentRelationIds=ids('AREA_DEPARTMENT_RELATION'); const legalRightIds=ids('LEGAL_RIGHT'); const legalRightOrganizationIds=ids('LEGAL_RIGHT_ORGANIZATION'); const departmentIds=ids('DEPARTMENT'); const checks:Array<{code:string;message:string;sql:string;params:unknown[]}>=[]; if(createdAssetIds.length){checks.push( {code:'ASSET_IMPORT_ROLLBACK_HAS_CHILDREN',message:'Hay activos posteriores que dependen de activos creados por este lote',sql:`SELECT 1 FROM assets WHERE parent_id=ANY($1::uuid[]) AND NOT (id=ANY($1::uuid[])) AND information_status<>'INACTIVE' LIMIT 1`,params:[createdAssetIds]}, {code:'ASSET_IMPORT_ROLLBACK_HAS_ASSIGNMENTS',message:'Hay activos posteriores asignados a áreas u organizaciones creadas por este lote',sql:`SELECT 1 FROM assets WHERE (operational_area_id=ANY($1::uuid[]) OR operator_company_id=ANY($1::uuid[])) AND NOT (id=ANY($1::uuid[])) AND information_status<>'INACTIVE' LIMIT 1`,params:[createdAssetIds]}, {code:'ASSET_IMPORT_ROLLBACK_HAS_RELATIONS',message:'Hay relaciones operativas externas al lote que dependen de activos importados',sql:`SELECT 1 FROM area_company_relations WHERE (area_id=ANY($1::uuid[]) OR company_id=ANY($1::uuid[])) AND valid_until IS NULL AND NOT (id=ANY($2::uuid[])) LIMIT 1`,params:[createdAssetIds,operatorRelationIds.length?operatorRelationIds:['00000000-0000-0000-0000-000000000000']]}, {code:'ASSET_IMPORT_ROLLBACK_IN_USE',message:'Hay relevamientos o inspecciones que ya utilizan activos creados por este lote',sql:`SELECT 1 FROM (SELECT asset_id FROM survey_campaign_targets WHERE asset_id=ANY($1::uuid[]) UNION ALL SELECT scope_asset_id FROM survey_campaigns WHERE scope_asset_id=ANY($1::uuid[]) UNION ALL SELECT asset_id FROM inspection_visit_assets WHERE asset_id=ANY($1::uuid[]) UNION ALL SELECT scope_asset_id FROM inspection_visits WHERE scope_asset_id=ANY($1::uuid[]) UNION ALL SELECT asset_id FROM inspection_act_assets WHERE asset_id=ANY($1::uuid[]) UNION ALL SELECT asset_id FROM inspection_findings WHERE asset_id=ANY($1::uuid[])) usage LIMIT 1`,params:[createdAssetIds]} );} if(departmentIds.length)checks.push({code:'ASSET_IMPORT_ROLLBACK_DEPARTMENT_IN_USE',message:'Un Departamento creado por el lote ya está relacionado con otra Área',sql:`SELECT 1 FROM area_department_relations WHERE department_id=ANY($1::uuid[]) AND valid_until IS NULL AND NOT (id=ANY($2::uuid[])) LIMIT 1`,params:[departmentIds,areaDepartmentRelationIds.length?areaDepartmentRelationIds:['00000000-0000-0000-0000-000000000000']]}); if(legalRightIds.length)checks.push({code:'ASSET_IMPORT_ROLLBACK_LEGAL_RIGHT_IN_USE',message:'Un derecho creado por el lote ya tiene relaciones posteriores',sql:`SELECT 1 FROM area_legal_right_organizations WHERE right_id=ANY($1::uuid[]) AND valid_until IS NULL AND NOT (id=ANY($2::uuid[])) LIMIT 1`,params:[legalRightIds,legalRightOrganizationIds.length?legalRightOrganizationIds:['00000000-0000-0000-0000-000000000000']]}); for(const check of checks){const [hit]=(await manager.query(check.sql,check.params)) as unknown[];if(hit)throw new ConflictException({code:check.code,message:check.message});} } private async insertRollbackVersions(manager:EntityManager,assetIds:string[],principal:AuthPrincipal,request:RequestWithContext):Promise{for(let start=0;start { const chunkSize = 200; for (let start = 0; start < rows.length; start += chunkSize) { const chunk = rows.slice(start, start + chunkSize); if (!chunk.length) continue; const params: unknown[] = []; const values = chunk.map((row) => { const base = params.length; params.push(batchId,row.sheetName,row.rowNumber,row.status,row.suggestedAction,JSON.stringify(row.raw),JSON.stringify(row.normalized),JSON.stringify(row.issues),row.fingerprint); return `($${base+1},$${base+2},$${base+3},$${base+4}::asset_import_row_status,$${base+5}::asset_import_suggested_action,$${base+6}::jsonb,$${base+7}::jsonb,$${base+8}::jsonb,$${base+9})`; }); await manager.query(` INSERT INTO asset_import_rows (batch_id,worksheet_name,row_number,status,suggested_action,raw_data,normalized_data,issue_codes,fingerprint) VALUES ${values.join(',')} `, params); } } private async loadBatch(manager: EntityManager, id: string, lock = false): Promise { const rows = (await manager.query(`${this.batchSelect()} WHERE batch.id=$1${lock ? ' FOR UPDATE OF batch' : ''}`, [id])) as ImportBatchRow[]; if (!rows[0]) throw importNotFound(); return rows[0]; } private batchSelect(): string { return `SELECT batch.id,batch.original_name AS "originalName",batch.mime_type AS "mimeType",batch.size_bytes AS "sizeBytes",batch.sha256, batch.profile_code AS "profileCode",batch.profile_confidence AS "profileConfidence",batch.worksheet_name AS "worksheetName",batch.header_row AS "headerRow", batch.total_rows AS "totalRows",batch.ready_rows AS "readyRows",batch.warning_rows AS "warningRows",batch.conflict_rows AS "conflictRows",batch.ignored_rows AS "ignoredRows", batch.status,batch.source_document_id AS "sourceDocumentId",batch.source_label AS "sourceLabel",batch.notes,batch.analysis, batch.uploaded_by AS "uploadedBy",u.username AS "uploadedByUsername",batch.analyzed_at AS "analyzedAt",batch.created_at AS "createdAt",batch.updated_at AS "updatedAt" FROM asset_import_batches batch LEFT JOIN users u ON u.id=batch.uploaded_by`; } }