gigafibre-fsm/services/targo-hub/lib/proration.js
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

212 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Moteur de PRORATA — crée les portions de facture selon le TYPE de chaque ligne.
// • Récurrent (service / produit / rabais récurrent) → PRORATÉ : montant × jours_restants ÷ jours_période.
// • Ponctuel / installation / frais unique → PLEIN montant (jamais proraté).
// • Rabais → suit le type de la ligne qu'il réduit (l'appelant pose `recurring` en conséquence ; montants négatifs OK).
// Mappe le `price_recurr_type` de F (1=récurrent, 0=ponctuel) → lines[].recurring.
// PUR + lecture seule : calcule des lignes proratées, n'écrit AUCUNE facture.
const { json, parseBody, erpFetch } = require('./helpers')
function dayStart (v) { const d = new Date(v); if (isNaN(d)) throw new Error('date invalide: ' + v); d.setHours(0, 0, 0, 0); return d }
function daysInclusive (a, b) { return Math.floor((dayStart(b) - dayStart(a)) / 86400000) + 1 }
const ONE_TIME = new Set(['installation', 'one_time', 'setup', 'ponctuel'])
// Méthode du PLUS GRAND RESTE (cf. gem OSS `lare_round`, comme Lago/Kill Bill par ligne) :
// arrondit chaque montant au cent de sorte que la SOMME des arrondis == arrondi de la somme exacte
// → aucun résidu d'arrondi (ex. un net mensuel de 0 donne bien 0,00 et non 0,01). Gère les négatifs.
function largestRemainderRound (exacts) {
const cents = exacts.map(v => v * 100)
const floors = cents.map(c => Math.floor(c))
let diff = Math.round(cents.reduce((s, c) => s + c, 0)) - floors.reduce((s, c) => s + c, 0)
const byRemainderDesc = exacts.map((_, i) => i).sort((a, b) => (cents[b] - floors[b]) - (cents[a] - floors[a]))
const out = floors.slice()
for (let k = 0; diff > 0 && k < byRemainderDesc.length; k++) { out[byRemainderDesc[k]] += 1; diff-- }
return out.map(c => c / 100)
}
// { activation_date, period_start, period_end, lines: [{ label, amount, recurring(bool), kind? }] }
function prorate ({ activation_date, period_start, period_end, lines = [] }) {
const periodDays = Math.max(1, daysInclusive(period_start, period_end))
const billFrom = dayStart(activation_date) > dayStart(period_start) ? activation_date : period_start
const billedDays = Math.max(0, Math.min(periodDays, daysInclusive(billFrom, period_end)))
const ratio = billedDays / periodDays
// Classer chaque ligne + plein montant
const norm = (lines || []).map(l => {
const recurring = !!l.recurring && !ONE_TIME.has(l.kind)
return { label: l.label || '', kind: l.kind || (recurring ? 'service' : 'one_time'), recurring, full: Math.round((Number(l.amount) || 0) * 100) / 100 }
})
// RÉCURRENT : prorata + arrondi « plus grand reste » (la somme des lignes == arrondi de la somme exacte)
const recIdx = norm.map((l, i) => (l.recurring ? i : -1)).filter(i => i >= 0)
const allocated = largestRemainderRound(recIdx.map(i => norm[i].full * ratio))
recIdx.forEach((i, k) => { norm[i].prorated = allocated[k] })
// PONCTUEL : plein montant (jamais proraté)
norm.forEach(l => { if (!l.recurring) l.prorated = l.full })
const out = norm.map(l => ({
label: l.label,
kind: l.kind,
recurring: l.recurring,
full_amount: l.full,
factor: l.recurring ? +ratio.toFixed(4) : 1,
prorated_amount: l.prorated
}))
const round2 = n => Math.round(n * 100) / 100
return {
period_start, period_end, activation_date,
period_days: periodDays, billed_days: billedDays, ratio: +ratio.toFixed(4),
lines: out,
prorated_total: round2(out.reduce((s, l) => s + l.prorated_amount, 0)),
full_total: round2(out.reduce((s, l) => s + l.full_amount, 0))
}
}
const ENC = v => encodeURIComponent(JSON.stringify(v))
const pad2 = n => String(n).padStart(2, '0')
const ymd = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
// Rassemble les lignes RÉELLES d'activation d'un client (abonnements récurrents + items ponctuels du devis),
// les classe (récurrent vs ponctuel) et calcule la facture consolidée proratée.
// LECTURE SEULE — n'écrit AUCUNE facture (`written:false`). Reproduit la convention de dispatch.js :
// jour d'activation GRATUIT → 1re journée facturée = lendemain ; période = mois de l'activation.
async function buildActivationPreview ({ customer, service_location, quotation, activation_date, statuses }) {
if (!customer) throw new Error('customer requis')
const lines = []
// 1) RÉCURRENT — Service Subscription (monthly_price ; négatif = rabais récurrent, conservé)
const subFilters = [['customer', '=', customer]]
if (service_location) subFilters.push(['service_location', '=', service_location])
if (Array.isArray(statuses) && statuses.length) subFilters.push(['status', 'in', statuses])
const subRes = await erpFetch(`/api/resource/Service Subscription?filters=${ENC(subFilters)}&fields=${ENC(['name', 'monthly_price', 'plan_name', 'status'])}&limit_page_length=100`)
const subs = (subRes.status === 200 && Array.isArray(subRes.data?.data)) ? subRes.data.data : []
for (const s of subs) {
const amt = Number(s.monthly_price) || 0
if (!amt) continue
lines.push({ label: s.plan_name || s.name, amount: amt, recurring: true, kind: amt < 0 ? 'discount' : 'service', source: s.name })
}
// 2) PONCTUEL — items du devis SANS "$/mois" (installation, équipement, frais unique). Les items
// récurrents du devis sont déjà couverts par les abonnements ci-dessus → sautés (anti-double-compte).
if (quotation) {
const qRes = await erpFetch(`/api/resource/Quotation/${encodeURIComponent(quotation)}`)
const items = (qRes.status === 200 && Array.isArray(qRes.data?.data?.items)) ? qRes.data.data.items : []
for (const it of items) {
if (/\$\s*\/\s*mois/i.test(it.description || '')) continue
const amt = Number(it.amount != null ? it.amount : (Number(it.rate) || 0) * (Number(it.qty) || 1)) || 0
if (!amt) continue
const txt = `${it.item_code || ''} ${it.item_name || ''}`.toLowerCase()
const kind = amt < 0 ? 'discount' : (/install|activ|frais|setup/.test(txt) ? 'installation' : 'one_time')
lines.push({ label: it.item_name || it.item_code || '', amount: amt, recurring: false, kind, source: quotation })
}
}
// 3) Période = mois de l'activation ; 1re journée facturée = lendemain (jour d'activation gratuit)
const on = activation_date ? new Date(activation_date + 'T12:00:00') : new Date()
const y = on.getFullYear(); const m = on.getMonth()
const dim = new Date(y, m + 1, 0).getDate()
const firstBilled = new Date(y, m, on.getDate() + 1)
const result = prorate({ activation_date: ymd(firstBilled), period_start: `${y}-${pad2(m + 1)}-01`, period_end: `${y}-${pad2(m + 1)}-${pad2(dim)}`, lines })
return {
customer,
service_location: service_location || null,
quotation: quotation || null,
activated_on: ymd(on),
courtesy_note: `Jour d'activation (${ymd(on)}) gratuit — facturation dès le lendemain`,
subscriptions: subs.map(s => s.name),
written: false,
...result
}
}
let _incomeAccount = null
async function defaultIncomeAccount (company) {
if (_incomeAccount) return _incomeAccount
try {
const r = await erpFetch(`/api/resource/Company/${encodeURIComponent(company)}?fields=${ENC(['default_income_account'])}`)
if (r.data?.data?.default_income_account) { _incomeAccount = r.data.data.default_income_account; return _incomeAccount }
} catch (_) { /* repli */ }
_incomeAccount = 'Ventes - T'
return _incomeAccount
}
// ÉCRITURE GATÉE — crée la facture d'activation consolidée en BROUILLON (docstatus=0, JAMAIS soumise).
// Double verrou : `commit:true` (param) ET env PRORATION_WRITE=on. Sinon → aperçu (written:false).
// Un brouillon ne touche AUCUN grand livre, ne débite RIEN, et reste supprimable d'un clic.
async function commitActivationInvoice (body) {
const preview = await buildActivationPreview(body)
const armed = process.env.PRORATION_WRITE === 'on'
if (!body.commit || !armed) {
return { ...preview, written: false, gate: { commit: !!body.commit, env_PRORATION_WRITE: armed ? 'on' : 'off' }, note: 'écriture désarmée — il faut commit:true ET PRORATION_WRITE=on' }
}
if (!preview.lines.length) return { ...preview, written: false, note: 'aucune ligne à facturer' }
const company = body.company || 'TARGO'
const incomeAccount = await defaultIncomeAccount(company)
const items = preview.lines.map(l => ({
item_name: `${l.label} — prorata ${preview.billed_days}/${preview.period_days} j`.slice(0, 140),
description: `${l.recurring ? 'Récurrent proraté' : 'Ponctuel — plein montant'} · ${l.full_amount.toFixed(2)}$ × ${l.factor} = ${l.prorated_amount.toFixed(2)}$ (${l.kind})`,
qty: 1, rate: l.prorated_amount, amount: l.prorated_amount, income_account: incomeAccount
}))
const payload = {
customer: preview.customer, company,
posting_date: preview.activated_on, due_date: preview.activated_on,
docstatus: 0, // BROUILLON — jamais soumis (aucune écriture comptable, aucun débit)
remarks: `Facture d'activation consolidée proratée (BROUILLON, non soumise). ${preview.courtesy_note}. Période ${preview.period_start}${preview.period_end} · ${preview.billed_days}/${preview.period_days} j.`,
items
}
const inv = await erpFetch('/api/resource/Sales Invoice', { method: 'POST', body: JSON.stringify(payload) })
if (inv.status === 200 && inv.data?.data?.name) {
const d = inv.data.data
return { ...preview, written: true, draft_invoice: d.name, docstatus: d.docstatus, submitted: d.docstatus === 1 }
}
throw new Error(`création brouillon échouée: HTTP ${inv.status} ${JSON.stringify(inv.data?.exception || inv.data).slice(0, 300)}`)
}
async function handle (req, res, method, path) {
// POST /billing/prorate-preview — calcule l'aperçu des lignes proratées (aucune écriture). Gaté (staff).
if (path === '/billing/prorate-preview' && method === 'POST') {
const b = await parseBody(req)
if (!b || !b.period_start || !b.period_end || !b.activation_date) return json(res, 400, { error: 'period_start, period_end, activation_date requis' })
try { return json(res, 200, prorate(b)) } catch (e) { return json(res, 400, { error: e.message }) }
}
// POST /billing/activation-preview — facture d'activation consolidée d'un VRAI client (LECTURE SEULE).
// body : { customer, service_location?, quotation?, activation_date?, statuses? }
if (path === '/billing/activation-preview' && method === 'POST') {
const b = await parseBody(req)
if (!b || !b.customer) return json(res, 400, { error: 'customer requis' })
try { return json(res, 200, b.commit ? await commitActivationInvoice(b) : await buildActivationPreview(b)) } catch (e) { return json(res, 400, { error: e.message }) }
}
// ── P1 : porte d'approbation de facturation (Ops). LECTURE SEULE + activation à l'approbation ; AUCUNE facturation ici. ──
// GET /billing/approvals — activations en attente d'approbation (enrichies client/adresse).
if (path === '/billing/approvals' && method === 'GET') {
const pend = require('./billing-approvals').listPending()
const out = []
for (const p of pend) {
const jr = await erpFetch(`/api/resource/Dispatch Job/${encodeURIComponent(p.job)}?fields=${ENC(['customer', 'service_location', 'customer_name'])}`)
const d = (jr.status === 200 && jr.data?.data) ? jr.data.data : {}
out.push({ ...p, customer: d.customer || null, customer_name: d.customer_name || null, service_location: d.service_location || null })
}
return json(res, 200, { pending: out })
}
// GET /billing/approvals/preview?job=… — aperçu prorata consolidé (LECTURE SEULE) pour un job.
if (path === '/billing/approvals/preview' && method === 'GET') {
const job = new URL(req.url, 'http://h').searchParams.get('job')
if (!job) return json(res, 400, { error: 'job requis' })
const jr = await erpFetch(`/api/resource/Dispatch Job/${encodeURIComponent(job)}?fields=${ENC(['customer', 'service_location'])}`)
const d = (jr.status === 200 && jr.data?.data) ? jr.data.data : null
if (!d || !d.customer) return json(res, 404, { error: 'job/client introuvable' })
try { return json(res, 200, await buildActivationPreview({ customer: d.customer, service_location: d.service_location, statuses: ['En attente'] })) } catch (e) { return json(res, 400, { error: e.message }) }
}
// POST /billing/approvals/approve { job, approver } — approuve → ACTIVE les abonnements (facturation gérée par PRORATION_WRITE ; OFF en P1 → active SANS facturer).
if (path === '/billing/approvals/approve' && method === 'POST') {
const b = await parseBody(req)
if (!b || !b.job) return json(res, 400, { error: 'job requis' })
const approver = b.approver || req.headers['x-authentik-email'] || 'inconnu'
try { return json(res, 200, await require('./billing-approvals').approve(b.job, approver)) } catch (e) { return json(res, 400, { error: e.message }) }
}
return json(res, 404, { error: 'not found' })
}
module.exports = { handle, prorate, buildActivationPreview, commitActivationInvoice }