/** * Vendor fingerprinting + the per-vendor learning loop. * * Fingerprint priority: VAT id → IBAN → normalized name. A profile remembers * defaults (currency / charge / tax / doctype / tax-included / linked c_bpartner) * and per-field hints (the box where a value sat + the last sample) so the next * invoice from the same vendor prefills with high confidence. * * Learning happens ONLY on a confirmed invoice (see confirm route) and ONLY from * the values the user approved — rejected/edited-away data never trains. */ import type { ZugferdInvoiceData } from '../zugferd/buildInvoiceXml' import { parseAmount, parseDate, type ExtractionResult, type FieldBox } from './fieldExtractors' import type { TextItem } from './pdfTextLayer' import { layoutSimilarity } from './layoutFingerprint' import { getVendorProfile, listVendorProfiles, upsertVendorProfile, type InboxCtx } from './inboxDb' /** * Find the learned template for a document: exact layout-hash match first, then a * fuzzy Jaccard match over the stored token sets (so a vendor whose letterhead * text shifts slightly still resolves to the same template instead of missing). */ export const matchVendorProfile = async (ctx: InboxCtx, matchKey: string, tokens: string[] = [], minSim = 0.6) => { const exact = await getVendorProfile(ctx, matchKey) if (exact) return exact if (!tokens.length) return null try { let best: any = null, bestScore = 0 for (const p of await listVendorProfiles(ctx)) { const pt: string[] = p?.field_hints?.__tokens || [] if (!pt.length) continue const s = layoutSimilarity(tokens, pt) if (s > bestScore) { bestScore = s; best = p } } return bestScore >= minSim ? best : null } catch { return null } } /** Concatenate the text whose item-centers fall inside a normalized region box. */ const readRegion = (items: TextItem[] | undefined, box: FieldBox): string => { if (!items?.length || box?.nx == null) return '' const hits = items.filter(it => { if (it.nx == null) return false const cx = it.nx + (it.nw || 0) / 2, cy = (it.ny ?? 0) + (it.nh || 0) / 2 return cx >= (box.nx as number) && cx <= (box.nx as number) + (box.nw || 0) && cy >= (box.ny as number) && cy <= (box.ny as number) + (box.nh || 0) }) hits.sort((a, b) => Math.abs((a.ny ?? 0) - (b.ny ?? 0)) > 0.012 ? (a.ny ?? 0) - (b.ny ?? 0) : (a.nx ?? 0) - (b.nx ?? 0)) return hits.map(h => h.text).join(' ').replace(/\s+/g, ' ').trim() } /** Parse + set a learned field value (read from its region) with high confidence. */ const setLearned = (fields: any, confidence: any, bbox: any, key: string, val: string, box: FieldBox) => { if (!val) return const C = 0.9 if (key === 'documentNo') { fields.documentNo = val; confidence.documentNo = C } else if (key === 'issueDate') { const d = parseDate(val); if (!d) return; fields.issueDate = d; confidence.issueDate = C } else if (key === 'dueDate') { const d = parseDate(val); if (!d) return; fields.dueDate = d; confidence.dueDate = C } else if (key === 'grandTotal') { const n = parseAmount(val); if (n == null) return; fields.grandTotal = n; confidence.grandTotal = C } else if (key === 'lineTotal') { const n = parseAmount(val); if (n == null) return; fields.lineTotal = n; fields.taxBasisTotal = n; confidence.lineTotal = C } else if (key === 'taxTotal') { const n = parseAmount(val); if (n == null) return; fields.taxTotal = n; confidence.taxTotal = C } else if (key === 'seller.vatId') { fields.seller = { ...(fields.seller || {}), vatId: val.replace(/\s+/g, '') }; confidence['seller.vatId'] = C } else if (key === 'seller.name') { fields.seller = { ...(fields.seller || {}), name: val }; confidence['seller.name'] = C } else if (key === 'paymentIban') { fields.paymentIban = val.replace(/\s+/g, ''); confidence.paymentIban = C } else return bbox[key] = box } const LEGAL_SUFFIXES = /\b(gmbh|ag|ug|kg|ohg|gbr|mbh|e\.?\s?k|co\.?\s?kg|ltd|inc|llc|s\.?a|b\.?v|e\.?\s?v|co|company|haftungsbeschränkt)\b/gi export const normalizeVendorName = (name?: string): string => String(name || '') .toLowerCase() .replace(/&/g, ' und ') .replace(LEGAL_SUFFIXES, ' ') .replace(/[^a-z0-9äöüß ]/g, ' ') .replace(/\s+/g, ' ') .trim() const normVat = (v?: string) => String(v || '').toUpperCase().replace(/[^A-Z0-9]/g, '') const normIban = (v?: string) => String(v || '').toUpperCase().replace(/[^A-Z0-9]/g, '') /** Compute the vendor fingerprint from extracted data. Returns '' when nothing usable. */ export const fingerprintFromData = (data: Partial): string => { const vat = normVat(data?.seller?.vatId) if (vat) return `vat:${vat}` const iban = normIban(data?.paymentIban) if (iban) return `iban:${iban}` const name = normalizeVendorName(data?.seller?.name) if (name) return `name:${name}` return '' } /** * Apply a stored profile to a fresh extraction: fill defaults the heuristics * couldn't get, and boost confidence for fields whose value lands near the * remembered box. Mutates a copy and returns it. */ export const applyProfile = (extraction: ExtractionResult, profile: any, items?: TextItem[]): ExtractionResult => { if (!profile) return extraction const fields = { ...extraction.fields } const confidence = { ...extraction.confidence } const bbox = { ...(extraction.bbox || {}) } const hints = profile.field_hints || {} // Defaults: only used downstream (charge/tax/doctype) — surfaced via _profile. ;(fields as any)._profile = { cBpartnerId: profile.c_bpartner_id || null, vendorName: profile.vendor_name || null, currencyId: profile.currency_id || null, defaultChargeId: profile.default_charge_id || null, defaultTaxId: profile.default_tax_id || null, defaultDoctypeId: profile.default_doctype_id || null, isTaxIncluded: !!profile.is_tax_included, samplesCount: profile.samples_count || 0 } if (profile.is_tax_included != null && fields.isTaxIncluded === undefined) { ;(fields as any).isTaxIncluded = !!profile.is_tax_included confidence['isTaxIncluded'] = Math.max(confidence['isTaxIncluded'] || 0, 0.7) } for (const key of Object.keys(hints)) { const hint = hints[key] if (!hint?.bbox) continue // 1) READ the value straight from the learned region (the big payoff — a // field the user once corrected is auto-filled from the right spot). if (items?.length && hint.bbox.nx != null) { const val = readRegion(items, hint.bbox) if (val) setLearned(fields, confidence, bbox, key, val, hint.bbox) } // 2) Otherwise, at least boost confidence when the value sits near the box. const box: FieldBox | undefined = extraction.bbox?.[key] if (box && nearBox(hint.bbox, box)) confidence[key] = Math.min(1, (confidence[key] || 0.5) + 0.25) } return { fields, confidence, bbox } } // Compare in page-normalized coords (0..1) when available — works across renders // and matches the editable overlay's coordinates; fall back to raw points. const nearBox = (a: FieldBox, b: FieldBox): boolean => { if (a?.nx != null && b?.nx != null) { return Math.abs((a.nx || 0) - (b.nx || 0)) <= 0.04 && Math.abs((a.ny || 0) - (b.ny || 0)) <= 0.04 } return Math.abs((a?.x || 0) - (b?.x || 0)) <= 40 && Math.abs((a?.y || 0) - (b?.y || 0)) <= 40 } /** * Learn from a confirmed invoice. `approved` carries the final (user-approved) * field values + the box map from extraction + the resolved iDempiere defaults. * Upserts the profile (samples_count++). */ export const learnFromConfirmation = async (ctx: InboxCtx, params: { fingerprint: string data: Partial bbox?: Record layoutTokens?: string[] cBpartnerId?: number | null currencyId?: number | null chargeId?: number | null taxId?: number | null doctypeId?: number | null isTaxIncluded?: boolean }) => { if (!params.fingerprint) return const existing = await getVendorProfile(ctx, params.fingerprint) const hints: any = existing?.field_hints || {} // Store box + last sample for every mappable field (position learning). const learnKeys = ['documentNo', 'issueDate', 'dueDate', 'grandTotal', 'taxTotal', 'lineTotal', 'seller.name', 'seller.vatId', 'paymentIban', 'email', 'address.street', 'address.postal', 'address.city'] for (const key of learnKeys) { const box = params.bbox?.[key] const sample = key.includes('.') ? (params.data as any)?.seller?.[key.split('.')[1]] : (params.data as any)?.[key] if (box || sample != null) { hints[key] = { ...(hints[key] || {}), bbox: box || hints[key]?.bbox, sample: sample ?? hints[key]?.sample } } } // Layout token set for value-independent (Jaccard) matching of future invoices. if (params.layoutTokens?.length) hints.__tokens = params.layoutTokens await upsertVendorProfile(ctx, params.fingerprint, { cBpartnerId: params.cBpartnerId ?? null, vendorName: params.data?.seller?.name ?? null, vatId: params.data?.seller?.vatId ?? null, iban: params.data?.paymentIban ?? null, currencyId: params.currencyId ?? null, defaultChargeId: params.chargeId ?? null, defaultTaxId: params.taxId ?? null, defaultDoctypeId: params.doctypeId ?? null, isTaxIncluded: params.isTaxIncluded, fieldHints: hints }) } export default fingerprintFromData