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 items(query: InventoryBrowserQueryDto) { const params: unknown[] = []; const conditions = [ "asset.information_status<>'INACTIVE'", 'type.is_active=true', "(type.operational_role='COMPANY' OR lower(type.code) IN ('departamento','area','yacimiento','instalacion','subinstalacion'))", ]; const add = (value: unknown): string => { params.push(value); return `$${params.length}`; }; if (query.search?.trim()) { const p = add(`%${query.search.trim()}%`); conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`); } if (query.typeId) conditions.push(`asset.asset_type_id=${add(query.typeId)}::uuid`); if (query.status) conditions.push(`asset.information_status=${add(query.status)}::asset_information_status`); if (query.operationalStatus) conditions.push(`asset.operational_status=${add(query.operationalStatus)}::asset_operational_status`); if (query.needsValidation === true) conditions.push("asset.information_status NOT IN ('VALIDATED','INACTIVE')"); if (query.needsValidation === false) conditions.push("asset.information_status='VALIDATED'"); if (query.hasGeometry === true) conditions.push('EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)'); if (query.hasGeometry === false) conditions.push('NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)'); if (query.operationalAreaId) { const area = add(query.operationalAreaId); conditions.push(`(asset.id=${area}::uuid OR asset.operational_area_id=${area}::uuid)`); } if (query.operatorCompanyId) { const company = add(query.operatorCompanyId); conditions.push(`( asset.id=${company}::uuid OR EXISTS ( SELECT 1 FROM area_company_relations relation WHERE (relation.area_id=asset.operational_area_id OR relation.area_id=asset.id) AND relation.company_id=${company}::uuid AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL ) )`); } const where = conditions.join(' AND '); const [countRow] = (await this.dataSource.query(` SELECT COUNT(*)::integer AS total FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE ${where} `,params)) as Array<{total:number}>; const total=Number(countRow?.total ?? 0); const offset=(query.page-1)*query.pageSize; params.push(query.pageSize); const limit=`$${params.length}`; params.push(offset); const offsetParam=`$${params.length}`; const data=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.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id',parent.id,'code',parent.code,'name',parent.name ) END AS parent, CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id',area.id,'code',area.code,'name',area.name ) END AS "operationalArea", ( 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=COALESCE(asset.operational_area_id, CASE WHEN type.operational_role='AREA' THEN asset.id ELSE NULL END) AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL ORDER BY relation.valid_from DESC,relation.created_at DESC LIMIT 1 ) AS "operatorCompany", asset.information_status AS "informationStatus", asset.operational_status AS "operationalStatus", (SELECT COUNT(*)::integer FROM assets child WHERE child.parent_id=asset.id AND child.information_status<>'INACTIVE') AS "childrenCount", EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry", CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType", (SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount", asset.data_origin AS "dataOrigin", (asset.provenance_verified_at IS NOT NULL) AS "provenanceVerified", asset.current_version AS "currentVersion", asset.updated_at AS "updatedAt" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id LEFT JOIN assets parent ON parent.id=asset.parent_id LEFT JOIN assets area ON area.id=asset.operational_area_id LEFT JOIN LATERAL ( SELECT ST_GeometryType(geometry.geometry)::text AS type FROM asset_geometries geometry WHERE geometry.asset_id=asset.id ORDER BY geometry.updated_at DESC LIMIT 1 ) geometry_type ON true WHERE ${where} ORDER BY CASE WHEN lower(type.code)='departamento' THEN 0 WHEN type.operational_role='COMPANY' THEN 1 WHEN lower(type.code)='area' THEN 2 WHEN lower(type.code)='yacimiento' THEN 3 WHEN lower(type.code)='instalacion' THEN 4 WHEN lower(type.code)='subinstalacion' THEN 5 ELSE 9 END, asset.name,asset.code LIMIT ${limit} OFFSET ${offsetParam} `,params); return { data, meta:{ page:query.page, pageSize:query.pageSize, total, totalPages:total===0 ? 0 : Math.ceil(total/query.pageSize), }, }; } async departments(query: InventoryBrowserQueryDto) { const params: unknown[] = []; const conditions = [ "lower(type.code)='departamento'", "department.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(`(department.code ILIKE ${p} OR department.name ILIKE ${p} OR COALESCE(department.common_name,'') ILIKE ${p})`); } if (query.operatorCompanyId) { const p = add(query.operatorCompanyId); conditions.push(`EXISTS ( SELECT 1 FROM assets area JOIN asset_types atype ON atype.id=area.asset_type_id JOIN area_company_relations relation ON relation.area_id=area.id WHERE area.parent_id=department.id AND lower(atype.code)='area' AND relation.company_id=${p}::uuid AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL )`); } const data = await this.dataSource.query(` SELECT department.id,department.code,department.name,department.common_name AS "commonName", JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, department.information_status AS "informationStatus", department.operational_status AS "operationalStatus", (SELECT COUNT(*)::integer FROM assets area JOIN asset_types atype ON atype.id=area.asset_type_id WHERE area.parent_id=department.id AND lower(atype.code)='area' AND area.information_status<>'INACTIVE') AS "areaCount", (SELECT COUNT(*)::integer FROM assets child WHERE child.parent_id=department.id AND child.information_status<>'INACTIVE') AS "childrenCount" FROM assets department JOIN asset_types type ON type.id=department.asset_type_id WHERE ${conditions.join(' AND ')} ORDER BY department.name,department.code `, params); return { data, meta: { count: data.length } }; } async companies(query: InventoryBrowserQueryDto) { const params: unknown[] = []; const conditions = [ "type.operational_role='COMPANY'", "company.information_status<>'INACTIVE'", 'type.is_active=true', ]; if (query.search?.trim()) { params.push(`%${query.search.trim()}%`); conditions.push(`(company.code ILIKE $1 OR company.name ILIKE $1 OR COALESCE(company.common_name,'') ILIKE $1)`); } const data = await this.dataSource.query(` SELECT company.id,company.code,company.name,company.common_name AS "commonName", JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, company.information_status AS "informationStatus", (SELECT COUNT(*)::integer FROM area_company_relations relation WHERE relation.company_id=company.id AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL) AS "areaCount" FROM assets company JOIN asset_types type ON type.id=company.asset_type_id WHERE ${conditions.join(' AND ')} ORDER BY company.name,company.code `,params); return { data,meta:{count:data.length} }; } 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.operationalAreaId) conditions.push(`area.id=${add(query.operationalAreaId)}::uuid`); 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.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 (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') AS "childrenCount", CASE WHEN family.id IS NULL THEN 0 ELSE ( SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id ) END AS "findingCount", 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 (!['departamento','area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) { throw new BadRequestException({ code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID', message:'La navegación de Inventarios admite Departamento → Área → Yacimiento → Instalación → Subinstalación', }); } return parent; } private allowedChildType(typeCode: string): string | null { switch (typeCode.toLowerCase()) { case 'departamento': return 'area'; case 'area': return 'yacimiento'; case 'yacimiento': return 'instalacion'; case 'instalacion': return 'subinstalacion'; default: return null; } } }