diff --git a/api-v3/src/inspection-visits/f4-inspection-visits.service.ts b/api-v3/src/inspection-visits/f4-inspection-visits.service.ts new file mode 100644 index 0000000..373f4f7 --- /dev/null +++ b/api-v3/src/inspection-visits/f4-inspection-visits.service.ts @@ -0,0 +1,152 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +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 { InspectionVisitsService } from './inspection-visits.service'; + +/** + * Capa activa F4 sobre el servicio histórico de Inspecciones. + * + * Mantiene las operaciones de planificación/equipo/Inventario ya probadas, pero + * elimina de la salida funcional los conceptos retirados en F4: + * - no existe un título de negocio independiente del código institucional; + * - no existe fecha prevista de fin; + * - el checklist es exclusivamente técnico y nunca depende de respuestas de empresa. + * + * Las columnas legacy permanecen temporalmente en PostgreSQL para una migración de + * limpieza posterior a la validación del piloto, sin intervenir datos históricos a ciegas. + */ +@Injectable() +export class F4InspectionVisitsService extends InspectionVisitsService { + constructor( + private readonly f4DataSource: DataSource, + audit: AuditService, + ) { + super(f4DataSource, audit); + } + + override async list(query: ListInspectionVisitsQueryDto) { + const response = await super.list(query); + return { + ...response, + data: response.data.map((visit) => this.normalizeVisit(visit)), + }; + } + + override async getById(id: string): Promise { + await this.reclassifyCurrentChecklist(id); + return this.normalizeVisit(await super.getById(id)); + } + + override async create( + dto: CreateInspectionVisitDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + const created = await super.create(dto, principal, request); + await this.reclassifyCurrentChecklist(created.id); + return this.normalizeVisit(await super.getById(created.id)); + } + + override async generateChecklist( + id: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + await super.generateChecklist(id, principal, request); + await this.reclassifyCurrentChecklist(id); + return this.normalizeVisit(await super.getById(id)); + } + + private normalizeVisit(visit: T): T { + const normalized = { + ...visit, + // Compatibilidad de contrato: consumidores antiguos reciben el identificador, + // no un segundo nombre persistente que pueda divergir. + title: visit.code, + plannedEndAt: null, + } as T; + + if ('checklist' in normalized) { + const view = normalized as T & InspectionVisitView; + view.checklist = { + ...view.checklist, + companyOverdue: 0, + items: view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE'), + }; + } + return normalized; + } + + 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; + + 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]); + }); + } +}