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 };