diff --git a/api-v3/src/inspection-findings/finding-catalog-merge.service.ts b/api-v3/src/inspection-findings/finding-catalog-merge.service.ts new file mode 100644 index 0000000..9c4e36c --- /dev/null +++ b/api-v3/src/inspection-findings/finding-catalog-merge.service.ts @@ -0,0 +1,268 @@ +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 { AuditAction } from '../database/entities'; +import type { MergeFindingCatalogItemDto } from './dto/merge-finding-catalog-item.dto'; + +type CatalogItemRow = { + id: string; + categoryId: string; + categoryCode: string; + categoryName: string; + code: string; + sourceNumber: number; + title: string; + legalBasis: string | null; + glossary: string | null; + importNote: string | null; + suggestedSeverity: number | null; + revision: number; + isActive: boolean; +}; + +@Injectable() +export class FindingCatalogMergeService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async status(itemId: string) { + const requested = await this.load(this.dataSource.manager, itemId, false); + const canonicalId = await this.resolveCanonicalId(this.dataSource.manager, itemId); + const canonical = canonicalId === itemId + ? requested + : await this.load(this.dataSource.manager, canonicalId, false); + const aliases = await this.dataSource.query(` + WITH RECURSIVE aliases AS ( + SELECT source_item_id,canonical_item_id,reason,merged_at,1 AS depth + FROM finding_catalog_item_merges WHERE canonical_item_id=$1::uuid + UNION ALL + SELECT merge_record.source_item_id,merge_record.canonical_item_id, + merge_record.reason,merge_record.merged_at,aliases.depth+1 + FROM finding_catalog_item_merges merge_record + JOIN aliases ON merge_record.canonical_item_id=aliases.source_item_id + WHERE aliases.depth<32 + ) + SELECT alias.source_item_id AS id,item.code,item.title,alias.reason, + alias.merged_at AS "mergedAt",alias.depth + FROM aliases alias + JOIN finding_catalog_items item ON item.id=alias.source_item_id + ORDER BY alias.merged_at,alias.depth + `, [canonicalId]); + return { + requested, + isMerged: canonicalId !== itemId, + canonical, + aliases, + }; + } + + async merge( + sourceItemId: string, + dto: MergeFindingCatalogItemDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + return this.dataSource.transaction(async (manager) => { + const existing = await manager.query(` + SELECT canonical_item_id AS "canonicalItemId" + FROM finding_catalog_item_merges WHERE source_item_id=$1::uuid + `, [sourceItemId]) as Array<{ canonicalItemId: string }>; + if (existing[0]) { + throw new ConflictException({ + code: 'FINDING_CATALOG_ALREADY_MERGED', + message: 'Este Hallazgo de catálogo ya fue fusionado', + canonicalItemId: existing[0].canonicalItemId, + }); + } + + const canonicalItemId = await this.resolveCanonicalId(manager, dto.canonicalItemId); + if (canonicalItemId === sourceItemId) { + throw new BadRequestException({ + code: 'FINDING_CATALOG_MERGE_CYCLE', + message: 'La fusión generaría un ciclo en el catálogo', + }); + } + + const ids = [sourceItemId, canonicalItemId].sort(); + const rows = await manager.query(` + SELECT item.id,item.category_id AS "categoryId",category.code AS "categoryCode", + category.name AS "categoryName",item.code,item.source_number AS "sourceNumber", + item.title,item.legal_basis AS "legalBasis",item.glossary,item.import_note AS "importNote", + item.suggested_severity AS "suggestedSeverity",item.revision,item.is_active AS "isActive" + FROM finding_catalog_items item + JOIN finding_categories category ON category.id=item.category_id + WHERE item.id=ANY($1::uuid[]) + ORDER BY item.id + FOR UPDATE OF item + `, [ids]) as CatalogItemRow[]; + const source = rows.find((item) => item.id === sourceItemId); + const canonical = rows.find((item) => item.id === canonicalItemId); + if (!source || !canonical) throw new NotFoundException({ + code: 'FINDING_CATALOG_ITEM_NOT_FOUND', + message: 'Hallazgo de catálogo no encontrado', + }); + if (!source.isActive) throw new ConflictException({ + code: 'FINDING_CATALOG_MERGE_SOURCE_INACTIVE', + message: 'El Hallazgo de origen ya está inactivo', + }); + if (!canonical.isActive) throw new ConflictException({ + code: 'FINDING_CATALOG_MERGE_CANONICAL_INACTIVE', + message: 'El Hallazgo canónico debe estar activo', + }); + + const sourceSnapshot = this.snapshot(source); + const canonicalSnapshot = this.snapshot(canonical); + + // La aplicabilidad futura pasa al canónico; los Hallazgos emitidos nunca se reescriben. + await manager.query(` + INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) + SELECT $2::uuid,inventory_family_id + FROM finding_catalog_item_inventory_families + WHERE catalog_item_id=$1::uuid + ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING + `, [source.id, canonical.id]); + await manager.query(` + INSERT INTO finding_catalog_item_asset_types(catalog_item_id,asset_type_id,created_by) + SELECT $2::uuid,asset_type_id,$3::uuid + FROM finding_catalog_item_asset_types + WHERE catalog_item_id=$1::uuid + ON CONFLICT (catalog_item_id,asset_type_id) DO NOTHING + `, [source.id, canonical.id, principal.userId]); + await manager.query(` + INSERT INTO finding_catalog_asset_overrides( + asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by + ) + SELECT asset_id,$2::uuid,is_enabled, + 'F3.1 merge catálogo: ' || COALESCE(reason,''),$3::uuid,$3::uuid + FROM finding_catalog_asset_overrides source_override + WHERE source_override.catalog_item_id=$1::uuid + AND NOT EXISTS ( + SELECT 1 FROM finding_catalog_asset_overrides target_override + WHERE target_override.asset_id=source_override.asset_id + AND target_override.catalog_item_id=$2::uuid + ) + `, [source.id, canonical.id, principal.userId]); + + const [mergeRecord] = await manager.query(` + INSERT INTO finding_catalog_item_merges( + source_item_id,canonical_item_id,reason,merged_by,request_id, + source_snapshot,canonical_snapshot + ) VALUES ($1::uuid,$2::uuid,$3::text,$4::uuid,$5::varchar,$6::jsonb,$7::jsonb) + RETURNING id,source_item_id AS "sourceItemId",canonical_item_id AS "canonicalItemId", + reason,merged_at AS "mergedAt",merged_by AS "mergedBy",request_id AS "requestId" + `, [ + source.id, + canonical.id, + dto.reason, + principal.userId, + request.requestId, + sourceSnapshot, + canonicalSnapshot, + ]) as Array>; + + await manager.query(` + UPDATE finding_catalog_items + SET is_active=false,revision=revision+1,updated_at=CURRENT_TIMESTAMP + WHERE id=$1::uuid + `, [source.id]); + const updatedSource = await this.load(manager, source.id, false); + await manager.query(` + INSERT INTO finding_catalog_item_versions( + item_id,revision,snapshot,actor_user_id,actor_username + ) VALUES ($1::uuid,$2::integer,$3::jsonb,$4::uuid,$5::varchar) + `, [ + updatedSource.id, + updatedSource.revision, + this.snapshot(updatedSource), + principal.userId, + principal.username, + ]); + + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.FINDING_CATALOG_ITEM_UPDATED, + entityType: 'finding_catalog_item_merge', + entityId: String(mergeRecord.id), + beforeData: sourceSnapshot, + afterData: canonicalSnapshot, + metadata: { + operation: 'CHRONOLOGICAL_CATALOG_MERGE', + sourceItemId: source.id, + canonicalItemId: canonical.id, + reason: dto.reason, + historicalFindingsRewritten: false, + historicalProposalsRewritten: false, + futureApplicabilityMovedToCanonical: true, + }, + }, manager); + + return { + merge: mergeRecord, + source: updatedSource, + canonical, + historyPolicy: 'EMITTED_FINDINGS_PRESERVED', + }; + }); + } + + private async resolveCanonicalId(manager: EntityManager, itemId: string): Promise { + const rows = await manager.query(` + WITH RECURSIVE chain AS ( + SELECT source_item_id,canonical_item_id,1 AS depth + FROM finding_catalog_item_merges WHERE source_item_id=$1::uuid + UNION ALL + SELECT merge_record.source_item_id,merge_record.canonical_item_id,chain.depth+1 + FROM finding_catalog_item_merges merge_record + JOIN chain ON merge_record.source_item_id=chain.canonical_item_id + WHERE chain.depth<32 + ) + SELECT canonical_item_id AS id FROM chain ORDER BY depth DESC LIMIT 1 + `, [itemId]) as Array<{ id: string }>; + return rows[0]?.id ?? itemId; + } + + private async load(manager: EntityManager, itemId: string, lock: boolean): Promise { + const lockSql = lock ? 'FOR UPDATE OF item' : ''; + const rows = await manager.query(` + SELECT item.id,item.category_id AS "categoryId",category.code AS "categoryCode", + category.name AS "categoryName",item.code,item.source_number AS "sourceNumber", + item.title,item.legal_basis AS "legalBasis",item.glossary,item.import_note AS "importNote", + item.suggested_severity AS "suggestedSeverity",item.revision,item.is_active AS "isActive" + FROM finding_catalog_items item + JOIN finding_categories category ON category.id=item.category_id + WHERE item.id=$1::uuid ${lockSql} + `, [itemId]) as CatalogItemRow[]; + if (!rows[0]) throw new NotFoundException({ + code: 'FINDING_CATALOG_ITEM_NOT_FOUND', + message: 'Hallazgo de catálogo no encontrado', + }); + return rows[0]; + } + + private snapshot(item: CatalogItemRow): Record { + return { + id: item.id, + categoryId: item.categoryId, + categoryCode: item.categoryCode, + categoryName: item.categoryName, + code: item.code, + sourceNumber: item.sourceNumber, + title: item.title, + legalBasis: item.legalBasis, + glossary: item.glossary, + importNote: item.importNote, + suggestedSeverity: item.suggestedSeverity, + revision: item.revision, + isActive: item.isActive, + }; + } +}