feat(f4): implement INF editing, GEDO officialization and follow-up

This commit is contained in:
2026-09-07 20:57:22 -03:00
parent ad66bb3e61
commit 31256ea0b5
@@ -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<string>('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<void> {
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<Date> {
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<ReportRow> {
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<ReportRow | null> {
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),
};
}
}