F5 · Inventario operativo, territorio y catálogo autorizado (#25)

* 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
This commit is contained in:
2026-09-08 23:18:15 -03:00
committed by GitHub
parent 1dc3282055
commit 35d4630581
49 changed files with 3988 additions and 669 deletions
@@ -29,8 +29,8 @@ import { InventoryFamilyCatalogService } from './inventory-family-catalog.servic
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryFunctionController } from './inventory-function.controller';
import { InventoryFunctionService } from './inventory-function.service';
import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryBrowserService } from './inventory-browser.service';
@Module({
imports: [AuditModule],
@@ -39,7 +39,7 @@ import { InventoryFunctionService } from './inventory-function.service';
AssetsController,
InventoryStructureController,
InventoryFamilyCatalogController,
InventoryFunctionController,
InventoryBrowserController,
InventoryMergeController,
FieldInventoryMergeController,
AssetGeometriesController,
@@ -56,7 +56,7 @@ import { InventoryFunctionService } from './inventory-function.service';
AssetsService,
InventoryStructureService,
InventoryFamilyCatalogService,
InventoryFunctionService,
InventoryBrowserService,
InventoryMergeService,
MergedInventoryDossierService,
AssetGeometriesService,
@@ -71,7 +71,6 @@ import { InventoryFunctionService } from './inventory-function.service';
exports: [
AssetHistoryService,
AssetsService,
InventoryFunctionService,
InventoryMergeService,
MergedInventoryDossierService,
AssetGeometriesService,
@@ -79,23 +79,15 @@ export class AssetOperationalRelationsService {
async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> {
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
const data = (await this.dataSource.query(`
SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName"
FROM (
SELECT relation.company_id
FROM area_company_relations relation
WHERE relation.area_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
UNION
SELECT asset.operator_company_id AS company_id
FROM assets asset
WHERE asset.operational_area_id = $1
AND asset.operator_company_id IS NOT NULL
AND asset.information_status <> 'INACTIVE'
) linked
INNER JOIN assets company ON company.id = linked.company_id
SELECT DISTINCT company.id, company.code, company.name,
company.common_name AS "commonName", company_type.name AS "typeName"
FROM area_company_relations relation
INNER JOIN assets company ON company.id = relation.company_id
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
WHERE company.information_status <> 'INACTIVE'
WHERE relation.area_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
AND company.information_status <> 'INACTIVE'
ORDER BY company.name, company.code
`, [areaId])) as OperationalAssetSummary[];
return { data };
@@ -104,23 +96,15 @@ export class AssetOperationalRelationsService {
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
const data = (await this.dataSource.query(`
SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName"
FROM (
SELECT relation.area_id
FROM area_company_relations relation
WHERE relation.company_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
UNION
SELECT asset.operational_area_id AS area_id
FROM assets asset
WHERE asset.operator_company_id = $1
AND asset.operational_area_id IS NOT NULL
AND asset.information_status <> 'INACTIVE'
) linked
INNER JOIN assets area ON area.id = linked.area_id
SELECT DISTINCT area.id, area.code, area.name,
area.common_name AS "commonName", area_type.name AS "typeName"
FROM area_company_relations relation
INNER JOIN assets area ON area.id = relation.area_id
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
WHERE area.information_status <> 'INACTIVE'
WHERE relation.company_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
AND area.information_status <> 'INACTIVE'
ORDER BY area.name, area.code
`, [companyId])) as OperationalAssetSummary[];
return { data };
@@ -157,12 +141,49 @@ export class AssetOperationalRelationsService {
const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]);
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
}
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
const [currentOperator] = (await manager.query(`
SELECT relation.id,company.name AS "companyName"
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=$1
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND relation.company_id<>$2
ORDER BY relation.valid_from DESC
LIMIT 1
FOR UPDATE OF relation
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
if (currentOperator) {
throw new ConflictException({
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
});
}
}
const [row] = (await manager.query(`
INSERT INTO area_company_relations (
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
// Compatibility snapshot only. This does not move or re-parent Inventory.
// area_company_relations remains the temporal source of truth.
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
await manager.query(`
UPDATE assets asset
SET operator_company_id=$2::uuid,updated_at=CURRENT_TIMESTAMP,updated_by=$3::uuid
FROM asset_types type
WHERE type.id=asset.asset_type_id
AND type.operational_role='GENERIC'
AND asset.operational_area_id=$1::uuid
AND asset.operator_company_id IS DISTINCT FROM $2::uuid
`,[dto.areaId,dto.companyId,principal.userId]);
}
const created = await this.loadRelation(manager, row.id);
await this.audit.record({
...administrationAuditContext(principal, request),
@@ -170,6 +191,9 @@ export class AssetOperationalRelationsService {
entityType: 'area_company_relation',
entityId: row.id,
afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true }
: undefined,
}, manager);
return created;
});
@@ -198,12 +222,9 @@ export class AssetOperationalRelationsService {
message: 'La relación ya se encuentra finalizada',
});
}
if (before.assignedAssetCount > 0) {
throw new ConflictException({
code: 'AREA_COMPANY_RELATION_IN_USE',
message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`,
});
}
// F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
// Ending an operator relation must never be blocked by existing Inventory.
await manager.query(`
UPDATE area_company_relations
SET valid_until = CURRENT_TIMESTAMP,
@@ -220,6 +241,10 @@ export class AssetOperationalRelationsService {
entityId: id,
beforeData: this.auditView(before),
afterData: this.auditView(updated),
metadata: {
inventoryHierarchyChanged:false,
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
},
}, manager);
return updated;
});
@@ -330,7 +355,8 @@ export class AssetOperationalRelationsService {
(relation.valid_until IS NULL) AS active,
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
WHERE asset.operational_area_id = relation.area_id
AND asset.operator_company_id = relation.company_id) ELSE 0 END AS "assignedAssetCount"
AND asset.operator_company_id = relation.company_id
AND asset.is_inventory_instance=true) ELSE 0 END AS "assignedAssetCount"
FROM area_company_relations relation
INNER JOIN assets area ON area.id = relation.area_id
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
@@ -10,6 +10,7 @@ import {
} from 'class-validator';
export const INVENTORY_STRUCTURE_KINDS = [
'EMPRESA',
'AREA',
'YACIMIENTO',
'INSTALACION',
@@ -0,0 +1,63 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class InventoryBrowserQueryDto {
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsUUID('4')
typeId?: string;
@IsOptional()
@IsEnum(AssetInformationStatus)
status?: AssetInformationStatus;
@IsOptional()
@IsEnum(AssetOperationalStatus)
operationalStatus?: AssetOperationalStatus;
@IsOptional()
@IsUUID('4')
operationalAreaId?: string;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
needsValidation?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
hasGeometry?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
}
@@ -0,0 +1,71 @@
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateInventoryFamilyDto {
@IsIn(['INSTALLATION', 'SUBINSTALLATION'])
level!: 'INSTALLATION' | 'SUBINSTALLATION';
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(240)
name!: string;
@IsOptional()
@IsUUID('4')
parentFamilyId?: string | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@IsString({ each: true })
@MaxLength(160, { each: true })
informationLabels?: string[];
}
export class UpdateInventoryFamilyDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(240)
name?: string;
@IsOptional()
@IsUUID('4')
parentFamilyId?: string | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@IsString({ each: true })
@MaxLength(160, { each: true })
informationLabels?: string[];
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class ReplaceInventoryFamilyFindingsDto {
@IsArray()
@ArrayMaxSize(2000)
@IsUUID('4', { each: true })
itemIds!: string[];
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(5)
@MaxLength(2000)
reason!: string;
}
@@ -37,4 +37,9 @@ export class ListAssetTreeQueryDto {
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
hasGeometry?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
inventoryOnly?: boolean;
}
@@ -39,7 +39,6 @@ export class ListAssetsQueryDto {
@IsEnum(AssetInformationStatus)
status?: AssetInformationStatus;
@IsOptional()
@IsEnum(AssetOperationalStatus)
operationalStatus?: AssetOperationalStatus;
@@ -54,6 +53,11 @@ export class ListAssetsQueryDto {
@IsBoolean()
hasGeometry?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
inventoryOnly?: boolean;
@IsOptional()
@IsUUID('4')
parentId?: string;
@@ -0,0 +1,28 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
import { InventoryBrowserService } from './inventory-browser.service';
@Controller('inventory-browser')
@RequirePermissions('assets.read')
export class InventoryBrowserController {
constructor(private readonly inventoryBrowser: InventoryBrowserService) {}
@Get('items')
items(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.items(query);
}
@Get('areas')
areas(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.areas(query);
}
@Get(':parentId/children')
children(
@Param('parentId', new ParseUUIDPipe({ version: '4' })) parentId: string,
@Query() query: InventoryBrowserQueryDto,
) {
return this.inventoryBrowser.children(parentId, query);
}
}
@@ -0,0 +1,283 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
type ParentContext = {
id: string;
code: string;
name: string;
typeCode: string;
};
@Injectable()
export class InventoryBrowserService {
constructor(private readonly dataSource: DataSource) {}
async items(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
'asset.is_inventory_instance=true',
"asset.information_status<>'INACTIVE'",
'type.is_active=true',
];
const add = (value: unknown): string => {
params.push(value);
return `$${params.length}`;
};
if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`);
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
}
if (query.typeId) conditions.push(`asset.asset_type_id=${add(query.typeId)}::uuid`);
if (query.status) conditions.push(`asset.information_status=${add(query.status)}::asset_information_status`);
if (query.operationalStatus) conditions.push(`asset.operational_status=${add(query.operationalStatus)}::asset_operational_status`);
if (query.needsValidation === true) conditions.push("asset.information_status NOT IN ('VALIDATED','INACTIVE')");
if (query.needsValidation === false) conditions.push("asset.information_status='VALIDATED'");
if (query.hasGeometry === true) conditions.push('EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
if (query.hasGeometry === false) conditions.push('NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
if (query.operationalAreaId) conditions.push(`asset.operational_area_id=${add(query.operationalAreaId)}::uuid`);
if (query.operatorCompanyId) {
const company = add(query.operatorCompanyId);
conditions.push(`EXISTS (
SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=asset.operational_area_id
AND relation.company_id=${company}::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
)`);
}
const where = conditions.join(' AND ');
const [countRow] = (await this.dataSource.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE ${where}
`,params)) as Array<{total:number}>;
const total=Number(countRow?.total ?? 0);
const offset=(query.page-1)*query.pageSize;
params.push(query.pageSize);
const limit=`$${params.length}`;
params.push(offset);
const offsetParam=`$${params.length}`;
const data=await this.dataSource.query(`
SELECT
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',parent.id,'code',parent.code,'name',parent.name
) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',area.id,'code',area.code,'name',area.name
) END AS "operationalArea",
(
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=asset.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC
LIMIT 1
) AS "operatorCompany",
asset.information_status AS "informationStatus",
asset.operational_status AS "operationalStatus",
0::integer AS "childrenCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType",
(SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount",
asset.data_origin AS "dataOrigin",
(asset.provenance_verified_at IS NOT NULL) AS "provenanceVerified",
asset.current_version AS "currentVersion",
asset.updated_at AS "updatedAt"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN assets area ON area.id=asset.operational_area_id
LEFT JOIN LATERAL (
SELECT ST_GeometryType(geometry.geometry)::text AS type
FROM asset_geometries geometry
WHERE geometry.asset_id=asset.id
ORDER BY geometry.updated_at DESC
LIMIT 1
) geometry_type ON true
WHERE ${where}
ORDER BY asset.name,asset.code
LIMIT ${limit} OFFSET ${offsetParam}
`,params);
return {
data,
meta:{
page:query.page,
pageSize:query.pageSize,
total,
totalPages:total===0 ? 0 : Math.ceil(total/query.pageSize),
},
};
}
async areas(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"type.operational_role='AREA'",
"area.information_status<>'INACTIVE'",
'type.is_active=true',
];
const add = (value: unknown): string => {
params.push(value);
return `$${params.length}`;
};
if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`);
conditions.push(`(area.code ILIKE ${p} OR area.name ILIKE ${p} OR COALESCE(area.common_name,'') ILIKE ${p})`);
}
if (query.operationalAreaId) conditions.push(`area.id=${add(query.operationalAreaId)}::uuid`);
if (query.operatorCompanyId) {
const p = add(query.operatorCompanyId);
conditions.push(`EXISTS (
SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=area.id
AND relation.company_id=${p}::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
)`);
}
const data = await this.dataSource.query(`
SELECT
area.id,area.code,area.name,area.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
area.information_status AS "informationStatus",
area.operational_status AS "operationalStatus",
(
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=area.id
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC
LIMIT 1
) AS "currentOperator",
(
SELECT COUNT(*)::integer
FROM assets yacimiento
JOIN asset_types ytype ON ytype.id=yacimiento.asset_type_id
WHERE yacimiento.parent_id=area.id
AND lower(ytype.code)='yacimiento'
AND yacimiento.information_status<>'INACTIVE'
) AS "yacimientoCount",
(
SELECT COUNT(*)::integer
FROM assets inventory
WHERE inventory.is_inventory_instance=true
AND inventory.information_status<>'INACTIVE'
AND inventory.operational_area_id=area.id
) AS "inventoryCount"
FROM assets area
JOIN asset_types type ON type.id=area.asset_type_id
WHERE ${conditions.join(' AND ')}
ORDER BY area.name,area.code
`, params);
return { data, meta: { count: data.length } };
}
async children(parentId: string, query: InventoryBrowserQueryDto) {
const parent = await this.parent(parentId);
const allowedChildType = this.allowedChildType(parent.typeCode);
if (!allowedChildType) return { parent, data: [], meta: { count: 0, hasMore: false } };
const params: unknown[] = [parentId, allowedChildType];
const conditions = [
'asset.parent_id=$1::uuid',
'lower(type.code)=lower($2)',
"asset.information_status<>'INACTIVE'",
'type.is_active=true',
];
if (allowedChildType === 'instalacion' || allowedChildType === 'subinstalacion') {
conditions.push('asset.is_inventory_instance=true');
}
if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`);
const p = `$${params.length}`;
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
}
params.push(201);
const limit = `$${params.length}`;
const rows = await this.dataSource.query(`
SELECT
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent_asset.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',parent_asset.id,'code',parent_asset.code,'name',parent_asset.name
) END AS parent,
asset.information_status AS "informationStatus",
asset.operational_status AS "operationalStatus",
asset.is_inventory_instance AS "isInventoryInstance",
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level
) END AS "inventoryFamily",
(
SELECT COUNT(*)::integer
FROM assets child
WHERE child.parent_id=asset.id
AND child.information_status<>'INACTIVE'
AND (
lower(type.code)='area'
OR child.is_inventory_instance=true
OR EXISTS (
SELECT 1 FROM asset_types child_type
WHERE child_type.id=child.asset_type_id AND lower(child_type.code)='yacimiento'
)
)
) AS "childrenCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
asset.updated_at AS "updatedAt"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent_asset ON parent_asset.id=asset.parent_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE ${conditions.join(' AND ')}
ORDER BY asset.name,asset.code
LIMIT ${limit}
`, params);
const hasMore = rows.length > 200;
const data = hasMore ? rows.slice(0,200) : rows;
return { parent, data, meta: { count: data.length, hasMore } };
}
private async parent(parentId: string): Promise<ParentContext> {
const [parent] = await this.dataSource.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
`,[parentId]) as ParentContext[];
if (!parent) {
throw new NotFoundException({ code:'INVENTORY_BROWSER_PARENT_NOT_FOUND',message:'El nivel de Inventario no existe' });
}
if (!['area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) {
throw new BadRequestException({
code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID',
message:'La navegación de Inventarios admite Área → Yacimiento → Instalación → Subinstalación',
});
}
return parent;
}
private allowedChildType(typeCode: string): string | null {
switch (typeCode.toLowerCase()) {
case 'area': return 'yacimiento';
case 'yacimiento': return 'instalacion';
case 'instalacion': return 'subinstalacion';
default: return null;
}
}
}
@@ -1,11 +1,66 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Req,
} from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
CreateInventoryFamilyDto,
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
@Controller('inventory-families')
export class InventoryFamilyCatalogController {
constructor(private readonly families: InventoryFamilyCatalogService) {}
@Get('admin')
@RequirePermissions('asset_types.read')
admin() {
return this.families.listAdmin();
}
@Post()
@RequirePermissions('asset_types.manage')
create(
@Body() dto: CreateInventoryFamilyDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.families.create(dto,principal,request);
}
@Patch(':familyId')
@RequirePermissions('asset_types.manage')
update(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:UpdateInventoryFamilyDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.update(familyId,dto,principal,request);
}
@Put(':familyId/findings')
@RequirePermissions('finding_catalog.manage')
replaceFindings(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:ReplaceInventoryFamilyFindingsDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.replaceFindings(familyId,dto,principal,request);
}
@Get(':familyId/findings')
@RequirePermissions('assets.read')
findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) {
@@ -1,28 +1,71 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import type {
CreateInventoryFamilyDto,
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto';
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
informationLabels: string[];
sourceReference: string | null;
isActive: boolean;
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
assetCount: number;
findingCount: number;
findingItemIds: string[];
};
@Injectable()
export class InventoryFamilyCatalogService {
constructor(private readonly dataSource: DataSource) {}
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async listAdmin() {
const data = await this.dataSource.query(`
SELECT
family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",
family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((
SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY item.title,item.id)
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
WHERE mapping.inventory_family_id=family.id
),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
ORDER BY family.level,family.is_active DESC,
COALESCE(parent.name,''),family.name,family.code
`) as FamilyRow[];
return { data };
}
async findings(familyId: string) {
const [family] = await this.dataSource.query(`
SELECT id,code,name,level,information_labels AS "informationLabels"
FROM inventory_families
WHERE id=$1::uuid AND is_active=true
`, [familyId]) as Array<{
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
}>;
if (!family) {
throw new NotFoundException({
code: 'INVENTORY_FAMILY_NOT_FOUND',
message: 'La familia técnica no existe',
});
}
const family = await this.family(familyId, false);
const items = await this.dataSource.query(`
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
@@ -36,4 +79,221 @@ export class InventoryFamilyCatalogService {
`, [familyId]);
return { family, items, count: items.length };
}
async create(
dto: CreateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const parentId = await this.validateParent(manager,dto.level,dto.parentFamilyId ?? null,null);
const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`;
const [inserted] = (await manager.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F5',true)
RETURNING id
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario');
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[inserted.id,parentId]);
}
const created = await this.family(inserted.id,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType: 'inventory_family',
entityId: inserted.id,
afterData: created as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F5' },
},manager);
return created;
});
}
async update(
familyId: string,
dto: UpdateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const before = await this.family(familyId,false,manager,true);
const nextParentId = dto.parentFamilyId === undefined
? before.parentFamilyId
: dto.parentFamilyId;
const parentId = await this.validateParent(manager,before.level,nextParentId ?? null,familyId);
await manager.query(`
UPDATE inventory_families SET
name=COALESCE($2::varchar,name),
information_labels=COALESCE($3::jsonb,information_labels),
is_active=COALESCE($4::boolean,is_active),
updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`,[
familyId,
dto.name ?? null,
dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)),
dto.isActive ?? null,
]);
if (before.level==='SUBINSTALLATION') {
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[familyId,parentId]);
}
}
const after = await this.family(familyId,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType: 'inventory_family',
entityId: familyId,
beforeData: before as unknown as Record<string,unknown>,
afterData: after as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_UPDATED' },
},manager);
return after;
});
}
async replaceFindings(
familyId: string,
dto: ReplaceInventoryFamilyFindingsDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const family = await this.family(familyId,false,manager,true);
const uniqueIds=[...new Set(dto.itemIds)];
if (uniqueIds.length) {
const [count] = (await manager.query(`
SELECT COUNT(*)::integer AS total
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=ANY($1::uuid[]) AND item.is_active=true AND category.is_active=true
`,[uniqueIds])) as Array<{total:number}>;
if (Number(count?.total ?? 0)!==uniqueIds.length) {
throw new BadRequestException({
code:'INVENTORY_FAMILY_FINDING_INVALID',
message:'Uno o más Hallazgos elegidos no están activos en el catálogo',
});
}
}
const beforeIds=family.findingItemIds;
await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]);
if (uniqueIds.length) {
await manager.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
SELECT item_id,$2::uuid FROM UNNEST($1::uuid[]) AS selected(item_id)
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
`,[uniqueIds,familyId]);
}
const after=await this.family(familyId,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_findings',
entityId:familyId,
beforeData:{ itemIds:beforeIds },
afterData:{ itemIds:after.findingItemIds },
metadata:{ operation:'INVENTORY_FAMILY_FINDINGS_REPLACED', reason:dto.reason },
},manager);
return this.findingsWithManager(manager,familyId);
});
}
private async findingsWithManager(manager:EntityManager,familyId:string) {
const family=await this.family(familyId,false,manager);
const items=await manager.query(`
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
category.id AS "categoryId",category.code AS "categoryCode",category.name AS "categoryName"
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
JOIN finding_categories category ON category.id=item.category_id
WHERE mapping.inventory_family_id=$1::uuid
AND item.is_active=true AND category.is_active=true
ORDER BY category.sort_order,item.source_number,item.title
`,[familyId]);
return {family,items,count:items.length};
}
private async family(
familyId:string,
activeOnly:boolean,
manager:EntityManager=this.dataSource.manager,
lock=false,
):Promise<FamilyRow> {
const rows=(await manager.query(`
SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id)
FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''}
${lock ? 'FOR UPDATE OF family' : ''}
`,[familyId])) as FamilyRow[];
if (!rows[0]) throw new NotFoundException({
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
});
return rows[0];
}
private async validateParent(
manager:EntityManager,
level:'INSTALLATION'|'SUBINSTALLATION',
parentFamilyId:string|null,
ownId:string|null,
):Promise<string|null> {
if (level==='INSTALLATION') {
if (parentFamilyId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
message:'Una clasificación de Instalación no tiene clasificación padre',
});
return null;
}
if (!parentFamilyId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_REQUIRED',
message:'Una Subinstalación debe pertenecer a un tipo de Instalación',
});
if (parentFamilyId===ownId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre',
});
const rows=(await manager.query(`
SELECT id FROM inventory_families
WHERE id=$1::uuid AND level='INSTALLATION' AND is_active=true
LIMIT 1
`,[parentFamilyId])) as Array<{id:string}>;
if (!rows[0]) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_INVALID',
message:'La Subinstalación debe vincularse a una clasificación de Instalación activa',
});
return parentFamilyId;
}
private cleanLabels(labels:string[]):string[] {
const unique=new Map<string,string>();
for (const raw of labels) {
const clean=raw.trim();
if (!clean) continue;
const identity=clean.toLocaleLowerCase('es-AR');
if (!unique.has(identity)) unique.set(identity,clean);
}
if (unique.size>100) throw new ConflictException({
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información',
});
return [...unique.values()];
}
}
@@ -39,6 +39,22 @@ type MergeRow = {
requestId: string | null;
};
type DocumentInvariantRow = {
actId: string;
actStatus: string;
lockedSha256: string | null;
closureSha256: string | null;
sealedAt: Date | null;
actVersion: number;
reportId: string | null;
reportStatus: string | null;
reportActClosureSha256: string | null;
reportFrozenSha256: string | null;
gedoPdfSha256: string | null;
wordSha256: string | null;
reportRevision: number | null;
};
const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']);
@Injectable()
@@ -161,6 +177,19 @@ export class InventoryMergeService {
if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
this.validatePair(source, canonical);
// Empresa is temporal inspection context, not physical ownership. A merge is
// valid when both records resolve to the same Area in the physical hierarchy,
// even if their historical operator snapshots differ.
const [sourceAreaId, canonicalAreaId] = await Promise.all([
this.resolvePhysicalAreaId(manager, source.id),
this.resolvePhysicalAreaId(manager, canonical.id),
]);
if (!sourceAreaId || sourceAreaId !== canonicalAreaId) throw new BadRequestException({
code: 'INVENTORY_MERGE_AREA_MISMATCH',
message: 'Los duplicados deben pertenecer a la misma Área física',
});
const sourceParentCanonical = source.parentId
? await this.resolveCanonicalId(manager, source.parentId)
: null;
@@ -197,6 +226,8 @@ export class InventoryMergeService {
});
}
const affectedAssetIds = [source.id, canonical.id];
const documentInvariantsBefore = await this.documentInvariants(manager, affectedAssetIds);
const [sourceSnapshot, canonicalSnapshot] = await Promise.all([
this.snapshot(manager, source.id),
this.snapshot(manager, canonical.id),
@@ -279,6 +310,17 @@ export class InventoryMergeService {
request,
);
// No historical Acta/Finding/Informe foreign key is rewritten by a merge.
// Verify that legal/documentary fingerprints are byte-for-byte unchanged
// before committing the transaction; otherwise rollback the entire merge.
const documentInvariantsAfter = await this.documentInvariants(manager, affectedAssetIds);
if (JSON.stringify(documentInvariantsBefore) !== JSON.stringify(documentInvariantsAfter)) {
throw new ConflictException({
code: 'INVENTORY_MERGE_DOCUMENT_INVARIANT_BROKEN',
message: 'La fusión intentó alterar la huella documental histórica y fue revertida',
});
}
const result = {
merge: mergeRecord,
source: { id: source.id, code: source.code, name: source.name },
@@ -286,6 +328,7 @@ export class InventoryMergeService {
sourceVersionNumber,
reparentedChildIds,
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED',
documentaryInvariantsVerified: true,
};
await this.audit.record({
...administrationAuditContext(principal, request),
@@ -298,10 +341,12 @@ export class InventoryMergeService {
operation: 'CHRONOLOGICAL_MERGE',
sourceAssetId: source.id,
canonicalAssetId: canonical.id,
physicalAreaId: sourceAreaId,
reason: dto.reason,
sourceVersionNumber,
reparentedChildIds,
historicalReferencesRewritten: false,
documentaryInvariantsVerified: true,
},
}, manager);
return result;
@@ -322,14 +367,59 @@ export class InventoryMergeService {
code: 'INVENTORY_MERGE_CANONICAL_INACTIVE',
message: 'El registro canónico no puede estar inactivo',
});
if (!source.operationalAreaId || !source.operatorCompanyId
|| source.operationalAreaId !== canonical.operationalAreaId
|| source.operatorCompanyId !== canonical.operatorCompanyId) {
throw new BadRequestException({
code: 'INVENTORY_MERGE_CONTEXT_MISMATCH',
message: 'Los registros deben pertenecer a la misma Área y Operadora',
});
}
}
private async resolvePhysicalAreaId(manager: EntityManager, assetId: string): Promise<string | null> {
const rows = (await manager.query(`
WITH RECURSIVE lineage AS (
SELECT asset.id,asset.parent_id,asset.asset_type_id,0 AS depth
FROM assets asset WHERE asset.id=$1::uuid
UNION ALL
SELECT parent.id,parent.parent_id,parent.asset_type_id,lineage.depth+1
FROM assets parent
JOIN lineage ON lineage.parent_id=parent.id
WHERE lineage.depth<32
)
SELECT lineage.id
FROM lineage
JOIN asset_types type ON type.id=lineage.asset_type_id
WHERE type.operational_role='AREA'
ORDER BY lineage.depth
LIMIT 1
`, [assetId])) as Array<{ id: string }>;
return rows[0]?.id ?? null;
}
private async documentInvariants(manager: EntityManager, assetIds: string[]): Promise<DocumentInvariantRow[]> {
return (await manager.query(`
WITH affected_acts AS (
SELECT DISTINCT act.id
FROM inspection_acts act
LEFT JOIN inspection_act_assets act_asset
ON act_asset.act_id=act.id AND act_asset.included=true
LEFT JOIN inspection_findings finding ON finding.act_id=act.id
WHERE act_asset.asset_id=ANY($1::uuid[])
OR finding.asset_id=ANY($1::uuid[])
)
SELECT
act.id AS "actId",
act.status AS "actStatus",
act.locked_sha256 AS "lockedSha256",
act.closure_sha256 AS "closureSha256",
act.sealed_at AS "sealedAt",
act.current_version AS "actVersion",
report.id AS "reportId",
report.status AS "reportStatus",
report.act_closure_sha256 AS "reportActClosureSha256",
report.frozen_sha256 AS "reportFrozenSha256",
report.gedo_pdf_sha256 AS "gedoPdfSha256",
report.word_sha256 AS "wordSha256",
report.current_revision_number AS "reportRevision"
FROM affected_acts affected
JOIN inspection_acts act ON act.id=affected.id
LEFT JOIN inspection_reports report ON report.act_id=act.id
ORDER BY act.id,report.id NULLS FIRST
`, [assetIds])) as DocumentInvariantRow[];
}
private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> {
@@ -36,18 +36,17 @@ type ParentRow = {
code: string;
name: string;
typeCode: string;
operationalAreaId: string | null;
operatorCompanyId: string | null;
inventoryFamilyId: string | null;
};
const TYPE_CODE_BY_KIND: Record<InventoryStructureKind, string> = {
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
AREA: 'area',
YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion',
SUBINSTALACION: 'subinstalacion',
};
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
EMPRESA: null,
AREA: null,
YACIMIENTO: 'area',
INSTALACION: 'yacimiento',
@@ -70,18 +69,29 @@ export class InventoryStructureService {
const types = (await this.dataSource.query(`
SELECT id,code,name
FROM asset_types
WHERE lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
AND is_active=true
ORDER BY CASE lower(code)
WHEN 'area' THEN 1 WHEN 'yacimiento' THEN 2
WHEN 'instalacion' THEN 3 WHEN 'subinstalacion' THEN 4 ELSE 9 END
WHERE (
lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
OR operational_role='COMPANY'
) AND is_active=true
ORDER BY CASE
WHEN operational_role='COMPANY' THEN 0
WHEN lower(code)='area' THEN 1
WHEN lower(code)='yacimiento' THEN 2
WHEN lower(code)='instalacion' THEN 3
WHEN lower(code)='subinstalacion' THEN 4 ELSE 9 END
`)) as StructureTypeRow[];
if (types.length !== 4) {
const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase()));
const area = types.find((item) => item.code.toLowerCase()==='area');
const yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento');
const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion');
const subinstalacion = types.find((item) => item.code.toLowerCase()==='subinstalacion');
if (!company || !area || !yacimiento || !instalacion || !subinstalacion) {
throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
message: 'La estructura del Inventario todavía no está completamente configurada',
message: 'La configuración maestra de Empresa e Inventario todavía no está completa',
});
}
const families = (await this.dataSource.query(`
SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode",
@@ -93,12 +103,16 @@ export class InventoryStructureService {
WHERE family.is_active=true
ORDER BY family.level,family.name,family.code
`)) as FamilyRow[];
return {
independentMasters: [
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
],
levels: [
{ kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false },
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false },
{ kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true },
{ kind: 'AREA', label: 'Área', type: area, parentKind: null, requiresFamily: false },
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false },
{ kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
],
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
@@ -107,7 +121,7 @@ export class InventoryStructureService {
async parents(kindValue: string, search?: string) {
const kind = kindValue.toUpperCase() as InventoryStructureKind;
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA') {
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA' || kind === 'EMPRESA') {
throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
message: 'El nivel indicado no requiere un registro padre',
@@ -154,9 +168,8 @@ export class InventoryStructureService {
const type = await this.requireStructureType(manager, dto.kind);
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
const code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operationalAreaId = parent?.operationalAreaId ?? null;
const operatorCompanyId = parent?.operatorCompanyId ?? null;
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operationalAreaId = parent ? await this.resolveAreaId(manager, parent) : null;
const inserted = (await manager.query(`
INSERT INTO assets (
@@ -164,30 +177,37 @@ export class InventoryStructureService {
code,name,common_name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
) VALUES (
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,
$6::varchar,$7::varchar,$8::varchar,$9::text,$10::asset_information_status,$11::asset_operational_status,
$12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid
$1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid,
$5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status,
$11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid
) RETURNING id
`, [
type.id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
family?.id ?? null,
code,
generatedCode,
dto.name,
dto.commonName ?? null,
dto.description ?? null,
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
'Inventario estructural F3.1',
`inventory-structure:${dto.kind.toLowerCase()}`,
dto.kind === 'EMPRESA' ? 'Maestro de Empresas F5' : 'Estructura de Inventario F5',
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family ? `Familia técnica: ${family.code} · ${family.name}` : null,
principal.userId,
])) as Array<{ id: string }>;
const id = inserted[0]?.id;
if (!id) throw new Error('No se pudo crear el registro estructural');
if (!id) throw new Error('No se pudo crear el registro');
if (dto.kind === 'EMPRESA') {
await manager.query(`
INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by)
VALUES ($1::uuid,'COMPANY',$2,$3::uuid)
ON CONFLICT (asset_id) DO UPDATE SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
`,[id,dto.name,principal.userId]);
}
const versionNumber = await this.history.capture(
manager,
@@ -200,13 +220,12 @@ export class InventoryStructureService {
INSERT INTO asset_context_history (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
`, [
id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
'Alta guiada de Inventario estructural F3.1',
dto.kind === 'EMPRESA' ? 'Alta guiada de Empresa independiente F5' : 'Alta guiada de estructura de Inventario F5',
versionNumber,
request.requestId,
principal.userId,
@@ -223,6 +242,7 @@ export class InventoryStructureService {
inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null,
operatorOwnership: false,
},
}, manager);
return created;
@@ -231,7 +251,7 @@ export class InventoryStructureService {
if (isUniqueViolation(error)) {
throw new ConflictException({
code: 'ASSET_CODE_ALREADY_EXISTS',
message: 'Ya existe un registro de Inventario con ese código',
message: 'Ya existe un registro con ese código',
});
}
throw error;
@@ -239,10 +259,11 @@ export class InventoryStructureService {
}
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
const rows = (await manager.query(`
SELECT id,code,name FROM asset_types
WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1
`, [TYPE_CODE_BY_KIND[kind]])) as StructureTypeRow[];
const sql = kind === 'EMPRESA'
? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind,'EMPRESA'>]];
const rows = (await manager.query(sql,params)) as StructureTypeRow[];
if (!rows[0]) {
throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
@@ -261,8 +282,10 @@ export class InventoryStructureService {
if (!expectedType) {
if (parentId) {
throw new BadRequestException({
code: 'INVENTORY_AREA_MUST_BE_ROOT',
message: 'Un Área se crea como registro raíz y no puede tener padre',
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
message: kind === 'EMPRESA'
? 'Una Empresa es un maestro independiente y no puede tener padre'
: 'Un Área es un registro raíz y no puede tener padre',
});
}
return null;
@@ -275,8 +298,6 @@ export class InventoryStructureService {
}
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.operational_area_id AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId",
asset.inventory_family_id AS "inventoryFamilyId"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
@@ -294,6 +315,30 @@ export class InventoryStructureService {
return parent;
}
private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise<string> {
if (parent.typeCode.toLowerCase()==='area') return parent.id;
const rows = (await manager.query(`
WITH RECURSIVE lineage AS (
SELECT asset.id,asset.parent_id,asset.asset_type_id FROM assets asset WHERE asset.id=$1::uuid
UNION ALL
SELECT parent.id,parent.parent_id,parent.asset_type_id
FROM assets parent JOIN lineage child ON child.parent_id=parent.id
)
SELECT lineage.id
FROM lineage JOIN asset_types type ON type.id=lineage.asset_type_id
WHERE type.operational_role='AREA'
LIMIT 1
`,[parent.id])) as IdRow[];
const areaId=rows[0]?.id;
if (!areaId) {
throw new ConflictException({
code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING',
message:'La ubicación seleccionada no pertenece a un Área válida',
});
}
return areaId;
}
private async requireFamily(
manager: EntityManager,
kind: InventoryStructureKind,
@@ -304,7 +349,7 @@ export class InventoryStructureService {
if (!expectedLevel) {
if (familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
message: 'Área y Yacimiento no llevan familia técnica',
message: 'Empresa, Área y Yacimiento no llevan familia técnica',
});
return null;
}
@@ -339,7 +384,7 @@ export class InventoryStructureService {
}
private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
const prefix = kind === 'EMPRESA' ? 'EMP' : kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
const readable = name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
@@ -356,6 +401,7 @@ export class InventoryStructureService {
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
'informationLabels',family.information_labels
@@ -364,9 +410,12 @@ export class InventoryStructureService {
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN assets area ON area.id=asset.operational_area_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [id]);
return rows[0];
}
}
type IdRow = { id: string };
+46 -10
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
export interface DashboardAuditItem {
export interface DashboardInspectorActivityItem {
id: string;
occurredAt: Date;
actorUsername: string | null;
@@ -19,6 +19,7 @@ interface DashboardCountsRow {
inactiveUsers: number | string;
activeSessions: number | string;
openFindings: number | string;
actsInFollowUp: number | string;
findingsWithoutControlDate: number | string;
overdueControls: number | string;
controlsNext30Days: number | string;
@@ -49,9 +50,14 @@ export class DashboardService {
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
const [counts] = (await manager.query(`
SELECT
(SELECT COUNT(*) FROM assets WHERE information_status <> 'INACTIVE') AS "totalAssets",
(SELECT COUNT(*) FROM assets WHERE information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation",
(SELECT COUNT(*) FROM assets asset WHERE asset.information_status <> 'INACTIVE' AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)) AS "assetsWithoutGeometry",
(SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status <> 'INACTIVE') AS "totalAssets",
(SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation",
(
SELECT COUNT(*) FROM assets asset
WHERE asset.is_inventory_instance=true
AND asset.information_status <> 'INACTIVE'
AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)
) AS "assetsWithoutGeometry",
(SELECT COUNT(*) FROM inspection_visits WHERE status='PLANNED') AS "plannedInspections",
(SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS "activeUsers",
(SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers",
@@ -61,9 +67,22 @@ export class DashboardService {
WHERE revoked_at IS NULL
AND expires_at > CURRENT_TIMESTAMP
) AS "activeSessions",
(SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN') AS "openFindings",
(
SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN'
) AS "openFindings",
SELECT COUNT(*)
FROM inspection_acts act
WHERE act.status IN ('SEALED','CLOSED','RECTIFIED')
AND NOT (
EXISTS (
SELECT 1 FROM inspection_findings finding
WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
)
AND NOT EXISTS (
SELECT 1 FROM inspection_findings finding
WHERE finding.act_id=act.id AND finding.status='OPEN'
)
)
) AS "actsInFollowUp",
(
SELECT COUNT(*)
FROM inspection_findings finding
@@ -115,7 +134,7 @@ export class DashboardService {
) AS "sealedActsWithoutReport"
`)) as DashboardCountsRow[];
const recentAudit = (await manager.query(`
const recentInspectorActivity = (await manager.query(`
SELECT
event.id,
event.occurred_at AS "occurredAt",
@@ -124,9 +143,25 @@ export class DashboardService {
event.entity_type AS "entityType",
event.entity_id AS "entityId"
FROM audit_events event
WHERE event.actor_user_id IS NOT NULL
AND (
EXISTS (
SELECT 1 FROM inspection_visit_members member
WHERE member.user_id=event.actor_user_id AND member.included=true
)
OR EXISTS (
SELECT 1 FROM inspection_visits visit
WHERE visit.lead_inspector_user_id=event.actor_user_id
)
)
AND (
event.action LIKE 'INSPECTION_%'
OR event.action LIKE 'ASSET_%'
OR event.action LIKE 'FINDING_%'
)
ORDER BY event.occurred_at DESC, event.id DESC
LIMIT 6
`)) as DashboardAuditItem[];
LIMIT 8
`)) as DashboardInspectorActivityItem[];
const upcomingControls = (await manager.query(`
SELECT
@@ -168,6 +203,7 @@ export class DashboardService {
inactiveUsers: Number(counts?.inactiveUsers ?? 0),
activeSessions: Number(counts?.activeSessions ?? 0),
openFindings: Number(counts?.openFindings ?? 0),
actsInFollowUp: Number(counts?.actsInFollowUp ?? 0),
findingsWithoutControlDate: Number(counts?.findingsWithoutControlDate ?? 0),
overdueControls: Number(counts?.overdueControls ?? 0),
controlsNext30Days: Number(counts?.controlsNext30Days ?? 0),
@@ -175,7 +211,7 @@ export class DashboardService {
reportsOfficialized: Number(counts?.reportsOfficialized ?? 0),
sealedActsWithoutReport: Number(counts?.sealedActsWithoutReport ?? 0),
},
recentAudit,
recentInspectorActivity,
upcomingControls,
generatedAt: new Date().toISOString(),
};
@@ -57,6 +57,9 @@ export class Asset extends TimestampedEntity {
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
inventoryFamilyId!: string | null;
@Column({ name: 'is_inventory_instance', type: 'boolean', default: false })
isInventoryInstance!: boolean;
@Column({ type: 'varchar', length: 120 })
code!: string;
@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F5InventoryPhysicalInstance1790087100000 implements MigrationInterface {
name = 'F5InventoryPhysicalInstance1790087100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE assets
ADD COLUMN is_inventory_instance boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
CREATE INDEX idx_assets_inventory_instance_active
ON assets (is_inventory_instance, information_status)
WHERE is_inventory_instance = true
`);
await queryRunner.query(`
COMMENT ON COLUMN assets.is_inventory_instance IS
'True only for a concrete Instalacion/Subinstalacion instance. Empresa, Area and Yacimiento are structural/context masters.'
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION classify_new_asset_inventory_instance()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
type_code varchar;
BEGIN
SELECT lower(code)
INTO type_code
FROM asset_types
WHERE id = NEW.asset_type_id;
NEW.is_inventory_instance := COALESCE(type_code, '') IN ('instalacion', 'subinstalacion');
RETURN NEW;
END;
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_assets_classify_inventory_instance
BEFORE INSERT OR UPDATE OF asset_type_id ON assets
FOR EACH ROW EXECUTE FUNCTION classify_new_asset_inventory_instance()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_assets_classify_inventory_instance ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS classify_new_asset_inventory_instance()');
await queryRunner.query('DROP INDEX IF EXISTS idx_assets_inventory_instance_active');
await queryRunner.query('ALTER TABLE assets DROP COLUMN IF EXISTS is_inventory_instance');
}
}
@@ -0,0 +1,192 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type TypeRow = {
id: string;
code: string;
role: string;
active: boolean;
canBeRoot: boolean;
};
type RuleRow = {
childTypeId: string;
parentTypeId: string;
};
type CountRow = { total: number };
const CREATED_TYPES_TABLE = 'f5_canonical_hierarchy_created_types';
const CREATED_RULES_TABLE = 'f5_canonical_hierarchy_created_rules';
const CANONICAL_TYPES = [
{
code: 'yacimiento',
name: 'Yacimiento',
description: 'Yacimiento perteneciente a un Área.',
},
{
code: 'instalacion',
name: 'Instalación',
description: 'Instancia física de una Instalación dentro de un Yacimiento.',
},
{
code: 'subinstalacion',
name: 'Subinstalación',
description: 'Instancia física subordinada a una Instalación.',
},
] as const;
const CANONICAL_RULES = [
['yacimiento', 'area'],
['instalacion', 'yacimiento'],
['subinstalacion', 'instalacion'],
] as const;
export class F5CanonicalInventoryHierarchy1790087150000 implements MigrationInterface {
name = 'F5CanonicalInventoryHierarchy1790087150000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE ${CREATED_TYPES_TABLE} (
type_id uuid PRIMARY KEY,
code varchar(80) NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_f5_canonical_created_type FOREIGN KEY (type_id)
REFERENCES asset_types(id) ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE TABLE ${CREATED_RULES_TABLE} (
child_type_id uuid NOT NULL,
parent_type_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (child_type_id,parent_type_id),
CONSTRAINT fk_f5_canonical_created_rule_child FOREIGN KEY (child_type_id)
REFERENCES asset_types(id) ON DELETE CASCADE,
CONSTRAINT fk_f5_canonical_created_rule_parent FOREIGN KEY (parent_type_id)
REFERENCES asset_types(id) ON DELETE CASCADE
)
`);
const area = await this.requireType(queryRunner, 'area');
if (area.role !== 'AREA' || !area.active || !area.canBeRoot) {
throw new Error('F5 requires canonical active root type area with AREA operational role');
}
for (const definition of CANONICAL_TYPES) {
const inserted = (await queryRunner.query(`
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
SELECT $1::varchar,$2::varchar,$3::text,false,true,'GENERIC'
WHERE NOT EXISTS (
SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar)
)
RETURNING id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
`, [definition.code, definition.name, definition.description])) as TypeRow[];
if (inserted[0]?.id) {
await queryRunner.query(`
INSERT INTO ${CREATED_TYPES_TABLE}(type_id,code)
VALUES ($1::uuid,$2::varchar)
`, [inserted[0].id, definition.code]);
}
const type = await this.requireType(queryRunner, definition.code);
if (type.role !== 'GENERIC' || !type.active || type.canBeRoot) {
throw new Error(`F5 incompatible canonical type configuration: ${definition.code}`);
}
}
for (const [childCode, parentCode] of CANONICAL_RULES) {
const inserted = (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)=lower($1::varchar)
AND lower(parent.code)=lower($2::varchar)
AND NOT EXISTS (
SELECT 1 FROM asset_type_parent_rules existing
WHERE existing.child_type_id=child.id AND existing.parent_type_id=parent.id
)
RETURNING child_type_id AS "childTypeId",parent_type_id AS "parentTypeId"
`, [childCode, parentCode])) as RuleRow[];
if (inserted[0]?.childTypeId && inserted[0]?.parentTypeId) {
await queryRunner.query(`
INSERT INTO ${CREATED_RULES_TABLE}(child_type_id,parent_type_id)
VALUES ($1::uuid,$2::uuid)
`, [inserted[0].childTypeId, inserted[0].parentTypeId]);
}
}
const [verified] = (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(verified?.total ?? 0) !== 3) {
throw new Error(`F5 canonical hierarchy verification failed: rules=${Number(verified?.total ?? 0)}`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const [usedCreatedTypes] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN ${CREATED_TYPES_TABLE} owned ON owned.type_id=asset.asset_type_id
`)) as CountRow[];
if (Number(usedCreatedTypes?.total ?? 0) !== 0) {
throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type is already used by Inventory');
}
const [foreignRules] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM asset_type_parent_rules rule
WHERE (
rule.child_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE})
OR rule.parent_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE})
)
AND NOT EXISTS (
SELECT 1 FROM ${CREATED_RULES_TABLE} owned
WHERE owned.child_type_id=rule.child_type_id
AND owned.parent_type_id=rule.parent_type_id
)
`)) as CountRow[];
if (Number(foreignRules?.total ?? 0) !== 0) {
throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type gained external parent rules');
}
await queryRunner.query(`
DELETE FROM asset_type_parent_rules rule
USING ${CREATED_RULES_TABLE} owned
WHERE rule.child_type_id=owned.child_type_id
AND rule.parent_type_id=owned.parent_type_id
`);
await queryRunner.query(`
DELETE FROM asset_types type
USING ${CREATED_TYPES_TABLE} owned
WHERE type.id=owned.type_id
`);
await queryRunner.query(`DROP TABLE ${CREATED_RULES_TABLE}`);
await queryRunner.query(`DROP TABLE ${CREATED_TYPES_TABLE}`);
}
private async requireType(queryRunner: QueryRunner, code: string): Promise<TypeRow> {
const rows = (await queryRunner.query(`
SELECT id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
FROM asset_types
WHERE lower(code)=lower($1::varchar)
ORDER BY created_at
`, [code])) as TypeRow[];
if (rows.length !== 1) {
throw new Error(`F5 requires exactly one canonical asset type ${code}; found ${rows.length}`);
}
return rows[0];
}
}
@@ -0,0 +1,602 @@
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;
}
}
@@ -0,0 +1,182 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* F5 makes the physical hierarchy Area-owned. Empresa is never a parent nor a
* required property of Yacimiento/Instalación/Subinstalación. The current and
* historical operator/concession truth lives in area_company_relations and is
* frozen separately by each Inspección/Acta.
*
* operator_company_id is retained only as a backwards-compatible creation/
* historical snapshot. Runtime ownership and search MUST NOT depend on it.
*/
export class F5OperationalContextCompatibility1790087250000 implements MigrationInterface {
name = 'F5OperationalContextCompatibility1790087250000';
public async up(queryRunner: QueryRunner): Promise<void> {
await this.installAreaOwnedGuard(queryRunner);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await this.installLegacyPairedGuard(queryRunner);
}
private async installAreaOwnedGuard(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
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 de Inventario';
END IF;
RETURN NEW;
END IF;
IF NEW.operator_company_id IS NOT NULL AND NEW.operational_area_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='Un snapshot de Empresa requiere un Área física';
END IF;
-- Once written, an old/current company snapshot cannot be repointed to
-- simulate physical ownership. Company changes happen in the temporal
-- Area↔Empresa relation instead.
IF TG_OP='UPDATE'
AND NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='La Empresa se cambia en la relación temporal del Área, no en el Inventario';
END IF;
IF NEW.operational_area_id IS NULL THEN
RETURN NEW;
END IF;
SELECT type.operational_role INTO area_role
FROM assets area
JOIN asset_types type ON type.id=area.asset_type_id
WHERE area.id=NEW.operational_area_id
AND area.information_status<>'INACTIVE'
AND type.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 parent.id,parent.parent_id
FROM assets parent
JOIN ancestors child ON parent.id=child.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;
-- A company value is allowed only as the context snapshot that was valid
-- at creation time. It is never used to decide future membership.
IF NEW.operator_company_id IS NOT NULL THEN
SELECT type.operational_role INTO company_role
FROM assets company
JOIN asset_types type ON type.id=company.asset_type_id
WHERE company.id=NEW.operator_company_id
AND company.information_status<>'INACTIVE'
AND type.is_active=true;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='operator snapshot must reference an active COMPANY-role asset';
END IF;
IF TG_OP='INSERT' THEN
SELECT relation.id INTO active_relation_id
FROM area_company_relations relation
WHERE relation.area_id=NEW.operational_area_id
AND relation.company_id=NEW.operator_company_id
AND relation.relation_role='OPERATOR'::area_organization_role
AND relation.valid_until IS NULL
FOR KEY SHARE;
IF active_relation_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='creation operator snapshot must be active for the selected Area';
END IF;
END IF;
END IF;
RETURN NEW;
END $$;
`);
}
/** Restores the production F4-era paired Area+Empresa guard on rollback. */
private async installLegacyPairedGuard(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 type.operational_role INTO area_role
FROM assets area JOIN asset_types type ON type.id=area.asset_type_id
WHERE area.id=NEW.operational_area_id AND area.information_status<>'INACTIVE' AND type.is_active=true;
SELECT type.operational_role INTO company_role
FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.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 relation.id INTO active_relation_id
FROM area_company_relations relation
WHERE relation.area_id=NEW.operational_area_id
AND relation.company_id=NEW.operator_company_id
AND relation.relation_role='OPERATOR'::area_organization_role
AND relation.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 parent.id,parent.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.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 $$;
`);
}
}
@@ -0,0 +1,606 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import {
loadF5InventoryAuthoritativeSource,
type F5InstallationCatalogRow,
type F5SubinstallationCatalogRow,
} from '../../reference-data/f5-authoritative-inventory-source';
type IdRow = { id: string };
type CountRow = { total: number };
const CATALOG_DOCUMENT_NUMBER = 'DH-F5-INVENTORY-CATALOG';
const CATALOG_CATEGORY_CODE = 'F5MODEL';
const CATALOG_SOURCE_NAME = 'final_modelov2.xlsx';
const F5_AUTO_REASON = 'F5 familia técnica: catálogo contextual automático';
const F5_SOURCE_FAMILY_COUNT = 123;
function findingKey(value: string): string {
return value.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function hashCode(prefix: string, value: string, length = 12): string {
return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`;
}
function installationCode(name: string): string {
return hashCode('F5-I', findingKey(name));
}
function subinstallationCode(installation: string, name: string): string {
return hashCode('F5-S', `${findingKey(installation)}|${findingKey(name)}`);
}
function subOtherCode(parentCode: string): string {
return hashCode('F5-S-OTRO', parentCode);
}
export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationInterface {
name = 'F5AuthoritativeInventoryCatalog1790087300000';
public async up(queryRunner: QueryRunner): Promise<void> {
const source = loadF5InventoryAuthoritativeSource();
if (
source.catalogSource.file !== CATALOG_SOURCE_NAME
|| source.catalogSource.sheet !== 'Hoja1'
|| source.catalogSource.sha256 !== 'c9a2d1db59fff2157162c41009b8c9042a3c7a3001649239a07732a3b8fca155'
|| source.catalogSource.installations.length !== 14
|| source.catalogSource.subinstallations.length !== 109
) {
throw new Error('F5 inventory catalog source contract mismatch');
}
if (source.catalogSource.universalFindings.length !== 3) {
throw new Error(`F5 universal finding contract mismatch: ${source.catalogSource.universalFindings.length}`);
}
const universalKeys = new Set(source.catalogSource.universalFindings.map(findingKey));
for (const required of [
'ORDEN Y LIMPIEZA',
'CARTELERIA PREVENTIVA / INFORMATIVA',
'EXTINTORES',
]) {
if (!universalKeys.has(findingKey(required))) {
throw new Error(`F5 missing authoritative universal finding: ${required}`);
}
}
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
`, [
CATALOG_DOCUMENT_NUMBER,
CATALOG_SOURCE_NAME,
`sha256:${source.catalogSource.sha256}`,
`F5 · catálogo técnico autorizado · hoja ${source.catalogSource.sheet} · 14 Instalaciones · 109 Subinstalaciones`,
]);
// Only the known historical spreadsheet catalog is superseded. Families
// created manually by DH (including source_reference NULL) remain untouched.
await queryRunner.query(`
UPDATE inventory_families
SET is_active=false,updated_at=CURRENT_TIMESTAMP
WHERE source_reference LIKE 'APLICACION APP%'
OR source_reference LIKE 'SYSTEM:F3.1:%'
`);
await queryRunner.query(`
UPDATE finding_categories
SET is_active=false,updated_at=CURRENT_TIMESTAMP
WHERE lower(code) IN ('app26','app26r2')
`);
const installationIds = new Map<string,string>();
for (const installation of source.catalogSource.installations) {
const familyId = await this.upsertFamily(
queryRunner,
installationCode(installation.name),
installation.name,
'INSTALLATION',
`F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${installation.sourceStartRow}-${installation.sourceEndRow}`,
);
installationIds.set(findingKey(installation.name),familyId);
}
for (const subinstallation of source.catalogSource.subinstallations) {
const parentId = installationIds.get(findingKey(subinstallation.installation));
if (!parentId) throw new Error(`F5 missing installation family ${subinstallation.installation}`);
const childId = await this.upsertFamily(
queryRunner,
subinstallationCode(subinstallation.installation,subinstallation.name),
subinstallation.name,
'SUBINSTALLATION',
`F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${subinstallation.sourceStartRow}-${subinstallation.sourceEndRow}${subinstallation.reference ? `|reference:${subinstallation.reference}` : ''}`,
);
await this.parentRule(queryRunner,childId,parentId);
}
const installationOtherId = await this.upsertFamily(
queryRunner,
'F5-I-OTRO',
'Otro / no catalogado',
'INSTALLATION',
'F5:SYSTEM:OTHER:INSTALLATION',
);
for (const [installationKey,parentId] of installationIds) {
const parent = source.catalogSource.installations.find((item) => findingKey(item.name)===installationKey);
if (!parent) continue;
const childId = await this.upsertFamily(
queryRunner,
subOtherCode(installationCode(parent.name)),
'Otro / no catalogado',
'SUBINSTALLATION',
`F5:SYSTEM:OTHER:SUBINSTALLATION:${installationCode(parent.name)}`,
);
await this.parentRule(queryRunner,childId,parentId);
}
const rootOtherChild = await this.upsertFamily(
queryRunner,
subOtherCode('F5-I-OTRO'),
'Otro / no catalogado',
'SUBINSTALLATION',
'F5:SYSTEM:OTHER:SUBINSTALLATION:F5-I-OTRO',
);
await this.parentRule(queryRunner,rootOtherChild,installationOtherId);
await queryRunner.query(`
INSERT INTO finding_categories(code,name,sort_order,is_active)
SELECT $1::varchar,'DH · Modelo de Inventarios F5',270,true
WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)=lower($1::varchar))
`, [CATALOG_CATEGORY_CODE]);
await queryRunner.query(`
UPDATE finding_categories
SET name='DH · Modelo de Inventarios F5',sort_order=270,is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE lower(code)=lower($1::varchar)
`,[CATALOG_CATEGORY_CODE]);
const categoryId = await this.id(
queryRunner,
`SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[CATALOG_CATEGORY_CODE],
'F5 finding category',
);
const titleByKey = new Map<string,string>();
const register = (title: string): void => {
const clean = title.trim();
if (!clean || /^idem\b/i.test(clean) || findingKey(clean)==='hallazgos') return;
const itemKey = findingKey(clean);
if (!titleByKey.has(itemKey)) titleByKey.set(itemKey,clean);
};
for (const title of source.catalogSource.universalFindings) register(title);
for (const family of source.catalogSource.installations) for (const title of family.findings) register(title);
for (const family of source.catalogSource.subinstallations) for (const title of family.findings) register(title);
if (titleByKey.size !== 177) {
throw new Error(`F5 finding normalization contract mismatch: ${titleByKey.size}`);
}
const itemIdByKey = new Map<string,string>();
const orderedTitles = [...titleByKey.entries()].sort((a,b)=>a[1].localeCompare(b[1],'es'));
let sourceNumber=1;
for (const [itemKey,title] of orderedTitles) {
const itemCode = hashCode('F5-H',itemKey);
let itemId = await this.optionalId(
queryRunner,
`SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[itemCode],
);
if (!itemId) {
const itemRows = (await queryRunner.query(`
INSERT INTO finding_catalog_items (
category_id,code,source_number,title,import_note,revision,is_active
) VALUES ($1::uuid,$2,$3,$4,$5,1,true)
RETURNING id
`,[
categoryId,itemCode,sourceNumber,title,
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
])) as IdRow[];
itemId=itemRows[0]?.id ?? null;
} else {
await queryRunner.query(`
UPDATE finding_catalog_items
SET category_id=$2::uuid,source_number=$3,title=$4,import_note=$5,
is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`,[
itemId,categoryId,sourceNumber,title,
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
]);
}
if (!itemId) throw new Error(`F5 could not create finding ${title}`);
itemIdByKey.set(itemKey,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:F5'
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;
}
// Add F5 mappings only. Never delete mappings created by office users or by
// historical migrations; inactive historical families simply stop being offered.
for (const family of source.catalogSource.installations) {
await this.mapFindings(
queryRunner,
installationCode(family.name),
family,
source.catalogSource.universalFindings,
itemIdByKey,
);
}
for (const family of source.catalogSource.subinstallations) {
await this.mapFindings(
queryRunner,
subinstallationCode(family.installation,family.name),
family,
source.catalogSource.universalFindings,
itemIdByKey,
);
}
await this.installFamilySyncFunctions(queryRunner);
// Keep pre-existing profile administration untouched. Yacimiento needs a
// profile only to expose OTROS because the source does not provide a family.
await queryRunner.query(`
INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason)
SELECT id,'F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.'
FROM asset_types WHERE lower(code)='yacimiento'
ON CONFLICT (asset_type_id) DO NOTHING
`);
const [counts] = (await queryRunner.query(`
SELECT
COUNT(*) FILTER (WHERE level='INSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS installations,
COUNT(*) FILTER (WHERE level='SUBINSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS subinstallations
FROM inventory_families WHERE is_active=true
`)) as Array<{ installations:number; subinstallations:number }>;
if (Number(counts?.installations ?? 0)!==14 || Number(counts?.subinstallations ?? 0)!==109) {
throw new Error(`F5 family preload verification failed: ${JSON.stringify(counts ?? {})}`);
}
const [itemCount] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM finding_catalog_items
WHERE category_id=$1::uuid AND is_active=true
`,[categoryId])) as CountRow[];
if (Number(itemCount?.total ?? 0)!==177) {
throw new Error(`F5 finding preload verification failed: ${itemCount?.total ?? 0}`);
}
// Verify every universal finding is independently attached to every one of
// the 14 + 109 source families. This intentionally avoids optional DB text
// extensions such as unaccent.
for (const universalTitle of source.catalogSource.universalFindings) {
const universalItemId = itemIdByKey.get(findingKey(universalTitle));
if (!universalItemId) throw new Error(`F5 missing universal catalog item ${universalTitle}`);
const [mappedCount] = (await queryRunner.query(`
SELECT COUNT(DISTINCT mapping.inventory_family_id)::integer AS total
FROM finding_catalog_item_inventory_families mapping
JOIN inventory_families family ON family.id=mapping.inventory_family_id
WHERE mapping.catalog_item_id=$1::uuid
AND family.is_active=true
AND family.source_reference LIKE $2
`,[universalItemId,`F5:${CATALOG_SOURCE_NAME}%`])) as CountRow[];
if (Number(mappedCount?.total ?? 0)!==F5_SOURCE_FAMILY_COUNT) {
throw new Error(`F5 universal mapping verification failed for ${universalTitle}: ${mappedCount?.total ?? 0}`);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const categoryId = await this.optionalId(
queryRunner,
`SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[CATALOG_CATEGORY_CODE],
);
if (categoryId) {
const [usedFinding] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM inspection_findings finding
JOIN finding_catalog_items item ON item.id=finding.catalog_item_id
WHERE item.category_id=$1::uuid
`,[categoryId])) as CountRow[];
if (Number(usedFinding?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 catalog: inspection findings already reference F5 catalog items');
}
}
const [usedFamily] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE family.source_reference LIKE 'F5:%'
`)) as CountRow[];
if (Number(usedFamily?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 catalog: inventory instances already reference F5 families');
}
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason=$1::text
`,[F5_AUTO_REASON]);
if (categoryId) {
await queryRunner.query(`
DELETE FROM finding_catalog_item_inventory_families mapping
USING finding_catalog_items item
WHERE item.id=mapping.catalog_item_id AND item.category_id=$1::uuid
`,[categoryId]);
await queryRunner.query(`
DELETE FROM finding_catalog_item_versions version
USING finding_catalog_items item
WHERE item.id=version.item_id AND item.category_id=$1::uuid
`,[categoryId]);
await queryRunner.query(`DELETE FROM finding_catalog_items WHERE category_id=$1::uuid`,[categoryId]);
await queryRunner.query(`DELETE FROM finding_categories WHERE id=$1::uuid`,[categoryId]);
}
await queryRunner.query(`
DELETE FROM inventory_family_parent_rules rule
USING inventory_families child
WHERE child.id=rule.child_family_id AND child.source_reference LIKE 'F5:%'
`);
await queryRunner.query(`DELETE FROM inventory_families WHERE source_reference LIKE 'F5:%'`);
await queryRunner.query(`
UPDATE inventory_families
SET is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE source_reference LIKE 'APLICACION APP%'
OR source_reference LIKE 'SYSTEM:F3.1:%'
`);
await queryRunner.query(`
UPDATE finding_categories SET is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='app26r2'
`);
await queryRunner.query(`
DELETE FROM finding_catalog_asset_type_profiles profile
USING asset_types type
WHERE profile.asset_type_id=type.id
AND lower(type.code)='yacimiento'
AND profile.reason='F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.'
`);
await queryRunner.query(`
DELETE FROM source_documents
WHERE document_number=$1::varchar AND issuer='Dirección de Hidrocarburos'
`,[CATALOG_DOCUMENT_NUMBER]);
await this.restoreF31FamilySyncFunctions(queryRunner);
// Rebuild only automatic historical overrides. Manual overrides have never
// been touched by this migration.
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason LIKE 'F3.1 familia técnica:%'
`);
await queryRunner.query(`
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,mapping.catalog_item_id,true,
'F3.1 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by
FROM assets asset
JOIN finding_catalog_item_inventory_families mapping
ON mapping.inventory_family_id=asset.inventory_family_id
WHERE asset.inventory_family_id IS NOT NULL
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,
reason='F3.1 familia técnica: catálogo contextual automático',
updated_at=CURRENT_TIMESTAMP
`);
}
private async upsertFamily(
queryRunner: QueryRunner,
familyCode: string,
name: string,
level: 'INSTALLATION'|'SUBINSTALLATION',
sourceReference: string,
): Promise<string> {
await queryRunner.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1,$2,$3,NULL,'[]'::jsonb,$4,true)
ON CONFLICT (code) DO UPDATE SET
name=EXCLUDED.name,level=EXCLUDED.level,legacy_type_code=NULL,
information_labels='[]'::jsonb,source_reference=EXCLUDED.source_reference,
is_active=true,updated_at=CURRENT_TIMESTAMP
`,[familyCode,name,level,sourceReference]);
return this.id(
queryRunner,
`SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`,
[familyCode],
`inventory family ${familyCode}`,
);
}
private async parentRule(queryRunner: QueryRunner,childId:string,parentId:string):Promise<void> {
await queryRunner.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
ON CONFLICT (child_family_id) DO UPDATE SET parent_family_id=EXCLUDED.parent_family_id
`,[childId,parentId]);
}
private async mapFindings(
queryRunner: QueryRunner,
familyCode: string,
family: F5InstallationCatalogRow|F5SubinstallationCatalogRow,
universalFindings: string[],
itemIdByKey: Map<string,string>,
): Promise<void> {
const familyId = await this.id(
queryRunner,
`SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`,
[familyCode],
`family ${familyCode}`,
);
const mapped = new Set<string>();
for (const rawTitle of [...family.findings,...universalFindings]) {
const itemKey=findingKey(rawTitle);
if (!itemKey || itemKey==='hallazgos' || mapped.has(itemKey)) continue;
mapped.add(itemKey);
const itemId=itemIdByKey.get(itemKey);
if (!itemId) throw new Error(`F5 missing finding item ${rawTitle}`);
await queryRunner.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
VALUES ($1::uuid,$2::uuid)
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
`,[itemId,familyId]);
}
}
private async installFamilySyncFunctions(queryRunner: QueryRunner):Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM finding_catalog_asset_overrides
WHERE asset_id=NEW.id AND reason LIKE 'F% familia técnica:%';
IF NEW.inventory_family_id IS NOT NULL THEN
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT NEW.id,mapping.catalog_item_id,true,
'F5 familia técnica: catálogo contextual automático',
NEW.created_by,NEW.updated_by
FROM finding_catalog_item_inventory_families mapping
WHERE mapping.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F5 familia técnica: catálogo contextual automático',
updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' THEN
DELETE FROM finding_catalog_asset_overrides override_record
USING assets asset
WHERE override_record.asset_id=asset.id
AND asset.inventory_family_id=OLD.inventory_family_id
AND override_record.catalog_item_id=OLD.catalog_item_id
AND override_record.reason LIKE 'F% familia técnica:%';
RETURN OLD;
END IF;
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,NEW.catalog_item_id,true,
'F5 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by
FROM assets asset
WHERE asset.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F5 familia técnica: catálogo contextual automático',updated_at=CURRENT_TIMESTAMP;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason LIKE 'F% familia técnica:%'
`);
await queryRunner.query(`
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,mapping.catalog_item_id,true,$1::text,
asset.created_by,asset.updated_by
FROM assets asset
JOIN finding_catalog_item_inventory_families mapping
ON mapping.inventory_family_id=asset.inventory_family_id
WHERE asset.inventory_family_id IS NOT NULL
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason=$1::text,updated_at=CURRENT_TIMESTAMP
`,[F5_AUTO_REASON]);
}
private async restoreF31FamilySyncFunctions(queryRunner: QueryRunner):Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM finding_catalog_asset_overrides
WHERE asset_id=NEW.id AND reason LIKE 'F3.1 familia técnica:%';
IF NEW.inventory_family_id IS NOT NULL THEN
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT NEW.id,mapping.catalog_item_id,true,
'F3.1 familia técnica: catálogo contextual automático',
NEW.created_by,NEW.updated_by
FROM finding_catalog_item_inventory_families mapping
WHERE mapping.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F3.1 familia técnica: catálogo contextual automático',
updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' THEN
DELETE FROM finding_catalog_asset_overrides override_record
USING assets asset
WHERE override_record.asset_id=asset.id
AND asset.inventory_family_id=OLD.inventory_family_id
AND override_record.catalog_item_id=OLD.catalog_item_id
AND override_record.reason LIKE 'F3.1 familia técnica:%';
RETURN OLD;
END IF;
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,NEW.catalog_item_id,true,
'F3.1 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by
FROM assets asset
WHERE asset.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F3.1 familia técnica: catálogo contextual automático',
updated_at=CURRENT_TIMESTAMP;
RETURN NEW;
END $$;
`);
}
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;
}
}
@@ -1,41 +1,65 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import { FindingCatalogService } from './finding-catalog.service';
@Injectable()
export class F3FindingCatalogResolverService {
constructor(
private readonly dataSource: DataSource,
private readonly legacyCatalog: FindingCatalogService,
) {}
constructor(private readonly dataSource: DataSource) {}
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
const [asset] = await this.dataSource.query(`
SELECT asset.id,asset.code,asset.name,
type.code AS "typeCode",
asset.inventory_family_id AS "familyId",
family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel"
family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel",
family.is_active AS "familyActive"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [assetId]) as Array<{
id: string;
code: string;
name: string;
typeCode: string;
familyId: string | null;
familyCode: string | null;
familyName: string | null;
familyLevel: string | null;
familyActive: boolean | null;
}>;
if (!asset) {
throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
}
if (!asset.familyId) return this.legacyCatalog.listApplicableForAsset(assetId, query);
const other = {
enabled: true,
code: 'OTHER',
label: 'OTROS',
help: 'Usalo cuando el Hallazgo no exista en el catálogo aplicable. Se enviará una propuesta a revisión de oficina.',
};
// F5: no se mezclan catálogos históricos. Yacimiento no posee familia técnica
// en final_modelov2.xlsx, por lo que puede registrar Hallazgos mediante OTROS.
if (!asset.familyId || asset.familyActive !== true) {
return {
asset: { id: asset.id, code: asset.code, name: asset.name },
inventoryFamily: null,
typeConfigured: false,
configurationReason: asset.typeCode.toLowerCase() === 'yacimiento'
? 'F5 · Yacimiento sin catálogo precargado: Hallazgos disponibles mediante OTROS.'
: 'F5 · El elemento todavía no tiene una clasificación técnica activa.',
categories: [],
items: [],
other,
};
}
const filters = [
'mapping.inventory_family_id=$1::uuid',
'item.is_active=true',
'category.is_active=true',
"lower(category.code)='f5model'",
'merge_record.source_item_id IS NULL',
];
const params: unknown[] = [asset.familyId];
@@ -83,15 +107,10 @@ export class F3FindingCatalogResolverService {
level: asset.familyLevel,
},
typeConfigured: true,
configurationReason: `F3.1 · Catálogo del Excel asociado a ${asset.familyName ?? 'la familia técnica'}`,
configurationReason: `F5 · Catálogo final_modelov2.xlsx asociado a ${asset.familyName ?? 'la clasificación técnica'}`,
categories,
items,
other: {
enabled: true,
code: 'OTHER',
label: 'OTROS',
help: 'Usalo cuando el hallazgo no exista en la familia técnica. Se enviará una propuesta a revisión de oficina.',
},
other,
};
}
}
@@ -122,7 +122,20 @@ export class F3FieldInventoryStructureService {
asset: { id: string; code: string; name: string };
[key: string]: unknown;
};
if (!family) return created;
// Área/Yacimiento remain structural context. A concrete Installation/Subinstallation
// created in field is a real Inventory instance from the moment it is registered.
await this.dataSource.query(`
UPDATE assets
SET is_inventory_instance=$3::boolean,updated_by=$2::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`, [
created.asset.id,
principal.userId,
typeCode === 'instalacion' || typeCode === 'subinstalacion',
]);
if (!family) return this.fieldInventory.detail(visitId, created.asset.id, principal);
await this.dataSource.transaction(async (manager) => {
const [current] = await manager.query(`
@@ -29,6 +29,7 @@ interface MobileVisitContext {
companyCode: string;
companyName: string;
assigned: boolean;
operatorRelationValid: boolean;
}
interface FieldDiscoveryCreated {
@@ -79,11 +80,23 @@ export class FieldInventoryService {
const context = await this.requireVisitContext(visitId, principal, false);
if (query.parentId) await this.requireParentInContext(query.parentId, context);
const args: unknown[] = [context.areaId, context.companyId, visitId];
// Empresa is visit context, never physical ownership. Search by Area ancestry.
const args: unknown[] = [context.areaId, visitId];
const conditions = [
'asset.operational_area_id = $1::uuid',
'asset.operator_company_id = $2::uuid',
"asset.information_status <> 'INACTIVE'",
`(
asset.operational_area_id = $1::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
)
SELECT 1 FROM ancestors WHERE id=$1::uuid LIMIT 1
)
)`,
"(asset.is_inventory_instance=true OR lower(type.code)='yacimiento')",
];
if (query.search?.trim()) {
@@ -129,13 +142,13 @@ export class FieldInventoryService {
) END AS parent,
EXISTS (
SELECT 1 FROM inspection_visit_assets link
WHERE link.visit_id = $3::uuid
WHERE link.visit_id = $2::uuid
AND link.asset_id = asset.id
AND link.included = true
) AS "selectedInInspection",
EXISTS (
SELECT 1 FROM asset_field_discoveries discovery
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id
WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id
) AS "captureRequired",
EXISTS (
SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id
@@ -143,26 +156,26 @@ export class FieldInventoryService {
(
SELECT COUNT(*)::integer
FROM asset_field_capture_events capture
WHERE capture.visit_id = $3::uuid
WHERE capture.visit_id = $2::uuid
AND capture.asset_id = asset.id
AND capture.event_type = 'PHOTO'
) AS "fieldPhotoCount",
(
NOT EXISTS (
SELECT 1 FROM asset_field_discoveries discovery
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id
WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id
)
OR (
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id)
AND EXISTS (
SELECT 1 FROM asset_field_capture_events capture
WHERE capture.visit_id = $3::uuid
WHERE capture.visit_id = $2::uuid
AND capture.asset_id = asset.id
AND capture.event_type = 'CREATED'
)
AND EXISTS (
SELECT 1 FROM asset_field_capture_events capture
WHERE capture.visit_id = $3::uuid
WHERE capture.visit_id = $2::uuid
AND capture.asset_id = asset.id
AND capture.event_type = 'PHOTO'
)
@@ -279,6 +292,7 @@ export class FieldInventoryService {
typeId: dto.typeId,
parentId,
operationalAreaId: context.areaId,
// Compatibility-only creation snapshot. Membership never depends on it.
operatorCompanyId: context.companyId,
description: dto.description ?? null,
discoveryNotes: dto.discoveryNotes ?? null,
@@ -413,7 +427,19 @@ export class FieldInventoryService {
AND member.user_id = $2::uuid
AND member.included = true
)
) AS assigned
) AS assigned,
EXISTS (
SELECT 1
FROM area_company_relations relation
WHERE relation.area_id=visit.operational_area_id
AND relation.company_id=visit.operator_company_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
AND (
relation.valid_until IS NULL
OR relation.valid_until >= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
)
) AS "operatorRelationValid"
FROM inspection_visits visit
LEFT JOIN assets area ON area.id = visit.operational_area_id
LEFT JOIN assets company ON company.id = visit.operator_company_id
@@ -429,6 +455,12 @@ export class FieldInventoryService {
message: 'La inspección no tiene Área y Operadora definidas',
});
}
if (!context.operatorRelationValid) {
throw new ConflictException({
code: 'FIELD_INVENTORY_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección',
});
}
if (!context.assigned) {
throw new ConflictException({
code: 'FIELD_INVENTORY_INSPECTOR_NOT_ASSIGNED',
@@ -469,18 +501,27 @@ export class FieldInventoryService {
asset.code,
asset.name,
asset.asset_type_id AS "typeId",
asset.operational_area_id AS "areaId",
asset.operator_company_id AS "companyId"
(
asset.id=$2::uuid
OR asset.operational_area_id=$2::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
)
) AS "insideArea"
FROM assets asset
WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE'
`, [parentId])) as Array<{
`, [parentId, context.areaId])) as Array<{
id: string;
code: string;
name: string;
typeId: string;
areaId: string | null;
companyId: string | null;
insideArea: boolean;
}>;
if (!parent) {
@@ -489,13 +530,10 @@ export class FieldInventoryService {
message: 'La ubicación padre no existe',
});
}
if (
parent.id !== context.areaId
&& (parent.areaId !== context.areaId || parent.companyId !== context.companyId)
) {
if (!parent.insideArea) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
message: 'La ubicación padre no pertenece al Área y Operadora de la inspección',
message: 'La ubicación padre no pertenece al Área de la inspección',
});
}
return parent;
@@ -503,16 +541,28 @@ export class FieldInventoryService {
private async requireAssetInContext(assetId: string, context: MobileVisitContext) {
const [asset] = (await this.dataSource.query(`
SELECT id,
operational_area_id AS "areaId",
operator_company_id AS "companyId"
FROM assets
WHERE id = $1::uuid
AND information_status <> 'INACTIVE'
`, [assetId])) as Array<{
SELECT
asset.id,
(asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget",
(
asset.operational_area_id=$2::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
)
) AS "insideArea"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE'
`, [assetId, context.areaId])) as Array<{
id: string;
areaId: string | null;
companyId: string | null;
eligibleTarget: boolean;
insideArea: boolean;
}>;
if (!asset) {
@@ -521,10 +571,16 @@ export class FieldInventoryService {
message: 'Registro de Inventario no encontrado',
});
}
if (asset.areaId !== context.areaId || asset.companyId !== context.companyId) {
if (!asset.insideArea) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
message: 'El registro no pertenece al Área y Operadora de esta inspección',
message: 'El registro no pertenece al Área de esta inspección',
});
}
if (!asset.eligibleTarget) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_NOT_OPERATIONAL_TARGET',
message: 'El registro no es un Yacimiento ni una instancia real de Inventario',
});
}
return asset;
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.25.0-1';
export const API_PHASE = 'F4';
export const API_VERSION = '0.26.0-1';
export const API_PHASE = 'F5';