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
@@ -5,11 +5,16 @@ import { Injectable, InternalServerErrorException, NotFoundException } from '@ne
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { buildInspectionReportWord } from './inspection-report-word-builder';
import { InspectionActPdfService } from './inspection-act-pdf.service';
const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
interface WordRow {
id: string;
actId: string;
companyName: string | null;
areaName: string | null;
scopeName: string | null;
code: string;
title: string;
executiveSummary: string | null;
@@ -30,7 +35,7 @@ interface WordRow {
export class InspectionReportWordService {
private readonly root: string;
constructor(private readonly dataSource: DataSource, config: ConfigService) {
constructor(private readonly dataSource: DataSource, private readonly actPdf: InspectionActPdfService, config: ConfigService) {
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT')
?? '/app/storage/asset-media/inspection-reports-word';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path');
@@ -48,6 +53,7 @@ export class InspectionReportWordService {
} catch {}
}
try {
const photos = (await this.actPdf.fieldImages(row.actId)).filter((image) => image.findingId || image.assetId);
const built = buildInspectionReportWord({
code: row.code,
title: row.title,
@@ -56,6 +62,10 @@ export class InspectionReportWordService {
frozenSnapshot: row.frozenSnapshot,
executiveSummary: row.executiveSummary,
reportDescription: row.reportDescription,
companyName: row.companyName,
areaName: row.areaName,
scopeName: row.scopeName,
photos,
});
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`;
@@ -114,6 +124,56 @@ export class InspectionReportWordService {
};
}
// The prior editable Word and its revision history stay available unchanged.
async consolidatedContent(reportId: string): Promise<{ filePath: string; 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_report_consolidated_word_artifacts WHERE report_id=$1
`, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (existing) return this.verifiedConsolidated(existing);
const row = await this.load(reportId);
const photos = (await this.actPdf.fieldImages(row.actId)).filter((image) => image.findingId || image.assetId);
const built = buildInspectionReportWord({
code: row.code, title: row.title, generatedAt: new Date(row.generatedAt),
frozenSha256: row.frozenSha256, frozenSnapshot: row.frozenSnapshot,
executiveSummary: row.executiveSummary, reportDescription: row.reportDescription,
companyName: row.companyName, areaName: row.areaName, scopeName: row.scopeName, photos,
});
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${row.id}-consolidado-${built.sha256.slice(0, 24)}.docx`;
const originalName = `${row.code}-consolidado.docx`;
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.verifiedConsolidated({
storedName, originalName, sizeBytes: built.buffer.length, sha256: built.sha256,
});
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
`, [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
`, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (!saved) throw this.storageError();
return this.verifiedConsolidated(saved);
}
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);
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
const file = await stat(filePath).catch(() => null);
if (!file?.isFile() || file.size !== Number(row.sizeBytes)) throw this.storageError();
const buffer = await readFile(filePath);
if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw this.storageError();
return { filePath, originalName: row.originalName, mimeType: WORD_MIME };
}
private async ensureInitialRevision(row: WordRow): Promise<void> {
if (
row.wordStatus !== 'READY'
@@ -150,21 +210,26 @@ export class InspectionReportWordService {
private async load(reportId: string): Promise<WordRow> {
const [row] = await this.dataSource.query(`
SELECT id,code,title,
executive_summary AS "executiveSummary",
report_description AS "reportDescription",
generated_at AS "generatedAt",
frozen_sha256 AS "frozenSha256",
frozen_snapshot AS "frozenSnapshot",
word_status AS "wordStatus",
word_original_name AS "wordOriginalName",
word_stored_name AS "wordStoredName",
word_mime_type AS "wordMimeType",
SELECT report.id,report.act_id AS "actId",report.code,report.title,
company.name AS "companyName",area.name AS "areaName",scope.name AS "scopeName",
report.executive_summary AS "executiveSummary",
report.report_description AS "reportDescription",
report.generated_at AS "generatedAt",
report.frozen_sha256 AS "frozenSha256",
report.frozen_snapshot AS "frozenSnapshot",
report.word_status AS "wordStatus",
report.word_original_name AS "wordOriginalName",
report.word_stored_name AS "wordStoredName",
report.word_mime_type AS "wordMimeType",
word_size_bytes::integer AS "wordSizeBytes",
word_sha256 AS "wordSha256",
generated_by AS "generatedBy"
FROM inspection_reports
WHERE id=$1
report.word_sha256 AS "wordSha256",
report.generated_by AS "generatedBy"
FROM inspection_reports report
JOIN inspection_visits visit ON visit.id=report.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 report.id=$1
`, [reportId]) as WordRow[];
if (!row) {
throw new NotFoundException({