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
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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user