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>
212 lines
13 KiB
JavaScript
212 lines
13 KiB
JavaScript
// 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 }
|