/** * Review-time resolution of the learned vendor defaults (doc type / currency / tax / * charge / tax-included / linked c_bpartner). * * Why not at extraction time: the profile used to be looked up ONCE at upload and frozen * into ExtractedJson — documents uploaded before the vendor's first confirmation (batch * uploads), e-invoices (tier 0 returned before the lookup) and profiles stored under a * different key scheme (`vat:` vs `layout:`) never saw their defaults. Resolving live, by * several keys, fixes all three. * * Match priority (first non-null value per field wins): * 1. exact Fingerprint of the document * 2. profile VatId / IBAN == the document's VAT id / IBAN (also `vat:` / `iban:` keys) * 3. fuzzy layout-token match (Jaccard ≥ 0.6) * 4. profiles linked to the SELECTED partner ← learning by chosen partner * 5. history: the partner's latest AP invoice (doc type, currency, first line's tax/charge) * * A document-matched profile that is bound to a DIFFERENT partner than the one the user * selected is ignored — the explicit pick outranks the recognition. * * Fail-soft: any error → empty defaults (the review then uses its generic defaults). */ import { string } from 'alga-js' import fetchHelper from '../fetchHelper' import { listVendorProfiles, type InboxCtx } from './inboxDb' import { layoutSimilarity } from './layoutFingerprint' import { normVat, normIban } from './vendorProfile' export interface VendorDefaults { currencyId: number | null docTypeId: number | null taxId: number | null chargeId: number | null isTaxIncluded: boolean | null } export type DefaultSource = 'profile' | 'partner' | 'history' export interface VendorDefaultsResult { defaults: VendorDefaults sources: Partial> samplesCount: number rememberedBpartnerId: number | null historyInvoiceNo: string } const fkId = (v: any) => (v && typeof v === 'object') ? (v.id ?? null) : (v ?? null) const LAYOUT_MIN_SIMILARITY = 0.6 const emptyResult = (): VendorDefaultsResult => ({ defaults: { currencyId: null, docTypeId: null, taxId: null, chargeId: null, isTaxIncluded: null }, sources: {}, samplesCount: 0, rememberedBpartnerId: null, historyInvoiceNo: '' }) /** Profiles that identify the DOCUMENT (keys 1–3), best first. */ const profilesForDocument = (profiles: any[], doc: any): any[] => { const ex = doc?.extracted || {} const fp = String(doc?.vendorFingerprint || '') const vat = normVat(ex?.seller?.vatId) const iban = normIban(ex?.paymentIban) const tokens: string[] = ex?.__layout?.tokens || [] const layoutKey = ex?.__layout?.hash ? 'layout:' + ex.__layout.hash : '' const out: any[] = [] const add = (p: any) => { if (p && !out.includes(p)) out.push(p) } for (const p of profiles) if (p.fingerprint && (p.fingerprint === fp || p.fingerprint === layoutKey)) add(p) for (const p of profiles) { if (vat && (normVat(p.vat_id) === vat || p.fingerprint === `vat:${vat}`)) add(p) else if (iban && (normIban(p.iban) === iban || p.fingerprint === `iban:${iban}`)) add(p) } if (tokens.length) { let best: any = null, bestScore = 0 for (const p of profiles) { const pt: string[] = p?.field_hints?.__tokens || [] if (!pt.length) continue const s = layoutSimilarity(tokens, pt) if (s > bestScore) { bestScore = s; best = p } } if (best && bestScore >= LAYOUT_MIN_SIMILARITY) add(best) } return out } /** The partner's latest AP invoice → doc type / currency / tax-included + first line's tax & charge. */ const historyDefaults = async (ctx: InboxCtx, bpartnerId: number) => { // never filter on an empty id — `C_BPartner_ID eq ` would return the whole table if (!(Number(bpartnerId) > 0)) return null const filter = `C_BPartner_ID eq ${Number(bpartnerId)} AND IsSOTrx eq false AND (DocStatus eq 'CO' OR DocStatus eq 'CL' OR DocStatus eq 'DR')` const res: any = await fetchHelper(ctx.event, `models/c_invoice?$filter=${string.urlEncode(filter)}&$orderby=${string.urlEncode('Created desc')}&$top=1`, 'GET', ctx.token, null) const inv = res?.records?.[0] if (!inv?.id) return null const out: any = { docTypeId: fkId(inv.C_DocTypeTarget_ID) || fkId(inv.C_DocType_ID) || null, currencyId: fkId(inv.C_Currency_ID) || null, isTaxIncluded: inv.IsTaxIncluded === true || inv.IsTaxIncluded === 'Y', taxId: null, chargeId: null, documentNo: inv.DocumentNo || '' } try { // separate bounded query — no child-table $expand (see "iDempiere REST load rules") const lines: any = await fetchHelper(ctx.event, `models/c_invoiceline?$filter=${string.urlEncode(`C_Invoice_ID eq ${inv.id}`)}&$orderby=${string.urlEncode('Line asc')}&$top=1`, 'GET', ctx.token, null) const line = lines?.records?.[0] out.taxId = fkId(line?.C_Tax_ID) || null out.chargeId = fkId(line?.C_Charge_ID) || null } catch {} return out } export const resolveVendorDefaults = async ( ctx: InboxCtx, params: { doc: any; bpartnerId?: number | string | null; withHistory?: boolean } ): Promise => { const result = emptyResult() let profiles: any[] = [] try { profiles = (await listVendorProfiles(ctx)).filter(Boolean) } catch { profiles = [] } const docProfiles = profilesForDocument(profiles, params.doc) result.rememberedBpartnerId = Number(docProfiles.find(p => p.c_bpartner_id)?.c_bpartner_id) || null const selected = Number(params.bpartnerId) || 0 const partnerId = selected || result.rememberedBpartnerId || 0 const ranked: Array<{ p: any; source: DefaultSource }> = [] for (const p of docProfiles) { if (selected && p.c_bpartner_id && Number(p.c_bpartner_id) !== selected) continue // explicit pick wins ranked.push({ p, source: 'profile' }) } if (partnerId) { const ofPartner = profiles .filter(p => Number(p.c_bpartner_id) === partnerId && !ranked.some(r => r.p === p)) .sort((a, b) => (b.samples_count || 0) - (a.samples_count || 0)) for (const p of ofPartner) ranked.push({ p, source: 'partner' }) } const take = (key: keyof VendorDefaults, value: any, source: DefaultSource) => { if (result.defaults[key] == null && value != null && value !== '') { (result.defaults as any)[key] = value; result.sources[key] = source } } for (const { p, source } of ranked) { take('currencyId', p.currency_id, source) take('docTypeId', p.default_doctype_id, source) take('taxId', p.default_tax_id, source) take('chargeId', p.default_charge_id, source) // the column is mandatory (always false/true) — only meaningful once the profile was trained if ((p.samples_count || 0) > 0) take('isTaxIncluded', !!p.is_tax_included, source) result.samplesCount = Math.max(result.samplesCount, p.samples_count || 0) } const d = result.defaults const incomplete = d.currencyId == null || d.docTypeId == null || d.taxId == null || d.chargeId == null if (params.withHistory !== false && partnerId && incomplete) { try { const h = await historyDefaults(ctx, partnerId) if (h) { take('currencyId', h.currencyId, 'history') take('docTypeId', h.docTypeId, 'history') take('taxId', h.taxId, 'history') take('chargeId', h.chargeId, 'history') take('isTaxIncluded', h.isTaxIncluded, 'history') if (Object.values(result.sources).includes('history')) result.historyInvoiceNo = h.documentNo } } catch {} } return result } export default resolveVendorDefaults