import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import type { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto'; export interface InspectionFindingWorklistCounters { open: number; withoutControlDate: number; toVerify: number; verificationOverdue: number; verificationNext30Days: number; readyToClose: number; closed: number; } export interface InspectionFindingWorklistItem { id: string; actId: string; assetId: string; code: string; status: 'OPEN' | 'CLOSED' | 'VOIDED'; title: string; description: string; severity: number | null; nextControlOn: string | null; closedAt: Date | null; closureNotes: string | null; asset: { id: string; code: string; name: string; commonName: string | null; typeName: string; operatorCompany: { id: string; code: string; name: string } | null; operationalArea: { id: string; code: string; name: string } | null; }; document: { actId: string; actCode: string; actStatus: string; visitId: string; visitCode: string; visitStatus: string; }; verificationVisit: { id: string; code: string; status: string; plannedStartAt: Date | null; } | null; latestVerification: { visitId: string; visitCode: string; visitStatus: string; targetControlOn: string | null; outcome: 'RESOLVED' | 'NOT_RESOLVED' | 'REQUIRES_NEW_DATE' | null; resultNotes: string | null; verifiedAt: Date | null; resultRecordedAt: Date | null; rescheduledControlOn: string | null; evidenceCount: number; } | null; createdAt: Date; updatedAt: Date; } export interface InspectionFindingWorklistPage { data: InspectionFindingWorklistItem[]; meta: { page: number; pageSize: number; total: number; totalPages: number }; counters: InspectionFindingWorklistCounters; } @Injectable() export class InspectionFindingWorklistService { constructor(private readonly dataSource: DataSource) {} async list(query: ListInspectionFindingsQueryDto): Promise { const page = query.page ?? 1; const pageSize = query.pageSize ?? 25; const workflow = query.workflow ?? 'OPEN'; const values: unknown[] = []; const contextFilters: string[] = []; const today = `(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date`; const latestOutcome = `(SELECT latest_verification.outcome FROM inspection_finding_verification_visits latest_verification WHERE latest_verification.finding_id = finding.id AND latest_verification.outcome IS NOT NULL ORDER BY latest_verification.result_recorded_at DESC NULLS LAST, latest_verification.created_at DESC, latest_verification.id DESC LIMIT 1)`; const latestVisitStatus = `(SELECT latest_visit.status FROM inspection_finding_verification_visits latest_verification INNER JOIN inspection_visits latest_visit ON latest_visit.id = latest_verification.visit_id WHERE latest_verification.finding_id = finding.id AND latest_verification.outcome IS NOT NULL ORDER BY latest_verification.result_recorded_at DESC NULLS LAST, latest_verification.created_at DESC, latest_verification.id DESC LIMIT 1)`; const add = (value: unknown): string => { values.push(value); return `$${values.length}`; }; if (query.search) { const parameter = add(`%${query.search}%`); contextFilters.push(`( finding.code ILIKE ${parameter} OR finding.title ILIKE ${parameter} OR finding.description ILIKE ${parameter} OR asset.code ILIKE ${parameter} OR asset.name ILIKE ${parameter} OR company.name ILIKE ${parameter} OR area.name ILIKE ${parameter} OR act.code ILIKE ${parameter} )`); } if (query.companyId) contextFilters.push(`company.id = ${add(query.companyId)}::uuid`); if (query.areaId) contextFilters.push(`area.id = ${add(query.areaId)}::uuid`); if (query.inspectorId) { const inspector = add(query.inspectorId); contextFilters.push(`( visit.lead_inspector_user_id = ${inspector}::uuid OR EXISTS ( SELECT 1 FROM inspection_visit_members member_filter WHERE member_filter.visit_id = visit.id AND member_filter.included = true AND member_filter.user_id = ${inspector}::uuid ) )`); } if (query.dateFrom) contextFilters.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`); if (query.dateTo) contextFilters.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`); const workflowFilters: string[] = []; if (workflow === 'OPEN') workflowFilters.push(`finding.status = 'OPEN'`); if (workflow === 'TO_SCHEDULE_VERIFICATION') { workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NULL AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`); } if (workflow === 'TO_VERIFY') { workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NOT NULL AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`); } if (workflow === 'VERIFICATION_OVERDUE') { workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NOT NULL AND finding.next_control_on < ${today} AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`); } if (workflow === 'READY_TO_CLOSE') { workflowFilters.push(`finding.status = 'OPEN' AND ${latestOutcome} = 'RESOLVED' AND ${latestVisitStatus} = 'CLOSED'`); } if (workflow === 'CLOSED') workflowFilters.push(`finding.status = 'CLOSED'`); if (workflow === 'WAITING_COMPANY' || workflow === 'COMPANY_OVERDUE') { // Compatibilidad de URL histórica: estos estados dejaron de existir en F4. workflowFilters.push(`finding.status = 'OPEN'`); } // Hallazgo, Acta e Inspección forman un hecho histórico. Empresa y Área se // leen del contexto congelado de la Inspección que originó el Hallazgo, no // del snapshot de compatibilidad del Inventario. const joins = ` INNER JOIN inspection_acts act ON act.id = finding.act_id INNER JOIN inspection_visits visit ON visit.id = act.visit_id INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id LEFT JOIN assets company ON company.id = visit.operator_company_id LEFT JOIN assets area ON area.id = visit.operational_area_id `; const filters = [...contextFilters, ...workflowFilters]; const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; const contextWhere = contextFilters.length ? `WHERE ${contextFilters.join(' AND ')}` : ''; const [countRow] = await this.dataSource.query(` SELECT COUNT(*)::integer AS total FROM inspection_findings finding ${joins} ${where} `, values) as Array<{ total: number }>; const total = Number(countRow?.total ?? 0); const listValues = [...values, pageSize, (page - 1) * pageSize]; const limitParameter = `$${values.length + 1}`; const offsetParameter = `$${values.length + 2}`; const data = await this.dataSource.query(` SELECT finding.id, finding.act_id AS "actId", finding.asset_id AS "assetId", finding.code, finding.status, finding.title, finding.description, finding.severity, finding.next_control_on AS "nextControlOn", finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes", JSONB_BUILD_OBJECT( 'id', asset.id, 'code', asset.code, 'name', asset.name, 'commonName', asset.common_name, 'typeName', asset_type.name, 'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', company.id, 'code', company.code, 'name', company.name ) END, 'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', area.id, 'code', area.code, 'name', area.name ) END ) AS asset, JSONB_BUILD_OBJECT( 'actId', act.id, 'actCode', act.code, 'actStatus', act.status, 'visitId', visit.id, 'visitCode', visit.code, 'visitStatus', visit.status ) AS document, CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', verification_visit.id, 'code', verification_visit.code, 'status', verification_visit.status, 'plannedStartAt', verification_visit.planned_start_at ) END AS "verificationVisit", CASE WHEN latest_verification.visit_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'visitId', latest_verification.visit_id, 'visitCode', latest_verification.visit_code, 'visitStatus', latest_verification.visit_status, 'targetControlOn', latest_verification.target_control_on, 'outcome', latest_verification.outcome, 'resultNotes', latest_verification.result_notes, 'verifiedAt', latest_verification.verified_at, 'resultRecordedAt', latest_verification.result_recorded_at, 'rescheduledControlOn', latest_verification.rescheduled_control_on, 'evidenceCount', latest_verification.evidence_count ) END AS "latestVerification", finding.created_at AS "createdAt", finding.updated_at AS "updatedAt" FROM inspection_findings finding ${joins} LEFT JOIN LATERAL ( SELECT verification_target.id, verification_target.code, verification_target.status, verification_target.planned_start_at FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id WHERE verification_link.finding_id = finding.id AND verification_target.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS') ORDER BY verification_link.created_at DESC, verification_link.id DESC LIMIT 1 ) verification_visit ON true LEFT JOIN LATERAL ( SELECT verification_link.visit_id, verification_target.code AS visit_code, verification_target.status AS visit_status, verification_link.target_control_on, verification_link.outcome, verification_link.result_notes, verification_link.verified_at, verification_link.result_recorded_at, verification_link.rescheduled_control_on, (SELECT COUNT(*)::integer FROM inspection_finding_evidence evidence WHERE evidence.finding_id = finding.id AND evidence.verification_visit_id = verification_link.visit_id AND evidence.purpose = 'VERIFICATION') AS evidence_count FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id WHERE verification_link.finding_id = finding.id ORDER BY COALESCE(verification_link.result_recorded_at, verification_link.created_at) DESC, verification_link.id DESC LIMIT 1 ) latest_verification ON true ${where} ORDER BY CASE WHEN finding.status = 'OPEN' THEN 0 ELSE 1 END, COALESCE(finding.next_control_on, '9999-12-31'::date), finding.updated_at DESC, finding.code LIMIT ${limitParameter} OFFSET ${offsetParameter} `, listValues) as InspectionFindingWorklistItem[]; const [counterRow] = await this.dataSource.query(` SELECT COUNT(*) FILTER (WHERE finding.status='OPEN')::integer AS "open", COUNT(*) FILTER ( WHERE finding.status='OPEN' AND finding.next_control_on IS NULL AND COALESCE(${latestOutcome}, '') <> 'RESOLVED' )::integer AS "withoutControlDate", COUNT(*) FILTER ( WHERE finding.status='OPEN' AND finding.next_control_on IS NOT NULL AND COALESCE(${latestOutcome}, '') <> 'RESOLVED' )::integer AS "toVerify", COUNT(*) FILTER ( WHERE finding.status='OPEN' AND finding.next_control_on IS NOT NULL AND finding.next_control_on < ${today} AND COALESCE(${latestOutcome}, '') <> 'RESOLVED' )::integer AS "verificationOverdue", COUNT(*) FILTER ( WHERE finding.status='OPEN' AND finding.next_control_on BETWEEN ${today} AND ${today} + 30 AND COALESCE(${latestOutcome}, '') <> 'RESOLVED' )::integer AS "verificationNext30Days", COUNT(*) FILTER ( WHERE finding.status='OPEN' AND ${latestOutcome}='RESOLVED' AND ${latestVisitStatus}='CLOSED' )::integer AS "readyToClose", COUNT(*) FILTER (WHERE finding.status='CLOSED')::integer AS "closed" FROM inspection_findings finding ${joins} ${contextWhere} `, values) as InspectionFindingWorklistCounters[]; return { data, meta: { page, pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / pageSize), }, counters: { open: Number(counterRow?.open ?? 0), withoutControlDate: Number(counterRow?.withoutControlDate ?? 0), toVerify: Number(counterRow?.toVerify ?? 0), verificationOverdue: Number(counterRow?.verificationOverdue ?? 0), verificationNext30Days: Number(counterRow?.verificationNext30Days ?? 0), readyToClose: Number(counterRow?.readyToClose ?? 0), closed: Number(counterRow?.closed ?? 0), }, }; } }