import Client from '#models/client' import Invoice from '#models/invoice' import ClientTransformer from '#transformers/client_transformer' import InvoiceTransformer from '#transformers/invoice_transformer' import { createClientValidator, updateClientValidator } from '#validators/client' import { bulkComputeClientStats } from '#services/client_stats' import { computeClientTimeseries, type RangeMonths } from '#services/dashboard' import type { HttpContext } from '@adonisjs/core/http' import { Exception } from '@adonisjs/core/exceptions' import vine from '@vinejs/vine' const timeseriesValidator = vine.create({ range: vine.number().in([3, 6, 12]).optional(), }) // Priorité d'affichage : ce qui est actionnable en haut. const INVOICE_STATUS_PRIORITY: Record = { awaiting_user_confirmation: 0, in_relance: 1, pending: 2, litigation: 3, paid: 4, cancelled: 5, } /** * Petite cohérence d'identification orgnisation : si l'utilisateur * n'en a pas, on est dans un état illégal V1 — on bloque ferme. */ function requireOrgId(auth: HttpContext['auth']): string { const user = auth.getUserOrFail() if (!user.organizationId) { throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' }) } return user.organizationId } /** * Sérialisation directe (instanciation manuelle du transformer pour * éviter le wrapper Item — utile quand on doit fusionner des stats * computed par-dessus chaque client dans une liste). */ function serializeClient(c: Client) { return new ClientTransformer(c).toObject() } export default class ClientsController { /** * GET /clients?withStats=1&q= * * Sans `withStats`, retour à plat (utilisé par le combobox de saisie). * Avec `withStats`, chaque client est enrichi des compteurs de factures * et trié par actionnabilité (retards d'abord, puis activité récente). */ async index({ auth, request, response }: HttpContext) { const organizationId = requireOrgId(auth) const withStats = request.input('withStats') === '1' const q = (request.input('q') ?? '').toString().trim().toLowerCase() const query = Client.query().where('organization_id', organizationId) if (q.length > 0) { query.where((b) => { b.whereILike('name', `%${q}%`).orWhereILike('email', `%${q}%`) }) } const clients = await query.exec() if (!withStats) { // Tri alphabétique par défaut pour le combobox. clients.sort((a, b) => a.name.localeCompare(b.name, 'fr')) return response.json({ data: clients.map(serializeClient) }) } const statsMap = await bulkComputeClientStats( organizationId, clients.map((c) => c.id) ) const enriched = clients.map((c) => ({ ...serializeClient(c), ...statsMap.get(c.id)!, })) // Tri actionnable : retards d'abord, puis activité récente. enriched.sort((a, b) => { if (a.lateInvoiceCount !== b.lateInvoiceCount) { return b.lateInvoiceCount - a.lateInvoiceCount } const aLast = a.lastActivityAt ?? '' const bLast = b.lastActivityAt ?? '' return bLast.localeCompare(aLast) }) return response.json({ data: enriched }) } /** * GET /clients/:id — détail enrichi (stats + invoices à venir). */ async show({ auth, params, response }: HttpContext) { const organizationId = requireOrgId(auth) const client = await Client.query() .where('organization_id', organizationId) .where('id', params.id) .first() if (!client) { throw new Exception('Client introuvable', { status: 404, code: 'not_found' }) } const statsMap = await bulkComputeClientStats(organizationId, [client.id]) const stats = statsMap.get(client.id)! // Factures du client — actionnables en premier, puis échéance asc. const invoices = await Invoice.query() .where('organization_id', organizationId) .where('client_id', client.id) .preload('client') .preload('plan') .exec() invoices.sort((a, b) => { const dp = (INVOICE_STATUS_PRIORITY[a.status] ?? 99) - (INVOICE_STATUS_PRIORITY[b.status] ?? 99) if (dp !== 0) return dp return a.dueDate.toMillis() - b.dueDate.toMillis() }) return response.json({ data: { ...serializeClient(client), ...stats, invoices: invoices.map((inv) => new InvoiceTransformer(inv).toObject()), }, }) } /** * POST /clients — création manuelle. * Détecte les doublons de nom (case-insensitive) et renvoie 409 avec * la fiche existante pour permettre au SPA de proposer "voir le client". */ async store({ auth, request, response }: HttpContext) { const organizationId = requireOrgId(auth) const payload = await request.validateUsing(createClientValidator) // Doublon → 409 (cf. clients.ts MSW pour le contrat exact). const existing = await Client.query() .where('organization_id', organizationId) .whereILike('name', payload.name) .first() if (existing) { return response.status(409).json({ errors: [ { code: 'duplicate_client', message: `Un client nommé « ${existing.name} » existe déjà.`, field: 'name', }, ], existing: serializeClient(existing), }) } const created = await Client.create({ organizationId, name: payload.name, email: payload.email, contactFirstName: payload.contactFirstName ?? null, contactLastName: payload.contactLastName ?? null, phone: payload.phone ?? null, address: payload.address ?? null, siret: payload.siret ?? null, // Champs structurés pour l'éditeur de factures natif. Lus en cast : // le schema.ts auto-généré n'expose les colonnes qu'après migration:run. ...({ siren: payload.siren ?? null, tvaIntra: payload.tvaIntra ?? null, addressLine1: payload.addressLine1 ?? null, addressLine2: payload.addressLine2 ?? null, addressZip: payload.addressZip ?? null, addressCity: payload.addressCity ?? null, addressCountry: payload.addressCountry ?? null, } as Record), notes: payload.notes ?? null, }) return response.status(201).json({ data: serializeClient(created) }) } /** * GET /clients/:id/timeseries?range=6 — encaissé mensuel pour ce client. * Utilisé par le mini-chart sur la fiche client (santé du compte). */ async timeseries({ auth, request, params, response }: HttpContext) { const organizationId = requireOrgId(auth) const exists = await Client.query() .where('organization_id', organizationId) .where('id', params.id) .first() if (!exists) { throw new Exception('Client introuvable', { status: 404, code: 'not_found' }) } const { range } = await request.validateUsing(timeseriesValidator, { data: { range: request.input('range') ? Number(request.input('range')) : undefined, }, }) const data = await computeClientTimeseries( organizationId, params.id, (range ?? 6) as RangeMonths ) return response.json({ data }) } /** * PATCH /clients/:id — édition partielle. */ async update({ auth, request, params, response }: HttpContext) { const organizationId = requireOrgId(auth) const payload = await request.validateUsing(updateClientValidator) const client = await Client.query() .where('organization_id', organizationId) .where('id', params.id) .first() if (!client) { throw new Exception('Client introuvable', { status: 404, code: 'not_found' }) } client.merge(payload) await client.save() return response.json({ data: serializeClient(client) }) } }