From 782b0150c5a8de949b492d4dc2c585eed8245f6b Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 14:28:39 -0300 Subject: [PATCH] F3.1 APK: alinear alta de campo con estructura y familias --- .../f3-field-inventory-structure.service.ts | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts diff --git a/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts b/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts new file mode 100644 index 0000000..5e9f1d0 --- /dev/null +++ b/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts @@ -0,0 +1,214 @@ +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; +}; + +const STRUCTURAL_CHILD: Record = { + area: 'yacimiento', + yacimiento: 'instalacion', + instalacion: 'subinstalacion', +}; + +@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 { + context: { areaId: string }; + parent: { id: string; code: string; name: string }; + data: Array & { id: string; code: string; name: string }>; + }; + const effectiveParentId = parentId ?? base.context.areaId; + 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í la familia técnica 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 familia técnica', + }); + } + 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: 'La familia técnica no es válida 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; + }; + if (!family) return created; + + 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", + (source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther" + FROM inventory_families + WHERE level='INSTALLATION' AND is_active=true + ORDER BY (source_reference LIKE 'SYSTEM:F3.1:OTHER%') 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 una familia técnica 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%') 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%') ASC,family.name,family.code + `, [parent.inventoryFamilyId]) as Promise; + } + return []; + } +}