F3.1: migrar Inventario estructural y recargar catálogo
This commit is contained in:
+359
@@ -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<void> {
|
||||||
|
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<string, Map<string,string>>();
|
||||||
|
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<string,string>();
|
||||||
|
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<string,string>();
|
||||||
|
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<string,string>();
|
||||||
|
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<string,string>();
|
||||||
|
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<void> {
|
||||||
|
throw new Error(
|
||||||
|
'F3.1 recarga catálogo y agrega familias estructurales; no se revierte destructivamente. Restaurar backup PRE si fuera necesario.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user