Files
dh-inspeccion-v2/api-v3/src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts
T
admin 35d4630581 F5 · Inventario operativo, territorio y catálogo autorizado (#25)
* fix(web): simplify inventory administration menu

* fix(web): remove legacy imports and function catalog routes

* fix(web): remove redundant inspections lifecycle legend

* feat(inventory): distinguish physical instances from structural records

* fix(dashboard): align inventory and act follow-up metrics

* fix(web): align dashboard summary contract

* fix(web): clarify dashboard act and report concepts

* feat(inventory): mark field-created records as real instances

* feat(inventory): map physical instance flag on asset entity

* fix(inventory): keep field yacimientos structural

* feat(inventory): classify future concrete instances at database level

* fix(inventory): count only installation and subinstallation instances

* feat(inventory): add authoritative F5 source snapshot

* feat(inventory): preload authoritative territory model

* feat(inventory): preload authoritative technical catalog

* fix(findings): use only authoritative F5 family catalog

* fix(inventory): preserve non-hierarchical operator snapshot compatibility

* feat(inventory): add inventory-only asset filter

* feat(inventory): add inventory-only tree filter

* feat(inventory): add inventory browser query contract

* feat(inventory): add area-owned inventory browser

* feat(inventory): expose area-owned inventory browser

* refactor(inventory): remove function catalog and add inventory browser

* fix(inventory): make operator relation temporal and non-owning

* feat(web): add inventory browser API client

* feat(inventory): extend inventory browser filters

* feat(inventory): add real inventory list endpoint logic

* feat(inventory): expose real inventory list

* feat(web): add real inventory list client

* refactor(web): make inventory hierarchy area-owned

* fix(web): show only real inventory instances

* fix(web): style act follow-up tabs and F5 inventory context

* fix(web): load F5 flow styles

* fix(inventory): apply area-owned operational guard on F5 up

* fix(inventory): treat company on asset as non-owning creation snapshot

* fix(inventory): resolve field inventory by area hierarchy, not company ownership

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

* fix(inventory): harden authoritative catalog migration checks

* fix(inventory): make authoritative territory preload safely reversible

* feat(inventory): allow independent company master creation

* fix(inventory): make guided creation area-owned and support companies

* feat(web): expose independent company master in inventory setup

* feat(web): create companies independently from physical inventory hierarchy

* fix(inventory): merge by physical area and preserve sealed documents

* test(inventory): lock F5 authoritative model and merge invariants

* feat(inventory): add family administration DTOs

* feat(inventory): administer installation and subinstallation classifications

* feat(inventory): expose family classification administration

* feat(web): add inventory classification administration API

* fix(web): configure finding applicability by inventory classification

* fix(web): redefine inventory configuration around hierarchy classifications and columns

* chore(release): identify F5 inventory model

* chore(release): bump API for F5 inventory model

* test(release): expect F5 health metadata

* chore(release): align WEB package with F5 inventory cut

* chore(release): expose F5 WEB phase

* test(dashboard): expect inspector activity and act follow-up metrics

* test(dashboard): route F5 summary query mocks explicitly

* ci: rehearse all migrations on clean PostGIS before merge

* ci: prove F5 migrations revert and reapply cleanly

* test(f5): align operational navigation contract

* test(f5): align operator lifecycle with area-owned inventory

* test(f5): make merge compatibility area-based

* test(f5): distinguish literal and normalized yacimiento counts

* test(f5): model normalized yacimiento collision explicitly

* ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal

* ci(f5): bypass irreversible historical reset in clean rehearsal

* fix(f5): make territory SQL parameter types explicit

* fix(f5): guarantee canonical inventory hierarchy before territory preload

* ci(f5): include canonical hierarchy migration in rollback gate

* fix(f5): type relation backup markers explicitly

* fix(f5): make catalog SQL text parameter types explicit
2026-09-08 23:18:15 -03:00

607 lines
25 KiB
TypeScript

import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import {
loadF5InventoryAuthoritativeSource,
type F5InstallationCatalogRow,
type F5SubinstallationCatalogRow,
} 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';
const F5_SOURCE_FAMILY_COUNT = 123;
function findingKey(value: string): string {
return value.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function hashCode(prefix: string, value: string, length = 12): string {
return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`;
}
function installationCode(name: string): string {
return hashCode('F5-I', findingKey(name));
}
function subinstallationCode(installation: string, name: string): string {
return hashCode('F5-S', `${findingKey(installation)}|${findingKey(name)}`);
}
function subOtherCode(parentCode: string): string {
return hashCode('F5-S-OTRO', parentCode);
}
export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationInterface {
name = 'F5AuthoritativeInventoryCatalog1790087300000';
public async up(queryRunner: QueryRunner): Promise<void> {
const source = loadF5InventoryAuthoritativeSource();
if (
source.catalogSource.file !== CATALOG_SOURCE_NAME
|| source.catalogSource.sheet !== 'Hoja1'
|| source.catalogSource.sha256 !== 'c9a2d1db59fff2157162c41009b8c9042a3c7a3001649239a07732a3b8fca155'
|| source.catalogSource.installations.length !== 14
|| source.catalogSource.subinstallations.length !== 109
) {
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
)
VALUES ('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4)
ON CONFLICT (document_number,issuer) WHERE document_number IS NOT NULL AND issuer IS NOT NULL
DO UPDATE SET
title=EXCLUDED.title,
external_reference=EXCLUDED.external_reference,
notes=EXCLUDED.notes,
updated_at=CURRENT_TIMESTAMP
`, [
CATALOG_DOCUMENT_NUMBER,
CATALOG_SOURCE_NAME,
`sha256:${source.catalogSource.sha256}`,
`F5 · catálogo técnico autorizado · hoja ${source.catalogSource.sheet} · 14 Instalaciones · 109 Subinstalaciones`,
]);
// 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 LIKE 'APLICACION APP%'
OR source_reference LIKE 'SYSTEM:F3.1:%'
`);
await queryRunner.query(`
UPDATE finding_categories
SET is_active=false,updated_at=CURRENT_TIMESTAMP
WHERE lower(code) IN ('app26','app26r2')
`);
const installationIds = new Map<string,string>();
for (const installation of source.catalogSource.installations) {
const familyId = await this.upsertFamily(
queryRunner,
installationCode(installation.name),
installation.name,
'INSTALLATION',
`F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${installation.sourceStartRow}-${installation.sourceEndRow}`,
);
installationIds.set(findingKey(installation.name),familyId);
}
for (const subinstallation of source.catalogSource.subinstallations) {
const parentId = installationIds.get(findingKey(subinstallation.installation));
if (!parentId) throw new Error(`F5 missing installation family ${subinstallation.installation}`);
const childId = await this.upsertFamily(
queryRunner,
subinstallationCode(subinstallation.installation,subinstallation.name),
subinstallation.name,
'SUBINSTALLATION',
`F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${subinstallation.sourceStartRow}-${subinstallation.sourceEndRow}${subinstallation.reference ? `|reference:${subinstallation.reference}` : ''}`,
);
await this.parentRule(queryRunner,childId,parentId);
}
const installationOtherId = await this.upsertFamily(
queryRunner,
'F5-I-OTRO',
'Otro / no catalogado',
'INSTALLATION',
'F5:SYSTEM:OTHER:INSTALLATION',
);
for (const [installationKey,parentId] of installationIds) {
const parent = source.catalogSource.installations.find((item) => findingKey(item.name)===installationKey);
if (!parent) continue;
const childId = await this.upsertFamily(
queryRunner,
subOtherCode(installationCode(parent.name)),
'Otro / no catalogado',
'SUBINSTALLATION',
`F5:SYSTEM:OTHER:SUBINSTALLATION:${installationCode(parent.name)}`,
);
await this.parentRule(queryRunner,childId,parentId);
}
const rootOtherChild = await this.upsertFamily(
queryRunner,
subOtherCode('F5-I-OTRO'),
'Otro / no catalogado',
'SUBINSTALLATION',
'F5:SYSTEM:OTHER:SUBINSTALLATION:F5-I-OTRO',
);
await this.parentRule(queryRunner,rootOtherChild,installationOtherId);
await queryRunner.query(`
INSERT INTO finding_categories(code,name,sort_order,is_active)
SELECT $1::varchar,'DH · Modelo de Inventarios F5',270,true
WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)=lower($1::varchar))
`, [CATALOG_CATEGORY_CODE]);
await queryRunner.query(`
UPDATE finding_categories
SET name='DH · Modelo de Inventarios F5',sort_order=270,is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE lower(code)=lower($1::varchar)
`,[CATALOG_CATEGORY_CODE]);
const categoryId = await this.id(
queryRunner,
`SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[CATALOG_CATEGORY_CODE],
'F5 finding category',
);
const titleByKey = new Map<string,string>();
const register = (title: string): void => {
const clean = title.trim();
if (!clean || /^idem\b/i.test(clean) || findingKey(clean)==='hallazgos') return;
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) {
throw new Error(`F5 finding normalization contract mismatch: ${titleByKey.size}`);
}
const itemIdByKey = new Map<string,string>();
const orderedTitles = [...titleByKey.entries()].sort((a,b)=>a[1].localeCompare(b[1],'es'));
let sourceNumber=1;
for (const [itemKey,title] of orderedTitles) {
const itemCode = hashCode('F5-H',itemKey);
let itemId = await this.optionalId(
queryRunner,
`SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[itemCode],
);
if (!itemId) {
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)
RETURNING id
`,[
categoryId,itemCode,sourceNumber,title,
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
])) as IdRow[];
itemId=itemRows[0]?.id ?? null;
} else {
await queryRunner.query(`
UPDATE finding_catalog_items
SET category_id=$2::uuid,source_number=$3,title=$4,import_note=$5,
is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`,[
itemId,categoryId,sourceNumber,title,
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
]);
}
if (!itemId) throw new Error(`F5 could not create finding ${title}`);
itemIdByKey.set(itemKey,itemId);
await queryRunner.query(`
INSERT INTO finding_catalog_item_versions(item_id,revision,snapshot,actor_username)
SELECT item.id,item.revision,
jsonb_build_object(
'id',item.id,'categoryId',category.id,'categoryCode',category.code,
'categoryName',category.name,'code',item.code,'sourceNumber',item.source_number,
'title',item.title,'legalBasis',item.legal_basis,'glossary',item.glossary,
'importNote',item.import_note,'revision',item.revision,'isActive',item.is_active
),'migration:F5'
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=$1::uuid
AND NOT EXISTS (
SELECT 1 FROM finding_catalog_item_versions version
WHERE version.item_id=item.id AND version.revision=item.revision
)
`,[itemId]);
sourceNumber+=1;
}
// 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,
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: 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
`)) 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 ?? {})}`);
}
const [itemCount] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM finding_catalog_items
WHERE category_id=$1::uuid AND is_active=true
`,[categoryId])) as CountRow[];
if (Number(itemCount?.total ?? 0)!==177) {
throw new Error(`F5 finding preload verification failed: ${itemCount?.total ?? 0}`);
}
// Verify every universal finding is independently attached to every one of
// the 14 + 109 source families. This intentionally avoids optional DB text
// extensions such as unaccent.
for (const universalTitle of source.catalogSource.universalFindings) {
const universalItemId = itemIdByKey.get(findingKey(universalTitle));
if (!universalItemId) throw new Error(`F5 missing universal catalog item ${universalTitle}`);
const [mappedCount] = (await queryRunner.query(`
SELECT COUNT(DISTINCT mapping.inventory_family_id)::integer AS total
FROM finding_catalog_item_inventory_families mapping
JOIN inventory_families family ON family.id=mapping.inventory_family_id
WHERE mapping.catalog_item_id=$1::uuid
AND family.is_active=true
AND family.source_reference LIKE $2
`,[universalItemId,`F5:${CATALOG_SOURCE_NAME}%`])) as CountRow[];
if (Number(mappedCount?.total ?? 0)!==F5_SOURCE_FAMILY_COUNT) {
throw new Error(`F5 universal mapping verification failed for ${universalTitle}: ${mappedCount?.total ?? 0}`);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const categoryId = await this.optionalId(
queryRunner,
`SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) 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::text
`,[F5_AUTO_REASON]);
if (categoryId) {
await queryRunner.query(`
DELETE FROM finding_catalog_item_inventory_families mapping
USING finding_catalog_items item
WHERE item.id=mapping.catalog_item_id AND item.category_id=$1::uuid
`,[categoryId]);
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
`,[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_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:%'
`);
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::varchar 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(
queryRunner: QueryRunner,
familyCode: string,
name: string,
level: 'INSTALLATION'|'SUBINSTALLATION',
sourceReference: string,
): Promise<string> {
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)
ON CONFLICT (code) DO UPDATE SET
name=EXCLUDED.name,level=EXCLUDED.level,legacy_type_code=NULL,
information_labels='[]'::jsonb,source_reference=EXCLUDED.source_reference,
is_active=true,updated_at=CURRENT_TIMESTAMP
`,[familyCode,name,level,sourceReference]);
return this.id(
queryRunner,
`SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`,
[familyCode],
`inventory family ${familyCode}`,
);
}
private async parentRule(queryRunner: QueryRunner,childId:string,parentId:string):Promise<void> {
await queryRunner.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
ON CONFLICT (child_family_id) DO UPDATE SET parent_family_id=EXCLUDED.parent_family_id
`,[childId,parentId]);
}
private async mapFindings(
queryRunner: QueryRunner,
familyCode: string,
family: F5InstallationCatalogRow|F5SubinstallationCatalogRow,
universalFindings: string[],
itemIdByKey: Map<string,string>,
): Promise<void> {
const familyId = await this.id(
queryRunner,
`SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`,
[familyCode],
`family ${familyCode}`,
);
const mapped = new Set<string>();
for (const rawTitle of [...family.findings,...universalFindings]) {
const itemKey=findingKey(rawTitle);
if (!itemKey || itemKey==='hallazgos' || mapped.has(itemKey)) continue;
mapped.add(itemKey);
const itemId=itemIdByKey.get(itemKey);
if (!itemId) throw new Error(`F5 missing finding item ${rawTitle}`);
await queryRunner.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
VALUES ($1::uuid,$2::uuid)
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
`,[itemId,familyId]);
}
}
private async installFamilySyncFunctions(queryRunner: QueryRunner):Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM finding_catalog_asset_overrides
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',
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;
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' THEN
DELETE FROM finding_catalog_asset_overrides override_record
USING assets asset
WHERE override_record.asset_id=asset.id
AND asset.inventory_family_id=OLD.inventory_family_id
AND override_record.catalog_item_id=OLD.catalog_item_id
AND override_record.reason LIKE 'F% familia técnica:%';
RETURN OLD;
END IF;
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',
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;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason LIKE 'F% 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,$1::text,
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=$1::text,updated_at=CURRENT_TIMESTAMP
`,[F5_AUTO_REASON]);
}
private async restoreF31FamilySyncFunctions(queryRunner: QueryRunner):Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM finding_catalog_asset_overrides
WHERE asset_id=NEW.id AND reason LIKE 'F3.1 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,
'F3.1 familia técnica: catálogo contextual automático',
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='F3.1 familia técnica: catálogo contextual automático',
updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' THEN
DELETE FROM finding_catalog_asset_overrides override_record
USING assets asset
WHERE override_record.asset_id=asset.id
AND asset.inventory_family_id=OLD.inventory_family_id
AND override_record.catalog_item_id=OLD.catalog_item_id
AND override_record.reason LIKE 'F3.1 familia técnica:%';
RETURN OLD;
END IF;
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,
'F3.1 familia técnica: catálogo contextual automático',
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='F3.1 familia técnica: catálogo contextual automático',
updated_at=CURRENT_TIMESTAMP;
RETURN NEW;
END $$;
`);
}
private async id(queryRunner: QueryRunner,sql:string,params:unknown[],label:string):Promise<string> {
const value=await this.optionalId(queryRunner,sql,params);
if (!value) throw new Error(`F5 could not resolve ${label}`);
return value;
}
private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise<string|null> {
const rows=(await queryRunner.query(sql,params)) as IdRow[];
return rows[0]?.id ?? null;
}
}