import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; 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 { AssetVersionChangeType, AuditAction, } from '../database/entities'; import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import type { MergeInventoryAssetDto } from './dto/merge-inventory-asset.dto'; import { AssetHistoryService } from './asset-history.service'; type MergeableAssetRow = { id: string; code: string; name: string; typeCode: string; parentId: string | null; operationalAreaId: string | null; operatorCompanyId: string | null; inventoryFamilyId: string | null; informationStatus: string; }; type MergeRow = { id: string; sourceAssetId: string; canonicalAssetId: string; reason: string; mergedAt: Date; mergedBy: string | null; source: string; requestId: string | null; }; type DocumentInvariantRow = { actId: string; actStatus: string; lockedSha256: string | null; closureSha256: string | null; sealedAt: Date | null; actVersion: number; reportId: string | null; reportStatus: string | null; reportActClosureSha256: string | null; reportFrozenSha256: string | null; gedoPdfSha256: string | null; wordSha256: string | null; reportRevision: number | null; }; const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']); @Injectable() export class InventoryMergeService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly history: AssetHistoryService, ) {} async status(assetId: string) { const requested = await this.loadAsset(this.dataSource.manager, assetId, false); const canonicalId = await this.resolveCanonicalId(this.dataSource.manager, assetId); const canonical = canonicalId === assetId ? requested : await this.loadAsset(this.dataSource.manager, canonicalId, false); const chain = await this.mergeChain(assetId); const aliases = await this.aliasesFor(canonicalId); return { requested, isMerged: canonicalId !== assetId, canonical, chain, aliases, }; } async merge( sourceAssetId: string, dto: MergeInventoryAssetDto, principal: AuthPrincipal, request: RequestWithContext, ) { return this.dataSource.transaction((manager) => this.mergeInTransaction(manager, sourceAssetId, dto, principal, request), ); } async mergeFromVisit( visitId: string, sourceAssetId: string, dto: MergeInventoryAssetDto, principal: AuthPrincipal, request: RequestWithContext, ) { assertMobileInspector(principal); const [gate] = (await this.dataSource.query(` SELECT visit.status, EXISTS ( SELECT 1 FROM inspection_visit_members member WHERE member.visit_id=visit.id AND member.user_id=$3::uuid AND member.included=true ) OR visit.lead_inspector_user_id=$3::uuid AS assigned, EXISTS ( SELECT 1 FROM asset_field_discoveries discovery WHERE discovery.visit_id=visit.id AND discovery.asset_id=$2::uuid ) AS "createdInVisit" FROM inspection_visits visit WHERE visit.id=$1::uuid `, [visitId, sourceAssetId, principal.userId])) as Array<{ status: string; assigned: boolean; createdInVisit: boolean; }>; if (!gate) throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' }); if (gate.status !== 'IN_PROGRESS') throw new ConflictException({ code: 'FIELD_MERGE_VISIT_NOT_IN_PROGRESS', message: 'Sólo se puede conciliar Inventario mientras la inspección está en curso', }); if (!gate.assigned) throw new ConflictException({ code: 'FIELD_MERGE_INSPECTOR_NOT_ASSIGNED', message: 'El inspector no está asignado a esta inspección', }); if (!gate.createdInVisit) throw new ConflictException({ code: 'FIELD_MERGE_SOURCE_MUST_BE_DISCOVERY', message: 'Desde la APK sólo se puede fusionar un alta nacida en esta inspección', }); return this.merge(sourceAssetId, dto, principal, request); } private async mergeInTransaction( manager: EntityManager, sourceAssetId: string, dto: MergeInventoryAssetDto, principal: AuthPrincipal, request: RequestWithContext, ) { const existing = await this.currentMerge(manager, sourceAssetId); if (existing) throw new ConflictException({ code: 'INVENTORY_ALREADY_MERGED', message: 'Este registro ya fue fusionado', canonicalAssetId: existing.canonicalAssetId, }); const canonicalAssetId = await this.resolveCanonicalId(manager, dto.canonicalAssetId); if (canonicalAssetId === sourceAssetId) throw new BadRequestException({ code: 'INVENTORY_MERGE_CYCLE', message: 'La fusión generaría un ciclo entre registros', }); const ids = [sourceAssetId, canonicalAssetId].sort(); const locked = (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", asset.inventory_family_id AS "inventoryFamilyId", asset.information_status AS "informationStatus" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id=ANY($1::uuid[]) ORDER BY asset.id FOR UPDATE OF asset `, [ids])) as MergeableAssetRow[]; const source = locked.find((row) => row.id === sourceAssetId); const canonical = locked.find((row) => row.id === canonicalAssetId); if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' }); this.validatePair(source, canonical); // Empresa is temporal inspection context, not physical ownership. A merge is // valid when both records resolve to the same Area in the physical hierarchy, // even if their historical operator snapshots differ. const [sourceAreaId, canonicalAreaId] = await Promise.all([ this.resolvePhysicalAreaId(manager, source.id), this.resolvePhysicalAreaId(manager, canonical.id), ]); if (!sourceAreaId || sourceAreaId !== canonicalAreaId) throw new BadRequestException({ code: 'INVENTORY_MERGE_AREA_MISMATCH', message: 'Los duplicados deben pertenecer a la misma Área física', }); const sourceParentCanonical = source.parentId ? await this.resolveCanonicalId(manager, source.parentId) : null; const targetParentCanonical = canonical.parentId ? await this.resolveCanonicalId(manager, canonical.parentId) : null; if (sourceParentCanonical !== targetParentCanonical) throw new BadRequestException({ code: 'INVENTORY_MERGE_PARENT_MISMATCH', message: 'Los duplicados deben pertenecer al mismo padre estructural (considerando fusiones previas)', }); if (source.typeCode.toLowerCase() === 'instalacion') { const invalidChildren = await manager.query(` SELECT child.id,child.code,child.name FROM assets child WHERE child.parent_id=$1::uuid AND child.information_status<>'INACTIVE' AND ( child.inventory_family_id IS NULL OR $2::uuid IS NULL OR NOT EXISTS ( SELECT 1 FROM inventory_family_parent_rules rule WHERE rule.child_family_id=child.inventory_family_id AND rule.parent_family_id=$2::uuid ) ) ORDER BY child.name,child.code LIMIT 20 `, [source.id, canonical.inventoryFamilyId]) as Array<{ id: string; code: string; name: string }>; if (invalidChildren.length > 0) throw new ConflictException({ code: 'INVENTORY_MERGE_CHILD_FAMILY_CONFLICT', message: 'Hay Subinstalaciones que no son compatibles con la familia de la Instalación canónica', data: invalidChildren, }); } const affectedAssetIds = [source.id, canonical.id]; const documentInvariantsBefore = await this.documentInvariants(manager, affectedAssetIds); const [sourceSnapshot, canonicalSnapshot] = await Promise.all([ this.snapshot(manager, source.id), this.snapshot(manager, canonical.id), ]); const sourceLabel = principal.transport === 'bearer' ? 'ANDROID' : 'WEB'; const [mergeRecord] = (await manager.query(` INSERT INTO asset_merges ( source_asset_id,canonical_asset_id,reason,merged_by,source,request_id, source_snapshot,canonical_snapshot ) VALUES ($1::uuid,$2::uuid,$3::text,$4::uuid,$5::varchar,$6::varchar,$7::jsonb,$8::jsonb) RETURNING id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId", reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId" `, [ source.id, canonical.id, dto.reason, principal.userId, sourceLabel, request.requestId, sourceSnapshot, canonicalSnapshot, ])) as MergeRow[]; const children = (await manager.query(` SELECT id FROM assets WHERE parent_id=$1::uuid AND information_status<>'INACTIVE' ORDER BY id FOR UPDATE `, [source.id])) as Array<{ id: string }>; const reparentedChildIds: string[] = []; for (const child of children) { await manager.query(` UPDATE asset_context_history SET valid_until=CURRENT_TIMESTAMP, end_reason=$2::text, ended_by=$3::uuid, ended_at=CURRENT_TIMESTAMP WHERE asset_id=$1::uuid AND valid_until IS NULL `, [child.id, `Reparentado por fusión cronológica ${source.code} → ${canonical.code}`, principal.userId]); await manager.query(` UPDATE assets SET parent_id=$2::uuid,updated_by=$3::uuid,updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid `, [child.id, canonical.id, principal.userId]); const versionNumber = await this.history.capture( manager, child.id, AssetVersionChangeType.UPDATED, principal, request, ); await manager.query(` INSERT INTO asset_context_history ( asset_id,parent_id,operational_area_id,operator_company_id,valid_from, change_reason,asset_version_number,source,request_id,created_by ) SELECT id,parent_id,operational_area_id,operator_company_id,CURRENT_TIMESTAMP, $2::text,$3::integer,$4::varchar,$5::varchar,$6::uuid FROM assets WHERE id=$1::uuid `, [ child.id, `Reparentado por fusión cronológica ${source.code} → ${canonical.code}`, versionNumber, sourceLabel, request.requestId, principal.userId, ]); reparentedChildIds.push(child.id); } await manager.query(` UPDATE assets SET information_status='INACTIVE',updated_by=$2::uuid,updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid `, [source.id, principal.userId]); const sourceVersionNumber = await this.history.capture( manager, source.id, AssetVersionChangeType.STATUS_CHANGED, principal, request, ); // No historical Acta/Finding/Informe foreign key is rewritten by a merge. // Verify that legal/documentary fingerprints are byte-for-byte unchanged // before committing the transaction; otherwise rollback the entire merge. const documentInvariantsAfter = await this.documentInvariants(manager, affectedAssetIds); if (JSON.stringify(documentInvariantsBefore) !== JSON.stringify(documentInvariantsAfter)) { throw new ConflictException({ code: 'INVENTORY_MERGE_DOCUMENT_INVARIANT_BROKEN', message: 'La fusión intentó alterar la huella documental histórica y fue revertida', }); } const result = { merge: mergeRecord, source: { id: source.id, code: source.code, name: source.name }, canonical: { id: canonical.id, code: canonical.code, name: canonical.name }, sourceVersionNumber, reparentedChildIds, historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED', documentaryInvariantsVerified: true, }; await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_UPDATED, entityType: 'asset_merge', entityId: mergeRecord.id, beforeData: sourceSnapshot, afterData: canonicalSnapshot, metadata: { operation: 'CHRONOLOGICAL_MERGE', sourceAssetId: source.id, canonicalAssetId: canonical.id, physicalAreaId: sourceAreaId, reason: dto.reason, sourceVersionNumber, reparentedChildIds, historicalReferencesRewritten: false, documentaryInvariantsVerified: true, }, }, manager); return result; } private validatePair(source: MergeableAssetRow, canonical: MergeableAssetRow): void { const sourceType = source.typeCode.toLowerCase(); const targetType = canonical.typeCode.toLowerCase(); if (!MERGEABLE_TYPES.has(sourceType) || sourceType !== targetType) throw new BadRequestException({ code: 'INVENTORY_MERGE_TYPE_INVALID', message: 'Sólo se pueden fusionar Instalaciones entre sí o Subinstalaciones entre sí', }); if (source.informationStatus === 'INACTIVE') throw new ConflictException({ code: 'INVENTORY_MERGE_SOURCE_INACTIVE', message: 'El registro de origen ya está inactivo', }); if (canonical.informationStatus === 'INACTIVE') throw new ConflictException({ code: 'INVENTORY_MERGE_CANONICAL_INACTIVE', message: 'El registro canónico no puede estar inactivo', }); } private async resolvePhysicalAreaId(manager: EntityManager, assetId: string): Promise { const rows = (await manager.query(` WITH RECURSIVE lineage AS ( SELECT asset.id,asset.parent_id,asset.asset_type_id,0 AS depth FROM assets asset WHERE asset.id=$1::uuid UNION ALL SELECT parent.id,parent.parent_id,parent.asset_type_id,lineage.depth+1 FROM assets parent JOIN lineage ON lineage.parent_id=parent.id WHERE lineage.depth<32 ) SELECT lineage.id FROM lineage JOIN asset_types type ON type.id=lineage.asset_type_id WHERE type.operational_role='AREA' ORDER BY lineage.depth LIMIT 1 `, [assetId])) as Array<{ id: string }>; return rows[0]?.id ?? null; } private async documentInvariants(manager: EntityManager, assetIds: string[]): Promise { return (await manager.query(` WITH affected_acts AS ( SELECT DISTINCT act.id FROM inspection_acts act LEFT JOIN inspection_act_assets act_asset ON act_asset.act_id=act.id AND act_asset.included=true LEFT JOIN inspection_findings finding ON finding.act_id=act.id WHERE act_asset.asset_id=ANY($1::uuid[]) OR finding.asset_id=ANY($1::uuid[]) ) SELECT act.id AS "actId", act.status AS "actStatus", act.locked_sha256 AS "lockedSha256", act.closure_sha256 AS "closureSha256", act.sealed_at AS "sealedAt", act.current_version AS "actVersion", report.id AS "reportId", report.status AS "reportStatus", report.act_closure_sha256 AS "reportActClosureSha256", report.frozen_sha256 AS "reportFrozenSha256", report.gedo_pdf_sha256 AS "gedoPdfSha256", report.word_sha256 AS "wordSha256", report.current_revision_number AS "reportRevision" FROM affected_acts affected JOIN inspection_acts act ON act.id=affected.id LEFT JOIN inspection_reports report ON report.act_id=act.id ORDER BY act.id,report.id NULLS FIRST `, [assetIds])) as DocumentInvariantRow[]; } private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise { const lockSql = lock ? 'FOR UPDATE OF asset' : ''; 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", asset.inventory_family_id AS "inventoryFamilyId", asset.information_status AS "informationStatus" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id=$1::uuid ${lockSql} `, [id])) as MergeableAssetRow[]; if (!rows[0]) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' }); return rows[0]; } private async currentMerge(manager: EntityManager, sourceAssetId: string): Promise { const rows = (await manager.query(` SELECT id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId", reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId" FROM asset_merges WHERE source_asset_id=$1::uuid `, [sourceAssetId])) as MergeRow[]; return rows[0] ?? null; } private async resolveCanonicalId(manager: EntityManager, assetId: string): Promise { const rows = (await manager.query(` WITH RECURSIVE chain AS ( SELECT source_asset_id,canonical_asset_id,1 AS depth FROM asset_merges WHERE source_asset_id=$1::uuid UNION ALL SELECT merge_record.source_asset_id,merge_record.canonical_asset_id,chain.depth+1 FROM asset_merges merge_record JOIN chain ON merge_record.source_asset_id=chain.canonical_asset_id WHERE chain.depth<32 ) SELECT canonical_asset_id AS id FROM chain ORDER BY depth DESC LIMIT 1 `, [assetId])) as Array<{ id: string }>; return rows[0]?.id ?? assetId; } private async mergeChain(assetId: string): Promise { return (await this.dataSource.query(` WITH RECURSIVE chain AS ( SELECT id,source_asset_id,canonical_asset_id,reason,merged_at,merged_by,source,request_id,1 AS depth FROM asset_merges WHERE source_asset_id=$1::uuid UNION ALL SELECT merge_record.id,merge_record.source_asset_id,merge_record.canonical_asset_id, merge_record.reason,merge_record.merged_at,merge_record.merged_by,merge_record.source,merge_record.request_id, chain.depth+1 FROM asset_merges merge_record JOIN chain ON merge_record.source_asset_id=chain.canonical_asset_id WHERE chain.depth<32 ) SELECT id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId", reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId" FROM chain ORDER BY depth `, [assetId])) as MergeRow[]; } private async aliasesFor(canonicalAssetId: string) { return this.dataSource.query(` WITH RECURSIVE aliases AS ( SELECT source_asset_id,canonical_asset_id,reason,merged_at,1 AS depth FROM asset_merges WHERE canonical_asset_id=$1::uuid UNION ALL SELECT merge_record.source_asset_id,merge_record.canonical_asset_id, merge_record.reason,merge_record.merged_at,aliases.depth+1 FROM asset_merges merge_record JOIN aliases ON merge_record.canonical_asset_id=aliases.source_asset_id WHERE aliases.depth<32 ) SELECT alias.source_asset_id AS id,asset.code,asset.name,alias.reason, alias.merged_at AS "mergedAt",alias.depth FROM aliases alias JOIN assets asset ON asset.id=alias.source_asset_id ORDER BY alias.merged_at,alias.depth `, [canonicalAssetId]); } private async snapshot(manager: EntityManager, assetId: string): Promise> { const rows = (await manager.query(` SELECT TO_JSONB(asset) || JSONB_BUILD_OBJECT( 'typeCode',type.code, 'typeName',type.name, 'inventoryFamily',CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id',family.id,'code',family.code,'name',family.name,'level',family.level ) END ) AS snapshot FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id WHERE asset.id=$1::uuid `, [assetId])) as Array<{ snapshot: Record }>; if (!rows[0]) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' }); return rows[0].snapshot; } }