import db from '@adonisjs/lucid/services/db' import Organization from '#models/organization' import { DateTime } from 'luxon' 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 = DateTime.now() 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(DateTime.now()) 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, })) }