'use strict' // ── Factures fournisseurs ────────────────────────────────────────────────── // Courriels to:factures@ (reçus dans cc@, membre du groupe) → OCR des pièces (image/PDF) → un INTAKE par pièce // stocké dans ERPNext (doctype « Supplier Invoice Intake », auditable/permissionné) + pièce jointe versée dans ERPNext. // Revue dans OPS → création d'un Purchase Invoice DRAFT (jamais soumis auto). ERPNext = registre ; OPS = capture/triage. const http = require('http') const cfg = require('./config') const { log, json, parseBody, loadSeenSet, saveSeenSet } = require('./helpers') const erp = require('./erp') const SEEN = '/app/data/supplier_invoices_seen.json' const DOCTYPE = 'Supplier Invoice Intake' const INVOICE_TO = () => (process.env.GMAIL_INVOICE_TO || 'factures@targointernet.com').trim() const isOcrable = (a) => /pdf$/i.test(a.mimeType) || /^image\//i.test(a.mimeType) || /\.(pdf|jpe?g|png|webp|heic)$/i.test(a.filename || '') // Verse la pièce dans ERPNext (File privé attaché à l'intake) → le champ `attachment` reçoit l'URL. Best-effort. async function attachToErp (name, filename, mime, base64) { const r = await erp.create('File', { file_name: filename || (name + (/pdf/i.test(mime) ? '.pdf' : '.jpg')), is_private: 1, content: base64, decode: true, attached_to_doctype: DOCTYPE, attached_to_name: name, attached_to_field: 'attachment', }) if (!r.ok) log('attachToErp ' + name + ': ' + r.error) return r.ok } // OCR une pièce → crée un intake ERPNext (+ pièce versée). Dédup gérée en amont (seen-set). async function ingestAttachment ({ mb, msgId, from, subject, date, att }) { const vision = require('./vision'); const gmail = require('./gmail') const isPdf = /pdf/i.test(att.mimeType) || /\.pdf$/i.test(att.filename || '') const mime = isPdf ? 'application/pdf' : (att.mimeType && att.mimeType.startsWith('image/') ? att.mimeType : 'image/jpeg') const base64 = await gmail.getAttachment(msgId, att.attachmentId, mb) let p = {} try { p = await vision.extractInvoice(base64, mime) } catch (e) { log('supplier OCR ' + att.filename + ': ' + e.message); p = { ocr_error: e.message } } const doc = { status: p.ocr_error ? 'Error' : 'New', vendor: p.vendor || null, invoice_number: p.invoice_number || null, invoice_date: p.date || null, due_date: p.due_date || null, currency: p.currency || 'CAD', subtotal: p.subtotal || 0, tax_gst: p.tax_gst || 0, tax_qst: p.tax_qst || 0, total: p.total || 0, email_from: from, email_subject: subject, email_date: date, gmail_msg_id: msgId, attachment_filename: att.filename || '', ocr_error: p.ocr_error || null, raw_ocr: JSON.stringify(p).slice(0, 100000), } const r = await erp.create(DOCTYPE, doc) if (!r.ok) { log('intake create échec: ' + r.error); return false } await attachToErp(r.name, att.filename, mime, base64).catch(() => {}) return true } // Poll : courriels to:factures@ (dans cc@). Idempotent (seen par message). async function pollInvoices () { const gmail = require('./gmail'); const mb = gmail.mailbox() const seen = loadSeenSet(SEEN); let added = 0 let ids = [] try { ids = await gmail.listMessages({ q: `newer_than:14d to:${INVOICE_TO()}`, max: 30, mb }) } catch (e) { log('pollInvoices list: ' + e.message); return { ok: false, error: e.message } } for (const { id } of ids.slice().reverse()) { if (seen.has(id)) continue try { const m = await gmail.getMessage(id, mb) for (const att of (m.attachments || []).filter(isOcrable)) { if (await ingestAttachment({ mb, msgId: id, from: m.from, subject: m.subject, date: m.date, att })) added++ } seen.add(id) } catch (e) { log('pollInvoices msg ' + id + ': ' + e.message) } } saveSeenSet(SEEN, seen) return { ok: true, scanned: ids.length, added } } // Crée un Purchase Invoice DRAFT (best-effort, jamais soumis). Échec → intake passe 'Error' + message ; saisie manuelle possible. async function createDraft (name) { const c = await erp.get(DOCTYPE, name) if (!c || !c.name) return { ok: false, error: 'intake introuvable' } let supplier = c.supplier || null if (!supplier && c.vendor) { try { const sups = await erp.list('Supplier', { filters: [['supplier_name', 'like', '%' + c.vendor + '%']], fields: ['name'], limit: 1 }); if (sups && sups[0]) supplier = sups[0].name } catch (e) { /* */ } } const doc = { bill_no: c.invoice_number || undefined, bill_date: c.invoice_date || undefined, currency: c.currency || 'CAD', items: [{ item_name: ((c.vendor || 'Facture') + (c.invoice_number ? ' ' + c.invoice_number : '')).slice(0, 140), description: 'Facture fournisseur (OCR) — ' + (c.attachment_filename || name), qty: 1, rate: Number(c.total || c.subtotal || 0) || 0 }], } if (supplier) doc.supplier = supplier const pi = await erp.create('Purchase Invoice', doc) if (pi && pi.ok) { await erp.update(DOCTYPE, name, { status: 'Created', supplier: supplier || undefined, purchase_invoice: pi.name, ocr_error: null }); return { ok: true, name: pi.name, supplier_matched: !!supplier } } await erp.update(DOCTYPE, name, { status: 'Error', ocr_error: (pi && pi.error) || 'échec création' }) return { ok: false, error: (pi && pi.error) || 'échec création', supplier_matched: !!supplier } } // Récupère le binaire d'un fichier privé ERPNext (pour l'affichage dans OPS), authentifié comme erpFetch. function erpFileBinary (fileUrl) { return new Promise((resolve, reject) => { const u = new URL(cfg.ERP_URL + fileUrl) const req = http.request({ hostname: u.hostname, port: u.port || 8000, path: u.pathname + u.search, method: 'GET', headers: { Host: cfg.ERP_SITE, Authorization: 'token ' + cfg.ERP_TOKEN } }, (res) => { if (res.statusCode >= 400) { res.resume(); return reject(new Error('ERP file ' + res.statusCode)) } const chunks = []; res.on('data', c => chunks.push(c)); res.on('end', () => resolve({ buffer: Buffer.concat(chunks), contentType: res.headers['content-type'] || 'application/octet-stream' })) }) req.on('error', reject); req.end() }) } async function handle (req, res, method, p, url) { if (p === '/supplier-invoices' && method === 'GET') { const showAll = url && url.searchParams.get('all') === '1' const filters = showAll ? [] : [['status', '!=', 'Ignored']] const fields = ['name', 'status', 'vendor', 'supplier', 'invoice_number', 'invoice_date', 'due_date', 'currency', 'subtotal', 'tax_gst', 'tax_qst', 'total', 'attachment', 'attachment_filename', 'email_from', 'email_subject', 'email_date', 'ocr_error', 'purchase_invoice', 'creation'] let invoices = [] try { invoices = await erp.list(DOCTYPE, { filters, fields, limit: 200, orderBy: 'creation desc' }) } catch (e) { return json(res, 200, { invoices: [], error: e.message, invoice_to: INVOICE_TO() }) } return json(res, 200, { invoices, invoice_to: INVOICE_TO(), erp_base: (cfg.ERP_PUBLIC_URL || 'https://erp.gigafibre.ca') }) } if (p === '/supplier-invoices/poll' && method === 'POST') return json(res, 200, await pollInvoices()) const m = p.match(/^\/supplier-invoices\/([^/]+)\/(attachment|create|ignore)$/) if (m) { const name = decodeURIComponent(m[1]); const action = m[2] if (action === 'attachment' && method === 'GET') { try { const c = await erp.get(DOCTYPE, name) if (!c || !c.attachment) return json(res, 404, { error: 'pas de pièce' }) const f = await erpFileBinary(c.attachment) res.writeHead(200, { 'Content-Type': f.contentType, 'Content-Disposition': 'inline; filename="' + (c.attachment_filename || 'piece') + '"' }) return res.end(f.buffer) } catch (e) { return json(res, 404, { error: 'pièce introuvable: ' + e.message }) } } if (action === 'create' && method === 'POST') return json(res, 200, await createDraft(name)) if (action === 'ignore' && method === 'POST') { const r = await erp.update(DOCTYPE, name, { status: 'Ignored' }); return json(res, r.ok ? 200 : 500, r.ok ? { ok: true } : { error: r.error }) } } return json(res, 404, { error: 'not found' }) } let _timer = null function start () { if (String(process.env.GMAIL_INGEST || '').toLowerCase() === 'off') return const ms = (Number(process.env.INVOICE_POLL_MIN) || 10) * 60000 if (_timer) clearInterval(_timer) _timer = setInterval(() => pollInvoices().catch(e => log('pollInvoices: ' + e.message)), ms) log('Factures fournisseurs : poll ' + (ms / 60000) + ' min sur ' + require('./gmail').mailbox() + ' (to:' + INVOICE_TO() + ') → ERPNext ' + DOCTYPE) pollInvoices().catch(() => {}) } module.exports = { handle, pollInvoices, createDraft, start }