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 { 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> { 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 /** * Granularité d'agrégation des séries temporelles. * - `month` : 1 bucket = 1 mois (vue 6m / 12m, lecture macro) * - `week` : 1 bucket = 1 semaine ISO (lundi → dimanche), pour la vue * 3 mois où l'agrégation mensuelle masque trop la dynamique * (3 points seulement, on perd la lecture). */ export type Granularity = 'month' | 'week' export type PaidSeriesPoint = { /** * Premier jour du bucket en ISO date "YYYY-MM-DD". * - `month`: premier du mois (toujours -01) * - `week` : lundi de la semaine */ bucket: string /** Total encaissé sur le bucket (centimes). */ encaisseCents: number /** Nombre de factures payées sur le bucket. */ paidCount: number /** DSO moyen sur le bucket (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 granularity: Granularity /** Garde le nom pour ne pas casser le contrat — mais les buckets peuvent * être hebdo (cf. `granularity`). */ paidByMonth: PaidSeriesPoint[] pipelineByStatus: PipelineSlice[] } /** Choix de granularité par défaut pour un range donné. */ function pickGranularity(range: RangeMonths): Granularity { return range === 3 ? 'week' : 'month' } /** * 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 { const granularity = pickGranularity(range) const paidByMonth = await fetchPaidSeries({ organizationId, range, granularity }) 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, granularity, 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; granularity: Granularity; paidByMonth: PaidSeriesPoint[] }> { const granularity = pickGranularity(range) const paidByMonth = await fetchPaidSeries({ organizationId, clientId, range, granularity, }) return { range, granularity, paidByMonth } } /** * Helper interne — DRY entre computeTimeseries et computeClientTimeseries. * * Renvoie N buckets ordonnés (mensuels ou hebdomadaires selon `granularity`) * couvrant les derniers `range` mois. Chaque bucket porte encaisse/count/dso. * * - `month` : `range` buckets mensuels (1/mois), label = 1er du mois * - `week` : ~`range * 4.5` buckets hebdo (1/semaine), label = lundi de la * semaine. Postgres `date_trunc('week')` aligne sur lundi (ISO), * Luxon `startOf('week')` aussi → cohérence garantie. */ async function fetchPaidSeries(params: { organizationId: string clientId?: string range: RangeMonths granularity: Granularity }): Promise { const now = await clock.now(params.organizationId) // Garde-fou : `truncUnit` est interpolé dans du SQL brut, on whitelist. const truncUnit: 'month' | 'week' = params.granularity === 'week' ? 'week' : 'month' let firstBucket: DateTime let bucketCount: number let stepUnit: 'months' | 'weeks' if (truncUnit === 'week') { // ~range * 4.33 semaines couvrent la fenêtre, on prend ceil pour ne pas // tronquer le premier mois. bucketCount = Math.ceil(params.range * 4.34) firstBucket = now.startOf('week').minus({ weeks: bucketCount - 1 }) stepUnit = 'weeks' } else { bucketCount = params.range firstBucket = now.minus({ months: bucketCount - 1 }).startOf('month') stepUnit = 'months' } const buckets = new Map() for (let i = 0; i < bucketCount; i++) { const inc = stepUnit === 'weeks' ? { weeks: i } : { months: i } const b = firstBucket.plus(inc).toFormat('yyyy-LL-dd') buckets.set(b, { bucket: b, 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('${truncUnit}', paid_at), 'YYYY-MM-DD') as bucket`), 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('${truncUnit}', paid_at)`) .orderByRaw(`date_trunc('${truncUnit}', paid_at)`)) as Array<{ bucket: string encaisse_cents: number paid_count: number dso_days: number }> for (const r of rows) { if (!buckets.has(r.bucket)) continue buckets.set(r.bucket, { bucket: r.bucket, encaisseCents: r.encaisse_cents, paidCount: r.paid_count, dsoDays: r.dso_days, }) } return Array.from(buckets.values()) }