From 746592f896fccdcfa0c3afafedcc513c3cdc378f Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 13:26:16 -0300 Subject: [PATCH] fix(F6.1): make F4 checklist classification read-only --- .../f4-inspection-visits.service.ts | 185 ++++++++++-------- 1 file changed, 106 insertions(+), 79 deletions(-) diff --git a/api-v3/src/inspection-visits/f4-inspection-visits.service.ts b/api-v3/src/inspection-visits/f4-inspection-visits.service.ts index 8987e5d..3a515d3 100644 --- a/api-v3/src/inspection-visits/f4-inspection-visits.service.ts +++ b/api-v3/src/inspection-visits/f4-inspection-visits.service.ts @@ -4,15 +4,25 @@ import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; -import type { InspectionVisitView } from './inspection-visits.service'; +import type { + InspectionChecklistItemKind, + InspectionVisitView, +} from './inspection-visits.service'; import { InspectionVisitsService } from './inspection-visits.service'; +type F4ChecklistClassification = { + findingId: string; + itemKind: InspectionChecklistItemKind; + referenceOn: string | null; +}; + /** * Capa activa F4 sobre el servicio de Inspecciones. * - * Conserva las operaciones de planificación/equipo/Inventario ya probadas y aplica - * la clasificación técnica F4 del checklist. El contrato público de Inspección ya - * no expone título independiente ni fecha prevista de fin. + * El checklist persistido es una generación histórica append-only. F4 necesita + * mostrar la clasificación vigente de controles, pero una lectura nunca debe + * reescribir esa generación. La clasificación se deriva con SELECT y se aplica + * sólo a la vista devuelta por la API. */ @Injectable() export class F4InspectionVisitsService extends InspectionVisitsService { @@ -32,8 +42,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService { } override async getById(id: string): Promise { - await this.reclassifyCurrentChecklist(id); - return this.normalizeChecklist(await super.getById(id)); + const visit = await super.getById(id); + return this.normalizeChecklist(await this.classifyChecklistView(visit)); } override async create( @@ -41,9 +51,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService { principal: AuthPrincipal, request: RequestWithContext, ): Promise { - const created = await super.create(dto, principal, request); - await this.reclassifyCurrentChecklist(created.id); - return this.normalizeChecklist(await super.getById(created.id)); + const visit = await super.create(dto, principal, request); + return this.normalizeChecklist(await this.classifyChecklistView(visit)); } override async generateChecklist( @@ -51,88 +60,106 @@ export class F4InspectionVisitsService extends InspectionVisitsService { principal: AuthPrincipal, request: RequestWithContext, ): Promise { - await super.generateChecklist(id, principal, request); - await this.reclassifyCurrentChecklist(id); - return this.normalizeChecklist(await super.getById(id)); + const visit = await super.generateChecklist(id, principal, request); + return this.normalizeChecklist(await this.classifyChecklistView(visit)); } private normalizeChecklist(visit: T): T { if (!visit || typeof visit !== 'object' || !('checklist' in visit)) return visit; const view = visit as T & InspectionVisitView; + const items = view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE'); + const actionableAssetIds = new Set( + items + .filter((item) => ['VERIFICATION_OVERDUE', 'UPCOMING_CONTROL'].includes(item.itemKind)) + .map((item) => item.asset.id), + ); view.checklist = { ...view.checklist, + antecedents: items.filter((item) => item.itemKind === 'ANTECEDENT').length, companyOverdue: 0, - items: view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE'), + verificationOverdue: items.filter((item) => item.itemKind === 'VERIFICATION_OVERDUE').length, + upcomingControls: items.filter((item) => item.itemKind === 'UPCOMING_CONTROL').length, + actionableAssets: actionableAssetIds.size, + items, }; return view; } - private async reclassifyCurrentChecklist(visitId: string): Promise { - await this.f4DataSource.transaction(async (manager) => { - const [visit] = await manager.query(` - SELECT checklist_generation AS generation, planned_start_at AS "plannedStartAt" - FROM inspection_visits - WHERE id=$1 - FOR UPDATE - `, [visitId]) as Array<{ generation: number; plannedStartAt: Date | null }>; - if (!visit || Number(visit.generation) < 1 || !visit.plannedStartAt) return; + /** + * Deriva el estado F4 actual de los ítems sin mutar la generación persistida. + * La fuente administrativa de empresa vive a nivel Acta; en este checklist de + * campo sólo importan próximos controles y verificaciones no resueltas. + */ + private async classifyChecklistView(visit: InspectionVisitView): Promise { + if (!visit.plannedStartAt || visit.checklist.items.length === 0) return visit; + const findingIds = [...new Set(visit.checklist.items.map((item) => item.findingId))]; + if (findingIds.length === 0) return visit; + const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10); - const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10); - await manager.query(` - UPDATE inspection_visit_checklist_items item - SET - item_kind = CASE - WHEN finding.status='OPEN' - AND finding.next_control_on IS NOT NULL - AND finding.next_control_on < $3::date - AND COALESCE(( - SELECT verification.outcome - FROM inspection_finding_verification_visits verification - WHERE verification.finding_id=finding.id - AND verification.outcome IS NOT NULL - ORDER BY verification.result_recorded_at DESC NULLS LAST, - verification.created_at DESC, - verification.id DESC - LIMIT 1 - ), '') <> 'RESOLVED' - THEN 'VERIFICATION_OVERDUE' - WHEN finding.status='OPEN' - AND finding.next_control_on IS NOT NULL - AND finding.next_control_on BETWEEN $3::date AND ($3::date + 30) - AND COALESCE(( - SELECT verification.outcome - FROM inspection_finding_verification_visits verification - WHERE verification.finding_id=finding.id - AND verification.outcome IS NOT NULL - ORDER BY verification.result_recorded_at DESC NULLS LAST, - verification.created_at DESC, - verification.id DESC - LIMIT 1 - ), '') <> 'RESOLVED' - THEN 'UPCOMING_CONTROL' - ELSE 'ANTECEDENT' - END, - reference_on = CASE - WHEN finding.status='OPEN' - AND finding.next_control_on IS NOT NULL - AND COALESCE(( - SELECT verification.outcome - FROM inspection_finding_verification_visits verification - WHERE verification.finding_id=finding.id - AND verification.outcome IS NOT NULL - ORDER BY verification.result_recorded_at DESC NULLS LAST, - verification.created_at DESC, - verification.id DESC - LIMIT 1 - ), '') <> 'RESOLVED' - THEN finding.next_control_on - ELSE NULL - END - FROM inspection_findings finding - WHERE item.visit_id=$1 - AND item.generation_number=$2 - AND finding.id=item.finding_id - `, [visitId, visit.generation, targetDate]); - }); + const classifications = (await this.f4DataSource.query(` + SELECT + finding.id AS "findingId", + CASE + WHEN finding.status='OPEN' + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on < $2::date + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id=finding.id + AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, + verification.created_at DESC, + verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' + THEN 'VERIFICATION_OVERDUE' + WHEN finding.status='OPEN' + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on BETWEEN $2::date AND ($2::date + 30) + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id=finding.id + AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, + verification.created_at DESC, + verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' + THEN 'UPCOMING_CONTROL' + ELSE 'ANTECEDENT' + END AS "itemKind", + CASE + WHEN finding.status='OPEN' + AND finding.next_control_on IS NOT NULL + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id=finding.id + AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, + verification.created_at DESC, + verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' + THEN finding.next_control_on::text + ELSE NULL + END AS "referenceOn" + FROM inspection_findings finding + WHERE finding.id=ANY($1::uuid[]) + `, [findingIds, targetDate])) as F4ChecklistClassification[]; + + const byFinding = new Map(classifications.map((item) => [item.findingId, item])); + visit.checklist = { + ...visit.checklist, + items: visit.checklist.items.map((item) => { + const classification = byFinding.get(item.findingId); + return classification + ? { ...item, itemKind: classification.itemKind, referenceOn: classification.referenceOn } + : item; + }), + }; + return visit; } }