From 1334611ebc328ac4d1c4f26bdbed546d5755504c Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 08:26:28 -0300 Subject: [PATCH] F2.3: reforzar puerta servidor para Hallazgos de campo --- .../field-findings.service.ts | 185 +++++++++++++++++- 1 file changed, 178 insertions(+), 7 deletions(-) diff --git a/api-v3/src/inspection-visits/field-findings.service.ts b/api-v3/src/inspection-visits/field-findings.service.ts index 4f9b818..a96c9ad 100644 --- a/api-v3/src/inspection-visits/field-findings.service.ts +++ b/api-v3/src/inspection-visits/field-findings.service.ts @@ -1,11 +1,11 @@ -import { ConflictException, Injectable } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { FindingCatalogService } from '../inspection-findings/finding-catalog.service'; import type { CreateInspectionFindingDto } from '../inspection-findings/dto/create-inspection-finding.dto'; +import { FindingCatalogService } from '../inspection-findings/finding-catalog.service'; import { InspectionFindingsService } from '../inspection-findings/inspection-findings.service'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import type { CreateFieldFindingDto } from './dto/create-field-finding.dto'; -import { FieldInventoryService } from './field-inventory.service'; interface DraftActRow { id: string; @@ -13,17 +13,31 @@ interface DraftActRow { status: string; } +interface FieldFindingGate { + context: { + inspection: { id: string; code: string; status: string }; + area: { id: string; code: string; name: string }; + operatorCompany: { id: string; code: string; name: string }; + }; + capture: { + captureRequired: boolean; + hasGeometry: boolean; + creationGpsCaptured: boolean; + fieldPhotoCount: number; + readyForFinding: boolean; + }; +} + @Injectable() export class FieldFindingsService { constructor( private readonly dataSource: DataSource, - private readonly fieldInventory: FieldInventoryService, private readonly catalog: FindingCatalogService, private readonly findings: InspectionFindingsService, ) {} async options(visitId: string, assetId: string, principal: AuthPrincipal) { - const gate = await this.fieldInventory.requireReadyForFinding(visitId, assetId, principal); + const gate = await this.requireGate(visitId, assetId, principal); const act = await this.requireDraftAct(visitId); const [catalog, findings] = await Promise.all([ this.catalog.listApplicableForAsset(assetId, {}), @@ -41,7 +55,7 @@ export class FieldFindingsService { } async list(visitId: string, assetId: string, principal: AuthPrincipal) { - const gate = await this.fieldInventory.requireReadyForFinding(visitId, assetId, principal); + const gate = await this.requireGate(visitId, assetId, principal); const act = await this.requireDraftAct(visitId); const findings = await this.findings.listForAct(act.id); return { @@ -59,7 +73,7 @@ export class FieldFindingsService { principal: AuthPrincipal, request: RequestWithContext, ) { - const gate = await this.fieldInventory.requireReadyForFinding(visitId, assetId, principal); + const gate = await this.requireGate(visitId, assetId, principal); const act = await this.requireDraftAct(visitId); const payload: CreateInspectionFindingDto = { assetId, @@ -80,6 +94,163 @@ export class FieldFindingsService { }; } + private async requireGate( + visitId: string, + assetId: string, + principal: AuthPrincipal, + ): Promise { + assertMobileInspector(principal); + const [row] = await this.dataSource.query(` + SELECT + visit.id AS "visitId", + visit.code AS "visitCode", + visit.status AS "visitStatus", + visit.operational_area_id AS "areaId", + area.code AS "areaCode", + area.name AS "areaName", + visit.operator_company_id AS "companyId", + company.code AS "companyCode", + company.name AS "companyName", + asset.id AS "assetId", + asset.operational_area_id AS "assetAreaId", + asset.operator_company_id AS "assetCompanyId", + EXISTS ( + SELECT 1 + FROM inspection_visit_members member + WHERE member.visit_id = visit.id + AND member.user_id = $3::uuid + AND member.included = true + ) OR visit.lead_inspector_user_id = $3::uuid AS assigned, + EXISTS ( + SELECT 1 + FROM inspection_visit_assets link + WHERE link.visit_id = visit.id + AND link.asset_id = asset.id + AND link.included = true + ) AS selected, + EXISTS ( + SELECT 1 + FROM asset_field_discoveries discovery + WHERE discovery.visit_id = visit.id + AND discovery.asset_id = asset.id + ) AS "captureRequired", + EXISTS ( + SELECT 1 + FROM asset_geometries geometry + WHERE geometry.asset_id = asset.id + ) AS "hasGeometry", + EXISTS ( + SELECT 1 + FROM asset_field_capture_events event + WHERE event.visit_id = visit.id + AND event.asset_id = asset.id + AND event.event_type = 'CREATED' + ) AS "creationGpsCaptured", + ( + SELECT COUNT(*)::integer + FROM asset_field_capture_events event + WHERE event.visit_id = visit.id + AND event.asset_id = asset.id + AND event.event_type = 'PHOTO' + ) AS "fieldPhotoCount" + 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 + LEFT JOIN assets asset ON asset.id = $2::uuid + AND asset.information_status <> 'INACTIVE' + WHERE visit.id = $1::uuid + `, [visitId, assetId, principal.userId]) as Array<{ + visitId: string; + visitCode: string; + visitStatus: string; + areaId: string | null; + areaCode: string | null; + areaName: string | null; + companyId: string | null; + companyCode: string | null; + companyName: string | null; + assetId: string | null; + assetAreaId: string | null; + assetCompanyId: string | null; + assigned: boolean; + selected: boolean; + captureRequired: boolean; + hasGeometry: boolean; + creationGpsCaptured: boolean; + fieldPhotoCount: number; + }>; + + if (!row) { + throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' }); + } + if (row.visitStatus !== 'IN_PROGRESS') { + throw new ConflictException({ + code: 'FIELD_FINDING_VISIT_NOT_IN_PROGRESS', + message: 'Los hallazgos sólo pueden registrarse cuando la inspección está en curso', + }); + } + if (!row.assigned) { + throw new ConflictException({ + code: 'FIELD_FINDING_INSPECTOR_NOT_ASSIGNED', + message: 'El inspector no está asignado a esta inspección', + }); + } + if (!row.areaId || !row.companyId || !row.areaCode || !row.areaName || !row.companyCode || !row.companyName) { + throw new ConflictException({ + code: 'FIELD_FINDING_CONTEXT_REQUIRED', + message: 'La inspección no tiene Área y Operadora definidas', + }); + } + if (!row.assetId) { + throw new NotFoundException({ + code: 'FIELD_FINDING_INVENTORY_NOT_FOUND', + message: 'Registro de Inventario no encontrado', + }); + } + if (row.assetAreaId !== row.areaId || row.assetCompanyId !== row.companyId) { + throw new BadRequestException({ + code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT', + message: 'El Inventario no pertenece al Área y Operadora de esta inspección', + }); + } + if (!row.selected) { + throw new ConflictException({ + code: 'FIELD_FINDING_INVENTORY_NOT_SELECTED', + message: 'Seleccioná el Inventario dentro de la inspección antes de registrar un hallazgo', + }); + } + + const captureRequired = Boolean(row.captureRequired); + const hasGeometry = Boolean(row.hasGeometry); + const creationGpsCaptured = Boolean(row.creationGpsCaptured); + const fieldPhotoCount = Number(row.fieldPhotoCount ?? 0); + const readyForFinding = !captureRequired || ( + hasGeometry && creationGpsCaptured && fieldPhotoCount > 0 + ); + if (!readyForFinding) { + throw new ConflictException({ + code: 'FIELD_FINDING_CAPTURE_REQUIRED', + message: 'Antes del hallazgo, el Inventario creado en campo debe tener GPS y al menos una foto', + capture: { captureRequired, hasGeometry, creationGpsCaptured, fieldPhotoCount }, + }); + } + + return { + context: { + inspection: { id: row.visitId, code: row.visitCode, status: row.visitStatus }, + area: { id: row.areaId, code: row.areaCode, name: row.areaName }, + operatorCompany: { id: row.companyId, code: row.companyCode, name: row.companyName }, + }, + capture: { + captureRequired, + hasGeometry, + creationGpsCaptured, + fieldPhotoCount, + readyForFinding, + }, + }; + } + private async requireDraftAct(visitId: string): Promise { const rows = await this.dataSource.query(` SELECT id, code, status