* 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
603 lines
27 KiB
TypeScript
603 lines
27 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
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')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.trim()
|
|
.replace(/\s+/g, ' ');
|
|
}
|
|
|
|
function code(prefix: string, value: string, length = 12): string {
|
|
return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`;
|
|
}
|
|
|
|
function uniqueBy<T>(values: T[], identity: (value: T) => string): T[] {
|
|
const seen = new Set<string>();
|
|
const output: T[] = [];
|
|
for (const value of values) {
|
|
const id = identity(value);
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
output.push(value);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface {
|
|
name = 'F5AuthoritativeTerritory1790087200000';
|
|
|
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
const source = loadF5InventoryAuthoritativeSource();
|
|
if (
|
|
source.areaSource.file !== TERRITORY_SOURCE_NAME
|
|
|| source.areaSource.sheet !== 'cr26e_tabla1'
|
|
|| source.areaSource.sha256 !== '8260fcadebbcd631a4c95260d0a67c3ecb28d497b32decb02a1c0847be5afa78'
|
|
|| source.areaSource.rows.length !== 230
|
|
) {
|
|
throw new Error('F5 territory source contract mismatch');
|
|
}
|
|
|
|
const rows = source.areaSource.rows;
|
|
const areaRows = uniqueBy(rows, (row) => key(row.area));
|
|
const pairRows = uniqueBy(rows, (row) => `${key(row.area)}|${key(row.yacimiento)}`);
|
|
if (areaRows.length !== 64 || pairRows.length !== 230) {
|
|
throw new Error(`F5 territory cardinality mismatch: areas=${areaRows.length}, pairs=${pairRows.length}`);
|
|
}
|
|
|
|
// 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 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)
|
|
RETURNING id
|
|
`, [
|
|
TERRITORY_DOCUMENT_NUMBER,
|
|
TERRITORY_SOURCE_NAME,
|
|
`sha256:${source.areaSource.sha256}`,
|
|
`F5 · fuente territorial autorizada · hoja ${source.areaSource.sheet} · ${rows.length} filas`,
|
|
])) as IdRow[];
|
|
const sourceDocumentId = insertedDocument[0]?.id;
|
|
if (!sourceDocumentId) throw new Error('F5 could not create territory source document');
|
|
|
|
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'))
|
|
.sort((a, b) => a.localeCompare(b, 'es'));
|
|
const companyIds = new Map<string, string>();
|
|
for (const companyName of companyNames) {
|
|
const companyId = await this.ensureRootAsset(queryRunner, {
|
|
typeId: companyTypeId,
|
|
role: 'COMPANY',
|
|
code: code('F5-ORG', key(companyName)),
|
|
name: companyName,
|
|
sourceDocumentId,
|
|
sourceReference: `F5:TERRITORY:COMPANY:${code('SRC', key(companyName), 10)}`,
|
|
});
|
|
companyIds.set(key(companyName), companyId);
|
|
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 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);
|
|
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);
|
|
}
|
|
|
|
const areaIds = new Map<string, string>();
|
|
for (const areaRow of areaRows) {
|
|
const areaId = await this.ensureRootAsset(queryRunner, {
|
|
typeId: areaTypeId,
|
|
role: 'AREA',
|
|
code: code('F5-AREA', key(areaRow.area)),
|
|
name: areaRow.area,
|
|
sourceDocumentId,
|
|
sourceReference: `F5:TERRITORY:AREA:${code('SRC', key(areaRow.area), 10)}`,
|
|
});
|
|
areaIds.set(key(areaRow.area), areaId);
|
|
|
|
const departmentId = departmentIds.get(key(areaRow.departamento));
|
|
if (!departmentId) throw new Error(`F5 missing department ${areaRow.departamento}`);
|
|
await this.backupAndCloseDepartmentRelations(queryRunner,areaId,departmentId);
|
|
await queryRunner.query(`
|
|
INSERT INTO area_department_relations (
|
|
area_id,department_id,valid_from,source_document_id,notes
|
|
)
|
|
SELECT $1::uuid,$2::uuid,CURRENT_DATE,$3::uuid,$4
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM area_department_relations
|
|
WHERE area_id=$1::uuid AND department_id=$2::uuid AND valid_until IS NULL
|
|
)
|
|
`, [
|
|
areaId,
|
|
departmentId,
|
|
sourceDocumentId,
|
|
`F5 · ${TERRITORY_SOURCE_NAME} · ${source.areaSource.sheet}`,
|
|
]);
|
|
|
|
const operatorId = companyIds.get(key(areaRow.empresaOperadora)) ?? null;
|
|
await this.backupAndCloseOperatorRelations(queryRunner,areaId,operatorId);
|
|
if (operatorId) {
|
|
await queryRunner.query(`
|
|
INSERT INTO area_company_relations (
|
|
area_id,company_id,relation_role,source_document_id,valid_from,start_reason
|
|
)
|
|
SELECT $1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM area_company_relations
|
|
WHERE area_id=$1::uuid AND company_id=$2::uuid
|
|
AND relation_role='OPERATOR' AND valid_until IS NULL
|
|
)
|
|
`, [
|
|
areaId,
|
|
operatorId,
|
|
sourceDocumentId,
|
|
`F5 · operadora vigente según ${TERRITORY_SOURCE_NAME}`,
|
|
]);
|
|
}
|
|
|
|
const rightType = areaRow.tipoConcesion === 'Exploración'
|
|
? 'EXPLORATION_PERMIT'
|
|
: areaRow.tipoConcesion === 'Explotación'
|
|
? 'EXPLOITATION_CONCESSION'
|
|
: 'OTHER';
|
|
const rightName = `${areaRow.tipoConcesion} · ${areaRow.area}`;
|
|
await queryRunner.query(`
|
|
INSERT INTO area_legal_rights (
|
|
area_id,right_type,name,status,source_document_id,notes
|
|
)
|
|
SELECT $1::uuid,$2::area_legal_right_type,$3::varchar,'ACTIVE',$4::uuid,$5
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM area_legal_rights
|
|
WHERE area_id=$1::uuid
|
|
AND right_type=$2::area_legal_right_type
|
|
AND lower(btrim(name))=lower(btrim($3::varchar))
|
|
AND status IN ('ACTIVE','PENDING')
|
|
)
|
|
`, [
|
|
areaId,
|
|
rightType,
|
|
rightName,
|
|
sourceDocumentId,
|
|
`F5 · tipo de concesión tomado literalmente de ${TERRITORY_SOURCE_NAME}`,
|
|
]);
|
|
}
|
|
|
|
for (const row of pairRows) {
|
|
const areaId = areaIds.get(key(row.area));
|
|
if (!areaId) throw new Error(`F5 missing area ${row.area}`);
|
|
const sourceReference = `F5:TERRITORY:YAC:${code('SRC', `${key(row.area)}|${key(row.yacimiento)}`, 12)}`;
|
|
let yacimientoId = await this.optionalId(queryRunner, `
|
|
SELECT asset.id
|
|
FROM assets asset
|
|
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.created_at
|
|
LIMIT 1
|
|
`, [areaId, row.yacimiento]);
|
|
if (!yacimientoId) {
|
|
const inserted = (await queryRunner.query(`
|
|
INSERT INTO assets (
|
|
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 (
|
|
$1::uuid,$2::uuid,NULL,NULL,$3,$4,$5,'VALIDATED','UNKNOWN',
|
|
'PROVIDED_DOCUMENT',$6,$7,$8,false
|
|
) RETURNING id
|
|
`, [
|
|
fieldTypeId,
|
|
areaId,
|
|
code('F5-YAC', `${key(row.area)}|${key(row.yacimiento)}`),
|
|
row.yacimiento,
|
|
`Yacimiento del Área ${row.area}`,
|
|
TERRITORY_SOURCE_NAME,
|
|
sourceReference,
|
|
`${source.areaSource.sheet} · fila ${row.sourceRow}`,
|
|
])) as IdRow[];
|
|
yacimientoId = inserted[0]?.id ?? null;
|
|
}
|
|
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}`);
|
|
}
|
|
|
|
const [verification] = (await queryRunner.query(`
|
|
SELECT
|
|
COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA')::integer AS areas,
|
|
COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento')::integer AS yacimientos
|
|
FROM asset_source_documents link
|
|
JOIN assets asset ON asset.id=link.asset_id
|
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
|
WHERE link.document_id=$1::uuid
|
|
`, [sourceDocumentId])) as Array<{ areas: number; yacimientos: number }>;
|
|
if (Number(verification?.areas ?? 0) !== 64 || Number(verification?.yacimientos ?? 0) !== 230) {
|
|
throw new Error(`F5 territory preload verification failed: ${JSON.stringify(verification ?? {})}`);
|
|
}
|
|
}
|
|
|
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
const sourceDocumentId = 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 (!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 queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`);
|
|
}
|
|
|
|
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.'],
|
|
['subinstalacion','Subinstalación','Instancia física subordinada a una Instalación.'],
|
|
] as const) {
|
|
await queryRunner.query(`
|
|
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
|
|
SELECT $1::varchar,$2,$3,false,true,'GENERIC'
|
|
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar))
|
|
`,[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}`);
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
private async installHierarchyGuard(queryRunner: QueryRunner): Promise<void> {
|
|
await queryRunner.query(`
|
|
CREATE OR REPLACE FUNCTION enforce_f5_canonical_asset_hierarchy()
|
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
DECLARE child_code text; parent_code text;
|
|
BEGIN
|
|
SELECT lower(code) INTO child_code FROM asset_types WHERE id=NEW.asset_type_id;
|
|
IF child_code IN ('empresa','organizacion','area') THEN
|
|
IF NEW.parent_id IS NOT NULL THEN
|
|
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa y Área son maestros raíz independientes';
|
|
END IF;
|
|
RETURN NEW;
|
|
END IF;
|
|
IF child_code NOT IN ('yacimiento','instalacion','subinstalacion') THEN RETURN NEW; END IF;
|
|
IF NEW.parent_id IS NULL THEN
|
|
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento, Instalación y Subinstalación requieren padre';
|
|
END IF;
|
|
SELECT lower(type.code) INTO parent_code
|
|
FROM assets parent JOIN asset_types type ON type.id=parent.asset_type_id
|
|
WHERE parent.id=NEW.parent_id;
|
|
IF (child_code='yacimiento' AND parent_code<>'area')
|
|
OR (child_code='instalacion' AND parent_code<>'yacimiento')
|
|
OR (child_code='subinstalacion' AND parent_code<>'instalacion') THEN
|
|
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Jerarquía F5 inválida: Área → Yacimiento → Instalación → Subinstalación';
|
|
END IF;
|
|
RETURN NEW;
|
|
END $$;
|
|
`);
|
|
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
|
|
FOR EACH ROW EXECUTE FUNCTION enforce_f5_canonical_asset_hierarchy()
|
|
`);
|
|
}
|
|
|
|
private async ensureBackupTable(queryRunner: QueryRunner): Promise<void> {
|
|
await queryRunner.query(`
|
|
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 backupAndCloseDepartmentRelations(queryRunner: QueryRunner,areaId:string,departmentId:string):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_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::text))
|
|
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::text)
|
|
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(
|
|
queryRunner: QueryRunner,
|
|
input: {
|
|
typeId: string;
|
|
role: 'AREA' | 'COMPANY';
|
|
code: string;
|
|
name: string;
|
|
sourceDocumentId: string;
|
|
sourceReference: string;
|
|
},
|
|
): Promise<string> {
|
|
let assetId = await this.optionalId(queryRunner, `
|
|
SELECT asset.id
|
|
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.created_at
|
|
LIMIT 1
|
|
`,[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)
|
|
RETURNING id
|
|
`,[
|
|
input.typeId,input.code,input.name,TERRITORY_SOURCE_NAME,input.sourceReference,
|
|
'F5 · fuente territorial autorizada',
|
|
])) as IdRow[];
|
|
assetId=inserted[0]?.id ?? null;
|
|
}
|
|
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> {
|
|
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
|
|
`,[assetId,documentId,notes]);
|
|
}
|
|
|
|
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 result=(await queryRunner.query(sql,params)) as IdRow[];
|
|
return result[0]?.id ?? null;
|
|
}
|
|
}
|