From 60ba37c287f28b0e20127913102fe2c2a64bedae Mon Sep 17 00:00:00 2001 From: DH V2 Date: Tue, 15 Sep 2026 19:18:31 -0300 Subject: [PATCH] fix(f6.9): include every field photo and preserve document revisions --- ...00-f6-9-consolidated-document-revisions.ts | 49 +++++++++++++++++++ .../inspection-act-pdf-builder.ts | 12 +++-- .../inspection-act-pdf.service.ts | 21 ++++++-- .../inspection-report-word-builder.ts | 19 +++++-- .../inspection-report-word.service.ts | 21 ++++++-- .../inspection-reports.controller.ts | 31 ++++++++++++ .../unit/f6-9-consolidated-documents.test.ts | 28 +++++++++-- .../inspections/InspectionActMediaPanel.tsx | 3 ++ web-v2/src/styles.css | 3 ++ 9 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 api-v3/src/database/migrations/1790135400000-f6-9-consolidated-document-revisions.ts diff --git a/api-v3/src/database/migrations/1790135400000-f6-9-consolidated-document-revisions.ts b/api-v3/src/database/migrations/1790135400000-f6-9-consolidated-document-revisions.ts new file mode 100644 index 0000000..1c55756 --- /dev/null +++ b/api-v3/src/database/migrations/1790135400000-f6-9-consolidated-document-revisions.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F69ConsolidatedDocumentRevisions1790135400000 implements MigrationInterface { + name = 'F69ConsolidatedDocumentRevisions1790135400000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query('DROP TABLE inspection_report_consolidated_word_revisions'); + await queryRunner.query('DROP TABLE inspection_act_consolidated_pdf_revisions'); + } +} diff --git a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts index 510fe4e..d0ef348 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts @@ -64,10 +64,10 @@ export async function buildInspectionActPdf(snapshot: Record, 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, 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); diff --git a/api-v3/src/inspection-reports/inspection-act-pdf.service.ts b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts index 4d347cb..2c2b718 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf.service.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts @@ -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" diff --git a/api-v3/src/inspection-reports/inspection-report-word-builder.ts b/api-v3/src/inspection-reports/inspection-report-word-builder.ts index 64e0ef5..ba35e1c 100644 --- a/api-v3/src/inspection-reports/inspection-report-word-builder.ts +++ b/api-v3/src/inspection-reports/inspection-report-word-builder.ts @@ -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(); 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)), diff --git a/api-v3/src/inspection-reports/inspection-report-word.service.ts b/api-v3/src/inspection-reports/inspection-report-word.service.ts index 840051d..134775d 100644 --- a/api-v3/src/inspection-reports/inspection-report-word.service.ts +++ b/api-v3/src/inspection-reports/inspection-report-word.service.ts @@ -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); diff --git a/api-v3/src/inspection-reports/inspection-reports.controller.ts b/api-v3/src/inspection-reports/inspection-reports.controller.ts index 104fd16..ebd4b15 100644 --- a/api-v3/src/inspection-reports/inspection-reports.controller.ts +++ b/api-v3/src/inspection-reports/inspection-reports.controller.ts @@ -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( diff --git a/api-v3/test/unit/f6-9-consolidated-documents.test.ts b/api-v3/test/unit/f6-9-consolidated-documents.test.ts index 28a5b31..0a8b25c 100644 --- a/api-v3/test/unit/f6-9-consolidated-documents.test.ts +++ b/api-v3/test/unit/f6-9-consolidated-documents.test.ts @@ -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)); +}); diff --git a/web-v2/src/features/inspections/InspectionActMediaPanel.tsx b/web-v2/src/features/inspections/InspectionActMediaPanel.tsx index f0e2feb..5dd5dc3 100644 --- a/web-v2/src/features/inspections/InspectionActMediaPanel.tsx +++ b/web-v2/src/features/inspections/InspectionActMediaPanel.tsx @@ -66,11 +66,14 @@ export function InspectionActMediaPanel({ actId }: { actId: string }) { .finally(() => active && setLoading(false)); return () => { active = false; }; }, [actId]); + const findingAssetIds = new Set(items.map((item) => item.finding.asset.id)); + const otherPhotos = assetPhotos.filter((photo) => !findingAssetIds.has(photo.assetId)); return

Hallazgos del Acta

Cada hallazgo reúne su descripción y las fotos tomadas en campo.

{items.length}
{error && {error}} {loading ? : items.length ?
{items.map((item) => photo.assetId === item.finding.asset.id)} />)}
: } + {!loading && otherPhotos.length > 0 &&

Fotos de otras instalaciones

{otherPhotos.map((photo) => )}
}
; } diff --git a/web-v2/src/styles.css b/web-v2/src/styles.css index 1bc0a6c..2cd4159 100644 --- a/web-v2/src/styles.css +++ b/web-v2/src/styles.css @@ -1433,3 +1433,6 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; } .act-closed-date { font-weight: 700; } .act-integrity-details { border-top: 1px solid var(--border); padding-top: 10px; } .act-integrity-details code { overflow-wrap: anywhere; } + +.act-other-asset-photos { border-top: 1px solid #e3eaf5; margin-top: 24px; padding-top: 20px; } +.act-other-asset-photos h3 { margin: 0 0 14px; }