feat(f6.9): consolidate act and report documents and simplify follow-up
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m24s
DH V2 CI / API · typecheck, tests, build (push) Successful in 40s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m22s

This commit is contained in:
DH V2
2026-09-15 18:48:12 -03:00
parent 079728aa6d
commit 5cf99442a8
39 changed files with 1604 additions and 566 deletions
@@ -4,11 +4,15 @@ import { isAbsolute, parse, resolve } from 'node:path';
import { ConfigService } from '@nestjs/config';
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { buildInspectionActPdf } from './inspection-act-pdf-builder';
import { buildInspectionActPdf, type ActPdfImage } from './inspection-act-pdf-builder';
import { renderableInspectionImage } from './inspection-document-images';
@Injectable()
export class InspectionActPdfService {
private readonly root: string;
private readonly evidenceRoot: string;
private readonly assetRoot: string;
private readonly signatureRoot: string;
constructor(private readonly dataSource: DataSource, config: ConfigService) {
const configured = config.get<string>('INSPECTION_ACT_PDF_ROOT')
@@ -16,6 +20,9 @@ export class InspectionActPdfService {
if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path');
this.root = resolve(configured);
if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root');
this.evidenceRoot = resolve(config.get<string>('INSPECTION_EVIDENCE_ROOT') ?? '/app/storage/asset-media/inspection-findings');
this.assetRoot = resolve(config.get<string>('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media');
this.signatureRoot = resolve(config.get<string>('INSPECTION_SIGNATURE_ROOT') ?? '/app/storage/asset-media/inspection-signatures');
}
async ensure(actId: string): Promise<void> {
@@ -59,7 +66,7 @@ export class InspectionActPdfService {
`, [actId]);
try {
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
const built = buildInspectionActPdf(snapshot);
const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId), await this.actContext(actId));
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${actId}.pdf`;
const originalName = `${row.code}.pdf`;
@@ -82,6 +89,120 @@ export class InspectionActPdfService {
}
}
// The original sealed PDF remains immutable for historic delivery and audit.
// A consolidated presentation is stored separately and frozen at its first generation.
async consolidatedContent(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
const [existing] = await this.dataSource.query(`
SELECT stored_name AS "storedName",original_name AS "originalName",
size_bytes AS "sizeBytes",sha256
FROM inspection_act_consolidated_pdf_artifacts WHERE act_id=$1
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (existing) {
const buffer = await this.verifiedImage(this.root, existing);
return { buffer, originalName: existing.originalName, mimeType: 'application/pdf' };
}
const [row] = await this.dataSource.query(`
SELECT act.code,act.closure_sha256 AS "closureSha256",closure.final_snapshot AS "finalSnapshot"
FROM inspection_acts act
JOIN inspection_act_closures closure ON closure.act_id=act.id
WHERE act.id=$1 AND act.status='SEALED'
`, [actId]) as Array<{ code: string; closureSha256: string; finalSnapshot: Record<string, unknown> }>;
if (!row) throw new NotFoundException({
code: 'INSPECTION_ACT_NOT_SEALED',
message: 'El Acta debe estar firmada y sellada para generar el documento consolidado',
});
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId), await this.actContext(actId));
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${actId}-consolidado-${built.sha256.slice(0, 24)}.pdf`;
const originalName = `${row.code}-consolidada.pdf`;
await writeFile(resolve(this.root, storedName), built.buffer, { flag: 'wx', mode: 0o600 }).catch(async (error: NodeJS.ErrnoException) => {
if (error.code !== 'EEXIST') throw error;
const previous = await this.verifiedImage(this.root, {
storedName, sizeBytes: built.buffer.length, sha256: built.sha256,
});
if (!previous.equals(built.buffer)) throw this.storageError();
});
await this.dataSource.query(`
INSERT INTO inspection_act_consolidated_pdf_artifacts(act_id,stored_name,original_name,size_bytes,sha256)
VALUES($1,$2,$3,$4,$5) ON CONFLICT (act_id) DO NOTHING
`, [actId, storedName, originalName, built.buffer.length, built.sha256]);
const [saved] = await this.dataSource.query(`
SELECT stored_name AS "storedName",original_name AS "originalName",
size_bytes AS "sizeBytes",sha256
FROM inspection_act_consolidated_pdf_artifacts WHERE act_id=$1
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (!saved) throw this.storageError();
return {
buffer: await this.verifiedImage(this.root, saved),
originalName: saved.originalName, mimeType: 'application/pdf',
};
}
private async actContext(actId: string): Promise<{ companyName: string | null; areaName: string | null; scopeName: string | null }> {
const [row] = await this.dataSource.query(`
SELECT company.name AS "companyName",area.name AS "areaName",scope.name AS "scopeName"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id
LEFT JOIN assets company ON company.id=visit.operator_company_id
LEFT JOIN assets area ON area.id=visit.operational_area_id
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
WHERE act.id=$1
`, [actId]) as Array<{ companyName: string | null; areaName: string | null; scopeName: string | null }>;
return row ?? { companyName: null, areaName: null, scopeName: null };
}
private async verifiedImage(root: string, row: { storedName: string; sha256: string; sizeBytes: number }): Promise<Buffer> {
if (!/^[A-Za-z0-9_.-]+$/.test(row.storedName)) throw new Error('Ruta inválida de evidencia del Acta');
const path = resolve(root, row.storedName);
if (!path.startsWith(`${root}/`)) throw new Error('Ruta inválida de evidencia del Acta');
const file = await stat(path);
if (!file.isFile() || file.size !== Number(row.sizeBytes)) throw new Error('Evidencia incompleta del Acta');
const buffer = await readFile(path);
if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw new Error('Hash de evidencia distinto del registrado');
return buffer;
}
async fieldImages(actId: string): Promise<ActPdfImage[]> {
type ImageRow = { id: string; findingId?: string; assetId?: string; signerName?: string; title?: string; capturedAt?: Date; storedName: string; sha256: string; sizeBytes: number };
const findings = await this.dataSource.query(`
SELECT evidence.id, finding.id AS "findingId", evidence.title,
evidence.captured_at AS "capturedAt", evidence.stored_name AS "storedName",
evidence.sha256, evidence.size_bytes AS "sizeBytes"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id=finding.act_id
JOIN inspection_finding_evidence evidence ON evidence.finding_id=finding.id
WHERE act.id=$1 AND evidence.kind='PHOTO' AND evidence.purpose='OBSERVATION'
AND evidence.created_at<=act.locked_at
ORDER BY finding.finding_number,evidence.captured_at,evidence.id
`, [actId]) as ImageRow[];
const assets = await this.dataSource.query(`
SELECT media.id,asset.id AS "assetId", asset.name AS title,
capture.device_captured_at AS "capturedAt",media.stored_name AS "storedName",
media.sha256,media.size_bytes AS "sizeBytes"
FROM inspection_acts act
JOIN inspection_act_assets link ON link.act_id=act.id AND link.included=true
JOIN assets asset ON asset.id=link.asset_id
JOIN asset_field_capture_events capture ON capture.visit_id=act.visit_id
AND capture.asset_id=asset.id AND capture.event_type='PHOTO'
JOIN asset_media media ON media.id=capture.media_id AND media.deleted_at IS NULL AND media.kind='PHOTO'
WHERE act.id=$1 AND capture.created_at<=act.locked_at
ORDER BY capture.device_captured_at,media.id
`, [actId]) as ImageRow[];
const signatures = await this.dataSource.query(`
SELECT signature.id,signature.signer_name AS "signerName",signature.stored_name AS "storedName",
signature.image_sha256 AS sha256,signature.size_bytes AS "sizeBytes"
FROM inspection_act_signatures signature WHERE signature.act_id=$1
AND signature.status='SIGNED' AND signature.stored_name IS NOT NULL
ORDER BY signature.created_at,signature.id
`, [actId]) as ImageRow[];
const result: ActPdfImage[] = [];
for (const row of findings) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.evidenceRoot, row)) });
for (const row of assets) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.assetRoot, row)) });
for (const row of signatures) result.push({ ...row, buffer: await this.verifiedImage(this.signatureRoot, row) });
return result;
}
async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
const [row] = await this.dataSource.query(`
SELECT original_name AS "originalName",stored_name AS "storedName",