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.
92 lines
2.7 KiB
TypeScript
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
|
|
}
|