From 5e26b4a899483dde2b32e9028c115a050a3e18e7 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Tue, 8 Sep 2026 21:50:21 -0300 Subject: [PATCH] fix(inventory): merge by physical area and preserve sealed documents --- .../asset-master/inventory-merge.service.ts | 106 ++++++++++++++++-- 1 file changed, 98 insertions(+), 8 deletions(-) diff --git a/api-v3/src/asset-master/inventory-merge.service.ts b/api-v3/src/asset-master/inventory-merge.service.ts index afbe6ae..de47f7f 100644 --- a/api-v3/src/asset-master/inventory-merge.service.ts +++ b/api-v3/src/asset-master/inventory-merge.service.ts @@ -39,6 +39,22 @@ type MergeRow = { 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() @@ -161,6 +177,19 @@ export class InventoryMergeService { 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; @@ -197,6 +226,8 @@ export class InventoryMergeService { }); } + 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), @@ -279,6 +310,17 @@ export class InventoryMergeService { 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 }, @@ -286,6 +328,7 @@ export class InventoryMergeService { sourceVersionNumber, reparentedChildIds, historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED', + documentaryInvariantsVerified: true, }; await this.audit.record({ ...administrationAuditContext(principal, request), @@ -298,10 +341,12 @@ export class InventoryMergeService { operation: 'CHRONOLOGICAL_MERGE', sourceAssetId: source.id, canonicalAssetId: canonical.id, + physicalAreaId: sourceAreaId, reason: dto.reason, sourceVersionNumber, reparentedChildIds, historicalReferencesRewritten: false, + documentaryInvariantsVerified: true, }, }, manager); return result; @@ -322,14 +367,59 @@ export class InventoryMergeService { code: 'INVENTORY_MERGE_CANONICAL_INACTIVE', message: 'El registro canónico no puede estar inactivo', }); - if (!source.operationalAreaId || !source.operatorCompanyId - || source.operationalAreaId !== canonical.operationalAreaId - || source.operatorCompanyId !== canonical.operatorCompanyId) { - throw new BadRequestException({ - code: 'INVENTORY_MERGE_CONTEXT_MISMATCH', - message: 'Los registros deben pertenecer a la misma Área y Operadora', - }); - } + } + + 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 {