fix(inventory): resolve field inventory by area hierarchy, not company ownership
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user