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>
138 lines
8.6 KiB
JavaScript
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 }
|