diff --git a/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts b/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts new file mode 100644 index 0000000..0838a2d --- /dev/null +++ b/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts @@ -0,0 +1,333 @@ +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; + title: 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'`); + } + + 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 = asset.operator_company_id + LEFT JOIN assets area ON area.id = asset.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, + 'title', verification_visit.title, + '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.title, + 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), + }, + }; + } +}