fix(inventory): resolve field inventory by area hierarchy, not company ownership

This commit is contained in:
2026-09-08 21:31:37 -03:00
parent f6564194b6
commit a3dd705190
@@ -29,6 +29,7 @@ interface MobileVisitContext {
companyCode: string; companyCode: string;
companyName: string; companyName: string;
assigned: boolean; assigned: boolean;
operatorRelationValid: boolean;
} }
interface FieldDiscoveryCreated { interface FieldDiscoveryCreated {
@@ -79,11 +80,23 @@ export class FieldInventoryService {
const context = await this.requireVisitContext(visitId, principal, false); const context = await this.requireVisitContext(visitId, principal, false);
if (query.parentId) await this.requireParentInContext(query.parentId, context); 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 = [ const conditions = [
'asset.operational_area_id = $1::uuid',
'asset.operator_company_id = $2::uuid',
"asset.information_status <> 'INACTIVE'", "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()) { if (query.search?.trim()) {
@@ -129,13 +142,13 @@ export class FieldInventoryService {
) END AS parent, ) END AS parent,
EXISTS ( EXISTS (
SELECT 1 FROM inspection_visit_assets link 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.asset_id = asset.id
AND link.included = true AND link.included = true
) AS "selectedInInspection", ) AS "selectedInInspection",
EXISTS ( EXISTS (
SELECT 1 FROM asset_field_discoveries discovery 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", ) AS "captureRequired",
EXISTS ( EXISTS (
SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id
@@ -143,26 +156,26 @@ export class FieldInventoryService {
( (
SELECT COUNT(*)::integer SELECT COUNT(*)::integer
FROM asset_field_capture_events capture 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.asset_id = asset.id
AND capture.event_type = 'PHOTO' AND capture.event_type = 'PHOTO'
) AS "fieldPhotoCount", ) AS "fieldPhotoCount",
( (
NOT EXISTS ( NOT EXISTS (
SELECT 1 FROM asset_field_discoveries discovery 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 ( OR (
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id) EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id)
AND EXISTS ( AND EXISTS (
SELECT 1 FROM asset_field_capture_events capture 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.asset_id = asset.id
AND capture.event_type = 'CREATED' AND capture.event_type = 'CREATED'
) )
AND EXISTS ( AND EXISTS (
SELECT 1 FROM asset_field_capture_events capture 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.asset_id = asset.id
AND capture.event_type = 'PHOTO' AND capture.event_type = 'PHOTO'
) )
@@ -279,6 +292,7 @@ export class FieldInventoryService {
typeId: dto.typeId, typeId: dto.typeId,
parentId, parentId,
operationalAreaId: context.areaId, operationalAreaId: context.areaId,
// Compatibility-only creation snapshot. Membership never depends on it.
operatorCompanyId: context.companyId, operatorCompanyId: context.companyId,
description: dto.description ?? null, description: dto.description ?? null,
discoveryNotes: dto.discoveryNotes ?? null, discoveryNotes: dto.discoveryNotes ?? null,
@@ -413,7 +427,19 @@ export class FieldInventoryService {
AND member.user_id = $2::uuid AND member.user_id = $2::uuid
AND member.included = true 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 FROM inspection_visits visit
LEFT JOIN assets area ON area.id = visit.operational_area_id LEFT JOIN assets area ON area.id = visit.operational_area_id
LEFT JOIN assets company ON company.id = visit.operator_company_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', 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) { if (!context.assigned) {
throw new ConflictException({ throw new ConflictException({
code: 'FIELD_INVENTORY_INSPECTOR_NOT_ASSIGNED', code: 'FIELD_INVENTORY_INSPECTOR_NOT_ASSIGNED',
@@ -469,18 +501,27 @@ export class FieldInventoryService {
asset.code, asset.code,
asset.name, asset.name,
asset.asset_type_id AS "typeId", 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 FROM assets asset
WHERE asset.id = $1::uuid WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE' AND asset.information_status <> 'INACTIVE'
`, [parentId])) as Array<{ `, [parentId, context.areaId])) as Array<{
id: string; id: string;
code: string; code: string;
name: string; name: string;
typeId: string; typeId: string;
areaId: string | null; insideArea: boolean;
companyId: string | null;
}>; }>;
if (!parent) { if (!parent) {
@@ -489,13 +530,10 @@ export class FieldInventoryService {
message: 'La ubicación padre no existe', message: 'La ubicación padre no existe',
}); });
} }
if ( if (!parent.insideArea) {
parent.id !== context.areaId
&& (parent.areaId !== context.areaId || parent.companyId !== context.companyId)
) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT', 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; return parent;
@@ -503,16 +541,28 @@ export class FieldInventoryService {
private async requireAssetInContext(assetId: string, context: MobileVisitContext) { private async requireAssetInContext(assetId: string, context: MobileVisitContext) {
const [asset] = (await this.dataSource.query(` const [asset] = (await this.dataSource.query(`
SELECT id, SELECT
operational_area_id AS "areaId", asset.id,
operator_company_id AS "companyId" (asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget",
FROM assets (
WHERE id = $1::uuid asset.operational_area_id=$2::uuid
AND information_status <> 'INACTIVE' OR EXISTS (
`, [assetId])) as Array<{ 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; id: string;
areaId: string | null; eligibleTarget: boolean;
companyId: string | null; insideArea: boolean;
}>; }>;
if (!asset) { if (!asset) {
@@ -521,10 +571,16 @@ export class FieldInventoryService {
message: 'Registro de Inventario no encontrado', message: 'Registro de Inventario no encontrado',
}); });
} }
if (asset.areaId !== context.areaId || asset.companyId !== context.companyId) { if (!asset.insideArea) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT', 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; return asset;