feat(f4): implement INF editing, GEDO officialization and follow-up
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user