From a3dd705190ba177fcb0abed44deef94bdb694f89 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Tue, 8 Sep 2026 21:31:37 -0300 Subject: [PATCH] fix(inventory): resolve field inventory by area hierarchy, not company ownership --- .../field-inventory.service.ts | 118 +++++++++++++----- 1 file changed, 87 insertions(+), 31 deletions(-) diff --git a/api-v3/src/inspection-visits/field-inventory.service.ts b/api-v3/src/inspection-visits/field-inventory.service.ts index a2e41b7..04e3066 100644 --- a/api-v3/src/inspection-visits/field-inventory.service.ts +++ b/api-v3/src/inspection-visits/field-inventory.service.ts @@ -29,6 +29,7 @@ interface MobileVisitContext { companyCode: string; companyName: string; assigned: boolean; + operatorRelationValid: boolean; } interface FieldDiscoveryCreated { @@ -79,11 +80,23 @@ export class FieldInventoryService { const context = await this.requireVisitContext(visitId, principal, false); if (query.parentId) await this.requireParentInContext(query.parentId, context); - const args: unknown[] = [context.areaId, context.companyId, visitId]; + // Empresa is visit context, never physical ownership. Search by Area ancestry. + const args: unknown[] = [context.areaId, visitId]; const conditions = [ - 'asset.operational_area_id = $1::uuid', - 'asset.operator_company_id = $2::uuid', "asset.information_status <> 'INACTIVE'", + `( + asset.operational_area_id = $1::uuid + OR EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id,parent_id FROM assets WHERE id=asset.parent_id + UNION ALL + SELECT parent.id,parent.parent_id + FROM assets parent JOIN ancestors child ON parent.id=child.parent_id + ) + SELECT 1 FROM ancestors WHERE id=$1::uuid LIMIT 1 + ) + )`, + "(asset.is_inventory_instance=true OR lower(type.code)='yacimiento')", ]; if (query.search?.trim()) { @@ -129,13 +142,13 @@ export class FieldInventoryService { ) END AS parent, EXISTS ( SELECT 1 FROM inspection_visit_assets link - WHERE link.visit_id = $3::uuid + WHERE link.visit_id = $2::uuid AND link.asset_id = asset.id AND link.included = true ) AS "selectedInInspection", EXISTS ( SELECT 1 FROM asset_field_discoveries discovery - WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id + WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id ) AS "captureRequired", EXISTS ( SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id @@ -143,26 +156,26 @@ export class FieldInventoryService { ( SELECT COUNT(*)::integer FROM asset_field_capture_events capture - WHERE capture.visit_id = $3::uuid + WHERE capture.visit_id = $2::uuid AND capture.asset_id = asset.id AND capture.event_type = 'PHOTO' ) AS "fieldPhotoCount", ( NOT EXISTS ( SELECT 1 FROM asset_field_discoveries discovery - WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id + WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id ) OR ( EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id) AND EXISTS ( SELECT 1 FROM asset_field_capture_events capture - WHERE capture.visit_id = $3::uuid + WHERE capture.visit_id = $2::uuid AND capture.asset_id = asset.id AND capture.event_type = 'CREATED' ) AND EXISTS ( SELECT 1 FROM asset_field_capture_events capture - WHERE capture.visit_id = $3::uuid + WHERE capture.visit_id = $2::uuid AND capture.asset_id = asset.id AND capture.event_type = 'PHOTO' ) @@ -279,6 +292,7 @@ export class FieldInventoryService { typeId: dto.typeId, parentId, operationalAreaId: context.areaId, + // Compatibility-only creation snapshot. Membership never depends on it. operatorCompanyId: context.companyId, description: dto.description ?? null, discoveryNotes: dto.discoveryNotes ?? null, @@ -413,7 +427,19 @@ export class FieldInventoryService { AND member.user_id = $2::uuid AND member.included = true ) - ) AS assigned + ) AS assigned, + EXISTS ( + SELECT 1 + FROM area_company_relations relation + WHERE relation.area_id=visit.operational_area_id + AND relation.company_id=visit.operator_company_id + AND relation.relation_role='OPERATOR' + AND relation.valid_from <= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at) + AND ( + relation.valid_until IS NULL + OR relation.valid_until >= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at) + ) + ) AS "operatorRelationValid" FROM inspection_visits visit LEFT JOIN assets area ON area.id = visit.operational_area_id LEFT JOIN assets company ON company.id = visit.operator_company_id @@ -429,6 +455,12 @@ export class FieldInventoryService { message: 'La inspección no tiene Área y Operadora definidas', }); } + if (!context.operatorRelationValid) { + throw new ConflictException({ + code: 'FIELD_INVENTORY_OPERATOR_RELATION_INVALID', + message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección', + }); + } if (!context.assigned) { throw new ConflictException({ code: 'FIELD_INVENTORY_INSPECTOR_NOT_ASSIGNED', @@ -469,18 +501,27 @@ export class FieldInventoryService { asset.code, asset.name, asset.asset_type_id AS "typeId", - asset.operational_area_id AS "areaId", - asset.operator_company_id AS "companyId" + ( + asset.id=$2::uuid + OR asset.operational_area_id=$2::uuid + OR EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id,parent_id FROM assets WHERE id=asset.parent_id + UNION ALL + SELECT parent.id,parent.parent_id + FROM assets parent JOIN ancestors child ON parent.id=child.parent_id + ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 + ) + ) AS "insideArea" FROM assets asset WHERE asset.id = $1::uuid AND asset.information_status <> 'INACTIVE' - `, [parentId])) as Array<{ + `, [parentId, context.areaId])) as Array<{ id: string; code: string; name: string; typeId: string; - areaId: string | null; - companyId: string | null; + insideArea: boolean; }>; if (!parent) { @@ -489,13 +530,10 @@ export class FieldInventoryService { message: 'La ubicación padre no existe', }); } - if ( - parent.id !== context.areaId - && (parent.areaId !== context.areaId || parent.companyId !== context.companyId) - ) { + if (!parent.insideArea) { throw new BadRequestException({ code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT', - message: 'La ubicación padre no pertenece al Área y Operadora de la inspección', + message: 'La ubicación padre no pertenece al Área de la inspección', }); } return parent; @@ -503,16 +541,28 @@ export class FieldInventoryService { private async requireAssetInContext(assetId: string, context: MobileVisitContext) { const [asset] = (await this.dataSource.query(` - SELECT id, - operational_area_id AS "areaId", - operator_company_id AS "companyId" - FROM assets - WHERE id = $1::uuid - AND information_status <> 'INACTIVE' - `, [assetId])) as Array<{ + SELECT + asset.id, + (asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget", + ( + asset.operational_area_id=$2::uuid + OR EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id,parent_id FROM assets WHERE id=asset.parent_id + UNION ALL + SELECT parent.id,parent.parent_id + FROM assets parent JOIN ancestors child ON parent.id=child.parent_id + ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 + ) + ) AS "insideArea" + FROM assets asset + JOIN asset_types type ON type.id=asset.asset_type_id + WHERE asset.id = $1::uuid + AND asset.information_status <> 'INACTIVE' + `, [assetId, context.areaId])) as Array<{ id: string; - areaId: string | null; - companyId: string | null; + eligibleTarget: boolean; + insideArea: boolean; }>; if (!asset) { @@ -521,10 +571,16 @@ export class FieldInventoryService { message: 'Registro de Inventario no encontrado', }); } - if (asset.areaId !== context.areaId || asset.companyId !== context.companyId) { + if (!asset.insideArea) { throw new BadRequestException({ code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT', - message: 'El registro no pertenece al Área y Operadora de esta inspección', + message: 'El registro no pertenece al Área de esta inspección', + }); + } + if (!asset.eligibleTarget) { + throw new BadRequestException({ + code: 'FIELD_INVENTORY_NOT_OPERATIONAL_TARGET', + message: 'El registro no es un Yacimiento ni una instancia real de Inventario', }); } return asset;