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, actId: string | undefined, principal: AuthPrincipal, ) { const gate = await this.requireGate(visitId, assetId, principal); const act = await this.requireDraftAct(visitId, actId); const assetIncludedInAct = await this.actContainsAsset(act.id, assetId); const [catalog, findings] = await Promise.all([ this.catalog.listApplicableForAsset(assetId, {}), this.findings.listForAct(act.id), ]); return { context: gate.context, act, capture: gate.capture, assetIncludedInAct, catalog, findings: findings.data.filter((finding) => finding.assetId === assetId), canAddAnother: assetIncludedInAct, actSelectionMode: actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT', }; } async list( visitId: string, assetId: string, actId: string | undefined, principal: AuthPrincipal, ) { const gate = await this.requireGate(visitId, assetId, principal); const act = await this.requireDraftAct(visitId, actId); const findings = await this.findings.listForAct(act.id); return { context: gate.context, act, capture: gate.capture, assetIncludedInAct: await this.actContainsAsset(act.id, assetId), data: findings.data.filter((finding) => finding.assetId === assetId), actSelectionMode: actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT', }; } 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, dto.actId); if (!await this.actContainsAsset(act.id, assetId)) { throw new ConflictException({ code: 'FIELD_FINDING_ASSET_NOT_IN_ACT', message: 'Agregá este Inventario al Acta seleccionada antes de registrar el Hallazgo', actId: act.id, assetId, }); } 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, actSelectionMode: dto.actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT', }; } 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", COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId", 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.id=COALESCE(visit.scope_asset_id, visit.operational_area_id) 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=COALESCE(visit.scope_asset_id, visit.operational_area_id) LIMIT 1 ) ) AS "insideScope", 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", 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; scopeAssetId: string | null; 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; insideScope: boolean; operatorRelationValid: boolean; 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.operatorRelationValid) { throw new ConflictException({ code: 'FIELD_FINDING_OPERATOR_RELATION_INVALID', message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección', }); } if (!row.assetId) { throw new NotFoundException({ code: 'FIELD_FINDING_INVENTORY_NOT_FOUND', message: 'Registro de Inventario no encontrado', }); } const assetTypeCode = String(row.assetTypeCode).toLowerCase(); if (!['yacimiento', 'instalacion', 'subinstalacion'].includes(assetTypeCode)) { throw new BadRequestException({ code: 'FIELD_FINDING_TARGET_LEVEL_INVALID', message: 'Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación', }); } if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) { throw new ConflictException({ code: 'FIELD_FINDING_FAMILY_REQUIRED', message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos', }); } if (!row.insideScope) { throw new BadRequestException({ code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT', message: 'El Inventario no pertenece al Yacimiento definido como alcance 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 actContainsAsset(actId: string, assetId: string): Promise { const [row] = await this.dataSource.query(` SELECT EXISTS ( SELECT 1 FROM inspection_act_assets WHERE act_id=$1::uuid AND asset_id=$2::uuid AND included=true ) AS included `, [actId, assetId]) as Array<{ included: boolean }>; return Boolean(row?.included); } private async requireDraftAct(visitId: string, requestedActId?: string): Promise { if (requestedActId) { const [act] = await this.dataSource.query(` SELECT id,code,status FROM inspection_acts WHERE id=$1::uuid AND visit_id=$2::uuid `, [requestedActId, visitId]) as DraftActRow[]; if (!act) { throw new NotFoundException({ code: 'FIELD_FINDING_ACT_NOT_FOUND', message: 'El Acta seleccionada no pertenece a esta inspección', }); } if (act.status !== 'DRAFT') { throw new ConflictException({ code: 'FIELD_FINDING_ACT_NOT_DRAFT', message: 'Los Hallazgos nuevos sólo pueden agregarse a un Acta en borrador', actId: act.id, actStatus: act.status, }); } return act; } 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]; } }