F3.1: implementar merge cronológico del catálogo

This commit is contained in:
2026-09-07 14:42:50 -03:00
parent a521d0fec7
commit 6f335ab482
@@ -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<Record<string, unknown>>;
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<string> {
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<CatalogItemRow> {
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<string, unknown> {
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,
};
}
}