/** * Inbound-invoice extraction orchestrator (Tier 0 → 1 → 2), fail-soft. * * Tier 0 embedded e-invoice XML (ZUGFeRD/Factur-X/XRechnung) — perfect, no OCR * Tier 1 text-layer PDF (pdfjs) → provider field extraction * Tier 2 scanned/image PDF → rasterize → OCR → provider field extraction * * Runs detached in the background after upload; the only backend access is the * vendor-profile lookup through ctx (field-position hints). The learned DEFAULTS * (doc type / currency / tax / partner) are NOT baked in here any more — the review * resolves them live (server/utils/inbox/vendorDefaults.ts), so they also reach * e-invoices and documents uploaded before the vendor was first confirmed. Returns a * normalized partial ZugferdInvoiceData + per-field confidence/bbox + the tier * and engine that produced it. */ import { parseEinvoice } from './parseEinvoiceXml' import { extractTextLayer, type TextItem } from './pdfTextLayer' import { rasterizePdf } from './rasterizePdf' import { runOcr } from '../ocr/tesseractOcr' import { runExtraction } from './providers' import { fingerprintFromData, applyProfile, matchVendorProfile } from './vendorProfile' import { computeLayoutSignature } from './layoutFingerprint' import type { InboxCtx } from './inboxDb' import type { ZugferdInvoiceData } from '../zugferd/buildInvoiceXml' import { reconcileInboxAmounts } from '../../../app/utils/inboxTaxRate' export interface PipelineResult { sourceTier: number // 0/1/2, or -1 when nothing could be read engine: string vendorFingerprint: string extracted: Partial confidence: Record bbox: Record } const isXmlInput = (mimeType?: string, filename?: string): boolean => /xml/i.test(mimeType || '') || /\.xml$/i.test(filename || '') /** Every field present in a Tier-0 parse is authoritative → confidence 1.0. */ const tier0Confidence = (data: Partial): Record => { const c: Record = {} for (const k of Object.keys(data)) c[k] = 1 if (data.seller?.name) c['seller.name'] = 1 if (data.seller?.vatId) c['seller.vatId'] = 1 if (data.paymentIban) c['paymentIban'] = 1 return c } // Reference fields arrive from the text layer / OCR with stray separators. // - IBAN / VAT id → strip whitespace AND '.' ',' '-' (bare alphanumerics, e.g. "DE 12 3456…" → "DE123456…") // - invoice number → strip whitespace / '.' / ',' but KEEP '-' (it's a real separator, e.g. "RE-2024-001") const cleanId = (v: any): any => v == null ? v : String(v).replace(/[\s.,\-]+/g, '') const cleanDocNo = (v: any): any => v == null ? v : String(v).replace(/[\s.,]+/g, '') /** Light post-extraction normalization: defaults + derive missing totals + clean refs. */ const normalize = (f: Partial): Partial => { const out: any = { ...f } if (!out.currency) out.currency = 'EUR' if (!out.typeCode) out.typeCode = '380' if (out.documentNo) out.documentNo = cleanDocNo(out.documentNo) if (out.paymentIban) out.paymentIban = cleanId(out.paymentIban) if (out.seller?.vatId) out.seller = { ...out.seller, vatId: cleanId(out.seller.vatId) } if (out.buyer?.vatId) out.buyer = { ...out.buyer, vatId: cleanId(out.buyer.vatId) } const g = Number(out.grandTotal) const n = Number(out.lineTotal ?? out.taxBasisTotal) const t = Number(out.taxTotal) if (Number.isFinite(g) && Number.isFinite(n) && !Number.isFinite(t)) out.taxTotal = Math.round((g - n) * 100) / 100 if (Number.isFinite(g) && Number.isFinite(t) && !Number.isFinite(n)) out.lineTotal = Math.round((g - t) * 100) / 100 if (out.taxBasisTotal == null && out.lineTotal != null) out.taxBasisTotal = out.lineTotal if (out.duePayable == null && out.grandTotal != null) out.duePayable = out.grandTotal return out } const flattenTextItems = (pages: { items: TextItem[] }[]): TextItem[] => pages.flatMap(p => p.items || []) export const runPipeline = async ( pdfBuffer: Buffer, opts: { ctx?: InboxCtx; mimeType?: string; filename?: string } = {} ): Promise => { // ---- Tier 0: embedded / standalone e-invoice XML ---- try { const xmlInput = isXmlInput(opts.mimeType, opts.filename) ? pdfBuffer.toString('utf8') : undefined const data = await parseEinvoice({ pdf: xmlInput ? undefined : pdfBuffer, xml: xmlInput }) if (data && (data.documentNo || data.grandTotal != null)) { const extracted: any = normalize(data) // The XML is authoritative for the VALUES; still compute the value-independent // layout signature (metadata + letterhead + file-name pattern) so a vendor who // alternates between plain PDFs and e-invoices resolves to the same learned profile. let layout = { hash: '', tokens: [] as string[] } try { let sigText = [data.seller?.name, data.seller?.vatId, data.paymentIban].filter(Boolean).join('\n') if (!xmlInput) { try { const tl = await extractTextLayer(pdfBuffer); if (tl.hasText) sigText = tl.text + '\n' + sigText } catch {} } layout = await computeLayoutSignature(sigText, xmlInput ? null : pdfBuffer, { filename: opts.filename, einvoice: true }) } catch {} extracted.__layout = { hash: layout.hash, tokens: layout.tokens } return { sourceTier: 0, engine: 'einvoice-xml', vendorFingerprint: fingerprintFromData(extracted) || (layout.hash ? 'layout:' + layout.hash : ''), extracted, confidence: tier0Confidence(extracted), bbox: {} } } } catch (e: any) { console.warn('[Inbox] Tier 0 (xml) failed:', e?.message || e) } // ---- Tier 1: text-layer PDF ---- let text = '' let items: TextItem[] = [] let tier = -1 try { const tl = await extractTextLayer(pdfBuffer) if (tl.hasText) { text = tl.text items = flattenTextItems(tl.pages) tier = 1 } } catch (e: any) { console.warn('[Inbox] Tier 1 (text layer) failed:', e?.message || e) } // ---- Tier 2: rasterize + OCR ---- if (tier === -1) { try { const raster = await rasterizePdf(pdfBuffer) if (raster.pages.length) { const sharp = (await import('sharp')).default const texts: string[] = [] const ocrItems: TextItem[] = [] let pageNo = 0 for (const png of raster.pages) { pageNo++ // page pixel dims → normalize OCR word boxes (top-left origin, 0..1) so // the overlay + learned-region auto-fill work on scans just like text PDFs. let W = 0, H = 0 try { const m = await sharp(png).metadata(); W = m.width || 0; H = m.height || 0 } catch {} const ocr = await runOcr(png, { withWords: true }) texts.push(ocr.text) for (const w of ocr.words) { const x = w.bbox.x0, y = w.bbox.y0, ww = w.bbox.x1 - w.bbox.x0, hh = w.bbox.y1 - w.bbox.y0 ocrItems.push({ text: w.text, x, y, w: ww, h: hh, page: pageNo, ...(W && H ? { nx: x / W, ny: y / H, nw: ww / W, nh: hh / H } : {}) }) } } text = texts.join('\n') items = ocrItems tier = 2 } } catch (e: any) { console.warn('[Inbox] Tier 2 (ocr) failed:', e?.message || e) } } // Nothing readable — return an empty shell (the doc is still attached & reviewable). if (tier === -1) { return { sourceTier: -1, engine: 'none', vendorFingerprint: '', extracted: normalize({}), confidence: {}, bbox: {} } } // ---- Field extraction via provider (self-hosted default, optional LLM) ---- let result = await runExtraction({ text, items }, { ctx: opts.ctx }) const heuristic = result // label-based reading, before learned positions // Value-independent LAYOUT fingerprint identifies the vendor's template even if // a value (IBAN/amount) was misread → match key. Value fingerprint is fallback. let layout = { hash: '', tokens: [] as string[] } try { layout = await computeLayoutSignature(text, pdfBuffer, { filename: opts.filename }) } catch {} const matchKey = layout.hash ? ('layout:' + layout.hash) : fingerprintFromData(result.fields) // Load the learned template (exact hash → fuzzy Jaccard fallback) → read each // learned field FROM its region + defaults. if (matchKey && opts.ctx) { try { const profile = await matchVendorProfile(opts.ctx, matchKey, layout.tokens) if (profile) result = { ...applyProfile(result, profile, items), engine: result.engine } } catch {} } // Net + tax must equal gross. When a learned position broke that (totals block floats with // the number of line items), fall back to the label-based reading / derive the odd one out, // restore that reading's box and remember the key so the confirm stops learning its position. const amounts = reconcileInboxAmounts(result.fields, heuristic.fields) if (amounts.consistent && amounts.changed.length) { const f: any = result.fields const keyOf = { net: 'lineTotal', tax: 'taxTotal', gross: 'grandTotal' } as const if (amounts.changed.includes('net')) { f.lineTotal = amounts.net; f.taxBasisTotal = amounts.net } if (amounts.changed.includes('tax')) f.taxTotal = amounts.tax if (amounts.changed.includes('gross')) f.grandTotal = amounts.gross if (Array.isArray(f.taxes) && f.taxes[0]) f.taxes = [{ ...f.taxes[0], basis: amounts.net, amount: amounts.tax }, ...f.taxes.slice(1)] for (const role of amounts.changed) { const key = keyOf[role] result.confidence[key] = 0.85 if (heuristic.bbox?.[key]) result.bbox[key] = heuristic.bbox[key]; else delete result.bbox[key] } f.__unreliableBoxes = amounts.changed.map(r => keyOf[r]) } const extracted: any = normalize(result.fields) extracted.__layout = { hash: layout.hash, tokens: layout.tokens } // persisted for matching/learning return { sourceTier: tier, engine: result.engine, vendorFingerprint: matchKey, extracted, confidence: result.confidence, bbox: result.bbox } } export default runPipeline