335 lines
14 KiB
TypeScript
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),
|
|
},
|
|
};
|
|
}
|
|
}
|