import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; import type { InspectionChecklistItemKind, InspectionVisitView, } from './inspection-visits.service'; import { InspectionVisitsService } from './inspection-visits.service'; type F4ChecklistClassification = { findingId: string; itemKind: InspectionChecklistItemKind; referenceOn: string | null; }; /** * Capa activa F4 sobre el servicio de Inspecciones. * * El checklist persistido es una generación histórica append-only. F4 necesita * mostrar la clasificación vigente de controles, pero una lectura nunca debe * reescribir esa generación. La clasificación se deriva con SELECT y se aplica * sólo a la vista devuelta por la API. */ @Injectable() export class F4InspectionVisitsService extends InspectionVisitsService { constructor( private readonly f4DataSource: DataSource, audit: AuditService, ) { super(f4DataSource, audit); } override async list(query: ListInspectionVisitsQueryDto) { const response = await super.list(query); return { ...response, data: response.data.map((visit) => this.normalizeChecklist(visit)), }; } override async getById(id: string): Promise { const visit = await super.getById(id); return this.normalizeChecklist(await this.classifyChecklistView(visit)); } override async create( dto: CreateInspectionVisitDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { const visit = await super.create(dto, principal, request); return this.normalizeChecklist(await this.classifyChecklistView(visit)); } override async generateChecklist( id: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { const visit = await super.generateChecklist(id, principal, request); return this.normalizeChecklist(await this.classifyChecklistView(visit)); } private normalizeChecklist(visit: T): T { if (!visit || typeof visit !== 'object' || !('checklist' in visit)) return visit; const view = visit as T & InspectionVisitView; const items = view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE'); const actionableAssetIds = new Set( items .filter((item) => ['VERIFICATION_OVERDUE', 'UPCOMING_CONTROL'].includes(item.itemKind)) .map((item) => item.asset.id), ); view.checklist = { ...view.checklist, antecedents: items.filter((item) => item.itemKind === 'ANTECEDENT').length, companyOverdue: 0, verificationOverdue: items.filter((item) => item.itemKind === 'VERIFICATION_OVERDUE').length, upcomingControls: items.filter((item) => item.itemKind === 'UPCOMING_CONTROL').length, actionableAssets: actionableAssetIds.size, items, }; return view; } /** * Deriva el estado F4 actual de los ítems sin mutar la generación persistida. * La fuente administrativa de empresa vive a nivel Acta; en este checklist de * campo sólo importan próximos controles y verificaciones no resueltas. */ private async classifyChecklistView(visit: InspectionVisitView): Promise { if (!visit.plannedStartAt || visit.checklist.items.length === 0) return visit; const findingIds = [...new Set(visit.checklist.items.map((item) => item.findingId))]; if (findingIds.length === 0) return visit; const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10); const classifications = (await this.f4DataSource.query(` SELECT finding.id AS "findingId", CASE WHEN finding.status='OPEN' AND finding.next_control_on IS NOT NULL AND finding.next_control_on < $2::date AND COALESCE(( SELECT verification.outcome FROM inspection_finding_verification_visits verification WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC LIMIT 1 ), '') <> 'RESOLVED' THEN 'VERIFICATION_OVERDUE' WHEN finding.status='OPEN' AND finding.next_control_on IS NOT NULL AND finding.next_control_on BETWEEN $2::date AND ($2::date + 30) AND COALESCE(( SELECT verification.outcome FROM inspection_finding_verification_visits verification WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC LIMIT 1 ), '') <> 'RESOLVED' THEN 'UPCOMING_CONTROL' ELSE 'ANTECEDENT' END AS "itemKind", CASE WHEN finding.status='OPEN' AND finding.next_control_on IS NOT NULL AND COALESCE(( SELECT verification.outcome FROM inspection_finding_verification_visits verification WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC LIMIT 1 ), '') <> 'RESOLVED' THEN finding.next_control_on::text ELSE NULL END AS "referenceOn" FROM inspection_findings finding WHERE finding.id=ANY($1::uuid[]) `, [findingIds, targetDate])) as F4ChecklistClassification[]; const byFinding = new Map(classifications.map((item) => [item.findingId, item])); visit.checklist = { ...visit.checklist, items: visit.checklist.items.map((item) => { const classification = byFinding.get(item.findingId); return classification ? { ...item, itemKind: classification.itemKind, referenceOn: classification.referenceOn } : item; }), }; return visit; } }