feat(accounting): PDF/image preview in triage, opened at the QR-bill (no OCR)
Instead of OCR, let the admin read the payment slip directly: the triage modal now embeds the captured document and, for PDFs, opens at the LAST page scrolled to the Swiss QR-bill area so IBAN/amount/reference are visible while typing. - backend: capture PDF page count at upload via pdf-lib (new inbound_documents.page_count, added to in-flight migration 124); new GET /api/admin/expenses/inbound/:id/file streams the stored file inline (safePath-guarded, nosniff). Raw-serve is acceptable here (admin views own uploads); the hardened rasterise-in-isolated-worker path stays a follow-up. - frontend: getInboundFileBlob fetches the file with Bearer auth as a blob; the triage modal renders it (iframe for PDF with #page=<last>&view=FitH,300, <img> for camera photos) in a two-column layout next to the form. - i18n: accounting.inbox.previewLoading / qrHint (EN + DE). Verified: node -c, require-graph, migration-124 harness (page_count), npm run build green.
This commit is contained in:
@@ -14,6 +14,8 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { createReadStream } = require('fs');
|
||||
const { assertPathInside } = require('../utils/safePath');
|
||||
const { db } = require('../database/db');
|
||||
const expenseService = require('../services/expenseService');
|
||||
const expenseCategoriesService = require('../services/expenseCategoriesService');
|
||||
@@ -116,6 +118,25 @@ router.get('/inbound/:id', requirePermission('accounting.view'),
|
||||
return successResponse(res, { document: await expenseService.getInbound(parseInt(req.params.id, 10)) });
|
||||
}));
|
||||
|
||||
// Stream the stored file for in-browser preview (PDF / image). Admin-only,
|
||||
// path-containment guarded. NOTE: this serves the raw file inline — the
|
||||
// locked design's hardened path (rasterise in a network-isolated worker, never
|
||||
// serve raw) is a follow-up; acceptable here as the admin views their own
|
||||
// uploaded documents.
|
||||
router.get('/inbound/:id/file', requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const row = await db('inbound_documents').where({ id: parseInt(req.params.id, 10) })
|
||||
.first('file_path', 'mime_type');
|
||||
if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' });
|
||||
const safe = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]);
|
||||
res.setHeader('Content-Type', row.mime_type || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', 'inline');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
createReadStream(safe).pipe(res);
|
||||
}));
|
||||
|
||||
router.patch('/inbound/:id', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const fsp = require('fs').promises;
|
||||
const { PDFDocument } = require('pdf-lib');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
@@ -57,6 +58,7 @@ function transformInbound(row) {
|
||||
parseStatus: row.parse_status,
|
||||
parseMethod: row.parse_method,
|
||||
parseError: row.parse_error,
|
||||
pageCount: row.page_count,
|
||||
supplierName: row.supplier_name,
|
||||
invoiceNumber: row.invoice_number,
|
||||
invoiceDate: toIsoDate(row.invoice_date),
|
||||
@@ -119,9 +121,21 @@ function clampPage(page, pageSize) {
|
||||
return { p, ps };
|
||||
}
|
||||
|
||||
async function sha256OfFile(filePath) {
|
||||
// Read the file once: SHA-256 (for dedup) + PDF page count (for the
|
||||
// "jump to last page / QR" preview).
|
||||
async function inspectFile(filePath, mimeType) {
|
||||
const buf = await fsp.readFile(filePath);
|
||||
return crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const sha = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
let pageCount = null;
|
||||
if ((mimeType || '').includes('pdf')) {
|
||||
try {
|
||||
const pdf = await PDFDocument.load(buf, { updateMetadata: false });
|
||||
pageCount = pdf.getPageCount();
|
||||
} catch (e) {
|
||||
logger.warn?.(`expenseService: could not read PDF page count for ${filePath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return { sha, pageCount };
|
||||
}
|
||||
|
||||
// ── Inbound documents ──────────────────────────────────────────────────────
|
||||
@@ -132,8 +146,13 @@ async function sha256OfFile(filePath) {
|
||||
*/
|
||||
async function recordInboundDocument({ source, filePath, originalFilename, mimeType }, adminId) {
|
||||
let fileSha256 = null;
|
||||
try { fileSha256 = await sha256OfFile(filePath); } catch (e) {
|
||||
logger.warn?.(`expenseService: could not hash ${filePath}: ${e.message}`);
|
||||
let pageCount = null;
|
||||
try {
|
||||
const info = await inspectFile(filePath, mimeType);
|
||||
fileSha256 = info.sha;
|
||||
pageCount = info.pageCount;
|
||||
} catch (e) {
|
||||
logger.warn?.(`expenseService: could not inspect ${filePath}: ${e.message}`);
|
||||
}
|
||||
|
||||
let duplicateOfId = null;
|
||||
@@ -164,6 +183,7 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT
|
||||
parse_status: parse.error ? 'failed' : (parse.parsed ? 'parsed' : 'pending'),
|
||||
parse_method: parse.method || 'none',
|
||||
parse_error: parse.error || null,
|
||||
page_count: pageCount,
|
||||
supplier_name: f.supplierName || null,
|
||||
invoice_number: f.invoiceNumber || null,
|
||||
invoice_date: f.invoiceDate || null,
|
||||
|
||||
Reference in New Issue
Block a user