gigafibre-fsm/services/targo-hub/lib/supplier-invoices.js
louispaulb 0f65c02d83 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
Accumulated work on the dispatch/legacy-writeback branch:
- Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard,
  PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs;
  hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js.
- Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js,
  sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental
  migration scripts; LegacySyncPage, SupplierInvoices + negative-billing /
  terminated-active reports.
- Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit,
  olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers,
  legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets,
  ClientDetail updates.
- docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:12:12 -04:00

138 lines
8.6 KiB
JavaScript

'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 }