fix(f6.9): include every field photo and preserve document revisions
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m8s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
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 59s

This commit is contained in:
DH V2
2026-09-15 19:18:31 -03:00
parent 669c0d2656
commit 60ba37c287
9 changed files with 169 additions and 18 deletions
@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F69ConsolidatedDocumentRevisions1790135400000 implements MigrationInterface {
name = 'F69ConsolidatedDocumentRevisions1790135400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE inspection_act_consolidated_pdf_revisions (
act_id uuid NOT NULL REFERENCES inspection_acts(id) ON DELETE CASCADE,
template_version smallint NOT NULL CHECK (template_version >= 1),
stored_name varchar(255) NOT NULL,
original_name varchar(255) NOT NULL,
size_bytes integer NOT NULL CHECK (size_bytes > 0),
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (act_id,template_version)
)
`);
await queryRunner.query(`
INSERT INTO inspection_act_consolidated_pdf_revisions(
act_id,template_version,stored_name,original_name,size_bytes,sha256,generated_at)
SELECT act_id,1,stored_name,original_name,size_bytes,sha256,generated_at
FROM inspection_act_consolidated_pdf_artifacts
`);
await queryRunner.query(`
CREATE TABLE inspection_report_consolidated_word_revisions (
report_id uuid NOT NULL REFERENCES inspection_reports(id) ON DELETE CASCADE,
template_version smallint NOT NULL CHECK (template_version >= 1),
stored_name varchar(255) NOT NULL,
original_name varchar(255) NOT NULL,
size_bytes integer NOT NULL CHECK (size_bytes > 0),
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (report_id,template_version)
)
`);
await queryRunner.query(`
INSERT INTO inspection_report_consolidated_word_revisions(
report_id,template_version,stored_name,original_name,size_bytes,sha256,generated_at)
SELECT report_id,1,stored_name,original_name,size_bytes,sha256,generated_at
FROM inspection_report_consolidated_word_artifacts
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE inspection_report_consolidated_word_revisions');
await queryRunner.query('DROP TABLE inspection_act_consolidated_pdf_revisions');
}
}
@@ -64,10 +64,10 @@ export async function buildInspectionActPdf(snapshot: Record<string, unknown>, i
const body = (value: unknown) => { need(22); doc.font('body').fillColor('#202939').fontSize(10.5).text(text(value, '-'), { lineGap: 3 }); doc.moveDown(0.4); };
const label = (name: string, value: unknown) => { if (!text(value)) return; need(22); doc.font('body-bold').fillColor('#202939').fontSize(10).text(`${name}: `, { continued: true }); doc.font('body').text(text(value)); doc.moveDown(0.35); };
const image = (entry: ActPdfImage, caption: string) => {
need(290);
need(235);
const y = doc.y;
doc.image(entry.buffer, 58, y, { fit: [470, 245] });
doc.y = y + 250;
doc.image(entry.buffer, 58, y, { fit: [470, 190] });
doc.y = y + 195;
doc.font('body').fontSize(8).fillColor('#47536A').text(`${caption} · SHA-256 ${entry.sha256}`, 58, doc.y, { width: 475 });
doc.moveDown(0.5);
};
@@ -108,7 +108,11 @@ export async function buildInspectionActPdf(snapshot: Record<string, unknown>, i
}
doc.moveDown(0.4);
}
if (!findings.length) for (const photo of images.filter((item) => item.assetId)) image(photo, `Fotografía de inventario ${text(photo.title)}`);
const otherAssetPhotos = images.filter((item) => item.assetId && !shownAssetPhotos.has(item.id));
if (otherAssetPhotos.length) {
heading('Otras instalaciones inspeccionadas');
for (const photo of otherAssetPhotos) image(photo, `Fotografía de inventario ${text(photo.title)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`);
}
heading('Intervinientes y firmas');
for (const signature of signatures) {
const name = text(signature.signerName);
@@ -95,7 +95,8 @@ export class InspectionActPdfService {
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
FROM inspection_act_consolidated_pdf_revisions
WHERE act_id=$1 AND template_version=2
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (existing) {
const buffer = await this.verifiedImage(this.root, existing);
@@ -124,13 +125,13 @@ export class InspectionActPdfService {
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
INSERT INTO inspection_act_consolidated_pdf_revisions(act_id,template_version,stored_name,original_name,size_bytes,sha256)
VALUES($1,2,$2,$3,$4,$5) ON CONFLICT (act_id,template_version) 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
FROM inspection_act_consolidated_pdf_revisions WHERE act_id=$1 AND template_version=2
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (!saved) throw this.storageError();
return {
@@ -139,6 +140,18 @@ export class InspectionActPdfService {
};
}
async consolidatedRevisionContent(actId: string, version: number): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
if (![1, 2].includes(version)) throw new NotFoundException('Versión documental inexistente');
const [row] = await this.dataSource.query(`
SELECT stored_name AS "storedName",original_name AS "originalName",
size_bytes AS "sizeBytes",sha256
FROM inspection_act_consolidated_pdf_revisions
WHERE act_id=$1 AND template_version=$2
`, [actId, version]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (!row) throw new NotFoundException('Versión documental inexistente');
return { buffer: await this.verifiedImage(this.root, row), originalName: row.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"
@@ -122,7 +122,7 @@ function imageDimensions(buffer: Buffer): { width: number; height: number } {
return { width: 800, height: 500 };
}
function drawing(relationship: number, image: Buffer, name: string, maxWidth = 4572000, maxHeight = 2743200, right = false): string {
function drawing(relationship: number, image: Buffer, name: string, maxWidth = 4572000, maxHeight = 2057400, right = false): string {
const dimensions = imageDimensions(image);
const scale = Math.min(maxWidth / dimensions.width, maxHeight / dimensions.height);
const cx = Math.round(dimensions.width * scale);
@@ -136,6 +136,7 @@ function documentXml(input: ReportWordInput, logo: Buffer): string {
const photos = input.photos ?? [];
const authors = snapshot.signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR').map((item) => text(item.signerName)).join(', ');
const legalBasis = [...new Set(snapshot.findings.map((item) => text(item.legalBasis, '')).filter(Boolean))];
const quantity = `${snapshot.findings.length} ${snapshot.findings.length === 1 ? 'hallazgo' : 'hallazgos'}`;
const entries: string[] = [
paragraph('MINISTERIO DE ENERGÍA Y AMBIENTE'),
paragraph('DIRECCIÓN DE HIDROCARBUROS'),
@@ -155,10 +156,11 @@ function documentXml(input: ReportWordInput, logo: Buffer): string {
paragraph('MARCO LEGAL', 'Heading1'),
...(legalBasis.length ? legalBasis.map((basis) => paragraph(basis)) : [paragraph('No se consignó normativa específica en los hallazgos del Acta fuente.')]),
paragraph('DESCRIPCIÓN Y ANÁLISIS TÉCNICO', 'Heading1'),
paragraph(input.reportDescription?.trim() || `Según el Acta ${text(snapshot.act.code)}, se documentaron ${snapshot.findings.length} hallazgo(s) durante la inspección. Se detallan las observaciones y evidencias consignadas a continuación.`),
paragraph(input.reportDescription?.trim() || `Según el Acta ${text(snapshot.act.code)}, se documentaron ${quantity} durante la inspección. Se detallan las observaciones y evidencias consignadas a continuación.`),
paragraph('FOTOS Y HALLAZGOS', 'Heading1'),
];
if (!snapshot.findings.length) entries.push(paragraph('El Acta fuente no registra hallazgos.'));
const shownPhotos = new Set<number>();
for (const finding of snapshot.findings) {
entries.push(paragraph(`${text(finding.code)} ${text(finding.title)}`, 'Heading2'));
entries.push(labelValue('Instalación', text(snapshot.inventories.find((item) => text(item.id) === text(finding.assetId))?.name)));
@@ -168,13 +170,24 @@ function documentXml(input: ReportWordInput, logo: Buffer): string {
for (let index = 0; index < photos.length; index++) {
const photo = photos[index]!;
if (photo.findingId !== text(finding.id) && photo.assetId !== text(finding.assetId)) continue;
shownPhotos.add(index);
entries.push(drawing(index + 3, photo.buffer, photo.title || text(finding.title)));
entries.push(paragraph(`Fotografía vinculada · SHA-256 ${photo.sha256}`));
}
}
const otherPhotos = photos.map((photo, index) => ({ photo, index }))
.filter(({ photo, index }) => photo.assetId && !shownPhotos.has(index));
if (otherPhotos.length) {
entries.push(paragraph('OTRAS INSTALACIONES INSPECCIONADAS', 'Heading1'));
for (const { photo, index } of otherPhotos) {
entries.push(labelValue('Instalación', text(photo.title)));
entries.push(drawing(index + 3, photo.buffer, photo.title || 'Fotografía de instalación'));
entries.push(paragraph(`Fotografía de inventario · SHA-256 ${photo.sha256}`));
}
}
entries.push(
paragraph('CONCLUSIONES', 'Heading1'),
paragraph(input.executiveSummary?.trim() || `La inspección registró ${snapshot.findings.length} hallazgo(s). Su seguimiento y la respuesta de la empresa se documentan en el Informe.`),
paragraph(input.executiveSummary?.trim() || `La inspección registró ${quantity}. Su seguimiento y la respuesta de la empresa se documentan en el Informe.`),
paragraph('ACTA FUENTE E INTEGRIDAD', 'Heading1'),
labelValue('Acta sellada', text(snapshot.source.actCode ?? snapshot.act.code)),
labelValue('SHA-256 del cierre del Acta', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
@@ -129,7 +129,8 @@ export class InspectionReportWordService {
const [existing] = await this.dataSource.query(`
SELECT stored_name AS "storedName",original_name AS "originalName",
size_bytes AS "sizeBytes",sha256
FROM inspection_report_consolidated_word_artifacts WHERE report_id=$1
FROM inspection_report_consolidated_word_revisions
WHERE report_id=$1 AND template_version=2
`, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (existing) return this.verifiedConsolidated(existing);
const row = await this.load(reportId);
@@ -151,18 +152,30 @@ export class InspectionReportWordService {
if (!(await readFile(previous.filePath)).equals(built.buffer)) throw this.storageError();
});
await this.dataSource.query(`
INSERT INTO inspection_report_consolidated_word_artifacts(report_id,stored_name,original_name,size_bytes,sha256)
VALUES($1,$2,$3,$4,$5) ON CONFLICT (report_id) DO NOTHING
INSERT INTO inspection_report_consolidated_word_revisions(report_id,template_version,stored_name,original_name,size_bytes,sha256)
VALUES($1,2,$2,$3,$4,$5) ON CONFLICT (report_id,template_version) DO NOTHING
`, [reportId, 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_report_consolidated_word_artifacts WHERE report_id=$1
FROM inspection_report_consolidated_word_revisions WHERE report_id=$1 AND template_version=2
`, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (!saved) throw this.storageError();
return this.verifiedConsolidated(saved);
}
async consolidatedRevisionContent(reportId: string, version: number): Promise<{ filePath: string; originalName: string; mimeType: string }> {
if (![1, 2].includes(version)) throw new NotFoundException('Versión documental inexistente');
const [row] = await this.dataSource.query(`
SELECT stored_name AS "storedName",original_name AS "originalName",
size_bytes AS "sizeBytes",sha256
FROM inspection_report_consolidated_word_revisions
WHERE report_id=$1 AND template_version=$2
`, [reportId, version]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (!row) throw new NotFoundException('Versión documental inexistente');
return this.verifiedConsolidated(row);
}
private async verifiedConsolidated(row: { storedName: string; originalName: string; sizeBytes: number; sha256: string }): Promise<{ filePath: string; originalName: string; mimeType: string }> {
if (!/^[A-Za-z0-9_.-]+$/.test(row.storedName)) throw this.storageError();
const filePath = resolve(this.root, row.storedName);
@@ -4,6 +4,7 @@ import {
Get,
Param,
ParseUUIDPipe,
ParseIntPipe,
Patch,
Post,
Query,
@@ -83,6 +84,20 @@ export class InspectionReportsController {
return response.sendFile(content.filePath);
}
@Get(':id/consolidated-word/revisions/:version')
@RequirePermissions('inspection_reports.read')
async consolidatedWordRevision(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Param('version', ParseIntPipe) version: number,
@Res() response: Response,
) {
const content = await this.word.consolidatedRevisionContent(id, version);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
return response.sendFile(content.filePath);
}
@Get(':id/gedo-pdf')
@RequirePermissions('inspection_reports.read')
async gedoPdfContent(
@@ -206,6 +221,22 @@ export class InspectionActPdfController {
export class InspectionActConsolidatedPdfController {
constructor(private readonly pdf: InspectionActPdfService) {}
@Get('revisions/:version')
@RequirePermissions('inspection_acts.read')
async revision(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Param('version', ParseIntPipe) version: number,
@Res() response: Response,
) {
const content = await this.pdf.consolidatedRevisionContent(actId, version);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Length', String(content.buffer.length));
response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
return response.send(content.buffer);
}
@Get()
@RequirePermissions('inspection_acts.read')
async content(
@@ -50,10 +50,10 @@ test('F6.9 technical Informe embeds evidence and its institutional source hash',
test('F6.9 keeps prior sealed PDF and prior company responses available for audit', () => {
const pdf = readFileSync(resolve(process.cwd(), 'src/inspection-reports/inspection-act-pdf.service.ts'), 'utf8');
const controller = readFileSync(resolve(process.cwd(), 'src/act-administration/act-administration.controller.ts'), 'utf8');
assert.match(pdf, /inspection_act_consolidated_pdf_artifacts/);
assert.match(pdf, /inspection_act_consolidated_pdf_revisions/);
const word = readFileSync(resolve(process.cwd(), 'src/inspection-reports/inspection-report-word.service.ts'), 'utf8');
assert.match(word, /inspection_report_consolidated_word_artifacts/);
assert.match(pdf, /ON CONFLICT \(act_id\) DO NOTHING/);
assert.match(word, /inspection_report_consolidated_word_revisions/);
assert.match(pdf, /ON CONFLICT \(act_id,template_version\) DO NOTHING/);
assert.match(controller, /responseContent\(responseId\)/);
assert.doesNotMatch(controller, /@Post\('responses'\)/);
});
@@ -84,3 +84,25 @@ test('F6.9 built CommonJS runtime renders signed PDF and WebP evidence', async (
const pdf = await runtimePdf.buildInspectionActPdf(sealed, [{ ...evidence, buffer: rendered, sha256: digest(webp) }]);
assert.ok(pdf.buffer.subarray(0, 8).equals(Buffer.from('%PDF-1.4')));
});
test('F6.9 revision migration archives both first document versions before serving the corrected template', () => {
const migration = readFileSync(resolve(process.cwd(),
'src/database/migrations/1790135400000-f6-9-consolidated-document-revisions.ts'), 'utf8');
assert.match(migration, /SELECT act_id,1,stored_name,original_name,size_bytes,sha256,generated_at/);
assert.match(migration, /SELECT report_id,1,stored_name,original_name,size_bytes,sha256,generated_at/);
assert.match(migration, /PRIMARY KEY \(act_id,template_version\)/);
assert.match(migration, /PRIMARY KEY \(report_id,template_version\)/);
});
test('F6.9 includes field photos from installations without their own findings', async () => {
const other = await sharp(photo).resize(75).png().toBuffer();
const unlinked = { id: 'photo-other-asset', assetId: 'asset-2', title: 'Otra instalación', sha256: digest(other), buffer: other };
const withUnlinked = await buildInspectionActPdf(sealed, [evidence, unlinked]);
const linkedOnly = await buildInspectionActPdf(sealed, [evidence]);
assert.ok(withUnlinked.buffer.length > linkedOnly.buffer.length + 500);
const word = buildInspectionReportWord({ code: 'INF-OTHER', title: 'Informe', generatedAt: new Date(),
frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed }, photos: [evidence, unlinked] });
assert.ok(word.buffer.includes(Buffer.from('OTRAS INSTALACIONES INSPECCIONADAS')));
assert.ok(word.buffer.includes(Buffer.from('word/media/photo-2.png')));
assert.ok(word.buffer.includes(other));
});