/** * GET /api/accounting/incoming-invoices/match-vendor?id=&vat=&iban=&name=&email= * * Reuse-first vendor matching for the review card. Returns * - remembered: the c_bpartner bound to a learned profile of this document * (fingerprint / VAT id / IBAN / layout tokens — see vendorDefaults.ts), * - candidates: existing partners that look like the document's seller, best first, * - vendors: ALL active vendors of the org (+ org *) so the review's ComboBox is a * real searchable selection even when nothing matched. * * Candidates are scored in Node against the vendor list instead of an OData * `contains(Name,'')`: the extracted name is often a whole * letterhead line ("Christina Biendl Buchhaltungsservice | klar. zuverlässig. …") that * never is a substring of the partner's name. Signals: TaxID equality, fuzzy name * similarity, the partner's name tokens inside the letterhead words, e-mail domain. * A last pass looks for NON-vendor partners by the seller's most distinctive word * (flagged isVendor:false — confirm then sets IsVendor). */ import { string } from 'alga-js' import refreshTokenHelper from '../../../utils/refreshTokenHelper' import errorHandlingHelper from '../../../utils/errorHandlingHelper' import fetchHelper from '../../../utils/fetchHelper' import { buildInboxCtx } from '../../../utils/inbox/scope' import { getInboxDocument } from '../../../utils/inbox/inboxDb' import { normVat } from '../../../utils/inbox/vendorProfile' import { resolveVendorDefaults } from '../../../utils/inbox/vendorDefaults' import { nameSimilarity, nameTokens, foldText } from '../../../utils/bankReconciliationSuggestions' const CANDIDATE_MIN_SCORE = 0.5 const FREE_MAIL = new Set(['gmail.com', 'googlemail.com', 'gmx.de', 'gmx.net', 'web.de', 't-online.de', 'outlook.com', 'outlook.de', 'hotmail.com', 'hotmail.de', 'yahoo.com', 'yahoo.de', 'icloud.com', 'freenet.de', 'posteo.de', 'mailbox.org']) const isTrue = (v: any) => v === true || v === 'Y' const slim = (r: any) => ({ id: r.id, name: r.Name, taxId: r.TaxID || r.taxID || '', isVendor: isTrue(r.IsVendor ?? r.isVendor), email: r.EMail || r.eMail || '' }) const handleFunc = async (event: any, authToken: any = null) => { const token = authToken ?? await getTokenHelper(event) const organizationId = Number(getCookie(event, 'logship_organization_id')) || 0 const q = getQuery(event) const docId = Number(q.id) || 0 const ctx = buildInboxCtx(event, token) const out: any = { status: 200, remembered: null, candidates: [], vendors: [] } if (!organizationId) return out // the stored document carries the layout tokens + fingerprint; the query values are // the fallback (and override the stored ones when the user remapped a field) let doc: any = null if (docId) { try { doc = await getInboxDocument(ctx, docId) } catch {} } const ex = doc?.extracted || {} const vat = normVat(String(q.vat || ex?.seller?.vatId || '')) const name = String(q.name || ex?.seller?.name || '').trim() const email = String(q.email || '').trim().toLowerCase() const layoutTokens: string[] = ex?.__layout?.tokens || [] // 1. remembered partner (learned profile of this document) try { const lookupDoc = doc || { vendorFingerprint: '', extracted: { seller: { name, vatId: vat }, paymentIban: String(q.iban || '') } } const learned = await resolveVendorDefaults(ctx, { doc: lookupDoc, withHistory: false }) if (learned.rememberedBpartnerId) { const r: any = await fetchHelper(event, `models/c_bpartner/${learned.rememberedBpartnerId}`, 'GET', token, null) if (r?.id && r.IsActive !== false) out.remembered = { ...slim(r), score: 1, reason: 'learned' } } } catch {} // 2. all active vendors of the org (+ org * = shared vendors) const orgScope = `(AD_Org_ID eq ${organizationId} OR AD_Org_ID eq 0)` try { const filter = `IsActive eq true AND IsVendor eq true AND ${orgScope}` const res: any = await fetchHelper(event, `models/c_bpartner?$filter=${string.urlEncode(filter)}&$orderby=${string.urlEncode('Name asc')}&$top=500`, 'GET', token, null) out.vendors = (res?.records || []).map(slim) } catch { /* fail-soft: the picker then only shows the candidates */ } // The BUYER (our own organization) is printed on every invoice — its linked partner // (ad_org.C_BPartner_ID) and any partner named like the org must never be suggested. let ownPartnerId = 0 let ownTokens: string[] = [] try { const org: any = await fetchHelper(event, `models/ad_org/${organizationId}`, 'GET', token, null) ownPartnerId = Number(org?.C_BPartner_ID?.id ?? org?.C_BPartner_ID) || 0 ownTokens = nameTokens(org?.Name) } catch {} const isOwn = (v: any) => { if (ownPartnerId && Number(v.id) === ownPartnerId) return true const vt = nameTokens(v.name) return ownTokens.length > 0 && vt.length > 0 && vt.every(x => ownTokens.includes(x)) } // 3. score the vendors against the document const letterhead = new Set([ ...nameTokens(name), ...layoutTokens.filter(t => t.startsWith('w:')).map(t => foldText(t.slice(2)).replace(/[^a-z0-9]/g, '')).filter(Boolean) ]) const domains = new Set(layoutTokens.filter(t => t.startsWith('dom:')).map(t => t.slice(4))) if (email.includes('@')) domains.add(email.split('@')[1]) const score = (v: any): { score: number; reason: string } => { if (isOwn(v)) return { score: 0, reason: '' } let best = 0, reason = '' const bump = (s: number, r: string) => { if (s > best) { best = s; reason = r } } if (vat && normVat(v.taxId) === vat) bump(1, 'vat') if (name) bump(nameSimilarity(v.name, name) * 0.95, 'name') const vt = nameTokens(v.name) if (vt.length && letterhead.size) { const hits = vt.filter(x => letterhead.has(x)).length // every name token on the letterhead — a single short token ("post") is too weak if (hits === vt.length && (vt.length >= 2 || vt[0].length >= 6)) bump(0.85, 'letterhead') else if (vt.length >= 3 && hits >= vt.length - 1) bump(0.6, 'letterhead') } const dom = String(v.email || '').toLowerCase().split('@')[1] if (dom && !FREE_MAIL.has(dom) && domains.has(dom)) bump(0.8, 'email') return { score: Math.round(best * 100) / 100, reason } } const scored = out.vendors .map((v: any) => ({ ...v, ...score(v) })) .filter((v: any) => v.score >= CANDIDATE_MIN_SCORE) .sort((a: any, b: any) => b.score - a.score) .slice(0, 8) out.candidates = scored // 4. nothing convincing among the vendors → partners that are not flagged vendor yet if (!scored.some((c: any) => c.score >= 0.7) && !out.remembered) { const word = nameTokens(name).filter(w => w.length >= 5).sort((a, b) => b.length - a.length)[0] if (word) { try { const filter = `IsActive eq true AND IsVendor eq false AND ${orgScope} AND contains(tolower(Name),'${word.replace(/'/g, '')}')` const res: any = await fetchHelper(event, `models/c_bpartner?$filter=${string.urlEncode(filter)}&$top=10`, 'GET', token, null) const extra = (res?.records || []).map(slim) .map((v: any) => ({ ...v, ...score(v) })) .filter((v: any) => v.score >= CANDIDATE_MIN_SCORE) out.candidates = [...out.candidates, ...extra].sort((a: any, b: any) => b.score - a.score).slice(0, 8) } catch {} } } return out } export default defineEventHandler(async (event) => { let data: any = {} try { data = await handleFunc(event) } catch (err: any) { try { const authToken: any = await refreshTokenHelper(event) data = await handleFunc(event, authToken) } catch (error: any) { data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) } } return data })