F3.1: implementar merge cronológico del catálogo
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user