From 31256ea0b5f2a99885fb18c8057b0f40f897f3d9 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:57:22 -0300 Subject: [PATCH] feat(f4): implement INF editing, GEDO officialization and follow-up --- .../inspection-report-workflow.service.ts | 433 ++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 api-v3/src/inspection-reports/inspection-report-workflow.service.ts diff --git a/api-v3/src/inspection-reports/inspection-report-workflow.service.ts b/api-v3/src/inspection-reports/inspection-report-workflow.service.ts new file mode 100644 index 0000000..41d06d3 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-report-workflow.service.ts @@ -0,0 +1,433 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, unlink, writeFile } from 'node:fs/promises'; +import { isAbsolute, parse, resolve } from 'node:path'; +import { + BadRequestException, + ConflictException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { + AuditAction, + InspectionActUrgency, + InspectionDeadlineBasis, + InspectionDeadlineDayType, + InspectionReportStatus, +} from '../database/entities'; +import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto'; +import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto'; +import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; + +export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024; + +export interface UploadedInspectionReportFile { + buffer: Buffer; + originalname: string; + mimetype: string; + size: number; +} + +interface ReportRow { + id: string; + actId: string; + visitId: string; + code: string; + status: InspectionReportStatus; + executiveSummary: string | null; + reportDescription: string | null; + gedoIfIdentifier: string | null; + gedoOfficializedAt: Date | null; +} + +function reportNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_REPORT_NOT_FOUND', + message: 'Informe de inspección no encontrado', + }); +} + +@Injectable() +export class InspectionReportWorkflowService { + private readonly root: string; + + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + config: ConfigService, + ) { + const configured = config.get('INSPECTION_REPORT_UPLOAD_ROOT') + ?? '/app/storage/inspection-reports'; + if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_UPLOAD_ROOT must be absolute'); + this.root = resolve(configured); + if (this.root === parse(this.root).root) { + throw new Error('INSPECTION_REPORT_UPLOAD_ROOT cannot be filesystem root'); + } + } + + async updateNarrative( + reportId: string, + dto: UpdateInspectionReportDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + if (Object.keys(dto).length === 0) { + throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + } + return this.dataSource.transaction(async (manager) => { + const report = await this.lockReport(manager, reportId); + if (report.status !== InspectionReportStatus.WORKING) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED', + message: 'El contenido editable del INF queda cerrado cuando se registra su IF oficial de GEDO', + }); + } + const before = { + executiveSummary: report.executiveSummary, + reportDescription: report.reportDescription, + }; + const executiveSummary = dto.executiveSummary !== undefined + ? dto.executiveSummary + : report.executiveSummary; + const reportDescription = dto.description !== undefined + ? dto.description + : report.reportDescription; + await manager.query(` + UPDATE inspection_reports + SET executive_summary=$2, + report_description=$3, + updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [reportId, executiveSummary, reportDescription]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_UPDATED, + entityType: 'inspection_report', + entityId: reportId, + beforeData: before, + afterData: { executiveSummary, reportDescription }, + metadata: { reportCode: report.code, actId: report.actId }, + }, manager); + return this.getWorkflowView(manager, reportId); + }); + } + + async officialize( + reportId: string, + dto: OfficializeInspectionReportDto, + file: UploadedInspectionReportFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + this.assertPdf(file); + const id = randomUUID(); + const storedName = `gedo-${id}.pdf`; + const filePath = resolve(this.root, storedName); + const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); + await mkdir(this.root, { recursive: true, mode: 0o700 }); + await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); + try { + return await this.dataSource.transaction(async (manager) => { + const report = await this.lockReport(manager, reportId); + if (report.status !== InspectionReportStatus.WORKING) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED', + message: 'El IF oficial de GEDO ya fue registrado y es inmutable', + }); + } + const officializedAt = new Date(dto.gedoOfficializedAt); + const now = new Date(); + if (officializedAt.getTime() > now.getTime() + 24 * 60 * 60 * 1000) { + throw new BadRequestException({ + code: 'INVALID_GEDO_DATE', + message: 'La fecha de GEDO no puede estar más de 24 horas en el futuro', + }); + } + await manager.query(` + UPDATE inspection_reports + SET status='OFFICIALIZED', + gedo_if_identifier=$2, + gedo_officialized_at=$3, + gedo_pdf_original_name=$4, + gedo_pdf_stored_name=$5, + gedo_pdf_mime_type='application/pdf', + gedo_pdf_size_bytes=$6, + gedo_pdf_sha256=$7, + updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [ + reportId, + dto.gedoIfIdentifier, + officializedAt, + file!.originalname, + storedName, + file!.buffer.length, + sha256, + ]); + await this.activateNonUrgentDeadline(manager, report.actId, officializedAt); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_OFFICIALIZED, + entityType: 'inspection_report', + entityId: reportId, + afterData: { + status: InspectionReportStatus.OFFICIALIZED, + gedoIfIdentifier: dto.gedoIfIdentifier, + gedoOfficializedAt: officializedAt, + gedoPdfOriginalName: file!.originalname, + gedoPdfSha256: sha256, + }, + metadata: { reportCode: report.code, actId: report.actId, immutable: true }, + }, manager); + return this.getWorkflowView(manager, reportId); + }); + } catch (error) { + await unlink(filePath).catch(() => undefined); + throw error; + } + } + + async addFollowUp( + reportId: string, + dto: CreateInspectionReportFollowUpDto, + file: UploadedInspectionReportFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + if (file && file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) { + throw new BadRequestException({ + code: 'INSPECTION_REPORT_FILE_TOO_LARGE', + message: 'El archivo supera el máximo permitido de 40 MB', + }); + } + const followUpId = randomUUID(); + const ext = this.safeExtension(file?.originalname); + const storedName = file ? `followup-${followUpId}${ext}` : null; + const filePath = storedName ? resolve(this.root, storedName) : null; + const sha256 = file ? createHash('sha256').update(file.buffer).digest('hex') : null; + if (filePath && file) { + await mkdir(this.root, { recursive: true, mode: 0o700 }); + await writeFile(filePath, file.buffer, { flag: 'wx', mode: 0o600 }); + } + try { + return await this.dataSource.transaction(async (manager) => { + const report = await this.lockReport(manager, reportId); + const occurredAt = new Date(dto.occurredAt); + await manager.query(` + INSERT INTO inspection_report_follow_ups ( + id,report_id,type,external_reference,occurred_at,description, + original_name,stored_name,mime_type,size_bytes,sha256,created_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + `, [ + followUpId, + reportId, + dto.type, + dto.externalReference ?? null, + occurredAt, + dto.description ?? null, + file?.originalname ?? null, + storedName, + file?.mimetype ?? null, + file?.buffer.length ?? null, + sha256, + principal.userId, + ]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_FOLLOW_UP_ADDED, + entityType: 'inspection_report_follow_up', + entityId: followUpId, + afterData: { + reportId, + type: dto.type, + externalReference: dto.externalReference ?? null, + occurredAt, + description: dto.description ?? null, + originalName: file?.originalname ?? null, + sha256, + }, + metadata: { reportCode: report.code, actId: report.actId, appendOnly: true }, + }, manager); + return this.listFollowUpsWithManager(manager, reportId); + }); + } catch (error) { + if (filePath) await unlink(filePath).catch(() => undefined); + throw error; + } + } + + async listFollowUps(reportId: string) { + const report = await this.getReport(this.dataSource.manager, reportId); + if (!report) throw reportNotFound(); + return this.listFollowUpsWithManager(this.dataSource.manager, reportId); + } + + private async listFollowUpsWithManager(manager: EntityManager, reportId: string) { + return manager.query(` + SELECT + follow_up.id, + follow_up.report_id AS "reportId", + follow_up.type, + follow_up.external_reference AS "externalReference", + follow_up.occurred_at AS "occurredAt", + follow_up.description, + follow_up.original_name AS "originalName", + follow_up.mime_type AS "mimeType", + follow_up.size_bytes AS "sizeBytes", + follow_up.sha256, + follow_up.created_by AS "createdBy", + follow_up.created_at AS "createdAt" + FROM inspection_report_follow_ups follow_up + WHERE follow_up.report_id=$1 + ORDER BY follow_up.occurred_at,follow_up.created_at,follow_up.id + `, [reportId]); + } + + private async activateNonUrgentDeadline( + manager: EntityManager, + actId: string, + officializedAt: Date, + ): Promise { + const [act] = await manager.query(` + SELECT urgency,deadline_days AS "deadlineDays",deadline_day_type AS "deadlineDayType", + deadline_basis AS "deadlineBasis",deadline_at AS "deadlineAt" + FROM inspection_acts WHERE id=$1 FOR UPDATE + `, [actId]) as Array<{ + urgency: InspectionActUrgency; + deadlineDays: number | null; + deadlineDayType: InspectionDeadlineDayType | null; + deadlineBasis: InspectionDeadlineBasis | null; + deadlineAt: Date | null; + }>; + if (!act) return; + if (act.urgency !== InspectionActUrgency.NON_URGENT) return; + if (act.deadlineBasis !== InspectionDeadlineBasis.GEDO_DATE) { + throw new ConflictException({ + code: 'INVALID_NON_URGENT_DEADLINE_BASIS', + message: 'El acta no urgente no conserva la regla GEDO necesaria para calcular su vencimiento', + }); + } + if (!act.deadlineDays || !act.deadlineDayType) { + throw new ConflictException({ + code: 'MISSING_ACT_DEADLINE_SNAPSHOT', + message: 'El acta no conserva la política de plazo aplicada al momento de finalizarse', + }); + } + const deadlineAt = await this.calculateDeadline( + manager, + officializedAt, + Number(act.deadlineDays), + act.deadlineDayType, + ); + await manager.query(` + UPDATE inspection_acts + SET deadline_base_at=$2,deadline_at=$3,updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [actId, officializedAt, deadlineAt]); + } + + private async calculateDeadline( + manager: EntityManager, + baseAt: Date, + days: number, + dayType: InspectionDeadlineDayType, + ): Promise { + if (dayType === InspectionDeadlineDayType.CALENDAR) { + const [row] = await manager.query(` + SELECT ( + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + $2::integer)::timestamp + + time '23:59:59' + ) AT TIME ZONE 'America/Argentina/Mendoza' AS due_at + `, [baseAt, days]) as Array<{ due_at: Date }>; + return row.due_at; + } + const [row] = await manager.query(` + WITH candidates AS ( + SELECT day::date AS day, + COALESCE(override.is_business_day, EXTRACT(ISODOW FROM day)::integer BETWEEN 1 AND 5) AS business + FROM generate_series( + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 1)::timestamp, + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 730)::timestamp, + interval '1 day' + ) day + LEFT JOIN inspection_business_calendar_days override ON override.date=day::date + ), ranked AS ( + SELECT day,ROW_NUMBER() OVER (ORDER BY day) AS position FROM candidates WHERE business=true + ) + SELECT ((day::timestamp + time '23:59:59') AT TIME ZONE 'America/Argentina/Mendoza') AS due_at + FROM ranked WHERE position=$2 LIMIT 1 + `, [baseAt, days]) as Array<{ due_at: Date }>; + if (!row?.due_at) throw new InternalServerErrorException('No se pudo calcular el vencimiento'); + return row.due_at; + } + + private assertPdf(file: UploadedInspectionReportFile | undefined): void { + if (!file?.buffer?.length) { + throw new BadRequestException({ + code: 'GEDO_PDF_REQUIRED', + message: 'Debe adjuntarse el PDF oficial generado por GEDO', + }); + } + if (file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) { + throw new BadRequestException({ + code: 'GEDO_PDF_TOO_LARGE', + message: 'El PDF oficial supera el máximo permitido de 40 MB', + }); + } + if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') { + throw new BadRequestException({ + code: 'INVALID_GEDO_PDF', + message: 'El documento oficial de GEDO debe ser un PDF válido', + }); + } + } + + private safeExtension(name: string | undefined): string { + if (!name) return ''; + const dot = name.lastIndexOf('.'); + if (dot < 0) return ''; + const ext = name.slice(dot).toLowerCase(); + return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : ''; + } + + private async lockReport(manager: EntityManager, id: string): Promise { + const [row] = await manager.query(` + SELECT id,act_id AS "actId",visit_id AS "visitId",code,status, + executive_summary AS "executiveSummary",report_description AS "reportDescription", + gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt" + FROM inspection_reports WHERE id=$1 FOR UPDATE + `, [id]) as ReportRow[]; + if (!row) throw reportNotFound(); + return row; + } + + private async getReport(manager: EntityManager, id: string): Promise { + const [row] = await manager.query(` + SELECT id,act_id AS "actId",visit_id AS "visitId",code,status, + executive_summary AS "executiveSummary",report_description AS "reportDescription", + gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt" + FROM inspection_reports WHERE id=$1 + `, [id]) as ReportRow[]; + return row ?? null; + } + + private async getWorkflowView(manager: EntityManager, id: string) { + const report = await this.getReport(manager, id); + if (!report) throw reportNotFound(); + const [act] = await manager.query(` + SELECT code,urgency,deadline_days AS "deadlineDays",deadline_day_type AS "deadlineDayType", + deadline_basis AS "deadlineBasis",deadline_base_at AS "deadlineBaseAt",deadline_at AS "deadlineAt" + FROM inspection_acts WHERE id=$1 + `, [report.actId]); + return { + ...report, + act, + followUps: await this.listFollowUpsWithManager(manager, id), + }; + } +}