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'; } from '../../reference-data/f5-authoritative-inventory-source';
type IdRow = { id: string }; type IdRow = { id: string };
type CountRow = { total: number };
const CATALOG_DOCUMENT_NUMBER = 'DH-F5-INVENTORY-CATALOG'; const CATALOG_DOCUMENT_NUMBER = 'DH-F5-INVENTORY-CATALOG';
const CATALOG_CATEGORY_CODE = 'F5MODEL'; const CATALOG_CATEGORY_CODE = 'F5MODEL';
const CATALOG_SOURCE_NAME = 'final_modelov2.xlsx'; 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 { function findingKey(value: string): string {
return value.normalize('NFD') return value.normalize('NFD')
@@ -52,6 +54,20 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
throw new Error('F5 inventory catalog source contract mismatch'); 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(` await queryRunner.query(`
INSERT INTO source_documents ( INSERT INTO source_documents (
document_type,document_number,title,issuer,external_reference,notes 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`, `F5 · catálogo técnico autorizado · hoja ${source.catalogSource.sheet} · 14 Instalaciones · 109 Subinstalaciones`,
]); ]);
const sourceDocumentId = await this.id( // Only the known historical spreadsheet catalog is superseded. Families
queryRunner, // created manually by DH (including source_reference NULL) remain untouched.
`SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
[CATALOG_DOCUMENT_NUMBER],
'catalog source document',
);
await queryRunner.query(` await queryRunner.query(`
UPDATE inventory_families UPDATE inventory_families
SET is_active=false,updated_at=CURRENT_TIMESTAMP 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(` await queryRunner.query(`
UPDATE finding_categories UPDATE finding_categories
@@ -165,6 +177,7 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
const itemKey = findingKey(clean); const itemKey = findingKey(clean);
if (!titleByKey.has(itemKey)) titleByKey.set(itemKey,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.installations) for (const title of family.findings) register(title);
for (const family of source.catalogSource.subinstallations) 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) { if (titleByKey.size !== 177) {
@@ -182,17 +195,16 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
[itemCode], [itemCode],
); );
if (!itemId) { if (!itemId) {
const rows = (await queryRunner.query(` const itemRows = (await queryRunner.query(`
INSERT INTO finding_catalog_items ( INSERT INTO finding_catalog_items (
category_id,code,source_number,title,import_note,revision,is_active 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 RETURNING id
`,[ `,[
categoryId,itemCode,sourceNumber,title, categoryId,itemCode,sourceNumber,title,
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`, `${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
])) as IdRow[]; ])) as IdRow[];
itemId=rows[0]?.id ?? null; itemId=itemRows[0]?.id ?? null;
} else { } else {
await queryRunner.query(` await queryRunner.query(`
UPDATE finding_catalog_items UPDATE finding_catalog_items
@@ -226,36 +238,43 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
sourceNumber+=1; 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) { 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) { for (const family of source.catalogSource.subinstallations) {
await this.mapFindings( await this.mapFindings(
queryRunner, queryRunner,
subinstallationCode(family.installation,family.name), subinstallationCode(family.installation,family.name),
family, family,
source.catalogSource.universalFindings,
itemIdByKey, itemIdByKey,
); );
} }
await this.installFamilySyncFunctions(queryRunner); 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(` await queryRunner.query(`
INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason) 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.' SELECT id,'F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.'
FROM asset_types WHERE lower(code) IN ('yacimiento','instalacion','subinstalacion') FROM asset_types WHERE lower(code)='yacimiento'
ON CONFLICT (asset_type_id) DO UPDATE SET ON CONFLICT (asset_type_id) DO NOTHING
reason=EXCLUDED.reason,updated_at=CURRENT_TIMESTAMP
`); `);
const [counts] = (await queryRunner.query(` const [counts] = (await queryRunner.query(`
SELECT SELECT
COUNT(*) FILTER (WHERE level='INSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS installations, 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 COUNT(*) FILTER (WHERE level='SUBINSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS subinstallations
FROM inventory_families FROM inventory_families WHERE is_active=true
WHERE is_active=true
`)) as Array<{ installations:number; subinstallations:number }>; `)) as Array<{ installations:number; subinstallations:number }>;
if (Number(counts?.installations ?? 0)!==14 || Number(counts?.subinstallations ?? 0)!==109) { if (Number(counts?.installations ?? 0)!==14 || Number(counts?.subinstallations ?? 0)!==109) {
throw new Error(`F5 family preload verification failed: ${JSON.stringify(counts ?? {})}`); 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 SELECT COUNT(*)::integer AS total
FROM finding_catalog_items FROM finding_catalog_items
WHERE category_id=$1::uuid AND is_active=true WHERE category_id=$1::uuid AND is_active=true
`,[categoryId])) as Array<{total:number}>; `,[categoryId])) as CountRow[];
if (Number(itemCount?.total ?? 0)!==177) { if (Number(itemCount?.total ?? 0)!==177) {
throw new Error(`F5 finding preload verification failed: ${itemCount?.total ?? 0}`); 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> { 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`, `SELECT id FROM finding_categories WHERE lower(code)=lower($1) LIMIT 1`,
[CATALOG_CATEGORY_CODE], [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) { if (categoryId) {
await queryRunner.query(` await queryRunner.query(`
DELETE FROM finding_catalog_item_inventory_families mapping DELETE FROM finding_catalog_item_inventory_families mapping
@@ -288,45 +351,63 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
await queryRunner.query(` await queryRunner.query(`
DELETE FROM finding_catalog_item_versions version DELETE FROM finding_catalog_item_versions version
USING finding_catalog_items item USING finding_catalog_items item
WHERE item.id=version.item_id WHERE item.id=version.item_id AND item.category_id=$1::uuid
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)
`,[categoryId]); `,[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(` await queryRunner.query(`
DELETE FROM inventory_families family DELETE FROM inventory_family_parent_rules rule
WHERE family.source_reference LIKE 'F5:%' USING inventory_families child
AND NOT EXISTS (SELECT 1 FROM assets asset WHERE asset.inventory_family_id=family.id) 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(` await queryRunner.query(`
UPDATE inventory_families UPDATE inventory_families
SET is_active=true,updated_at=CURRENT_TIMESTAMP 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(` await queryRunner.query(`
UPDATE finding_categories SET is_active=true,updated_at=CURRENT_TIMESTAMP UPDATE finding_categories SET is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='app26r2' 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(` await queryRunner.query(`
DELETE FROM source_documents DELETE FROM source_documents
WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos'
`,[CATALOG_DOCUMENT_NUMBER]); `,[CATALOG_DOCUMENT_NUMBER]);
await this.restoreF31FamilySyncFunctions(queryRunner); 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( private async upsertFamily(
@@ -339,8 +420,7 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
await queryRunner.query(` await queryRunner.query(`
INSERT INTO inventory_families( INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active 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 ON CONFLICT (code) DO UPDATE SET
name=EXCLUDED.name,level=EXCLUDED.level,legacy_type_code=NULL, name=EXCLUDED.name,level=EXCLUDED.level,legacy_type_code=NULL,
information_labels='[]'::jsonb,source_reference=EXCLUDED.source_reference, information_labels='[]'::jsonb,source_reference=EXCLUDED.source_reference,
@@ -366,6 +446,7 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
queryRunner: QueryRunner, queryRunner: QueryRunner,
familyCode: string, familyCode: string,
family: F5InstallationCatalogRow|F5SubinstallationCatalogRow, family: F5InstallationCatalogRow|F5SubinstallationCatalogRow,
universalFindings: string[],
itemIdByKey: Map<string,string>, itemIdByKey: Map<string,string>,
): Promise<void> { ): Promise<void> {
const familyId = await this.id( const familyId = await this.id(
@@ -375,7 +456,7 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
`family ${familyCode}`, `family ${familyCode}`,
); );
const mapped = new Set<string>(); const mapped = new Set<string>();
for (const rawTitle of family.findings) { for (const rawTitle of [...family.findings,...universalFindings]) {
const itemKey=findingKey(rawTitle); const itemKey=findingKey(rawTitle);
if (!itemKey || itemKey==='hallazgos' || mapped.has(itemKey)) continue; if (!itemKey || itemKey==='hallazgos' || mapped.has(itemKey)) continue;
mapped.add(itemKey); mapped.add(itemKey);
@@ -395,23 +476,18 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
RETURNS trigger LANGUAGE plpgsql AS $$ RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN BEGIN
DELETE FROM finding_catalog_asset_overrides DELETE FROM finding_catalog_asset_overrides
WHERE asset_id=NEW.id WHERE asset_id=NEW.id AND reason LIKE 'F% familia técnica:%';
AND reason LIKE 'F% familia técnica:%';
IF NEW.inventory_family_id IS NOT NULL THEN IF NEW.inventory_family_id IS NOT NULL THEN
INSERT INTO finding_catalog_asset_overrides( INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
) )
SELECT NEW.id,mapping.catalog_item_id,true, SELECT NEW.id,mapping.catalog_item_id,true,$$${F5_AUTO_REASON}$$,
'F5 familia técnica: catálogo contextual automático',
NEW.created_by,NEW.updated_by NEW.created_by,NEW.updated_by
FROM finding_catalog_item_inventory_families mapping FROM finding_catalog_item_inventory_families mapping
WHERE mapping.inventory_family_id=NEW.inventory_family_id WHERE mapping.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true, is_enabled=true,reason=$$${F5_AUTO_REASON}$$,
reason='F5 familia técnica: catálogo contextual automático', updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
updated_by=NEW.updated_by,
updated_at=CURRENT_TIMESTAMP;
END IF; END IF;
RETURN NEW; RETURN NEW;
END $$; END $$;
@@ -432,18 +508,16 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
INSERT INTO finding_catalog_asset_overrides( INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
) )
SELECT asset.id,NEW.catalog_item_id,true, SELECT asset.id,NEW.catalog_item_id,true,$$${F5_AUTO_REASON}$$,
'F5 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by asset.created_by,asset.updated_by
FROM assets asset FROM assets asset
WHERE asset.inventory_family_id=NEW.inventory_family_id WHERE asset.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true, is_enabled=true,reason=$$${F5_AUTO_REASON}$$,updated_at=CURRENT_TIMESTAMP;
reason='F5 familia técnica: catálogo contextual automático',
updated_at=CURRENT_TIMESTAMP;
RETURN NEW; RETURN NEW;
END $$; END $$;
`); `);
await queryRunner.query(` await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides DELETE FROM finding_catalog_asset_overrides
WHERE reason LIKE 'F% familia técnica:%' WHERE reason LIKE 'F% familia técnica:%'
@@ -452,18 +526,15 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn
INSERT INTO finding_catalog_asset_overrides( INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
) )
SELECT asset.id,mapping.catalog_item_id,true, SELECT asset.id,mapping.catalog_item_id,true,$1,
'F5 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by asset.created_by,asset.updated_by
FROM assets asset FROM assets asset
JOIN finding_catalog_item_inventory_families mapping JOIN finding_catalog_item_inventory_families mapping
ON mapping.inventory_family_id=asset.inventory_family_id ON mapping.inventory_family_id=asset.inventory_family_id
WHERE asset.inventory_family_id IS NOT NULL WHERE asset.inventory_family_id IS NOT NULL
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true, is_enabled=true,reason=$1,updated_at=CURRENT_TIMESTAMP
reason='F5 familia técnica: catálogo contextual automático', `,[F5_AUTO_REASON]);
updated_at=CURRENT_TIMESTAMP
`);
} }
private async restoreF31FamilySyncFunctions(queryRunner: QueryRunner):Promise<void> { private async restoreF31FamilySyncFunctions(queryRunner: QueryRunner):Promise<void> {