import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { CreateInspectionFindingDto } from '../inspection-findings/dto/create-inspection-finding.dto'; import { F3FindingCatalogResolverService } from '../inspection-findings/f3-finding-catalog-resolver.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'; interface DraftActRow { id: string; code: string; 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 catalog: F3FindingCatalogResolverService, private readonly findings: InspectionFindingsService, ) {} async options(visitId: string, assetId: string, principal: AuthPrincipal) { const gate = await this.requireGate(visitId, assetId, principal); const act = await this.requireDraftAct(visitId); const [catalog, findings] = await Promise.all([ this.catalog.listApplicableForAsset(assetId, {}), this.findings.listForAct(act.id), ]); return { context: gate.context, act, capture: gate.capture, catalog, findings: findings.data.filter((finding) => finding.assetId === assetId), canAddAnother: true, }; } async list(visitId: string, assetId: string, principal: AuthPrincipal) { const gate = await this.requireGate(visitId, assetId, principal); const act = await this.requireDraftAct(visitId); const findings = await this.findings.listForAct(act.id); return { context: gate.context, act, capture: gate.capture, data: findings.data.filter((finding) => finding.assetId === assetId), }; } async create( visitId: string, assetId: string, dto: CreateFieldFindingDto, principal: AuthPrincipal, request: RequestWithContext, ) { const gate = await this.requireGate(visitId, assetId, principal); const act = await this.requireDraftAct(visitId); const payload: CreateInspectionFindingDto = { assetId, catalogItemId: dto.catalogItemId ?? null, customTitle: dto.customTitle ?? null, customLegalBasis: dto.customLegalBasis ?? null, description: dto.description, severity: dto.severity, correctionDueOn: dto.correctionDueOn ?? null, }; const finding = await this.findings.create(act.id, payload, principal, request); return { context: gate.context, act, capture: gate.capture, finding, canAddAnother: true, }; } 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_type.code AS "assetTypeCode", asset.inventory_family_id AS "inventoryFamilyId", 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' LEFT JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id 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; assetTypeCode: string | null; inventoryFamilyId: 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 (!['instalacion', 'subinstalacion'].includes(String(row.assetTypeCode).toLowerCase())) { throw new BadRequestException({ code: 'FIELD_FINDING_TECHNICAL_LEVEL_REQUIRED', message: 'Los Hallazgos se registran sobre una Instalación o Subinstalación, no sobre Área/Yacimiento', }); } if (!row.inventoryFamilyId) { throw new ConflictException({ code: 'FIELD_FINDING_FAMILY_REQUIRED', message: 'La Instalación/Subinstalación debe tener una familia técnica u Otro antes de registrar Hallazgos', }); } 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 FROM inspection_acts WHERE visit_id = $1::uuid AND status = 'DRAFT' ORDER BY created_at DESC, id DESC LIMIT 2 `, [visitId]) as DraftActRow[]; if (rows.length === 0) { throw new ConflictException({ code: 'FIELD_FINDING_DRAFT_ACT_REQUIRED', message: 'La inspección no tiene un Acta borrador abierta para registrar Hallazgos', }); } if (rows.length > 1) { throw new ConflictException({ code: 'FIELD_FINDING_MULTIPLE_DRAFT_ACTS', message: 'La inspección tiene más de un Acta borrador. Debe resolverse antes de continuar', }); } return rows[0]; } }