/** * iDempiere-backed store for the Incoming-Invoices inbox + per-vendor learning, * via the REST API (fetchHelper) against two custom tables: * - CUST_IncomingInvoice (staging: status + extraction JSON; PDF in Strapi) * - CUST_VendorInvoiceProfile (learning: fingerprint, defaults, field hints) * Engine config is kept in AD_Preference. NO SQLite, NO direct pg — everything is * Postgres-backed through iDempiere's REST layer. * * All helpers take a ctx = { event, token, scope } (REST needs the auth token). * Fail-soft: callers catch errors so a missing table only disables this feature. * Write casing follows the iDempiere rule (lowercase-first scalars, PascalCase FK * objects with { id, tableName }); reads accept PascalCase responses. */ import { string } from 'alga-js' import fetchHelper from '../fetchHelper' export interface InboxScope { clientId: number; orgId: number } export interface InboxCtx { event: any; token: string; scope: InboxScope } const DOC_MODEL = 'cust_incominginvoice' const DOC_TABLE = 'CUST_IncomingInvoice' const PROFILE_MODEL = 'cust_vendorinvoiceprofile' const PROFILE_TABLE = 'CUST_VendorInvoiceProfile' const safeParse = (s: any) => { if (s == null) return null; if (typeof s === 'object') return s; try { return JSON.parse(s) } catch { return null } } const fkId = (v: any) => (v && typeof v === 'object') ? (v.id ?? null) : (v ?? null) const req = (ctx: InboxCtx, path: string, method: string, body: any = null) => fetchHelper(ctx.event, path, method, ctx.token, body) /* ---------------------------- mapping ---------------------------- */ const mapDoc = (r: any) => r ? ({ id: r.id, status: r.InboxStatus ?? r.inboxStatus ?? 'new', filename: r.FileName ?? r.fileName ?? '', mimeType: r.MimeType ?? r.mimeType ?? 'application/pdf', byteSize: r.ByteSize ?? null, sourceTier: r.SourceTier ?? null, vendorFingerprint: r.VendorFingerprint ?? r.vendorFingerprint ?? '', extracted: safeParse(r.ExtractedJson), confidence: safeParse(r.ConfidenceJson), bbox: safeParse(r.BboxJson), cInvoiceId: fkId(r.C_Invoice_ID), strapiFileUrl: r.StrapiFileUrl ?? '', strapiAttachmentId: r.StrapiAttachmentId ?? null, error: r.ErrorMsg ?? '', uid: r[`${DOC_TABLE}_UU`] ?? r.UID ?? '', created: r.Created ?? null, updated: r.Updated ?? null }) : null const mapProfile = (r: any) => r ? ({ id: r.id, c_bpartner_id: fkId(r.C_BPartner_ID), vendor_name: r.VendorName ?? null, vat_id: r.VatId ?? null, iban: r.IBAN ?? null, currency_id: fkId(r.C_Currency_ID), default_charge_id: fkId(r.C_Charge_ID), default_tax_id: fkId(r.C_Tax_ID), default_doctype_id: fkId(r.C_DocType_ID), is_tax_included: (r.IsTaxIncluded === true || r.IsTaxIncluded === 'Y'), field_hints: safeParse(r.FieldHints) || {}, samples_count: Number(r.SamplesCount) || 0 }) : null /* ------------------------ inbox documents ------------------------ */ export const createInboxDocument = async (ctx: InboxCtx, params: { filename: string; mimeType: string; byteSize?: number }): Promise => { // The uploader is captured by iDempiere's standard CreatedBy (the record is // created with the uploader's token) — no separate UploadedBy_ID column. // Only InboxStatus / FileName / MimeType are set at create; the "filled-in-later" // columns (VendorFingerprint, ExtractedJson, …) must be NON-mandatory in the // dictionary, since they're populated by setInboxParsed after OCR. const res: any = await req(ctx, `models/${DOC_MODEL}`, 'POST', { AD_Org_ID: { id: ctx.scope.orgId, tableName: 'AD_Org' }, isActive: true, inboxStatus: 'processing', fileName: params.filename, mimeType: params.mimeType, ...(params.byteSize != null ? { byteSize: params.byteSize } : {}), tableName: DOC_TABLE }) return Number(res?.id) } export const setInboxStrapiFile = async (ctx: InboxCtx, id: number, fileUrl: string, attachmentId?: number | null) => { await req(ctx, `models/${DOC_MODEL}/${id}`, 'PUT', { strapiFileUrl: fileUrl || '', ...(attachmentId != null ? { strapiAttachmentId: attachmentId } : {}) }) } export const setInboxParsed = async (ctx: InboxCtx, id: number, result: { sourceTier: number; vendorFingerprint?: string | null; extracted: any; confidence: any; bbox: any }) => { await req(ctx, `models/${DOC_MODEL}/${id}`, 'PUT', { sourceTier: result.sourceTier, vendorFingerprint: result.vendorFingerprint || '', extractedJson: JSON.stringify(result.extracted ?? {}), confidenceJson: JSON.stringify(result.confidence ?? {}), bboxJson: JSON.stringify(result.bbox ?? {}), inboxStatus: 'parsed', errorMsg: '' }) } export const setInboxError = async (ctx: InboxCtx, id: number, error: string) => { await req(ctx, `models/${DOC_MODEL}/${id}`, 'PUT', { inboxStatus: 'error', errorMsg: String(error).slice(0, 500) }) } export const setInboxProcessing = async (ctx: InboxCtx, id: number) => { await req(ctx, `models/${DOC_MODEL}/${id}`, 'PUT', { inboxStatus: 'processing', errorMsg: '' }) } export const markInboxLinked = async (ctx: InboxCtx, id: number, cInvoiceId: number, strapiAttachmentId?: number | null) => { await req(ctx, `models/${DOC_MODEL}/${id}`, 'PUT', { inboxStatus: 'linked', C_Invoice_ID: { id: cInvoiceId, tableName: 'C_Invoice' }, ...(strapiAttachmentId != null ? { strapiAttachmentId } : {}) }) } export const rejectInboxDocument = async (ctx: InboxCtx, id: number) => { await req(ctx, `models/${DOC_MODEL}/${id}`, 'PUT', { inboxStatus: 'rejected', isActive: false }) } export const getInboxDocument = async (ctx: InboxCtx, id: number) => { const res: any = await req(ctx, `models/${DOC_MODEL}/${id}`, 'GET', null) if (!res?.id) return null // Org guard (REST already client-scopes via token; enforce org here). if (fkId(res.AD_Org_ID) && Number(fkId(res.AD_Org_ID)) !== Number(ctx.scope.orgId)) return null return mapDoc(res) } export const listInboxDocuments = async (ctx: InboxCtx, opts: { status?: string; limit?: number } = {}) => { const limit = Math.min(Math.max(opts.limit ?? 200, 1), 1000) let filter = `AD_Org_ID eq ${ctx.scope.orgId}` if (opts.status) filter += ` AND InboxStatus eq '${opts.status}'` const res: any = await req( ctx, `models/${DOC_MODEL}?$filter=${string.urlEncode(filter)}&$orderby=${string.urlEncode('Created desc')}&$top=${limit}`, 'GET', null ) return (res?.records || []).map(mapDoc) } /* ------------------------ vendor profiles ------------------------ */ export const getVendorProfile = async (ctx: InboxCtx, fingerprint: string) => { if (!fingerprint) return null const filter = `AD_Org_ID eq ${ctx.scope.orgId} AND Fingerprint eq '${fingerprint.replace(/'/g, '')}' AND IsActive eq true` const res: any = await req(ctx, `models/${PROFILE_MODEL}?$filter=${string.urlEncode(filter)}`, 'GET', null) return mapProfile(res?.records?.[0]) } /** All active vendor profiles for the org (for fuzzy layout matching). */ export const listVendorProfiles = async (ctx: InboxCtx) => { const filter = `AD_Org_ID eq ${ctx.scope.orgId} AND IsActive eq true` const res: any = await req(ctx, `models/${PROFILE_MODEL}?$filter=${string.urlEncode(filter)}&$top=500`, 'GET', null) return (res?.records || []).map(mapProfile) } export const upsertVendorProfile = async (ctx: InboxCtx, fingerprint: string, patch: { cBpartnerId?: number | null; vendorName?: string | null; vatId?: string | null; iban?: string | null currencyId?: number | null; defaultChargeId?: number | null; defaultTaxId?: number | null defaultDoctypeId?: number | null; isTaxIncluded?: boolean; fieldHints?: any }) => { if (!fingerprint) return const existing = await getVendorProfile(ctx, fingerprint) const body: any = { isActive: true } if (patch.vendorName != null) body.vendorName = patch.vendorName if (patch.vatId != null) body.vatId = patch.vatId if (patch.iban != null) body.iBAN = patch.iban if (patch.isTaxIncluded != null) body.isTaxIncluded = !!patch.isTaxIncluded if (patch.fieldHints != null) body.fieldHints = JSON.stringify(patch.fieldHints) if (patch.cBpartnerId) body.C_BPartner_ID = { id: patch.cBpartnerId, tableName: 'C_BPartner' } if (patch.currencyId) body.C_Currency_ID = { id: patch.currencyId, tableName: 'C_Currency' } if (patch.defaultChargeId) body.C_Charge_ID = { id: patch.defaultChargeId, tableName: 'C_Charge' } if (patch.defaultTaxId) body.C_Tax_ID = { id: patch.defaultTaxId, tableName: 'C_Tax' } if (patch.defaultDoctypeId) body.C_DocType_ID = { id: patch.defaultDoctypeId, tableName: 'C_DocType' } if (existing) { body.samplesCount = (existing.samples_count || 0) + 1 await req(ctx, `models/${PROFILE_MODEL}/${existing.id}`, 'PUT', body) } else { body.AD_Org_ID = { id: ctx.scope.orgId, tableName: 'AD_Org' } body.fingerprint = fingerprint body.samplesCount = 1 body.tableName = PROFILE_TABLE await req(ctx, `models/${PROFILE_MODEL}`, 'POST', body) } } /* ------------------------ engine config (AD_Preference) ---------- */ const prefAttribute = (key: string) => `ap_ocr_${key}` export const getInboxConfig = async (ctx: InboxCtx, key: string): Promise => { const filter = `Attribute eq '${prefAttribute(key)}' AND AD_Org_ID eq ${ctx.scope.orgId}` const res: any = await req(ctx, `models/ad_preference?$filter=${string.urlEncode(filter)}`, 'GET', null) return safeParse(res?.records?.[0]?.Value) } export const setInboxConfig = async (ctx: InboxCtx, key: string, value: any) => { const attr = prefAttribute(key) const filter = `Attribute eq '${attr}' AND AD_Org_ID eq ${ctx.scope.orgId}` const found: any = await req(ctx, `models/ad_preference?$filter=${string.urlEncode(filter)}`, 'GET', null) const existingId = found?.records?.[0]?.id const payload: any = { attribute: attr, value: JSON.stringify(value ?? null), isActive: true } if (existingId) { await req(ctx, `models/ad_preference/${existingId}`, 'PUT', payload) } else { await req(ctx, 'models/ad_preference', 'POST', { ...payload, AD_Org_ID: { id: ctx.scope.orgId, tableName: 'AD_Org' }, tableName: 'AD_Preference' }) } }