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