import { BadRequestException, ConflictException, Injectable, } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { administrationAuditContext } from '../administration/common/administration-audit'; import { AssetHistoryService } from '../asset-master/asset-history.service'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { AssetVersionChangeType, AuditAction } from '../database/entities'; import type { CreateFieldInventoryDto } from './dto/create-field-inventory.dto'; import { FieldInventoryService } from './field-inventory.service'; type FamilyRow = { id: string; code: string; name: string; level: 'INSTALLATION' | 'SUBINSTALLATION'; informationLabels: string[]; sourceReference: string | null; isOther: boolean; }; type ParentRow = { id: string; typeCode: string; inventoryFamilyId: string | null; }; type FieldTypesBase = { context: { area: { id: string; code: string; name: string }; operatorCompany: { id: string; code: string; name: string }; inspection: { id: string; code: string; status: string }; }; parent: { id: string; code: string; name: string }; data: Array & { id: string; code: string; name: string }>; }; const STRUCTURAL_CHILD: Record = { area: 'yacimiento', yacimiento: 'instalacion', instalacion: 'subinstalacion', }; const OTHER_FAMILY_SQL = "source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR source_reference LIKE 'F5:SYSTEM:OTHER:%'"; @Injectable() export class F3FieldInventoryStructureService { constructor( private readonly dataSource: DataSource, private readonly fieldInventory: FieldInventoryService, private readonly history: AssetHistoryService, private readonly audit: AuditService, ) {} async types( visitId: string, parentId: string | undefined, principal: AuthPrincipal, ) { const base = await this.fieldInventory.types(visitId, parentId, principal) as unknown as FieldTypesBase; const effectiveParentId = parentId ?? base.parent.id; const parent = await this.parent(effectiveParentId); const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()]; const data = expectedTypeCode ? base.data.filter((type) => type.code.toLowerCase() === expectedTypeCode) : []; const families = await this.familiesFor(parent, expectedTypeCode); return { ...base, data: data.map((type) => ({ ...type, structuralKind: type.code.toUpperCase(), families, familyRequired: type.code.toLowerCase() === 'instalacion' || type.code.toLowerCase() === 'subinstalacion', })), }; } async create( visitId: string, dto: CreateFieldInventoryDto, principal: AuthPrincipal, request: RequestWithContext, ) { const options = await this.types(visitId, dto.parentId, principal); const selectedType = options.data.find((type) => type.id === dto.typeId); if (!selectedType) { throw new BadRequestException({ code: 'FIELD_INVENTORY_STRUCTURE_TYPE_INVALID', message: 'El tipo elegido no corresponde al siguiente nivel estructural permitido', }); } const typeCode = String(selectedType.code).toLowerCase(); const familyRequired = typeCode === 'instalacion' || typeCode === 'subinstalacion'; const families = (selectedType.families ?? []) as FamilyRow[]; if (familyRequired && !dto.familyId) { throw new BadRequestException({ code: 'FIELD_INVENTORY_FAMILY_REQUIRED', message: 'Elegí el Tipo de instalación/subinstalación o la opción Otro / no catalogado', }); } if (!familyRequired && dto.familyId) { throw new BadRequestException({ code: 'FIELD_INVENTORY_FAMILY_NOT_ALLOWED', message: 'Este nivel estructural no utiliza Tipo de instalación/subinstalación', }); } const family = dto.familyId ? families.find((item) => item.id === dto.familyId) : null; if (dto.familyId && !family) { throw new BadRequestException({ code: 'FIELD_INVENTORY_FAMILY_INVALID', message: 'El Tipo de instalación/subinstalación no es válido para el padre seleccionado', }); } const created = await this.fieldInventory.create(visitId, dto, principal, request) as { asset: { id: string; code: string; name: string }; [key: string]: unknown; }; // Á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(` SELECT inventory_family_id AS "inventoryFamilyId" FROM assets WHERE id=$1::uuid FOR UPDATE `, [created.asset.id]) as Array<{ inventoryFamilyId: string | null }>; if (!current) throw new ConflictException({ code: 'FIELD_INVENTORY_CREATED_ASSET_MISSING', message: 'No se pudo clasificar el Inventario recién creado', }); if (current.inventoryFamilyId === family.id) return; await manager.query(` UPDATE assets SET inventory_family_id=$2::uuid,updated_by=$3::uuid,updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid `, [created.asset.id, family.id, principal.userId]); const versionNumber = await this.history.capture( manager, created.asset.id, AssetVersionChangeType.UPDATED, principal, request, ); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_UPDATED, entityType: 'asset', entityId: created.asset.id, beforeData: { inventoryFamilyId: current.inventoryFamilyId }, afterData: { inventoryFamilyId: family.id, inventoryFamilyCode: family.code, inventoryFamilyName: family.name, }, metadata: { operation: 'FIELD_INVENTORY_FAMILY_ASSIGNED', visitId, versionNumber, isOtherFamily: family.isOther, }, }, manager); }); return this.fieldInventory.detail(visitId, created.asset.id, principal); } private async parent(parentId: string): Promise { const rows = await this.dataSource.query(` SELECT asset.id,type.code AS "typeCode",asset.inventory_family_id AS "inventoryFamilyId" 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 ParentRow[]; if (!rows[0]) throw new BadRequestException({ code: 'FIELD_INVENTORY_PARENT_NOT_AVAILABLE', message: 'El padre estructural elegido no está disponible', }); return rows[0]; } private async familiesFor(parent: ParentRow, expectedTypeCode: string | undefined): Promise { if (expectedTypeCode === 'instalacion') { return this.dataSource.query(` SELECT id,code,name,level,information_labels AS "informationLabels", source_reference AS "sourceReference", (${OTHER_FAMILY_SQL}) AS "isOther" FROM inventory_families WHERE level='INSTALLATION' AND is_active=true ORDER BY (${OTHER_FAMILY_SQL}) ASC,name,code `) as Promise; } if (expectedTypeCode === 'subinstalacion') { if (!parent.inventoryFamilyId) { throw new ConflictException({ code: 'FIELD_INVENTORY_PARENT_FAMILY_REQUIRED', message: 'La Instalación debe tener un Tipo de instalación antes de agregar Subinstalaciones', }); } return this.dataSource.query(` SELECT family.id,family.code,family.name,family.level, family.information_labels AS "informationLabels", family.source_reference AS "sourceReference", (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR family.source_reference LIKE 'F5:SYSTEM:OTHER:%') AS "isOther" FROM inventory_family_parent_rules rule JOIN inventory_families family ON family.id=rule.child_family_id WHERE rule.parent_family_id=$1::uuid AND family.level='SUBINSTALLATION' AND family.is_active=true ORDER BY (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR family.source_reference LIKE 'F5:SYSTEM:OTHER:%') ASC,family.name,family.code `, [parent.inventoryFamilyId]) as Promise; } return []; } }