diff --git a/api-v3/src/asset-master/inventory-browser.service.ts b/api-v3/src/asset-master/inventory-browser.service.ts new file mode 100644 index 0000000..6e5cef6 --- /dev/null +++ b/api-v3/src/asset-master/inventory-browser.service.ts @@ -0,0 +1,176 @@ +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 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.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 { + 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; + } + } +}