import { BadRequestException, 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 { UpdateInventoryTechnicalValuesDto } from './dto/update-inventory-technical-values.dto'; type DefinitionRow = { id:string; code:string; name:string; dataType:'TEXT'|'NUMBER'|'BOOLEAN'|'DATE'|'DATETIME'|'SELECT'; isRequired:boolean; isActive:boolean; unit:string|null; options:string[]|null; sortOrder:number; }; type AssetRow = { id:string; inventoryFamilyId:string|null; familyCode:string|null; familyName:string|null; familyLevel:string|null }; @Injectable() export class InventoryTechnicalValuesService { constructor(private readonly dataSource:DataSource,private readonly audit:AuditService) {} async get(assetId:string) { return this.load(this.dataSource.manager,assetId); } async replace(assetId:string,dto:UpdateInventoryTechnicalValuesDto,principal:AuthPrincipal,request:RequestWithContext) { return this.dataSource.transaction(async(manager) => { const before=await this.load(manager,assetId,true); const definitions=before.definitions as DefinitionRow[]; const definitionById=new Map(definitions.map((definition)=>[definition.id,definition])); const normalized:Record={}; for (const [definitionId,raw] of Object.entries(dto.values)) { const definition=definitionById.get(definitionId); if (!definition || !definition.isActive) throw new BadRequestException({ code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen a la clasificación actual', }); const value=this.normalize(definition,raw); if (value!==undefined) normalized[definitionId]=value; } for (const definition of definitions.filter((item)=>item.isActive && item.isRequired)) { if (!(definition.id in normalized)) throw new BadRequestException({ code:'INVENTORY_TECHNICAL_FIELD_REQUIRED',message:`Completá el campo técnico obligatorio: ${definition.name}`, }); } await manager.query('DELETE FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid',[assetId]); const entries=Object.entries(normalized); if (entries.length) await manager.query(` INSERT INTO asset_inventory_attribute_values(asset_id,definition_id,value,updated_by) SELECT $1::uuid,item.definition_id,item.value,$3::uuid FROM JSONB_TO_RECORDSET($2::jsonb) AS item(definition_id uuid,value jsonb) `,[assetId,JSON.stringify(entries.map(([definition_id,value])=>({definition_id,value}))),principal.userId]); const after=await this.load(manager,assetId); await this.audit.record({ ...administrationAuditContext(principal,request),action:AuditAction.ASSET_UPDATED, entityType:'asset_inventory_technical_values',entityId:assetId, beforeData:{values:before.values},afterData:{values:after.values}, metadata:{operation:'INVENTORY_TECHNICAL_VALUES_REPLACED',inventoryFamilyId:after.family.id}, },manager); return after; }); } private async load(manager:EntityManager,assetId:string,lock=false) { const rows=(await manager.query(` SELECT asset.id,asset.inventory_family_id AS "inventoryFamilyId", family.code AS "familyCode",family.name AS "familyName",family.level::text AS "familyLevel" FROM assets asset LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id WHERE asset.id=$1::uuid ${lock ? 'FOR UPDATE OF asset' : ''} `,[assetId])) as AssetRow[]; const asset=rows[0]; if (!asset) throw new NotFoundException({code:'ASSET_NOT_FOUND',message:'El registro de Inventario no existe'}); if (!asset.inventoryFamilyId || !asset.familyCode || !asset.familyName || !asset.familyLevel) throw new BadRequestException({ code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene clasificación técnica y no admite campos técnicos por rubro', }); const definitions=(await manager.query(` SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive", unit,options,sort_order AS "sortOrder" FROM inventory_family_attribute_definitions WHERE inventory_family_id=$1::uuid ORDER BY is_active DESC,sort_order,name,code `,[asset.inventoryFamilyId])) as DefinitionRow[]; const valueRows=await manager.query(` SELECT definition_id AS id,value FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid `,[assetId]) as Array<{id:string;value:unknown}>; return { assetId, family:{id:asset.inventoryFamilyId,code:asset.familyCode,name:asset.familyName,level:asset.familyLevel}, definitions, values:Object.fromEntries(valueRows.map((row)=>[row.id,row.value])), }; } private normalize(definition:DefinitionRow,raw:unknown):unknown|undefined { if (raw===null || raw===undefined || raw==='') return undefined; switch(definition.dataType) { case 'TEXT': { if (typeof raw!=='string') return this.invalid(definition); const value=raw.trim(); if (!value) return undefined; if (value.length>4000) return this.invalid(definition); return value; } case 'NUMBER': { const value=typeof raw==='number' ? raw : typeof raw==='string' ? Number(raw) : Number.NaN; if (!Number.isFinite(value)) return this.invalid(definition); return value; } case 'BOOLEAN': if (typeof raw!=='boolean') return this.invalid(definition); return raw; case 'DATE': { if (typeof raw!=='string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw) || Number.isNaN(Date.parse(`${raw}T00:00:00Z`))) return this.invalid(definition); return raw; } case 'DATETIME': { if (typeof raw!=='string' || Number.isNaN(Date.parse(raw))) return this.invalid(definition); return new Date(raw).toISOString(); } case 'SELECT': { if (typeof raw!=='string' || !definition.options?.includes(raw)) return this.invalid(definition); return raw; } } } private invalid(definition:DefinitionRow):never { throw new BadRequestException({code:'INVENTORY_TECHNICAL_VALUE_INVALID',message:`Valor inválido para ${definition.name}`}); } }