rubis/apps/api/app/services/client_stats.ts
ordinarthur 005af557c2 feat(api): domaine Invoice + endpoints CRUD + branche stats Client/Plan
Migration invoices : uuid id, organization_id FK CASCADE, client_id FK RESTRICT (on n'efface pas les factures si l'utilisateur supprime un client par erreur — audit/comptable), plan_id FK SET NULL, numero, amount_ttc_cents (int, jamais float), issue_date, due_date, status ENUM PG natif (pending/awaiting_user_confirmation/in_relance/paid/litigation/cancelled), pdf_storage_key, notes, rubis_earned, paid_at. Indexes (org,status), (org,client_id), (org,due_date), unique (org,numero).

Modèles : Invoice avec belongsTo Organization/Client/Plan. Client et Plan étendus avec hasMany Invoice maintenant que la table existe.

Endpoints :
- GET /invoices : filtres status/q/clientId/page, tri actionnable (awaiting_user_confirmation puis in_relance puis pending puis litigation puis paid puis cancelled), pagination simple 50/page (cursor-based en V2).
- GET /invoices/counts : compteurs par statut pour les chips dashboard, requête agrégée groupBy.
- GET /invoices/:id : détail enrichi avec client + plan préchargés + timeline composée par buildTimeline() (étapes du plan calées sur due_date, états past/current/future).
- POST /invoices : saisie manuelle. Résolution client en 3 étapes (clientId → match par nom → création à la volée avec email REQUIS, sinon 422 client_email_required). Bonus +1 rubis à la création.
- POST /invoices/:id/mark-paid : status=paid + paid_at + bonus +1 rubis (sur invoice + sur organization.rubis_count). Idempotent.

L'ordre des routes /invoices/counts AVANT /invoices/:id est critique sinon `:id` matche "counts".

Branche les vraies stats :
- ClientStats : agrégation PG une seule requête (count, count actives, count en retard, paid_count, sum paid_cents, sum pending_cents, last_activity) avec FILTER clauses et casting enum::text. Plus de TODO/zéros.
- PlansController : usageCount calculé pareil (factures actives référençant le plan).

Skip pour l'instant (ImportBatch domain à venir) : POST /invoices/upload, GET /invoices/import-batch/*, validate/skip drafts.
2026-05-06 14:33:46 +02:00

92 lines
2.7 KiB
TypeScript

import db from '@adonisjs/lucid/services/db'
/**
* Stats agrégées d'un client. Calculées on-the-fly à partir des invoices
* (V1 : pas de cache, le volume reste raisonnable). Si le perf devient un
* sujet, on cachera dans Redis avec invalidation post-mutation invoice.
*/
export type ClientStats = {
invoiceCount: number
activeInvoiceCount: number
lateInvoiceCount: number
paidInvoiceCount: number
paidLifetimeCents: number
pendingLifetimeCents: number
lastActivityAt: string | null
}
export const EMPTY_CLIENT_STATS: ClientStats = {
invoiceCount: 0,
activeInvoiceCount: 0,
lateInvoiceCount: 0,
paidInvoiceCount: 0,
paidLifetimeCents: 0,
pendingLifetimeCents: 0,
lastActivityAt: null,
}
/**
* Calcule les stats pour un ensemble de clients d'une org en une seule
* requête agrégée par client_id. Les clients sans facture reçoivent EMPTY.
*
* @returns Map clientId → ClientStats
*/
export async function bulkComputeClientStats(
organizationId: string,
clientIds: string[]
): Promise<Map<string, ClientStats>> {
const map = new Map<string, ClientStats>()
for (const id of clientIds) {
map.set(id, { ...EMPTY_CLIENT_STATS })
}
if (clientIds.length === 0) return map
const today = new Date()
today.setHours(0, 0, 0, 0)
const ACTIVE = "('pending','in_relance','awaiting_user_confirmation')"
const rows = await db
.from('invoices')
.where('organization_id', organizationId)
.whereIn('client_id', clientIds)
.select('client_id')
.select(db.raw('count(*)::int as invoice_count'))
.select(db.raw(`count(*) filter (where status::text in ${ACTIVE})::int as active_count`))
.select(
db.raw(
`count(*) filter (where status::text in ${ACTIVE} and due_date < ?)::int as late_count`,
[today]
)
)
.select(db.raw(`count(*) filter (where status = 'paid')::int as paid_count`))
.select(
db.raw(`coalesce(sum(amount_ttc_cents) filter (where status = 'paid'), 0)::int as paid_cents`)
)
.select(
db.raw(
`coalesce(sum(amount_ttc_cents) filter (where status::text in ${ACTIVE}), 0)::int as pending_cents`
)
)
.select(db.raw('max(updated_at) as last_activity'))
.groupBy('client_id')
for (const r of rows) {
map.set(r.client_id, {
invoiceCount: r.invoice_count,
activeInvoiceCount: r.active_count,
lateInvoiceCount: r.late_count,
paidInvoiceCount: r.paid_count,
paidLifetimeCents: r.paid_cents,
pendingLifetimeCents: r.pending_cents,
lastActivityAt:
r.last_activity instanceof Date
? r.last_activity.toISOString()
: (r.last_activity as string | null),
})
}
return map
}