feat(inventory): preload authoritative territory model

This commit is contained in:
2026-09-08 19:54:00 -03:00
parent 91e346d4c4
commit 218f792f13
@@ -0,0 +1,579 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import {
loadF5InventoryAuthoritativeSource,
} from '../../reference-data/f5-authoritative-inventory-source';
type IdRow = { id: string };
const TERRITORY_DOCUMENT_NUMBER = 'DH-F5-TERRITORY';
const TERRITORY_SOURCE_NAME = 'Tablas de yacimiento y areas.xlsx';
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}`);
}
await this.ensureCanonicalTypes(queryRunner);
await this.installHierarchyGuard(queryRunner);
await this.installOperationalContextGuard(queryRunner);
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
`, [
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',
);
const companyTypeId = await this.typeId(queryRunner, 'empresa');
const areaTypeId = await this.typeId(queryRunner, 'area');
const fieldTypeId = await this.typeId(queryRunner, 'yacimiento');
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 UPDATE SET
legal_name=EXCLUDED.legal_name,
updated_at=CURRENT_TIMESTAMP
`, [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}`,
);
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 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 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 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]);
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,'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
)
`, [
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 lower(btrim(asset.name))=lower(btrim($2))
ORDER BY (asset.information_status<>'INACTIVE') DESC,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;
} 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}`);
}
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(`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]);
}
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);
}
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')
`);
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,$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]);
}
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]);
}
}
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 installOperationalContextGuard(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 $$;
`);
}
private async restoreLegacyOperationalContextGuard(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; 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 $$;
`);
}
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 lower(btrim(asset.name))=lower(btrim($2))
ORDER BY (asset.information_status<>'INACTIVE') DESC,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;
} 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> {
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 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);
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;
}
}