rubis/apps/api/app/services/client_stats.ts
ordinarthur b6006ad1f7 feat(api): domaine Client + CRUD /api/v1/clients
Migration clients (uuid id, organization_id FK uuid CASCADE, name, email REQUIS, phone, address, siret, notes). Index sur organization_id.

Modèle Client avec belongsTo Organization. La relation hasMany Invoice est volontairement omise tant que le domaine Invoice n'est pas câblé.

Validators Vine alignés sur le contrat MSW :
- create : name 2-120, email requis avec format, siret 14 chiffres si fourni
- update : tout optionnel
- email REQUIS au create — pivot produit, pas de relance possible sans

Endpoints (auth requise, scopés par organizationId du user courant) :
- GET /clients?withStats=1&q= : liste filtrée + recherche, enrichissement stats optionnel, tri par actionnabilité (retards d'abord) quand withStats
- GET /clients/:id : détail (id en UUID via router.matchers.uuid())
- POST /clients : 201 + détection doublon par nom case-insensitive → 409 avec payload `existing` (le SPA peut proposer "voir le client existant")
- PATCH /clients/:id : merge partiel

Service ClientStats avec interface bulkComputeClientStats() qui retourne EMPTY pour l'instant — sera vraiment branché quand Invoice arrive. Le contrat reste stable côté SPA, juste les compteurs à 0.

Sérialisation : pour les listes avec stats per-item, on instancie le transformer manuellement (`new ClientTransformer(c).toObject()`) plutôt que de passer par BaseTransformer.transform() qui retourne un Item nested non-unwrappable hors clé directe de serialize().
2026-05-06 14:13:13 +02:00

44 lines
1.2 KiB
TypeScript

/**
* Stats agrégées d'un client. Calculées on-the-fly à partir des invoices
* (V1 : pas de cache, le volume reste raisonnable).
*
* Tant que le domaine Invoice n'est pas câblé, on retourne EMPTY pour
* tous les clients — le contrat reste stable côté SPA.
*/
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.
* @returns Map clientId → ClientStats
*
* @todo Implémenter quand Invoice arrive — pour l'instant tout le monde a 0.
*/
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)
}
return map
}