346 lines
12 KiB
TypeScript
346 lines
12 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
|
|
|
|
/**
|
|
* 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<DashboardTimeseries> {
|
|
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<PaidSeriesPoint[]> {
|
|
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<string, PaidSeriesPoint>()
|
|
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())
|
|
}
|