Migration activity_events (uuid id, organization_id FK CASCADE, kind ENUM PG natif relance_sent/invoice_paid/invoice_imported/warning_drafted, at, label HTML léger, meta jsonb). Append-only — pas de mutation. Index (org, at).
Schema rules : kind typé en union + meta typé { invoiceId?, clientId?, planStepOrder? }.
Service activity_recorder.ts : recordActivity({orgId, kind, label, meta, trx?}). Branché dans :
- InvoicesController.markPaid → invoice_paid
- ImportBatchesController.validateDraft → invoice_imported
À venir : SendRelanceJob (relance_sent + warning_drafted) quand BullMQ sera là.
Service dashboard.ts :
- computeKpis(orgId) : 1 requête FILTER pour les counts par status + 1 requête pour les sommes paid this month / prev month / DSO. miseEnDemeurePending=0 et percentile=undefined V1 (placeholders honnêtes plutôt que faux chiffres).
- topLatePayers(orgId, 5) : INNER JOIN clients + agrégation count() par client_id, due_date < today + status actif.
Controller DashboardController :
- GET /dashboard/kpis : computeKpis
- GET /dashboard/activity : 20 derniers events de l'org, plus récent en tête
- GET /dashboard/top-late : top 5
Routes /api/v1/dashboard/* (auth requise).
Bruno : nouveau dossier 07-Dashboard avec 3 requêtes documentées.
Pour générer du contenu activity feed : encaisser une facture (Invoices → Mark paid) ou valider un draft (Imports → Validate). KPIs : créer des factures puis les marquer payées (paidAt rentre dans les sommes).
151 lines
5.1 KiB
TypeScript
151 lines
5.1 KiB
TypeScript
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<DashboardKpis> {
|
|
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<Array<{ clientId: string; name: string; lateInvoicesCount: number }>> {
|
|
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,
|
|
}))
|
|
}
|