diff --git a/api-v3/src/act-administration/field-briefing.service.ts b/api-v3/src/act-administration/field-briefing.service.ts new file mode 100644 index 0000000..c718ca8 --- /dev/null +++ b/api-v3/src/act-administration/field-briefing.service.ts @@ -0,0 +1,242 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +export type FieldBriefingActState = + | 'ACT_RESPONSE_OVERDUE' + | 'ACT_RESPONSE_DUE_SOON' + | 'WAITING_RESPONSE' + | 'COMPANY_COMMITMENT_OVERDUE' + | 'VERIFICATION_PENDING' + | 'RESPONSE_RECEIVED'; + +export interface FieldBriefingFinding { + id: string; + code: string; + title: string; + status: string; + severity: number | null; + nextControlOn: string | null; + asset: { + id: string; + code: string; + name: string; + typeName: string; + }; +} + +export interface FieldBriefingAct { + actId: string; + actCode: string; + occurredAt: Date; + adminState: FieldBriefingActState; + responseDueOn: string | null; + responseReceivedOn: string | null; + committedCorrectionOn: string | null; + latestResponseId: string | null; + findings: FieldBriefingFinding[]; +} + +@Injectable() +export class FieldBriefingService { + constructor(private readonly dataSource: DataSource) {} + + async forVisit(visitId: string) { + const [visit] = (await this.dataSource.query(` + SELECT + visit.id, + visit.code, + visit.status, + visit.planned_start_at AS "plannedStartAt", + visit.operational_area_id AS "areaId", + visit.operator_company_id AS "companyId", + area.code AS "areaCode", + area.name AS "areaName", + company.code AS "companyCode", + COALESCE(profile.legal_name, company.name) AS "companyName" + FROM inspection_visits visit + LEFT JOIN assets area ON area.id = visit.operational_area_id + LEFT JOIN assets company ON company.id = visit.operator_company_id + LEFT JOIN organization_profiles profile ON profile.asset_id = company.id + WHERE visit.id = $1 + `, [visitId])) as Array>; + + if (!visit) { + throw new NotFoundException({ + code: 'INSPECTION_VISIT_NOT_FOUND', + message: 'Inspección no encontrada', + }); + } + + const plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10); + + const rows = (await this.dataSource.query(` + WITH prior_acts AS ( + SELECT + act.id AS act_id, + act.code AS act_code, + act.occurred_at, + deadline.response_due_on, + response.id AS latest_response_id, + response.received_on AS response_received_on, + response.committed_correction_on, + finding.id AS finding_id, + finding.code AS finding_code, + finding.title AS finding_title, + finding.status AS finding_status, + finding.severity, + finding.next_control_on, + asset.id AS asset_id, + asset.code AS asset_code, + asset.name AS asset_name, + asset_type.name AS asset_type_name, + CASE + WHEN response.id IS NULL + AND deadline.response_due_on IS NOT NULL + AND deadline.response_due_on < $4::date + THEN 'ACT_RESPONSE_OVERDUE' + WHEN response.id IS NULL + AND deadline.response_due_on IS NOT NULL + AND deadline.response_due_on BETWEEN $4::date AND ($4::date + 3) + THEN 'ACT_RESPONSE_DUE_SOON' + WHEN response.id IS NOT NULL + AND response.committed_correction_on IS NOT NULL + AND response.committed_correction_on < $4::date + THEN 'COMPANY_COMMITMENT_OVERDUE' + WHEN response.id IS NOT NULL + AND finding.next_control_on IS NULL + THEN 'VERIFICATION_PENDING' + WHEN response.id IS NOT NULL + THEN 'RESPONSE_RECEIVED' + ELSE 'WAITING_RESPONSE' + END AS admin_state + FROM inspection_acts act + INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id + INNER JOIN inspection_findings finding ON finding.act_id = act.id + AND finding.status = 'OPEN' + 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 LATERAL ( + SELECT event.response_due_on + FROM inspection_act_deadline_events event + WHERE event.act_id = act.id + ORDER BY event.created_at DESC, event.id DESC + LIMIT 1 + ) deadline ON true + LEFT JOIN LATERAL ( + SELECT company_response.id, company_response.received_on, company_response.committed_correction_on + FROM inspection_act_company_responses company_response + WHERE company_response.act_id = act.id + ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC + LIMIT 1 + ) response ON true + WHERE act.status IN ('CLOSED', 'RECTIFIED') + AND source_visit.id <> $1 + AND source_visit.operational_area_id = $2::uuid + AND source_visit.operator_company_id = $3::uuid + ) + SELECT * + FROM prior_acts + WHERE admin_state IN ( + 'ACT_RESPONSE_OVERDUE', + 'ACT_RESPONSE_DUE_SOON', + 'COMPANY_COMMITMENT_OVERDUE', + 'VERIFICATION_PENDING', + 'RESPONSE_RECEIVED' + ) + OR next_control_on IS NOT NULL + ORDER BY + CASE admin_state + WHEN 'ACT_RESPONSE_OVERDUE' THEN 1 + WHEN 'COMPANY_COMMITMENT_OVERDUE' THEN 2 + WHEN 'VERIFICATION_PENDING' THEN 3 + WHEN 'ACT_RESPONSE_DUE_SOON' THEN 4 + ELSE 5 + END, + response_due_on NULLS LAST, + occurred_at, + act_code, + finding_code + `, [visitId, visit.areaId, visit.companyId, plannedOn])) as Array<{ + act_id: string; + act_code: string; + occurred_at: Date; + response_due_on: string | null; + latest_response_id: string | null; + response_received_on: string | null; + committed_correction_on: string | null; + finding_id: string; + finding_code: string; + finding_title: string; + finding_status: string; + severity: number | null; + next_control_on: string | null; + asset_id: string; + asset_code: string; + asset_name: string; + asset_type_name: string; + admin_state: FieldBriefingActState; + }>; + + const byAct = new Map(); + for (const row of rows) { + let act = byAct.get(row.act_id); + if (!act) { + act = { + actId: row.act_id, + actCode: row.act_code, + occurredAt: row.occurred_at, + adminState: row.admin_state, + responseDueOn: row.response_due_on, + responseReceivedOn: row.response_received_on, + committedCorrectionOn: row.committed_correction_on, + latestResponseId: row.latest_response_id, + findings: [], + }; + byAct.set(row.act_id, act); + } + act.findings.push({ + id: row.finding_id, + code: row.finding_code, + title: row.finding_title, + status: row.finding_status, + severity: row.severity, + nextControlOn: row.next_control_on, + asset: { + id: row.asset_id, + code: row.asset_code, + name: row.asset_name, + typeName: row.asset_type_name, + }, + }); + } + + const acts = [...byAct.values()]; + const assetIds = new Set(); + let findingCount = 0; + for (const act of acts) { + findingCount += act.findings.length; + for (const finding of act.findings) assetIds.add(finding.asset.id); + } + + return { + inspection: { + id: visit.id, + code: visit.code, + status: visit.status, + plannedStartAt: visit.plannedStartAt, + area: visit.areaId ? { id: visit.areaId, code: visit.areaCode, name: visit.areaName } : null, + operatorCompany: visit.companyId ? { id: visit.companyId, code: visit.companyCode, name: visit.companyName } : null, + }, + plannedOn, + summary: { + acts: acts.length, + findings: findingCount, + inventoryItems: assetIds.size, + responseOverdue: acts.filter((item) => item.adminState === 'ACT_RESPONSE_OVERDUE').length, + commitmentOverdue: acts.filter((item) => item.adminState === 'COMPANY_COMMITMENT_OVERDUE').length, + verificationPending: acts.filter((item) => item.adminState === 'VERIFICATION_PENDING').length, + }, + acts, + }; + } +}