diff --git a/api-v3/src/asset-master/inventory-merge.service.ts b/api-v3/src/asset-master/inventory-merge.service.ts new file mode 100644 index 0000000..afbe6ae --- /dev/null +++ b/api-v3/src/asset-master/inventory-merge.service.ts @@ -0,0 +1,432 @@ +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; +}; + +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); + 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 [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, + ); + + 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', + }; + 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, + reason: dto.reason, + sourceVersionNumber, + reparentedChildIds, + historicalReferencesRewritten: false, + }, + }, 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', + }); + 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 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; + } +}