fix(inventory): preserve custom catalog and apply authoritative universal findings

This commit is contained in:
2026-09-08 21:34:52 -03:00
parent a3dd705190
commit c651ef9616
@@ -7,10 +7,12 @@ import {
} from '../../reference-data/f5-authoritative-inventory-source';
type IdRow = { id: string };
type CountRow = { total: number };
const CATALOG_DOCUMENT_NUMBER = 'DH-F5-INVENTORY-CATALOG';
const CATALOG_CATEGORY_CODE = 'F5MODEL';
const CATALOG_SOURCE_NAME = 'final_modelov2.xlsx';
const F5_AUTO_REASON = 'F5 familia técnica: catálogo contextual automático';
function findingKey(value: string): string {
return value.normalize('NFD')
@@ -52,6 +54,20 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
throw new Error('F5 inventory catalog source contract mismatch');
}
if (source.catalogSource.universalFindings.length !== 3) {
throw new Error(`F5 universal finding contract mismatch: ${source.catalogSource.universalFindings.length}`);
}
const universalKeys = new Set(source.catalogSource.universalFindings.map(findingKey));
for (const required of [
'ORDEN Y LIMPIEZA',
'CARTELERIA PREVENTIVA / INFORMATIVA',
'EXTINTORES',
]) {
if (!universalKeys.has(findingKey(required))) {
throw new Error(`F5 missing authoritative universal finding: ${required}`);
}
}
await queryRunner.query(`
INSERT INTO source_documents (
document_type,document_number,title,issuer,external_reference,notes
@@ -70,17 +86,13 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
`F5 · catálogo técnico autorizado · hoja ${source.catalogSource.sheet} · 14 Instalaciones · 109 Subinstalaciones`,
]);
const sourceDocumentId = await this.id(
queryRunner,
`SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
[CATALOG_DOCUMENT_NUMBER],
'catalog source document',
);
// Only the known historical spreadsheet catalog is superseded. Families
// created manually by DH (including source_reference NULL) remain untouched.
await queryRunner.query(`
UPDATE inventory_families
SET is_active=false,updated_at=CURRENT_TIMESTAMP
WHERE source_reference IS NULL OR source_reference NOT LIKE 'F5:%'
WHERE source_reference LIKE 'APLICACION APP%'
OR source_reference LIKE 'SYSTEM:F3.1:%'
`);
await queryRunner.query(`
UPDATE finding_categories
@@ -165,6 +177,7 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
const itemKey = findingKey(clean);
if (!titleByKey.has(itemKey)) titleByKey.set(itemKey,clean);
};
for (const title of source.catalogSource.universalFindings) register(title);
for (const family of source.catalogSource.installations) for (const title of family.findings) register(title);
for (const family of source.catalogSource.subinstallations) for (const title of family.findings) register(title);
if (titleByKey.size !== 177) {
@@ -182,17 +195,16 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
[itemCode],
);
if (!itemId) {
const rows = (await queryRunner.query(`
const itemRows = (await queryRunner.query(`
INSERT INTO finding_catalog_items (
category_id,code,source_number,title,import_note,revision,is_active
)
VALUES ($1::uuid,$2,$3,$4,$5,1,true)
) VALUES ($1::uuid,$2,$3,$4,$5,1,true)
RETURNING id
`,[
categoryId,itemCode,sourceNumber,title,
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
])) as IdRow[];
itemId=rows[0]?.id ?? null;
itemId=itemRows[0]?.id ?? null;
} else {
await queryRunner.query(`
UPDATE finding_catalog_items
@@ -226,36 +238,43 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
sourceNumber+=1;
}
await queryRunner.query(`DELETE FROM finding_catalog_item_inventory_families`);
// Add F5 mappings only. Never delete mappings created by office users or by
// historical migrations; inactive historical families simply stop being offered.
for (const family of source.catalogSource.installations) {
await this.mapFindings(queryRunner,installationCode(family.name),family,itemIdByKey);
await this.mapFindings(
queryRunner,
installationCode(family.name),
family,
source.catalogSource.universalFindings,
itemIdByKey,
);
}
for (const family of source.catalogSource.subinstallations) {
await this.mapFindings(
queryRunner,
subinstallationCode(family.installation,family.name),
family,
source.catalogSource.universalFindings,
itemIdByKey,
);
}
await this.installFamilySyncFunctions(queryRunner);
// Keep pre-existing profile administration untouched. Yacimiento needs a
// profile only to expose OTROS because the source does not provide a family.
await queryRunner.query(`
INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason)
SELECT id,'F5: catálogo contextual por familia técnica. Yacimiento sin familia usa OTROS.'
FROM asset_types WHERE lower(code) IN ('yacimiento','instalacion','subinstalacion')
ON CONFLICT (asset_type_id) DO UPDATE SET
reason=EXCLUDED.reason,updated_at=CURRENT_TIMESTAMP
SELECT id,'F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.'
FROM asset_types WHERE lower(code)='yacimiento'
ON CONFLICT (asset_type_id) DO NOTHING
`);
const [counts] = (await queryRunner.query(`
SELECT
COUNT(*) FILTER (WHERE level='INSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS installations,
COUNT(*) FILTER (WHERE level='SUBINSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS subinstallations
FROM inventory_families
WHERE is_active=true
FROM inventory_families WHERE is_active=true
`)) as Array<{ installations:number; subinstallations:number }>;
if (Number(counts?.installations ?? 0)!==14 || Number(counts?.subinstallations ?? 0)!==109) {
throw new Error(`F5 family preload verification failed: ${JSON.stringify(counts ?? {})}`);
@@ -265,12 +284,28 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
SELECT COUNT(*)::integer AS total
FROM finding_catalog_items
WHERE category_id=$1::uuid AND is_active=true
`,[categoryId])) as Array<{total:number}>;
`,[categoryId])) as CountRow[];
if (Number(itemCount?.total ?? 0)!==177) {
throw new Error(`F5 finding preload verification failed: ${itemCount?.total ?? 0}`);
}
void sourceDocumentId;
const [universalMappingCount] = (await queryRunner.query(`
SELECT COUNT(DISTINCT mapping.inventory_family_id)::integer AS total
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
JOIN inventory_families family ON family.id=mapping.inventory_family_id
WHERE item.category_id=$1::uuid
AND lower(regexp_replace(unaccent(item.title),'[^a-zA-Z0-9]+',' ','g'))
IN (
lower(regexp_replace(unaccent('ORDEN Y LIMPIEZA'),'[^a-zA-Z0-9]+',' ','g')),
lower(regexp_replace(unaccent('CARTELERIA PREVENTIVA / INFORMATIVA'),'[^a-zA-Z0-9]+',' ','g')),
lower(regexp_replace(unaccent('EXTINTORES'),'[^a-zA-Z0-9]+',' ','g'))
)
AND family.source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%'
`,[categoryId])) as CountRow[];
if (Number(universalMappingCount?.total ?? 0)!==123) {
throw new Error(`F5 universal mapping verification failed: ${universalMappingCount?.total ?? 0}`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
@@ -279,6 +314,34 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
`SELECT id FROM finding_categories WHERE lower(code)=lower($1) LIMIT 1`,
[CATALOG_CATEGORY_CODE],
);
if (categoryId) {
const [usedFinding] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM inspection_findings finding
JOIN finding_catalog_items item ON item.id=finding.catalog_item_id
WHERE item.category_id=$1::uuid
`,[categoryId])) as CountRow[];
if (Number(usedFinding?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 catalog: inspection findings already reference F5 catalog items');
}
}
const [usedFamily] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE family.source_reference LIKE 'F5:%'
`)) as CountRow[];
if (Number(usedFamily?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 catalog: inventory instances already reference F5 families');
}
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason=$1
`,[F5_AUTO_REASON]);
if (categoryId) {
await queryRunner.query(`
DELETE FROM finding_catalog_item_inventory_families mapping
@@ -288,45 +351,63 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
await queryRunner.query(`
DELETE FROM finding_catalog_item_versions version
USING finding_catalog_items item
WHERE item.id=version.item_id
AND item.category_id=$1::uuid
AND NOT EXISTS (
SELECT 1 FROM inspection_findings finding WHERE finding.catalog_item_id=item.id
)
`,[categoryId]);
await queryRunner.query(`
DELETE FROM finding_catalog_items item
WHERE item.category_id=$1::uuid
AND NOT EXISTS (
SELECT 1 FROM inspection_findings finding WHERE finding.catalog_item_id=item.id
)
`,[categoryId]);
await queryRunner.query(`
DELETE FROM finding_categories category
WHERE category.id=$1::uuid
AND NOT EXISTS (SELECT 1 FROM finding_catalog_items item WHERE item.category_id=category.id)
WHERE item.id=version.item_id AND item.category_id=$1::uuid
`,[categoryId]);
await queryRunner.query(`DELETE FROM finding_catalog_items WHERE category_id=$1::uuid`,[categoryId]);
await queryRunner.query(`DELETE FROM finding_categories WHERE id=$1::uuid`,[categoryId]);
}
await queryRunner.query(`
DELETE FROM inventory_families family
WHERE family.source_reference LIKE 'F5:%'
AND NOT EXISTS (SELECT 1 FROM assets asset WHERE asset.inventory_family_id=family.id)
DELETE FROM inventory_family_parent_rules rule
USING inventory_families child
WHERE child.id=rule.child_family_id AND child.source_reference LIKE 'F5:%'
`);
await queryRunner.query(`DELETE FROM inventory_families WHERE source_reference LIKE 'F5:%'`);
await queryRunner.query(`
UPDATE inventory_families
SET is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE source_reference LIKE 'APLICACION APP%' OR source_reference LIKE 'SYSTEM:F3.1:%'
WHERE source_reference LIKE 'APLICACION APP%'
OR source_reference LIKE 'SYSTEM:F3.1:%'
`);
await queryRunner.query(`
UPDATE finding_categories SET is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='app26r2'
`);
await queryRunner.query(`
DELETE FROM finding_catalog_asset_type_profiles profile
USING asset_types type
WHERE profile.asset_type_id=type.id
AND lower(type.code)='yacimiento'
AND profile.reason='F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.'
`);
await queryRunner.query(`
DELETE FROM source_documents
WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos'
`,[CATALOG_DOCUMENT_NUMBER]);
await this.restoreF31FamilySyncFunctions(queryRunner);
// Rebuild only automatic historical overrides. Manual overrides have never
// been touched by this migration.
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason LIKE 'F3.1 familia técnica:%'
`);
await queryRunner.query(`
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,mapping.catalog_item_id,true,
'F3.1 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by
FROM assets asset
JOIN finding_catalog_item_inventory_families mapping
ON mapping.inventory_family_id=asset.inventory_family_id
WHERE asset.inventory_family_id IS NOT NULL
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,
reason='F3.1 familia técnica: catálogo contextual automático',
updated_at=CURRENT_TIMESTAMP
`);
}
private async upsertFamily(
@@ -339,8 +420,7 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
await queryRunner.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
)
VALUES ($1,$2,$3,NULL,'[]'::jsonb,$4,true)
) VALUES ($1,$2,$3,NULL,'[]'::jsonb,$4,true)
ON CONFLICT (code) DO UPDATE SET
name=EXCLUDED.name,level=EXCLUDED.level,legacy_type_code=NULL,
information_labels='[]'::jsonb,source_reference=EXCLUDED.source_reference,
@@ -366,6 +446,7 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
queryRunner: QueryRunner,
familyCode: string,
family: F5InstallationCatalogRow|F5SubinstallationCatalogRow,
universalFindings: string[],
itemIdByKey: Map<string,string>,
): Promise<void> {
const familyId = await this.id(
@@ -375,7 +456,7 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
`family ${familyCode}`,
);
const mapped = new Set<string>();
for (const rawTitle of family.findings) {
for (const rawTitle of [...family.findings,...universalFindings]) {
const itemKey=findingKey(rawTitle);
if (!itemKey || itemKey==='hallazgos' || mapped.has(itemKey)) continue;
mapped.add(itemKey);
@@ -395,23 +476,18 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM finding_catalog_asset_overrides
WHERE asset_id=NEW.id
AND reason LIKE 'F% familia técnica:%';
WHERE asset_id=NEW.id AND reason LIKE 'F% familia técnica:%';
IF NEW.inventory_family_id IS NOT NULL THEN
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT NEW.id,mapping.catalog_item_id,true,
'F5 familia técnica: catálogo contextual automático',
SELECT NEW.id,mapping.catalog_item_id,true,$$${F5_AUTO_REASON}$$,
NEW.created_by,NEW.updated_by
FROM finding_catalog_item_inventory_families mapping
WHERE mapping.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,
reason='F5 familia técnica: catálogo contextual automático',
updated_by=NEW.updated_by,
updated_at=CURRENT_TIMESTAMP;
is_enabled=true,reason=$$${F5_AUTO_REASON}$$,
updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END $$;
@@ -432,18 +508,16 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,NEW.catalog_item_id,true,
'F5 familia técnica: catálogo contextual automático',
SELECT asset.id,NEW.catalog_item_id,true,$$${F5_AUTO_REASON}$$,
asset.created_by,asset.updated_by
FROM assets asset
WHERE asset.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,
reason='F5 familia técnica: catálogo contextual automático',
updated_at=CURRENT_TIMESTAMP;
is_enabled=true,reason=$$${F5_AUTO_REASON}$$,updated_at=CURRENT_TIMESTAMP;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason LIKE 'F% familia técnica:%'
@@ -452,18 +526,15 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,mapping.catalog_item_id,true,
'F5 familia técnica: catálogo contextual automático',
SELECT asset.id,mapping.catalog_item_id,true,$1,
asset.created_by,asset.updated_by
FROM assets asset
JOIN finding_catalog_item_inventory_families mapping
ON mapping.inventory_family_id=asset.inventory_family_id
WHERE asset.inventory_family_id IS NOT NULL
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,
reason='F5 familia técnica: catálogo contextual automático',
updated_at=CURRENT_TIMESTAMP
`);
is_enabled=true,reason=$1,updated_at=CURRENT_TIMESTAMP
`,[F5_AUTO_REASON]);
}
private async restoreF31FamilySyncFunctions(queryRunner: QueryRunner):Promise<void> {