diff --git a/api-v3/src/asset-master/inventory-technical-values.service.ts b/api-v3/src/asset-master/inventory-technical-values.service.ts new file mode 100644 index 0000000..d01f663 --- /dev/null +++ b/api-v3/src/asset-master/inventory-technical-values.service.ts @@ -0,0 +1,118 @@ +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}`}); + } +}