diff --git a/api-v3/src/database/migrations/1789844400000-phase-f3-1-inventory-structure-catalog.ts b/api-v3/src/database/migrations/1789844400000-phase-f3-1-inventory-structure-catalog.ts new file mode 100644 index 0000000..facfe1c --- /dev/null +++ b/api-v3/src/database/migrations/1789844400000-phase-f3-1-inventory-structure-catalog.ts @@ -0,0 +1,359 @@ +import { createHash } from 'node:crypto'; +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { + F31_FINDING_GROUPS, + F31_INSTALLATION_FAMILIES, + F31_SUBINSTALLATION_FAMILIES, + F31_UNIVERSAL_FINDINGS, +} from '../../reference-data/f3-1-inventory-excel'; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +function findingKey(value: string): string { + return value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() + .replace(/\s+/g, ' '); +} + +function findingCode(title: string): string { + return `APP26R2-${createHash('sha1').update(findingKey(title)).digest('hex').slice(0, 12).toUpperCase()}`; +} + +type IdRow = { id: string }; +type CountRow = { total: string }; + +export class PhaseF31InventoryStructureCatalog1789844400000 implements MigrationInterface { + name = 'PhaseF31InventoryStructureCatalog1789844400000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO source_documents (document_type,document_number,title,issuer,notes) + SELECT 'SPREADSHEET','DH-F31-APP','APLICACION APP(2).xlsx','Dirección de Hidrocarburos', + 'F3.1: revisión estructural. Hoja1 define Instalaciones/Subinstalaciones; Hoja2 define catálogo contextual.' + WHERE NOT EXISTS ( + SELECT 1 FROM source_documents + WHERE document_number='DH-F31-APP' AND issuer='Dirección de Hidrocarburos' + ) + `); + + await queryRunner.query(` + INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role) + SELECT 'subinstalacion','Subinstalación', + 'Unidad física subordinada a una Instalación. Su familia técnica determina el catálogo contextual de Hallazgos.', + false,true,'GENERIC' + WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='subinstalacion') + `); + await queryRunner.query(` + INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id) + SELECT child.id,parent.id + FROM asset_types child CROSS JOIN asset_types parent + WHERE lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion' + ON CONFLICT DO NOTHING + `); + + await queryRunner.query(` + CREATE TABLE inventory_families ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(120) NOT NULL UNIQUE, + name varchar(240) NOT NULL, + level varchar(24) NOT NULL, + legacy_type_code varchar(120), + information_labels jsonb NOT NULL DEFAULT '[]'::jsonb, + source_reference varchar(240), + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_inventory_families_level CHECK (level IN ('INSTALLATION','SUBINSTALLATION')), + CONSTRAINT chk_inventory_families_information_labels CHECK (jsonb_typeof(information_labels)='array') + ) + `); + await queryRunner.query(` + CREATE INDEX idx_inventory_families_level_active + ON inventory_families(level,is_active,name) + `); + await queryRunner.query(` + CREATE TABLE inventory_family_parent_rules ( + child_family_id uuid PRIMARY KEY, + parent_family_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_inventory_family_parent_child FOREIGN KEY (child_family_id) + REFERENCES inventory_families(id) ON DELETE CASCADE, + CONSTRAINT fk_inventory_family_parent_parent FOREIGN KEY (parent_family_id) + REFERENCES inventory_families(id) ON DELETE CASCADE, + CONSTRAINT chk_inventory_family_parent_distinct CHECK (child_family_id <> parent_family_id) + ) + `); + await queryRunner.query(` + CREATE INDEX idx_inventory_family_parent_parent + ON inventory_family_parent_rules(parent_family_id,child_family_id) + `); + await queryRunner.query(` + ALTER TABLE assets ADD COLUMN inventory_family_id uuid + `); + await queryRunner.query(` + ALTER TABLE assets ADD CONSTRAINT fk_assets_inventory_family + FOREIGN KEY (inventory_family_id) REFERENCES inventory_families(id) ON DELETE RESTRICT + `); + await queryRunner.query(` + CREATE INDEX idx_assets_inventory_family ON assets(inventory_family_id) + `); + await queryRunner.query(` + CREATE TABLE finding_catalog_item_inventory_families ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + catalog_item_id uuid NOT NULL, + inventory_family_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_finding_catalog_item_inventory_family UNIQUE (catalog_item_id,inventory_family_id), + CONSTRAINT fk_finding_catalog_family_item FOREIGN KEY (catalog_item_id) + REFERENCES finding_catalog_items(id) ON DELETE CASCADE, + CONSTRAINT fk_finding_catalog_family_family FOREIGN KEY (inventory_family_id) + REFERENCES inventory_families(id) ON DELETE CASCADE + ) + `); + await queryRunner.query(` + CREATE INDEX idx_finding_catalog_family_family + ON finding_catalog_item_inventory_families(inventory_family_id,catalog_item_id) + `); + + for (const family of F31_INSTALLATION_FAMILIES) { + await queryRunner.query(` + INSERT INTO inventory_families + (code,name,level,legacy_type_code,information_labels,source_reference,is_active) + VALUES ($1,$2,'INSTALLATION',$3,'[]'::jsonb,$4,true) + ON CONFLICT (code) DO UPDATE SET + name=EXCLUDED.name, + legacy_type_code=EXCLUDED.legacy_type_code, + source_reference=EXCLUDED.source_reference, + is_active=true, + updated_at=CURRENT_TIMESTAMP + `, [family.code, family.name, family.legacyTypeCode, `APLICACION APP(2).xlsx|Hoja1|fila:${family.sourceRow}`]); + } + for (const family of F31_SUBINSTALLATION_FAMILIES) { + await queryRunner.query(` + INSERT INTO inventory_families + (code,name,level,legacy_type_code,information_labels,source_reference,is_active) + VALUES ($1,$2,'SUBINSTALLATION',$3,$4::jsonb,$5,true) + ON CONFLICT (code) DO UPDATE SET + name=EXCLUDED.name, + legacy_type_code=EXCLUDED.legacy_type_code, + information_labels=EXCLUDED.information_labels, + source_reference=EXCLUDED.source_reference, + is_active=true, + updated_at=CURRENT_TIMESTAMP + `, [ + family.code, + family.name, + family.legacyTypeCode, + JSON.stringify(family.informationLabels), + `APLICACION APP(2).xlsx|Hoja1|fila:${family.sourceRow}`, + ]); + await queryRunner.query(` + INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id) + SELECT child.id,parent.id + FROM inventory_families child CROSS JOIN inventory_families parent + WHERE child.code=$1 AND parent.code=$2 + ON CONFLICT (child_family_id) DO UPDATE SET parent_family_id=EXCLUDED.parent_family_id + `, [family.code, family.parentCode]); + } + + await queryRunner.query(` + UPDATE finding_categories SET is_active=false, updated_at=CURRENT_TIMESTAMP + WHERE lower(code)='app26' + `); + await queryRunner.query(` + INSERT INTO finding_categories(code,name,sort_order,is_active) + SELECT 'APP26R2','Aplicación APP 2026 · revisión estructural',261,true + WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)='app26r2') + `); + await queryRunner.query(` + UPDATE finding_categories + SET name='Aplicación APP 2026 · revisión estructural', sort_order=261, + is_active=true, updated_at=CURRENT_TIMESTAMP + WHERE lower(code)='app26r2' + `); + const categoryRows = (await queryRunner.query( + `SELECT id FROM finding_categories WHERE lower(code)='app26r2' LIMIT 1`, + )) as IdRow[]; + const categoryId = categoryRows[0]?.id; + if (!categoryId) throw new Error('F3.1 could not resolve APP26R2 category'); + + const groupByKey = new Map(F31_FINDING_GROUPS.map((group) => [group.key, group])); + const titlesByFamily = new Map>(); + const addFamilyTitle = (familyCode: string, title: string): void => { + const clean = title.trim(); + if (!clean || /^idem\b/i.test(clean) || clean.toUpperCase()==='HALLAZGOS') return; + const key = findingKey(clean); + const map = titlesByFamily.get(familyCode) ?? new Map(); + if (!map.has(key)) map.set(key, clean); + titlesByFamily.set(familyCode, map); + }; + const seedFamily = (family: { code: string; groupKeys: string[]; directFindings: string[] }): void => { + for (const title of F31_UNIVERSAL_FINDINGS) addFamilyTitle(family.code, title); + for (const groupKey of family.groupKeys) { + const group = groupByKey.get(groupKey); + if (!group) throw new Error(`F3.1 missing finding group ${groupKey}`); + for (const title of group.items) addFamilyTitle(family.code, title); + } + for (const title of family.directFindings) addFamilyTitle(family.code, title); + }; + for (const family of F31_INSTALLATION_FAMILIES) seedFamily(family); + for (const family of F31_SUBINSTALLATION_FAMILIES) seedFamily(family); + + const allTitles = new Map(); + for (const titles of titlesByFamily.values()) { + for (const [key,title] of titles) if (!allTitles.has(key)) allTitles.set(key,title); + } + const orderedTitles = [...allTitles.entries()].sort((a,b) => a[1].localeCompare(b[1],'es')); + const itemIdByKey = new Map(); + let sourceNumber = 1; + for (const [key,title] of orderedTitles) { + const code = findingCode(title); + let rows = (await queryRunner.query( + `SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::text) LIMIT 1`, + [code], + )) as IdRow[]; + if (!rows[0]?.id) { + rows = (await queryRunner.query(` + INSERT INTO finding_catalog_items + (category_id,code,source_number,title,import_note,revision,is_active) + VALUES ($1::uuid,$2::varchar,$3::integer,$4::varchar,$5::text,1,true) + RETURNING id + `, [ + categoryId, + code, + sourceNumber, + title, + 'APLICACION APP(2).xlsx · F3.1 · catálogo estructural revisado', + ])) as IdRow[]; + } else { + await queryRunner.query(` + UPDATE finding_catalog_items SET + category_id=$2::uuid,source_number=$3::integer,title=$4::varchar, + import_note=$5::text,is_active=true,updated_at=CURRENT_TIMESTAMP + WHERE id=$1::uuid + `, [rows[0].id, categoryId, sourceNumber, title, 'APLICACION APP(2).xlsx · F3.1 · catálogo estructural revisado']); + } + const itemId = rows[0]?.id; + if (!itemId) throw new Error(`F3.1 could not create catalog item ${title}`); + itemIdByKey.set(key,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:F3.1' + 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; + } + + await queryRunner.query(`DELETE FROM finding_catalog_item_inventory_families`); + const familyMeta = [ + ...F31_INSTALLATION_FAMILIES, + ...F31_SUBINSTALLATION_FAMILIES, + ]; + for (const family of familyMeta) { + const familyRows = (await queryRunner.query( + `SELECT id FROM inventory_families WHERE code=$1 LIMIT 1`, + [family.code], + )) as IdRow[]; + const familyId = familyRows[0]?.id; + if (!familyId) throw new Error(`F3.1 missing inventory family ${family.code}`); + const titles = titlesByFamily.get(family.code) ?? new Map(); + for (const key of titles.keys()) { + const itemId = itemIdByKey.get(key); + if (!itemId) continue; + await queryRunner.query(` + INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) + VALUES ($1::uuid,$2::uuid) ON CONFLICT DO NOTHING + `, [itemId,familyId]); + } + + const legacyRows = (await queryRunner.query( + `SELECT id FROM asset_types WHERE lower(code)=lower($1::text) LIMIT 1`, + [family.legacyTypeCode], + )) as IdRow[]; + const legacyTypeId = legacyRows[0]?.id; + if (legacyTypeId) { + await queryRunner.query(` + INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason) + VALUES ($1::uuid,'F3.1: compatibilidad con activos técnicos históricos usando catálogo APP26R2') + ON CONFLICT (asset_type_id) DO UPDATE SET + reason=EXCLUDED.reason,updated_at=CURRENT_TIMESTAMP + `, [legacyTypeId]); + for (const key of titles.keys()) { + const itemId = itemIdByKey.get(key); + if (!itemId) continue; + await queryRunner.query(` + INSERT INTO finding_catalog_item_asset_types(catalog_item_id,asset_type_id) + VALUES ($1::uuid,$2::uuid) ON CONFLICT DO NOTHING + `, [itemId,legacyTypeId]); + } + } + } + + await queryRunner.query(` + INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason) + SELECT id,'F3.1: las altas estructurales usan familia técnica contextual; sin familia no se sugiere catálogo.' + FROM asset_types WHERE lower(code) IN ('instalacion','subinstalacion') + ON CONFLICT (asset_type_id) DO UPDATE SET + reason=EXCLUDED.reason,updated_at=CURRENT_TIMESTAMP + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const roleRows = (await queryRunner.query('SELECT 1 FROM pg_roles WHERE rolname=$1',[appRole])) as unknown[]; + if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(`GRANT SELECT ON TABLE inventory_families TO ${applicationRole}`); + await queryRunner.query(`GRANT SELECT ON TABLE inventory_family_parent_rules TO ${applicationRole}`); + await queryRunner.query(`GRANT SELECT ON TABLE finding_catalog_item_inventory_families TO ${applicationRole}`); + + const familyCounts = (await queryRunner.query(` + SELECT + COUNT(*) FILTER (WHERE level='INSTALLATION')::text AS installations, + COUNT(*) FILTER (WHERE level='SUBINSTALLATION')::text AS subinstallations + FROM inventory_families WHERE is_active=true + `)) as Array<{ installations: string; subinstallations: string }>; + if (Number(familyCounts[0]?.installations ?? 0) !== F31_INSTALLATION_FAMILIES.length) { + throw new Error('F3.1 verification failed: installation-family count mismatch'); + } + if (Number(familyCounts[0]?.subinstallations ?? 0) !== F31_SUBINSTALLATION_FAMILIES.length) { + throw new Error('F3.1 verification failed: subinstallation-family count mismatch'); + } + const catalogCounts = (await queryRunner.query(` + SELECT COUNT(*)::text AS total + FROM finding_catalog_items item + JOIN finding_categories category ON category.id=item.category_id + WHERE lower(category.code)='app26r2' AND item.is_active=true + `)) as CountRow[]; + if (Number(catalogCounts[0]?.total ?? 0) !== orderedTitles.length) { + throw new Error('F3.1 verification failed: APP26R2 catalog count mismatch'); + } + // eslint-disable-next-line no-console + console.log( + `[F3.1] families=${F31_INSTALLATION_FAMILIES.length}+${F31_SUBINSTALLATION_FAMILIES.length}; groups=${F31_FINDING_GROUPS.length}; catalog=${orderedTitles.length}`, + ); + } + + public async down(): Promise { + throw new Error( + 'F3.1 recarga catálogo y agrega familias estructurales; no se revierte destructivamente. Restaurar backup PRE si fuera necesario.', + ); + } +}