rubis/apps/api/app/services/dashboard.ts
ordinarthur 933c6496b1 feat(demo): mode démo live — horloge virtuelle + emails capturés
Permet de faire vivre Rubis en accéléré pour démontrer le produit à des
prospects, SANS impacter la prod. Les vrais users ont demoMode=false par
défaut → toute la logique démo est court-circuitée.

Architecture (priorité : zéro impact prod, codebase propre)

Phase 1 — Abstraction Clock
- Migration : organizations.demo_mode + virtual_now + demo_speed_factor
  (défaut false/null/1, zéro effet sur les orgs existantes)
- services/clock.ts : now(orgId?) → DateTime.utc() en prod, virtualNow
  en démo. Cache mémoire 250ms pour pas spammer la DB. Helpers
  setVirtualNow / setDemoMode pour les transitions.
- Refacto 7 fichiers : relance_scheduler, checkin_scheduler, dashboard,
  send_relance_job, send_checkin_job, mail_dispatcher (buildRelanceVars
  daysLate), activity_recorder, checkin_controller, invoices_controller
  (buildTimeline + markPaid). DateTime.now() → clock.now(orgId).
- Tests existants (51) passent identique → preuve que la prod est intacte.

Phase 2 — Capture emails + dispatch
- Migration : demo_captured_emails (kind, to, from, subject, body, sent_at,
  meta) — index sur (org, sent_at desc) pour l'inbox.
- services/demo/capture.ts : captureEmailIfDemo() — UNIQUE point de fork
  dans la prod (deux lignes dans mail_dispatcher : if captured return).
  Hors démo, fonction retourne false → flux Resend inchangé.
- services/demo/dispatch.ts : tickAndDispatch(orgId, target) → bump
  virtual_now, trouve les tasks dues (relance + checkin), invoke les
  handlers existants synchronement (skip BullMQ, propre). Retourne les
  events fired pour l'UI.
- POST /api/v1/demo/{start,end,tick} + GET /demo/{state,inbox}, toutes
  protégées par requireDemoOrg() (403 si demoMode=false).

Phase 3 — UI horloge "vivante"
- lib/demo.ts : useDemoState, useDemoTick (boucle rAF locale qui avance
  virtualNow à `speed * elapsed` jours/sec, sync backend toutes les
  250ms, auto-pause sur fired events). Pas de boutons +1j/+3j —
  l'horloge tourne vraiment.
- DemoClock (top-right, fixed) : date pleine en font display, rail
  rubis-glow avec pastille ◆ qui glisse vers le prochain event,
  play/pause + sélecteur 1x/2x/5x. Auto-cachée si demoMode=false.
- DemoEmailSlide : slide-over droite quand event fires — affiche
  l'email capturé (de/à/sujet/body) façon vrai client mail. Pause
  forcée tant que tous les events ne sont pas acquittés ("comme si
  le temps était vraiment passé").
- DemoToggle dans /parametres : démarrer/quitter le mode démo, avec
  copy explicite ("emails capturés, pas envoyés à de vrais clients").

Le code démo vit isolé dans services/demo/, controllers/demo_controller.ts,
components/demo/, lib/demo.ts. La prod ne référence ces fichiers QUE
via captureEmailIfDemo dans mail_dispatcher.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 10:42:59 +02:00

290 lines
9.5 KiB
TypeScript

import db from '@adonisjs/lucid/services/db'
import Organization from '#models/organization'
import { DateTime } from 'luxon'
import * as clock from '#services/clock'
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
export type DashboardKpis = {
rubisCount: number
rubisThisMonth: number
// 1 rubis = 10 minutes libérées (cf. CLAUDE.md → glossaire)
hoursLiberatedThisMonth: number
encaisseCents: number
encaisseDeltaCents: number
dsoDays: number
dsoDeltaDays: number
factureToRelance: number
factureInRelance: number
factureNewToday: number
miseEnDemeurePending: number
monthlyGoalProgress: number
// Rang relatif à la cohorte (placeholder V1, calculé en V2 avec assez de data)
percentile?: number
}
function startOfMonth(d: DateTime): Date {
return d.startOf('month').toJSDate()
}
function startOfDay(d: DateTime): Date {
return d.startOf('day').toJSDate()
}
/**
* Calcule les KPIs dashboard pour une organisation.
*
* V1 — implémentation simple sans cache. Quelques metrics avancés
* (DSO, percentile) sont à 0 ou null tant qu'on a pas assez d'historique.
* Le contrat reste stable côté SPA.
*/
export async function computeKpis(organizationId: string): Promise<DashboardKpis> {
const now = await clock.now(organizationId)
const monthStart = startOfMonth(now)
const todayStart = startOfDay(now)
const prevMonthStart = startOfMonth(now.minus({ months: 1 }))
const org = await Organization.findOrFail(organizationId)
// Counts par statut + factures récentes
const counts = (await db
.from('invoices')
.where('organization_id', organizationId)
.select(
db.raw(`count(*) filter (where status = 'pending')::int as to_relance`),
db.raw(`count(*) filter (where status = 'in_relance')::int as in_relance`),
db.raw(`count(*) filter (where created_at >= ?)::int as new_today`, [todayStart])
)
.first()) as { to_relance: number; in_relance: number; new_today: number } | undefined
// Sommes d'encaissement (paid_at) ce mois et le précédent
const paidStats = (await db
.from('invoices')
.where('organization_id', organizationId)
.where('status', 'paid')
.select(
db.raw(
`coalesce(sum(amount_ttc_cents) filter (where paid_at >= ?), 0)::int as this_month`,
[monthStart]
),
db.raw(
`coalesce(sum(amount_ttc_cents) filter (where paid_at >= ? and paid_at < ?), 0)::int as prev_month`,
[prevMonthStart, monthStart]
),
db.raw(
`coalesce(sum(rubis_earned) filter (where paid_at >= ?), 0)::int as rubis_this_month`,
[monthStart]
),
db.raw(
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ?), 0)::int as dso_this_month`,
[monthStart]
),
db.raw(
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ? and paid_at < ?), 0)::int as dso_prev_month`,
[prevMonthStart, monthStart]
)
)
.first()) as
| {
this_month: number
prev_month: number
rubis_this_month: number
dso_this_month: number
dso_prev_month: number
}
| undefined
const encaisseCents = paidStats?.this_month ?? 0
const encaisseDeltaCents = encaisseCents - (paidStats?.prev_month ?? 0)
const rubisThisMonth = paidStats?.rubis_this_month ?? 0
const dsoDays = paidStats?.dso_this_month ?? 0
const dsoDeltaDays = dsoDays - (paidStats?.dso_prev_month ?? 0)
return {
rubisCount: org.rubisCount,
rubisThisMonth,
hoursLiberatedThisMonth: rubisThisMonth * 10,
encaisseCents,
encaisseDeltaCents,
dsoDays,
dsoDeltaDays,
factureToRelance: counts?.to_relance ?? 0,
factureInRelance: counts?.in_relance ?? 0,
factureNewToday: counts?.new_today ?? 0,
// Mise en demeure pending — sera calculé quand RelanceTask est branché
// (count des steps requires_manual_validation programmées). Pour V1 : 0.
miseEnDemeurePending: 0,
// Goal progress (V1 placeholder) : ratio rubis_count / 250 (objectif
// mensuel arbitraire). À paramétrer plus tard.
monthlyGoalProgress: Math.min(100, Math.round((rubisThisMonth / 25) * 100)),
percentile: undefined,
}
}
/**
* Top des clients en retard (top 5 par défaut).
* Compte les factures actives dont due_date est dépassée, agrégées par client.
*/
export async function topLatePayers(
organizationId: string,
limit = 5
): Promise<Array<{ clientId: string; name: string; lateInvoicesCount: number }>> {
const today = startOfDay(await clock.now(organizationId))
const rows = await db
.from('invoices')
.innerJoin('clients', 'clients.id', 'invoices.client_id')
.where('invoices.organization_id', organizationId)
.whereRaw(`invoices.status::text in ${ACTIVE_INVOICE_STATUSES}`)
.where('invoices.due_date', '<', today)
.groupBy('clients.id', 'clients.name')
.select('clients.id as client_id', 'clients.name as name')
.select(db.raw('count(*)::int as late_invoices_count'))
.orderBy('late_invoices_count', 'desc')
.limit(limit)
return rows.map((r) => ({
clientId: r.client_id,
name: r.name,
lateInvoicesCount: r.late_invoices_count,
}))
}
// ===========================================================================
// Time series — pour les graphes du dashboard et de /insights
// ===========================================================================
export type RangeMonths = 3 | 6 | 12
export type PaidByMonthPoint = {
/** Premier jour du mois en ISO date "YYYY-MM-01". */
month: string
/** Total encaissé sur le mois (centimes). */
encaisseCents: number
/** Nombre de factures payées sur le mois. */
paidCount: number
/** DSO moyen sur le mois (jours, 0 si aucun paiement). */
dsoDays: number
}
export type PipelineSlice = {
status: 'pending' | 'awaiting_user_confirmation' | 'in_relance' | 'litigation' | 'paid'
count: number
amountCents: number
}
export type DashboardTimeseries = {
range: RangeMonths
paidByMonth: PaidByMonthPoint[]
pipelineByStatus: PipelineSlice[]
}
/**
* Calcule les séries temporelles pour le dashboard / insights.
*
* `paidByMonth` : N derniers mois (range), 1 ligne par mois même si vide
* (sinon les charts affichent des "trous").
*
* `pipelineByStatus` : breakdown du portefeuille (count + montant) — pour
* donut/stacked-bar. Cancelled exclus pour réduire le bruit.
*/
export async function computeTimeseries(
organizationId: string,
range: RangeMonths = 6
): Promise<DashboardTimeseries> {
const paidByMonth = await fetchPaidByMonth({ organizationId, range })
const pipelineRows = (await db
.from('invoices')
.where('organization_id', organizationId)
.select('status')
.select(db.raw('count(*)::int as count'))
.select(db.raw('coalesce(sum(amount_ttc_cents), 0)::int as amount_cents'))
.groupBy('status')) as Array<{
status: PipelineSlice['status'] | 'cancelled'
count: number
amount_cents: number
}>
const pipelineOrder: PipelineSlice['status'][] = [
'pending',
'awaiting_user_confirmation',
'in_relance',
'litigation',
'paid',
]
const pipelineMap = new Map(pipelineRows.map((r) => [r.status, r]))
const pipelineByStatus: PipelineSlice[] = pipelineOrder.map((status) => {
const r = pipelineMap.get(status)
return { status, count: r?.count ?? 0, amountCents: r?.amount_cents ?? 0 }
})
return { range, paidByMonth, pipelineByStatus }
}
/** Variante par client — on filtre paidByMonth sur un client_id. */
export async function computeClientTimeseries(
organizationId: string,
clientId: string,
range: RangeMonths = 6
): Promise<{ range: RangeMonths; paidByMonth: PaidByMonthPoint[] }> {
const paidByMonth = await fetchPaidByMonth({ organizationId, clientId, range })
return { range, paidByMonth }
}
/**
* Helper interne — DRY entre computeTimeseries et computeClientTimeseries.
* Renvoie N buckets mensuels (range derniers mois inclus) avec encaisse/count/dso.
*/
async function fetchPaidByMonth(params: {
organizationId: string
clientId?: string
range: RangeMonths
}): Promise<PaidByMonthPoint[]> {
const now = await clock.now(params.organizationId)
const firstBucket = now.minus({ months: params.range - 1 }).startOf('month')
const buckets = new Map<string, PaidByMonthPoint>()
for (let i = 0; i < params.range; i++) {
const m = firstBucket.plus({ months: i }).toFormat('yyyy-LL-01')
buckets.set(m, { month: m, encaisseCents: 0, paidCount: 0, dsoDays: 0 })
}
const query = db
.from('invoices')
.where('organization_id', params.organizationId)
.where('status', 'paid')
.where('paid_at', '>=', firstBucket.toJSDate())
if (params.clientId) query.where('client_id', params.clientId)
const rows = (await query
.select(
db.raw(`to_char(date_trunc('month', paid_at), 'YYYY-MM-01') as month`),
db.raw(`coalesce(sum(amount_ttc_cents), 0)::int as encaisse_cents`),
db.raw(`count(*)::int as paid_count`),
db.raw(
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400)::int, 0) as dso_days`
)
)
.groupByRaw(`date_trunc('month', paid_at)`)
.orderByRaw(`date_trunc('month', paid_at)`)) as Array<{
month: string
encaisse_cents: number
paid_count: number
dso_days: number
}>
for (const r of rows) {
if (!buckets.has(r.month)) continue
buckets.set(r.month, {
month: r.month,
encaisseCents: r.encaisse_cents,
paidCount: r.paid_count,
dsoDays: r.dso_days,
})
}
return Array.from(buckets.values())
}