Files
dh-inspeccion-v2/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts
T

335 lines
14 KiB
TypeScript

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<InspectionFindingWorklistPage> {
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),
},
};
}
}