F4: add technical finding worklist service
This commit is contained in:
@@ -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<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'`);
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user