fix(inventory): make authoritative territory preload safely reversible
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import {
|
||||
loadF5InventoryAuthoritativeSource,
|
||||
} from '../../reference-data/f5-authoritative-inventory-source';
|
||||
import { loadF5InventoryAuthoritativeSource } from '../../reference-data/f5-authoritative-inventory-source';
|
||||
|
||||
type IdRow = { id: string };
|
||||
type CountRow = { total: number };
|
||||
|
||||
const TERRITORY_DOCUMENT_NUMBER = 'DH-F5-TERRITORY';
|
||||
const TERRITORY_SOURCE_NAME = 'Tablas de yacimiento y areas.xlsx';
|
||||
const BACKUP_TABLE = 'f5_territory_relation_backups';
|
||||
|
||||
function key(value: string): string {
|
||||
return value.normalize('NFD')
|
||||
@@ -55,39 +55,65 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface
|
||||
throw new Error(`F5 territory cardinality mismatch: areas=${areaRows.length}, pairs=${pairRows.length}`);
|
||||
}
|
||||
|
||||
await this.ensureCanonicalTypes(queryRunner);
|
||||
await this.installHierarchyGuard(queryRunner);
|
||||
await this.installOperationalContextGuard(queryRunner);
|
||||
// Every Area must have one unambiguous source context. The workbook is the
|
||||
// only authority for this preload; conflicting rows must abort the migration.
|
||||
for (const areaRow of areaRows) {
|
||||
const sameArea = rows.filter((row) => key(row.area)===key(areaRow.area));
|
||||
const dimensions = [
|
||||
new Set(sameArea.map((row) => key(row.departamento))),
|
||||
new Set(sameArea.map((row) => key(row.tipoConcesion))),
|
||||
new Set(sameArea.map((row) => key(row.empresaOperadora))),
|
||||
];
|
||||
if (dimensions.some((values) => values.size !== 1)) {
|
||||
throw new Error(`F5 territory source has conflicting Area context: ${areaRow.area}`);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
await this.assertCanonicalTypesAndRules(queryRunner);
|
||||
await this.installHierarchyGuard(queryRunner);
|
||||
await this.ensureBackupTable(queryRunner);
|
||||
|
||||
const preExistingDocument = await this.optionalId(
|
||||
queryRunner,
|
||||
`SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
|
||||
[TERRITORY_DOCUMENT_NUMBER],
|
||||
);
|
||||
if (preExistingDocument) {
|
||||
throw new Error('F5 territory source document already exists before migration');
|
||||
}
|
||||
|
||||
const insertedDocument = (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
|
||||
) VALUES ('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4)
|
||||
RETURNING id
|
||||
`, [
|
||||
TERRITORY_DOCUMENT_NUMBER,
|
||||
TERRITORY_SOURCE_NAME,
|
||||
`sha256:${source.areaSource.sha256}`,
|
||||
`F5 · fuente territorial autorizada · hoja ${source.areaSource.sheet} · ${rows.length} filas`,
|
||||
]);
|
||||
const sourceDocumentId = await this.id(
|
||||
queryRunner,
|
||||
`SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
|
||||
[TERRITORY_DOCUMENT_NUMBER],
|
||||
'territory source document',
|
||||
);
|
||||
])) as IdRow[];
|
||||
const sourceDocumentId = insertedDocument[0]?.id;
|
||||
if (!sourceDocumentId) throw new Error('F5 could not create territory source document');
|
||||
|
||||
const companyTypeId = await this.typeId(queryRunner, 'empresa');
|
||||
const areaTypeId = await this.typeId(queryRunner, 'area');
|
||||
const fieldTypeId = await this.typeId(queryRunner, 'yacimiento');
|
||||
const companyTypeId = await this.id(
|
||||
queryRunner,
|
||||
`SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`,
|
||||
[],
|
||||
'COMPANY asset type',
|
||||
);
|
||||
const areaTypeId = await this.id(
|
||||
queryRunner,
|
||||
`SELECT id FROM asset_types WHERE operational_role='AREA' AND is_active=true ORDER BY (lower(code)='area') DESC,created_at LIMIT 1`,
|
||||
[],
|
||||
'AREA asset type',
|
||||
);
|
||||
const fieldTypeId = await this.id(
|
||||
queryRunner,
|
||||
`SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`,
|
||||
[],
|
||||
'Yacimiento asset type',
|
||||
);
|
||||
|
||||
const companyNames = [...new Set(rows.map((row) => row.empresaOperadora.trim()))]
|
||||
.filter((name) => name && key(name) !== key('Sin Empresa Operadora'))
|
||||
@@ -106,32 +132,28 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface
|
||||
await queryRunner.query(`
|
||||
INSERT INTO organization_profiles (asset_id,organization_kind,legal_name)
|
||||
VALUES ($1::uuid,$2::organization_kind,$3)
|
||||
ON CONFLICT (asset_id) DO UPDATE SET
|
||||
legal_name=EXCLUDED.legal_name,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
ON CONFLICT (asset_id) DO NOTHING
|
||||
`, [companyId, companyName.trim().toUpperCase().startsWith('UTE (') ? 'UTE' : 'COMPANY', companyName]);
|
||||
}
|
||||
|
||||
const departmentIds = new Map<string, string>();
|
||||
for (const departmentName of [...new Set(rows.map((row) => row.departamento.trim()))].sort((a,b)=>a.localeCompare(b,'es'))) {
|
||||
const normalized = key(departmentName);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO administrative_departments (
|
||||
province_code,code,name,normalized_name,is_active,source_document_id
|
||||
)
|
||||
VALUES ('MENDOZA',$1,$2,$3,true,$4::uuid)
|
||||
ON CONFLICT (province_code,normalized_name) DO UPDATE SET
|
||||
name=EXCLUDED.name,
|
||||
is_active=true,
|
||||
source_document_id=EXCLUDED.source_document_id,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
`, [code('F5-DEP', normalized, 10), departmentName, normalized, sourceDocumentId]);
|
||||
const departmentId = await this.id(
|
||||
queryRunner,
|
||||
`SELECT id FROM administrative_departments WHERE province_code='MENDOZA' AND normalized_name=$1 LIMIT 1`,
|
||||
[normalized],
|
||||
`department ${departmentName}`,
|
||||
);
|
||||
let departmentId = await this.optionalId(queryRunner, `
|
||||
SELECT id FROM administrative_departments
|
||||
WHERE province_code='MENDOZA' AND normalized_name=$1 AND is_active=true
|
||||
LIMIT 1
|
||||
`, [normalized]);
|
||||
if (!departmentId) {
|
||||
const inserted = (await queryRunner.query(`
|
||||
INSERT INTO administrative_departments (
|
||||
province_code,code,name,normalized_name,is_active,source_document_id
|
||||
) VALUES ('MENDOZA',$1,$2,$3,true,$4::uuid)
|
||||
RETURNING id
|
||||
`, [code('F5-DEP', normalized, 10), departmentName, normalized, sourceDocumentId])) as IdRow[];
|
||||
departmentId=inserted[0]?.id ?? null;
|
||||
}
|
||||
if (!departmentId) throw new Error(`F5 could not seed department ${departmentName}`);
|
||||
departmentIds.set(normalized, departmentId);
|
||||
}
|
||||
|
||||
@@ -149,15 +171,7 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface
|
||||
|
||||
const departmentId = departmentIds.get(key(areaRow.departamento));
|
||||
if (!departmentId) throw new Error(`F5 missing department ${areaRow.departamento}`);
|
||||
await queryRunner.query(`
|
||||
UPDATE area_department_relations
|
||||
SET valid_until=CURRENT_DATE,
|
||||
notes=concat_ws(E'\n',notes,'F5: reemplazada por fuente territorial autorizada'),
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE area_id=$1::uuid
|
||||
AND valid_until IS NULL
|
||||
AND department_id<>$2::uuid
|
||||
`, [areaId, departmentId]);
|
||||
await this.backupAndCloseDepartmentRelations(queryRunner,areaId,departmentId);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO area_department_relations (
|
||||
area_id,department_id,valid_from,source_document_id,notes
|
||||
@@ -174,19 +188,8 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface
|
||||
`F5 · ${TERRITORY_SOURCE_NAME} · ${source.areaSource.sheet}`,
|
||||
]);
|
||||
|
||||
const operatorKey = key(areaRow.empresaOperadora);
|
||||
const operatorId = companyIds.get(operatorKey) ?? null;
|
||||
await queryRunner.query(`
|
||||
UPDATE area_company_relations
|
||||
SET valid_until=CURRENT_TIMESTAMP,
|
||||
end_reason='F5: reemplazada por fuente territorial autorizada',
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE area_id=$1::uuid
|
||||
AND relation_role='OPERATOR'
|
||||
AND valid_until IS NULL
|
||||
AND ($2::uuid IS NULL OR company_id<>$2::uuid)
|
||||
`, [areaId, operatorId]);
|
||||
|
||||
const operatorId = companyIds.get(key(areaRow.empresaOperadora)) ?? null;
|
||||
await this.backupAndCloseOperatorRelations(queryRunner,areaId,operatorId);
|
||||
if (operatorId) {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO area_company_relations (
|
||||
@@ -219,7 +222,10 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface
|
||||
SELECT $1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4::uuid,$5
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM area_legal_rights
|
||||
WHERE area_id=$1::uuid AND source_document_id=$4::uuid AND name=$3
|
||||
WHERE area_id=$1::uuid
|
||||
AND right_type=$2::area_legal_right_type
|
||||
AND lower(btrim(name))=lower(btrim($3))
|
||||
AND status IN ('ACTIVE','PENDING')
|
||||
)
|
||||
`, [
|
||||
areaId,
|
||||
@@ -240,8 +246,9 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
WHERE lower(type.code)='yacimiento'
|
||||
AND asset.parent_id=$1::uuid
|
||||
AND asset.information_status<>'INACTIVE'
|
||||
AND lower(btrim(asset.name))=lower(btrim($2))
|
||||
ORDER BY (asset.information_status<>'INACTIVE') DESC,asset.created_at
|
||||
ORDER BY asset.created_at
|
||||
LIMIT 1
|
||||
`, [areaId, row.yacimiento]);
|
||||
if (!yacimientoId) {
|
||||
@@ -250,12 +257,10 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface
|
||||
asset_type_id,parent_id,operational_area_id,operator_company_id,
|
||||
code,name,description,information_status,operational_status,
|
||||
data_origin,source_name,source_reference,source_notes,is_inventory_instance
|
||||
)
|
||||
VALUES (
|
||||
) VALUES (
|
||||
$1::uuid,$2::uuid,NULL,NULL,$3,$4,$5,'VALIDATED','UNKNOWN',
|
||||
'PROVIDED_DOCUMENT',$6,$7,$8,false
|
||||
)
|
||||
RETURNING id
|
||||
) RETURNING id
|
||||
`, [
|
||||
fieldTypeId,
|
||||
areaId,
|
||||
@@ -267,16 +272,6 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface
|
||||
`${source.areaSource.sheet} · fila ${row.sourceRow}`,
|
||||
])) as IdRow[];
|
||||
yacimientoId = inserted[0]?.id ?? null;
|
||||
} else {
|
||||
await queryRunner.query(`
|
||||
UPDATE assets
|
||||
SET parent_id=$2::uuid,
|
||||
operator_company_id=NULL,
|
||||
is_inventory_instance=false,
|
||||
information_status=CASE WHEN information_status='INACTIVE' THEN 'VALIDATED' ELSE information_status END,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1::uuid
|
||||
`, [yacimientoId, areaId]);
|
||||
}
|
||||
if (!yacimientoId) throw new Error(`F5 could not seed yacimiento ${row.area} / ${row.yacimiento}`);
|
||||
await this.linkSource(queryRunner, yacimientoId, sourceDocumentId, `Hoja ${source.areaSource.sheet} · fila ${row.sourceRow}`);
|
||||
@@ -302,62 +297,48 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface
|
||||
`SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
|
||||
[TERRITORY_DOCUMENT_NUMBER],
|
||||
);
|
||||
if (sourceDocumentId) {
|
||||
await queryRunner.query(`DELETE FROM area_legal_rights WHERE source_document_id=$1::uuid`, [sourceDocumentId]);
|
||||
await queryRunner.query(`DELETE FROM area_company_relations WHERE source_document_id=$1::uuid`, [sourceDocumentId]);
|
||||
await queryRunner.query(`DELETE FROM area_department_relations WHERE source_document_id=$1::uuid`, [sourceDocumentId]);
|
||||
await queryRunner.query(`DELETE FROM asset_source_documents WHERE document_id=$1::uuid`, [sourceDocumentId]);
|
||||
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:YAC:%'`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM assets
|
||||
WHERE source_reference LIKE 'F5:TERRITORY:AREA:%'
|
||||
AND NOT EXISTS (SELECT 1 FROM assets child WHERE child.parent_id=assets.id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM organization_profiles profile
|
||||
USING assets asset
|
||||
WHERE profile.asset_id=asset.id
|
||||
AND asset.source_reference LIKE 'F5:TERRITORY:COMPANY:%'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM assets
|
||||
WHERE source_reference LIKE 'F5:TERRITORY:COMPANY:%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM area_company_relations relation
|
||||
WHERE relation.company_id=assets.id
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`DELETE FROM administrative_departments WHERE source_document_id=$1::uuid`, [sourceDocumentId]);
|
||||
await queryRunner.query(`DELETE FROM source_documents WHERE id=$1::uuid`, [sourceDocumentId]);
|
||||
if (!sourceDocumentId) {
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
|
||||
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()');
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.assertRelationBackupsUnchanged(queryRunner);
|
||||
await this.assertCreatedMastersUnused(queryRunner,sourceDocumentId);
|
||||
|
||||
await queryRunner.query(`DELETE FROM area_legal_rights WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
|
||||
|
||||
// Remove relations created by F5 first so restoring the previously-active
|
||||
// relation cannot violate active-relation uniqueness constraints.
|
||||
await queryRunner.query(`DELETE FROM area_company_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
|
||||
await queryRunner.query(`DELETE FROM area_department_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
|
||||
await this.restoreRelationBackups(queryRunner);
|
||||
|
||||
await queryRunner.query(`DELETE FROM asset_source_documents WHERE document_id=$1::uuid`,[sourceDocumentId]);
|
||||
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:YAC:%'`);
|
||||
await queryRunner.query(`DELETE FROM organization_profiles profile USING assets asset WHERE profile.asset_id=asset.id AND asset.source_reference LIKE 'F5:TERRITORY:COMPANY:%'`);
|
||||
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:COMPANY:%'`);
|
||||
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:AREA:%'`);
|
||||
await queryRunner.query(`DELETE FROM administrative_departments WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
|
||||
await queryRunner.query(`DELETE FROM source_documents WHERE id=$1::uuid`,[sourceDocumentId]);
|
||||
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
|
||||
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()');
|
||||
await this.restoreLegacyOperationalContextGuard(queryRunner);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`);
|
||||
}
|
||||
|
||||
private async ensureCanonicalTypes(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE asset_types
|
||||
SET name='Empresa',description='Organización independiente. Su relación con un Área es temporal y no forma parte de la jerarquía física.',
|
||||
can_be_root=true,is_active=true,operational_role='COMPANY',updated_at=CURRENT_TIMESTAMP
|
||||
WHERE lower(code) IN ('empresa','organizacion')
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
|
||||
SELECT 'empresa','Empresa','Organización independiente. Su relación con un Área es temporal y no forma parte de la jerarquía física.',true,true,'COMPANY'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE operational_role='COMPANY')
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE asset_types
|
||||
SET name='Área',description='Ancla territorial independiente de la Empresa operadora.',
|
||||
can_be_root=true,is_active=true,operational_role='AREA',updated_at=CURRENT_TIMESTAMP
|
||||
WHERE lower(code)='area'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
|
||||
SELECT 'area','Área','Ancla territorial independiente de la Empresa operadora.',true,true,'AREA'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='area')
|
||||
`);
|
||||
private async assertCanonicalTypesAndRules(queryRunner: QueryRunner): Promise<void> {
|
||||
const [roles] = (await queryRunner.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE operational_role='AREA' AND is_active=true)::integer AS areas,
|
||||
COUNT(*) FILTER (WHERE operational_role='COMPANY' AND is_active=true)::integer AS companies
|
||||
FROM asset_types
|
||||
`)) as Array<{areas:number; companies:number}>;
|
||||
if (Number(roles?.areas ?? 0)<1 || Number(roles?.companies ?? 0)<1) {
|
||||
throw new Error('F5 requires active AREA and COMPANY master types');
|
||||
}
|
||||
|
||||
for (const [typeCode,typeName,description] of [
|
||||
['yacimiento','Yacimiento','Yacimiento perteneciente a un Área.'],
|
||||
['instalacion','Instalación','Instancia física de una Instalación dentro de un Yacimiento.'],
|
||||
@@ -367,26 +348,27 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface
|
||||
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
|
||||
SELECT $1,$2,$3,false,true,'GENERIC'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)=lower($1))
|
||||
`, [typeCode,typeName,description]);
|
||||
await queryRunner.query(`
|
||||
UPDATE asset_types
|
||||
SET name=$2,description=$3,can_be_root=false,is_active=true,operational_role='GENERIC',updated_at=CURRENT_TIMESTAMP
|
||||
WHERE lower(code)=lower($1)
|
||||
`, [typeCode,typeName,description]);
|
||||
`,[typeCode,typeName,description]);
|
||||
const [type] = (await queryRunner.query(`
|
||||
SELECT operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
|
||||
FROM asset_types WHERE lower(code)=lower($1) LIMIT 1
|
||||
`,[typeCode])) as Array<{role:string;active:boolean;canBeRoot:boolean}>;
|
||||
if (!type || type.role!=='GENERIC' || !type.active || type.canBeRoot) {
|
||||
throw new Error(`F5 incompatible master type configuration: ${typeCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [child,parent] of [
|
||||
['yacimiento','area'],
|
||||
['instalacion','yacimiento'],
|
||||
['subinstalacion','instalacion'],
|
||||
]) {
|
||||
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)=$1 AND lower(parent.code)=$2
|
||||
ON CONFLICT DO NOTHING
|
||||
`, [child,parent]);
|
||||
const [ruleCount] = (await queryRunner.query(`
|
||||
SELECT COUNT(*)::integer AS total
|
||||
FROM asset_type_parent_rules rule
|
||||
JOIN asset_types child ON child.id=rule.child_type_id
|
||||
JOIN asset_types parent ON parent.id=rule.parent_type_id
|
||||
WHERE (lower(child.code)='yacimiento' AND lower(parent.code)='area')
|
||||
OR (lower(child.code)='instalacion' AND lower(parent.code)='yacimiento')
|
||||
OR (lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion')
|
||||
`)) as CountRow[];
|
||||
if (Number(ruleCount?.total ?? 0)!==3) {
|
||||
throw new Error('F5 requires canonical parent rules Area → Yacimiento → Instalación → Subinstalación');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +400,7 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets`);
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
|
||||
await queryRunner.query(`
|
||||
CREATE TRIGGER trg_f5_canonical_asset_hierarchy
|
||||
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id ON assets
|
||||
@@ -426,67 +408,137 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface
|
||||
`);
|
||||
}
|
||||
|
||||
private async installOperationalContextGuard(queryRunner: QueryRunner): Promise<void> {
|
||||
private async ensureBackupTable(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE OR REPLACE FUNCTION enforce_asset_operational_context()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE asset_role asset_type_operational_role; area_role asset_type_operational_role;
|
||||
BEGIN
|
||||
SELECT operational_role INTO asset_role FROM asset_types WHERE id=NEW.asset_type_id;
|
||||
IF asset_role <> 'GENERIC'::asset_type_operational_role THEN
|
||||
IF NEW.operational_area_id IS NOT NULL OR NEW.operator_company_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área y Empresa no reciben contexto operativo';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.operator_company_id IS NOT NULL THEN
|
||||
IF TG_OP='INSERT' OR OLD.operator_company_id IS NULL OR NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa pertenece al contexto temporal del Área/Inspección y no al Inventario';
|
||||
END IF;
|
||||
END IF;
|
||||
IF NEW.operational_area_id IS NULL THEN RETURN NEW; END IF;
|
||||
SELECT t.operational_role INTO area_role
|
||||
FROM assets area JOIN asset_types t ON t.id=area.asset_type_id
|
||||
WHERE area.id=NEW.operational_area_id
|
||||
AND area.information_status<>'INACTIVE' AND t.is_active=true;
|
||||
IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an active AREA asset';
|
||||
END IF;
|
||||
IF NEW.parent_id IS NULL OR NOT EXISTS (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id,parent_id FROM assets WHERE id=NEW.parent_id
|
||||
UNION ALL
|
||||
SELECT p.id,p.parent_id FROM assets p JOIN ancestors c ON p.id=c.parent_id
|
||||
)
|
||||
SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1
|
||||
) THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an ancestor in the physical hierarchy';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TABLE ${BACKUP_TABLE} (
|
||||
relation_kind varchar(32) NOT NULL,
|
||||
relation_id uuid NOT NULL,
|
||||
previous_values jsonb NOT NULL,
|
||||
applied_values jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (relation_kind,relation_id),
|
||||
CONSTRAINT chk_f5_territory_backup_kind CHECK (relation_kind IN ('AREA_COMPANY','AREA_DEPARTMENT'))
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
private async restoreLegacyOperationalContextGuard(queryRunner: QueryRunner): Promise<void> {
|
||||
private async backupAndCloseDepartmentRelations(queryRunner: QueryRunner,areaId:string,departmentId:string):Promise<void> {
|
||||
const marker='F5: reemplazada por fuente territorial autorizada';
|
||||
await queryRunner.query(`
|
||||
CREATE OR REPLACE FUNCTION enforce_asset_operational_context() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE asset_role asset_type_operational_role; area_role asset_type_operational_role; company_role asset_type_operational_role; active_relation_id uuid;
|
||||
BEGIN
|
||||
IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN RETURN NEW; END IF;
|
||||
IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and organization must be assigned together'; END IF;
|
||||
SELECT operational_role INTO asset_role FROM asset_types WHERE id=NEW.asset_type_id;
|
||||
IF asset_role <> 'GENERIC'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area and organization assets cannot receive an operational assignment'; END IF;
|
||||
SELECT t.operational_role INTO area_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.operational_area_id AND a.information_status<>'INACTIVE' AND t.is_active=true;
|
||||
SELECT t.operational_role INTO company_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.operator_company_id AND a.information_status<>'INACTIVE' AND t.is_active=true;
|
||||
IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an active AREA asset'; END IF;
|
||||
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operator organization must be an active COMPANY-role asset'; END IF;
|
||||
SELECT r.id INTO active_relation_id FROM area_company_relations r WHERE r.area_id=NEW.operational_area_id AND r.company_id=NEW.operator_company_id AND r.relation_role='OPERATOR'::area_organization_role AND r.valid_until IS NULL FOR KEY SHARE;
|
||||
IF active_relation_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and organization do not have an active OPERATOR relation'; END IF;
|
||||
IF NEW.parent_id IS NULL OR NOT EXISTS (WITH RECURSIVE ancestors AS (SELECT id,parent_id FROM assets WHERE id=NEW.parent_id UNION ALL SELECT p.id,p.parent_id FROM assets p JOIN ancestors c ON p.id=c.parent_id) SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an ancestor in the physical hierarchy'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values)
|
||||
SELECT 'AREA_DEPARTMENT',relation.id,
|
||||
jsonb_build_object('validUntil',relation.valid_until,'notes',relation.notes),
|
||||
jsonb_build_object('validUntil',CURRENT_DATE,'notes',concat_ws(E'\n',relation.notes,$3))
|
||||
FROM area_department_relations relation
|
||||
WHERE relation.area_id=$1::uuid
|
||||
AND relation.valid_until IS NULL
|
||||
AND relation.department_id<>$2::uuid
|
||||
ON CONFLICT DO NOTHING
|
||||
`,[areaId,departmentId,marker]);
|
||||
await queryRunner.query(`
|
||||
UPDATE area_department_relations relation
|
||||
SET valid_until=(backup.applied_values->>'validUntil')::date,
|
||||
notes=backup.applied_values->>'notes',
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
FROM ${BACKUP_TABLE} backup
|
||||
WHERE backup.relation_kind='AREA_DEPARTMENT'
|
||||
AND backup.relation_id=relation.id
|
||||
AND relation.area_id=$1::uuid
|
||||
AND relation.valid_until IS NULL
|
||||
`,[areaId]);
|
||||
}
|
||||
|
||||
private async backupAndCloseOperatorRelations(queryRunner: QueryRunner,areaId:string,operatorId:string|null):Promise<void> {
|
||||
const marker='F5: reemplazada por fuente territorial autorizada';
|
||||
await queryRunner.query(`
|
||||
INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values)
|
||||
SELECT 'AREA_COMPANY',relation.id,
|
||||
jsonb_build_object('validUntil',relation.valid_until,'endReason',relation.end_reason),
|
||||
jsonb_build_object('validUntil',CURRENT_TIMESTAMP,'endReason',$3)
|
||||
FROM area_company_relations relation
|
||||
WHERE relation.area_id=$1::uuid
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
AND ($2::uuid IS NULL OR relation.company_id<>$2::uuid)
|
||||
ON CONFLICT DO NOTHING
|
||||
`,[areaId,operatorId,marker]);
|
||||
await queryRunner.query(`
|
||||
UPDATE area_company_relations relation
|
||||
SET valid_until=(backup.applied_values->>'validUntil')::timestamptz,
|
||||
end_reason=backup.applied_values->>'endReason',
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
FROM ${BACKUP_TABLE} backup
|
||||
WHERE backup.relation_kind='AREA_COMPANY'
|
||||
AND backup.relation_id=relation.id
|
||||
AND relation.area_id=$1::uuid
|
||||
AND relation.valid_until IS NULL
|
||||
`,[areaId]);
|
||||
}
|
||||
|
||||
private async assertRelationBackupsUnchanged(queryRunner: QueryRunner):Promise<void> {
|
||||
const [changed] = (await queryRunner.query(`
|
||||
SELECT COUNT(*)::integer AS total
|
||||
FROM ${BACKUP_TABLE} backup
|
||||
LEFT JOIN area_company_relations company_relation
|
||||
ON backup.relation_kind='AREA_COMPANY' AND company_relation.id=backup.relation_id
|
||||
LEFT JOIN area_department_relations department_relation
|
||||
ON backup.relation_kind='AREA_DEPARTMENT' AND department_relation.id=backup.relation_id
|
||||
WHERE (
|
||||
backup.relation_kind='AREA_COMPANY'
|
||||
AND (
|
||||
company_relation.id IS NULL
|
||||
OR company_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::timestamptz
|
||||
OR company_relation.end_reason IS DISTINCT FROM backup.applied_values->>'endReason'
|
||||
)
|
||||
) OR (
|
||||
backup.relation_kind='AREA_DEPARTMENT'
|
||||
AND (
|
||||
department_relation.id IS NULL
|
||||
OR department_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::date
|
||||
OR department_relation.notes IS DISTINCT FROM backup.applied_values->>'notes'
|
||||
)
|
||||
)
|
||||
`)) as CountRow[];
|
||||
if (Number(changed?.total ?? 0)>0) {
|
||||
throw new Error('Cannot safely rollback F5 territory: a relation closed by the preload was modified afterwards');
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreRelationBackups(queryRunner: QueryRunner):Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE area_company_relations relation
|
||||
SET valid_until=(backup.previous_values->>'validUntil')::timestamptz,
|
||||
end_reason=backup.previous_values->>'endReason',
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
FROM ${BACKUP_TABLE} backup
|
||||
WHERE backup.relation_kind='AREA_COMPANY' AND backup.relation_id=relation.id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE area_department_relations relation
|
||||
SET valid_until=(backup.previous_values->>'validUntil')::date,
|
||||
notes=backup.previous_values->>'notes',
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
FROM ${BACKUP_TABLE} backup
|
||||
WHERE backup.relation_kind='AREA_DEPARTMENT' AND backup.relation_id=relation.id
|
||||
`);
|
||||
}
|
||||
|
||||
private async assertCreatedMastersUnused(queryRunner:QueryRunner,sourceDocumentId:string):Promise<void> {
|
||||
const [used] = (await queryRunner.query(`
|
||||
SELECT COUNT(*)::integer AS total
|
||||
FROM assets asset
|
||||
WHERE asset.source_reference LIKE 'F5:TERRITORY:%'
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM assets child WHERE child.parent_id=asset.id AND child.source_reference NOT LIKE 'F5:TERRITORY:%')
|
||||
OR EXISTS (SELECT 1 FROM inspection_visits visit WHERE visit.operational_area_id=asset.id OR visit.operator_company_id=asset.id)
|
||||
OR EXISTS (SELECT 1 FROM inspection_visit_assets link WHERE link.asset_id=asset.id)
|
||||
OR EXISTS (SELECT 1 FROM inspection_findings finding WHERE finding.asset_id=asset.id)
|
||||
)
|
||||
`)) as CountRow[];
|
||||
if (Number(used?.total ?? 0)>0) {
|
||||
throw new Error('Cannot safely rollback F5 territory: F5-created master data is already used by operational records');
|
||||
}
|
||||
void sourceDocumentId;
|
||||
}
|
||||
|
||||
private async ensureRootAsset(
|
||||
@@ -505,75 +557,46 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
WHERE type.operational_role=$1::asset_type_operational_role
|
||||
AND asset.information_status<>'INACTIVE'
|
||||
AND lower(btrim(asset.name))=lower(btrim($2))
|
||||
ORDER BY (asset.information_status<>'INACTIVE') DESC,asset.created_at
|
||||
ORDER BY asset.created_at
|
||||
LIMIT 1
|
||||
`, [input.role,input.name]);
|
||||
`,[input.role,input.name]);
|
||||
if (!assetId) {
|
||||
const inserted = (await queryRunner.query(`
|
||||
INSERT INTO assets (
|
||||
asset_type_id,parent_id,operational_area_id,operator_company_id,
|
||||
code,name,information_status,operational_status,data_origin,
|
||||
source_name,source_reference,source_notes,is_inventory_instance
|
||||
)
|
||||
VALUES ($1::uuid,NULL,NULL,NULL,$2,$3,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$4,$5,$6,false)
|
||||
) VALUES ($1::uuid,NULL,NULL,NULL,$2,$3,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$4,$5,$6,false)
|
||||
RETURNING id
|
||||
`, [
|
||||
`,[
|
||||
input.typeId,input.code,input.name,TERRITORY_SOURCE_NAME,input.sourceReference,
|
||||
`F5 · fuente territorial autorizada`,
|
||||
'F5 · fuente territorial autorizada',
|
||||
])) as IdRow[];
|
||||
assetId=inserted[0]?.id ?? null;
|
||||
} else {
|
||||
await queryRunner.query(`
|
||||
UPDATE assets
|
||||
SET parent_id=NULL,operational_area_id=NULL,operator_company_id=NULL,
|
||||
is_inventory_instance=false,
|
||||
information_status=CASE WHEN information_status='INACTIVE' THEN 'VALIDATED' ELSE information_status END,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1::uuid
|
||||
`, [assetId]);
|
||||
}
|
||||
if (!assetId) throw new Error(`F5 could not seed ${input.role} ${input.name}`);
|
||||
await this.linkSource(queryRunner,assetId,input.sourceDocumentId,'F5 · fuente territorial autorizada');
|
||||
return assetId;
|
||||
}
|
||||
|
||||
private async linkSource(
|
||||
queryRunner: QueryRunner,
|
||||
assetId: string,
|
||||
documentId: string,
|
||||
notes: string,
|
||||
): Promise<void> {
|
||||
private async linkSource(queryRunner:QueryRunner,assetId:string,documentId:string,notes:string):Promise<void> {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes)
|
||||
VALUES ($1::uuid,$2::uuid,'SOURCE',$3)
|
||||
ON CONFLICT (asset_id,document_id,relation_type) DO UPDATE SET
|
||||
notes=EXCLUDED.notes,updated_at=CURRENT_TIMESTAMP
|
||||
ON CONFLICT (asset_id,document_id,relation_type) DO UPDATE SET notes=EXCLUDED.notes,updated_at=CURRENT_TIMESTAMP
|
||||
`,[assetId,documentId,notes]);
|
||||
}
|
||||
|
||||
private async typeId(queryRunner: QueryRunner, typeCode: string): Promise<string> {
|
||||
return this.id(
|
||||
queryRunner,
|
||||
`SELECT id FROM asset_types WHERE lower(code)=lower($1) LIMIT 1`,
|
||||
[typeCode],
|
||||
`asset type ${typeCode}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async id(
|
||||
queryRunner: QueryRunner,
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
label: string,
|
||||
): Promise<string> {
|
||||
const value = await this.optionalId(queryRunner,sql,params);
|
||||
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;
|
||||
private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise<string|null> {
|
||||
const result=(await queryRunner.query(sql,params)) as IdRow[];
|
||||
return result[0]?.id ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user