diff --git a/apps/api/commands/seed_demo.ts b/apps/api/commands/seed_demo.ts index 815cf49..9a35c64 100644 --- a/apps/api/commands/seed_demo.ts +++ b/apps/api/commands/seed_demo.ts @@ -11,7 +11,7 @@ import ActivityEvent from '#models/activity_event' import RelanceTask from '#models/relance_task' import CheckinTask from '#models/checkin_task' import { provisionDefaultPlans } from '#services/default_plans' -import { seedDemoOrg } from '#database/factories' +import { seedDemoOrg, wipeOrgInvoicePdfs } from '#database/factories' /** * Peuple l'org d'un user existant avec des données de démo réalistes — @@ -90,6 +90,15 @@ export default class SeedDemo extends BaseCommand { await ActivityEvent.query({ client: trx }).where('organization_id', org.id).delete() await Invoice.query({ client: trx }).where('organization_id', org.id).delete() await Client.query({ client: trx }).where('organization_id', org.id).delete() + + // Drop tous les PDFs MinIO de l'org pour repartir d'un drive propre. + // Hors transaction (drive n'a pas de notion de tx) — si la suite + // échoue, les PDFs sont déjà partis : pas grave, ils étaient destinés + // au reset de toute façon. + const wiped = await wipeOrgInvoicePdfs(org.id) + if (wiped > 0) { + this.logger.info(`MinIO : ${wiped} PDFs de facture supprimés.`) + } } // Configure l'org : nom (si fourni) + bucket volume mensuel diff --git a/apps/api/database/factories.ts b/apps/api/database/factories.ts index 7a609fd..9923e04 100644 --- a/apps/api/database/factories.ts +++ b/apps/api/database/factories.ts @@ -1,16 +1,16 @@ /** * Factories — créent des entités réalistes (FR, format Rubis) pour - * peupler une org de démo ou alimenter des tests. + * peupler une org de démo. * - * Pas de framework lourd type @adonisjs/lucid factories : des fonctions - * pures, idempotentes, qu'on compose dans une commande Ace ou un test. + * Particularité notable : pour CHAQUE invoice on génère un vrai PDF + * cohérent (vendeur = org du user, client = client réel, montant et + * dates = données BDD) via `invoice_pdf_factory.ts`, puis on l'upload + * sur MinIO. Les fiches facture montrent ainsi un document réel + * dont le contenu correspond exactement aux meta de la BDD. */ import { DateTime } from 'luxon' import { randomUUID } from 'node:crypto' -import { readdir, readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { existsSync } from 'node:fs' import type { TransactionClientContract } from '@adonisjs/lucid/types/database' import drive from '@adonisjs/drive/services/main' @@ -19,6 +19,14 @@ import Invoice from '#models/invoice' import ActivityEvent from '#models/activity_event' import RelanceTask from '#models/relance_task' import Plan from '#models/plan' +import Organization from '#models/organization' +import User from '#models/user' + +import { + generateInvoicePdfBuffer, + type InvoiceItem, + type PdfInvoiceInput, +} from '#database/invoice_pdf_factory' // --------------------------------------------------------------------------- // Sources de données déterministes (pas de Faker — moins de deps, plus stable) @@ -57,7 +65,7 @@ const CLIENT_TEMPLATES: Array<{ lastName: null, emailDomain: 'atelier-durand.fr', phone: null, - address: null, + address: '3 impasse des Artisans, 33000 Bordeaux', siret: null, }, { @@ -84,7 +92,7 @@ const CLIENT_TEMPLATES: Array<{ lastName: 'Lefèvre', emailDomain: 'studio-lefevre.com', phone: null, - address: null, + address: '17 rue des Lilas, 44000 Nantes', siret: null, }, { @@ -118,8 +126,171 @@ const INVOICE_NOTES_POOL = [ null, ] +/** + * Pool d'items B2B variés. Chaque item a une fourchette de prix unitaire + * (HT, en €) — on tire random à l'intérieur, et la quantité s'ajuste + * pour atteindre le total cible. + */ +const ITEM_POOL: Array<{ + description: string + unit: string + /** Prix unitaire HT, fourchette en € (pas en centimes). */ + unitPriceMinEur: number + unitPriceMaxEur: number +}> = [ + { description: 'Conseil stratégique', unit: 'jour', unitPriceMinEur: 600, unitPriceMaxEur: 1200 }, + { description: 'Développement sur mesure', unit: 'h', unitPriceMinEur: 80, unitPriceMaxEur: 150 }, + { description: 'Audit financier', unit: 'forfait', unitPriceMinEur: 800, unitPriceMaxEur: 2400 }, + { description: 'Maintenance trimestrielle', unit: 'forfait', unitPriceMinEur: 400, unitPriceMaxEur: 900 }, + { description: 'Formation équipe', unit: 'jour', unitPriceMinEur: 800, unitPriceMaxEur: 1500 }, + { description: 'Design graphique', unit: 'h', unitPriceMinEur: 60, unitPriceMaxEur: 120 }, + { description: 'Livraison matières premières', unit: 'palette', unitPriceMinEur: 180, unitPriceMaxEur: 800 }, + { description: 'Prestation photographique', unit: 'jour', unitPriceMinEur: 500, unitPriceMaxEur: 1100 }, + { description: 'Travaux second œuvre', unit: 'jour', unitPriceMinEur: 380, unitPriceMaxEur: 850 }, + { description: 'Pain de campagne 1kg', unit: 'u', unitPriceMinEur: 4, unitPriceMaxEur: 8 }, + { description: 'Quiche lorraine 6 parts', unit: 'u', unitPriceMinEur: 12, unitPriceMaxEur: 18 }, + { description: 'Composition florale événement', unit: 'u', unitPriceMinEur: 40, unitPriceMaxEur: 110 }, + { description: 'Réparation moteur', unit: 'forfait', unitPriceMinEur: 220, unitPriceMaxEur: 1500 }, + { description: 'Impression brochures', unit: '1000ex', unitPriceMinEur: 90, unitPriceMaxEur: 280 }, + { description: 'Couvert (déjeuner d\'affaires)', unit: 'u', unitPriceMinEur: 28, unitPriceMaxEur: 65 }, +] + // --------------------------------------------------------------------------- -// Factories +// Vendeur (info "fixe" du PDF — l'org du user en émetteur de facture) +// --------------------------------------------------------------------------- + +/** + * Construit l'info vendeur exposée dans le PDF à partir de l'org + user. + * Quelques placeholders raisonnables (adresse, IBAN, téléphone) sont + * fournis car ces champs n'existent pas encore au niveau Org. + * + * Quand on aura `address`, `iban`, `phone` dans la table organizations, + * on lira directement depuis là. + */ +async function buildSellerInfoForOrg( + organizationId: string, + trx?: TransactionClientContract +): Promise { + const org = await Organization.findOrFail(organizationId, trx ? { client: trx } : undefined) + const user = await User.query(trx ? { client: trx } : undefined) + .where('organization_id', organizationId) + .first() + + // Simulé pour la démo. La V2 ajoutera ces champs sur Organization. + return { + name: org.name || 'Mon entreprise', + address: '12 rue de la République, 75001 Paris', + siret: org.siret || '83245678900015', + tvaNumber: org.siret ? `FR${(org.siret).slice(0, 11)}` : 'FR83245678900', + email: user?.email || 'contact@rubis-demo.fr', + phone: '+33 1 84 60 12 34', + iban: 'FR76 3000 4012 3456 7890 1234 567', + } +} + +function clientInfoForPdf(client: Client): PdfInvoiceInput['client'] { + return { + name: client.name, + contactFirstName: client.contactFirstName, + contactLastName: client.contactLastName, + address: client.address, + siret: client.siret, + } +} + +// --------------------------------------------------------------------------- +// Générateur d'items — crée 1-3 items dont le total HT match la cible +// --------------------------------------------------------------------------- + +/** + * Génère 1 à 3 items dont la somme des `quantity * unitPriceHtCents` + * approche le `targetHtCents` donné (rounding ±1 cent par item, négligeable). + */ +function makeItemsForTargetHt(targetHtCents: number): InvoiceItem[] { + const itemCount = targetHtCents > 80_000 ? randomInt(2, 3) : randomInt(1, 2) + const picked = sampleN(ITEM_POOL, itemCount) + + // Splits aléatoires (ratios qui somment à 1). + const ratios = picked.map(() => 0.3 + Math.random()) + const ratioSum = ratios.reduce((a, b) => a + b, 0) + const splits = ratios.map((r) => Math.round((targetHtCents * r) / ratioSum)) + + return picked.map((tpl, i) => { + const itemTotal = Math.max(1_00, splits[i]!) + // Prix unitaire dans la fourchette du template (en centimes). + const minUnit = tpl.unitPriceMinEur * 100 + const maxUnit = tpl.unitPriceMaxEur * 100 + const targetUnit = randomInt(minUnit, maxUnit) + // Quantité ajustée pour atteindre itemTotal en restant entière >= 1. + const quantity = Math.max(1, Math.round(itemTotal / targetUnit)) + // Re-calibrage du prix unitaire pour que qty*price = itemTotal pile. + const unitPriceHtCents = Math.max(50, Math.round(itemTotal / quantity)) + return { + description: tpl.description, + unit: tpl.unit, + quantity, + unitPriceHtCents, + } + }) +} + +/** Total TTC en centimes pour une liste d'items (HT + 20% TVA). */ +function computeTotalTtc(items: InvoiceItem[]): number { + const ht = items.reduce((s, i) => s + i.quantity * i.unitPriceHtCents, 0) + const tva = Math.round(ht * 0.2) + return ht + tva +} + +// --------------------------------------------------------------------------- +// Upload PDF — génère le buffer puis push sur Drive (MinIO en prod, FS dev) +// --------------------------------------------------------------------------- + +async function generateAndUploadInvoicePdf( + organizationId: string, + pdfInput: PdfInvoiceInput +): Promise { + const buffer = await generateInvoicePdfBuffer(pdfInput) + const storageKey = `invoice-pdfs/${organizationId}/${randomUUID()}.pdf` + await drive.use().put(storageKey, buffer) + return storageKey +} + +// --------------------------------------------------------------------------- +// Cleanup MinIO — drop tous les PDFs d'une org (appelé au --reset) +// --------------------------------------------------------------------------- + +/** + * Supprime tous les `invoice-pdfs/{orgId}/...` du drive. Utilisé par le + * command `seed:demo --reset` pour repartir d'une MinIO propre. + * + * Boucle sur `paginationToken` pour gérer les listes > page-size par défaut + * (généralement 1000 — suffisant en une page pour nos 227 PDFs, mais on + * gère propre au cas où on monte en volume). + */ +export async function wipeOrgInvoicePdfs(organizationId: string): Promise { + const disk = drive.use() + const prefix = `invoice-pdfs/${organizationId}/` + let count = 0 + let paginationToken: string | undefined + + do { + const result = await disk.listAll(prefix, { + recursive: true, + paginationToken, + }) + for (const item of result.objects) { + if (!item.isFile) continue + await disk.delete(item.key) + count++ + } + paginationToken = result.paginationToken + } while (paginationToken) + + return count +} + +// --------------------------------------------------------------------------- +// Clients // --------------------------------------------------------------------------- export type ClientFactoryInput = { @@ -157,67 +328,9 @@ export async function makeClient(input: ClientFactoryInput): Promise { export type InvoiceStatus = Invoice['status'] -export type InvoiceFactoryInput = { - organizationId: string - clientId: string - planId: string | null - /** Status cible — drive aussi les dates (pending = future, paid = passé). */ - status: InvoiceStatus - /** Numéro override, sinon F-YYYY-XXXX random. */ - numero?: string - /** Montant en centimes. Sinon random 250€-8000€. */ - amountTtcCents?: number - /** Décalage en jours par rapport à aujourd'hui pour issueDate. - * Négatif = passé. */ - issueOffsetDays?: number - /** Délai de paiement en jours (par défaut 30, conforme LME). */ - paymentTermDays?: number - trx?: TransactionClientContract -} - -export async function makeInvoice(input: InvoiceFactoryInput): Promise { - const issueOffset = input.issueOffsetDays ?? -randomInt(7, 90) - const paymentTerm = input.paymentTermDays ?? 30 - const issueDate = DateTime.utc().plus({ days: issueOffset }).startOf('day') - const dueDate = issueDate.plus({ days: paymentTerm }) - const amount = input.amountTtcCents ?? randomInt(25_000, 800_000) - const numero = - input.numero ?? `F-${issueDate.year}-${String(randomInt(1, 9999)).padStart(4, '0')}` - - // paidAt : pour les statuts paid, on simule un paiement après l'échéance - // (parfois en avance, parfois en retard — réaliste). - let paidAt: DateTime | null = null - if (input.status === 'paid') { - const paidOffset = randomInt(-5, 25) // -5 = payé en avance, +25 = en retard - paidAt = dueDate.plus({ days: paidOffset }) - } - - // rubisEarned : 1 par relance envoyée + 1 si payée - let rubisEarned = 0 - if (input.status === 'in_relance' || input.status === 'awaiting_user_confirmation') { - rubisEarned = randomInt(1, 3) - } else if (input.status === 'paid') { - rubisEarned = randomInt(0, 4) // certaines payées sans relance - } - - return Invoice.create( - { - organizationId: input.organizationId, - clientId: input.clientId, - planId: input.planId, - numero, - amountTtcCents: amount, - issueDate, - dueDate, - paidAt, - status: input.status, - pdfStorageKey: null, - rubisEarned, - notes: pickRandom(INVOICE_NOTES_POOL), - }, - { client: input.trx } - ) -} +// --------------------------------------------------------------------------- +// Activity events +// --------------------------------------------------------------------------- export type ActivityFactoryInput = { organizationId: string @@ -237,7 +350,6 @@ export async function makeActivityForInvoice( ): Promise { const { invoice, client, trx } = input - // Import — toujours await ActivityEvent.create( { organizationId: input.organizationId, @@ -286,7 +398,7 @@ export async function makeActivityForInvoice( } // --------------------------------------------------------------------------- -// Recettes — combine les factories pour produire une org démo cohérente +// Recettes — orchestration globale du seed démo // --------------------------------------------------------------------------- export type DemoSeedConfig = { @@ -305,34 +417,53 @@ export type DemoSeedResult = { } /** - * Mix de statuts représentatif d'une TPE active : - * - 5 paid (DSO calc + encaissé total) - * - 4 in_relance (à voir dans le funnel "en relance") - * - 2 awaiting_user_confirmation (check-in en attente) - * - 3 pending (récentes, pas encore relancées) - * - 1 litigation (cas tendu) + * Recipe actionnable — 27 factures représentatives du quotidien : + * + * - 6 pending (échéance future) + * - 3 awaiting_user_confirmation (échéance toute fraîche, check-in) + * - 11 in_relance (déjà échues, relances en cours) + * - 5 paid récentes (donnent du DSO et du delta d'encaissement) + * - 2 litigation (cas tendu, mise en demeure imminente) */ -const INVOICE_RECIPE: Array<{ status: InvoiceStatus; issueOffsetDays: number; planIdx?: number }> = [ - // Paid — réparties sur 6 mois pour faire vivre le DSO - { status: 'paid', issueOffsetDays: -180 }, - { status: 'paid', issueOffsetDays: -135 }, - { status: 'paid', issueOffsetDays: -95 }, - { status: 'paid', issueOffsetDays: -65 }, - { status: 'paid', issueOffsetDays: -40 }, - // En relance — échéances passées récentes - { status: 'in_relance', issueOffsetDays: -55 }, - { status: 'in_relance', issueOffsetDays: -50 }, - { status: 'in_relance', issueOffsetDays: -42 }, - { status: 'in_relance', issueOffsetDays: -38 }, - // Awaiting check-in — échéance toute fraîche - { status: 'awaiting_user_confirmation', issueOffsetDays: -32 }, - { status: 'awaiting_user_confirmation', issueOffsetDays: -30 }, - // Pending — récentes, pas encore arrivées à échéance - { status: 'pending', issueOffsetDays: -10 }, - { status: 'pending', issueOffsetDays: -5 }, - { status: 'pending', issueOffsetDays: -2 }, - // Litigation — ancienne, contestée - { status: 'litigation', issueOffsetDays: -90 }, +type ActionableSpec = { + status: InvoiceStatus + /** Décalage en jours de la dueDate par rapport à aujourd'hui. */ + dueOffsetDays: number +} + +const ACTIONABLE_RECIPE: ActionableSpec[] = [ + // Pending — échéance future + { status: 'pending', dueOffsetDays: 30 }, + { status: 'pending', dueOffsetDays: 30 }, + { status: 'pending', dueOffsetDays: 15 }, + { status: 'pending', dueOffsetDays: 15 }, + { status: 'pending', dueOffsetDays: 5 }, + { status: 'pending', dueOffsetDays: 5 }, + // Awaiting check-in — échue tout pile / tout récente + { status: 'awaiting_user_confirmation', dueOffsetDays: 0 }, + { status: 'awaiting_user_confirmation', dueOffsetDays: -3 }, + { status: 'awaiting_user_confirmation', dueOffsetDays: -3 }, + // In relance — échue, relances en cours + { status: 'in_relance', dueOffsetDays: -7 }, + { status: 'in_relance', dueOffsetDays: -7 }, + { status: 'in_relance', dueOffsetDays: -15 }, + { status: 'in_relance', dueOffsetDays: -15 }, + { status: 'in_relance', dueOffsetDays: -15 }, + { status: 'in_relance', dueOffsetDays: -30 }, + { status: 'in_relance', dueOffsetDays: -30 }, + { status: 'in_relance', dueOffsetDays: -45 }, + { status: 'in_relance', dueOffsetDays: -60 }, + { status: 'in_relance', dueOffsetDays: -60 }, + { status: 'in_relance', dueOffsetDays: -90 }, + // Paid récentes (réglées tardivement, dans les ~30 derniers jours) + { status: 'paid', dueOffsetDays: -30 }, + { status: 'paid', dueOffsetDays: -45 }, + { status: 'paid', dueOffsetDays: -60 }, + { status: 'paid', dueOffsetDays: -90 }, + { status: 'paid', dueOffsetDays: -120 }, + // Litigation + { status: 'litigation', dueOffsetDays: -120 }, + { status: 'litigation', dueOffsetDays: -180 }, ] /** CA cible sur 12 mois pour le seed démo (en centimes). */ @@ -343,17 +474,34 @@ const HISTORICAL_INVOICE_COUNT = 200 export async function seedDemoOrg(config: DemoSeedConfig): Promise { const { organizationId, plans, trx } = config - // Phase 1 — Actionnables : si on a des PDFs réels dans assets/test-invoices, - // on les utilise (mix pending/in_relance/paid/litigation, avec preview PDF). - // Sinon on tombe sur la recipe synthétique. - const assetsDir = resolveTestInvoicesDir() - const actionable: DemoSeedResult = assetsDir - ? await seedFromAssetPdfs({ ...config, assetsDir }) - : await seedSyntheticActionable(config) + // Charge les plans avec leurs steps (utile pour seeder les RelanceTask). + const plansWithSteps = await Plan.query({ client: trx }) + .whereIn( + 'id', + plans.map((p) => p.id) + ) + .preload('steps', (q) => q.orderBy('order', 'asc')) - // Phase 2 — Historique : ~200 factures `paid` réparties sur 12 mois pour - // alimenter les graphes (encaissé mensuel, DSO, etc.) et donner un CA - // annuel d'environ 400 K€. + // Crée les clients du pool. + const clientCount = Math.min(config.clientCount ?? 8, CLIENT_TEMPLATES.length) + const clients: Client[] = [] + for (let i = 0; i < clientCount; i++) { + clients.push(await makeClient({ organizationId, index: i, trx })) + } + + // Vendeur — l'org du user. Construit une fois, réutilisé pour les 227 PDFs. + const seller = await buildSellerInfoForOrg(organizationId, trx) + + // Phase 1 — Actionnables (27 factures avec PDF coherent) + const actionable = await seedActionableInvoices({ + organizationId, + clients, + plans: plansWithSteps, + trx, + seller, + }) + + // Phase 2 — Historique (200 factures paid sur 12 mois pour les graphes) const paidInActionable = actionable.invoices .filter((inv) => inv.status === 'paid') .reduce((s, inv) => s + inv.amountTtcCents, 0) @@ -363,46 +511,105 @@ export async function seedDemoOrg(config: DemoSeedConfig): Promise { - const { organizationId, plans, trx } = config - const clientCount = Math.min(config.clientCount ?? 8, CLIENT_TEMPLATES.length) - const clients: Client[] = [] - for (let i = 0; i < clientCount; i++) { - clients.push(await makeClient({ organizationId, index: i, trx })) - } +// --------------------------------------------------------------------------- +// Phase 1 — actionnables (recipe 27 factures) +// --------------------------------------------------------------------------- +type ActionableSeedConfig = { + organizationId: string + clients: Client[] + plans: Array }> + trx: TransactionClientContract + seller: PdfInvoiceInput['seller'] +} + +async function seedActionableInvoices( + config: ActionableSeedConfig +): Promise { + const { organizationId, clients, plans, trx, seller } = config const invoices: Invoice[] = [] - for (const [i, recipe] of INVOICE_RECIPE.entries()) { + const today = DateTime.utc().startOf('day') + const yearPrefix = today.year + + for (const [i, spec] of ACTIONABLE_RECIPE.entries()) { const client = clients[i % clients.length]! const plan = plans[i % plans.length] ?? null - const invoice = await makeInvoice({ - organizationId, - clientId: client.id, - planId: plan?.id ?? null, - status: recipe.status, - issueOffsetDays: recipe.issueOffsetDays, - trx, + const numero = `F${yearPrefix}-${String(i + 1).padStart(4, '0')}` + + // Items + total TTC réel = somme exacte des items × 1.20. + // Cible HT random entre 200€ et 6000€ (large fourchette, lisible). + const targetHtCents = randomInt(20_000, 600_000) + const items = makeItemsForTargetHt(targetHtCents) + const amountTtcCents = computeTotalTtc(items) + + // Dates issues du recipe (terme 30 jours). + const dueDate = today.plus({ days: spec.dueOffsetDays }) + const issueDate = dueDate.minus({ days: 30 }) + + let paidAt: DateTime | null = null + if (spec.status === 'paid') { + // Réglée 5-30j après dueDate (mais pas dans le futur) + const proposed = dueDate.plus({ days: randomInt(5, 30) }) + paidAt = proposed > today ? today.minus({ days: randomInt(1, 5) }) : proposed + } + + let rubisEarned = 0 + if (spec.status === 'in_relance' || spec.status === 'awaiting_user_confirmation') { + rubisEarned = randomInt(1, 3) + } else if (spec.status === 'paid') { + rubisEarned = randomInt(1, 4) + } + + // Génère le PDF + upload sur MinIO + const pdfStorageKey = await generateAndUploadInvoicePdf(organizationId, { + seller, + client: clientInfoForPdf(client), + invoice: { + numero, + issueDate: issueDate.toJSDate(), + dueDate: dueDate.toJSDate(), + items, + }, }) + + const invoice = await Invoice.create( + { + organizationId, + clientId: client.id, + planId: plan?.id ?? null, + numero, + amountTtcCents, + issueDate, + dueDate, + paidAt, + status: spec.status, + pdfStorageKey, + rubisEarned, + notes: pickRandom(INVOICE_NOTES_POOL), + }, + { client: trx } + ) invoices.push(invoice) + await makeActivityForInvoice({ organizationId, invoice, client, trx }) + if (plan) await seedRelanceTasksForInvoice(invoice, plan, trx) } const rubisEarned = invoices.reduce((s, inv) => s + inv.rubisEarned, 0) @@ -410,98 +617,153 @@ async function seedSyntheticActionable( } // --------------------------------------------------------------------------- -// Recette "PDFs réels" — utilise assets/test-invoices/*.pdf +// Phase 2 — historique (200 paid sur 12 mois pour alimenter les graphes) // --------------------------------------------------------------------------- -/** - * Localise le dossier `assets/test-invoices`. La résolution couvre 3 contextes : - * - dev local : cwd = `apps/api/` → `../../assets/test-invoices` - * - prod (init) : cwd = `/app/apps/api` → `../../assets/test-invoices` - * - prod (build) : cwd = `/app/apps/api/build` → `../../../assets/test-invoices` - */ -function resolveTestInvoicesDir(): string | null { - const candidates = [ - join(process.cwd(), '..', '..', 'assets', 'test-invoices'), - join(process.cwd(), '..', '..', '..', 'assets', 'test-invoices'), - join(process.cwd(), 'assets', 'test-invoices'), - ] - for (const c of candidates) { - if (existsSync(c)) return c +type HistoricalSeedConfig = { + organizationId: string + clients: Client[] + plans: Array }> + trx: TransactionClientContract + seller: PdfInvoiceInput['seller'] + /** Cible CA cumulé visé (centimes). Soft : approximé via les items. */ + targetRevenueCents: number + /** Nombre de factures à générer. */ + invoiceCount: number + /** Profondeur en mois (issueDate étalée sur cette fenêtre). */ + monthsBack: number + /** Décalage de numérotation pour ne pas écraser les actionnables (F2026-XXXX). */ + numeroOffset: number +} + +async function seedHistoricalInvoices( + config: HistoricalSeedConfig +): Promise { + const { organizationId, clients, plans, trx, seller, monthsBack } = config + if (clients.length === 0) return { clients, invoices: [], rubisEarned: 0 } + + // ~5% de cancelled pour la variété. + const cancelledIndexes = new Set() + const cancelledCount = Math.round(config.invoiceCount * 0.05) + while (cancelledIndexes.size < cancelledCount) { + cancelledIndexes.add(randomInt(0, config.invoiceCount - 1)) } - return null -} -/** - * Mappe le suffixe descriptif d'un nom de fichier vers (status, dueOffset). - * Filenames : - * - facture-pas-en-retard-echeance-{N}j-XXX.pdf → due dans +N jours - * - facture-echue-aujourdhui-XXX.pdf → due aujourd'hui - * - facture-en-retard-{N}j-XXX.pdf → due il y a N jours - * - * Pour certaines factures très en retard, on simule un règlement tardif - * (paid) ou une mise en demeure (litigation) — ça donne un mix réaliste. - */ -type AssetSpec = { - filename: string - /** Décalage de la dueDate par rapport à aujourd'hui (en jours). */ - dueOffsetDays: number - status: InvoiceStatus -} + // Distribution de "tailles cibles" log-uniformes, rescalées pour + // approximer le CA total cible. + const paidCount = config.invoiceCount - cancelledIndexes.size + const rawAmounts = Array.from( + { length: paidCount }, + () => 0.3 + Math.random() * 2.5 + ) + const sumRaw = rawAmounts.reduce((a, b) => a + b, 0) + const scale = config.targetRevenueCents / sumRaw + const targetAmounts = rawAmounts.map((r) => + Math.max(20_000, Math.round(r * scale)) + ) -/** Quelques numéros qu'on bascule en paid / litigation pour le mix démo. */ -const PAID_OVERRIDES = new Set([ - 'facture-en-retard-30j-017.pdf', - 'facture-en-retard-45j-019.pdf', - 'facture-en-retard-60j-022.pdf', - 'facture-en-retard-90j-024.pdf', - 'facture-en-retard-120j-026.pdf', -]) -const LITIGATION_OVERRIDES = new Set([ - 'facture-en-retard-120j-025.pdf', - 'facture-en-retard-180j-027.pdf', -]) + const invoices: Invoice[] = [] + const today = DateTime.utc().startOf('day') + let paidIdx = 0 -function parseAssetFilename(filename: string): AssetSpec | null { - // Pattern 1 : pas-en-retard-echeance-{N}j → due in +N - const futureMatch = filename.match(/pas-en-retard-echeance-(\d+)j/) - if (futureMatch) { - return { - filename, - dueOffsetDays: Number(futureMatch[1]), - status: 'pending', + for (let i = 0; i < config.invoiceCount; i++) { + const isCancelled = cancelledIndexes.has(i) + const client = clients[i % clients.length]! + const plan = i % 2 === 0 ? (plans[i % plans.length] ?? null) : null + + // Date d'émission étalée sur monthsBack mois (avec jitter par jour). + const monthOffset = randomInt(0, monthsBack - 1) + const dayJitter = randomInt(0, 27) + const issueDate = today + .minus({ months: monthOffset }) + .startOf('month') + .plus({ days: dayJitter }) + const dueDate = issueDate.plus({ days: 30 }) + + // Items en partant d'une cible TTC ; on dérive ensuite la cible HT. + const targetTtcCents = isCancelled + ? randomInt(30_000, 200_000) + : targetAmounts[paidIdx++]! + const targetHtCents = Math.round(targetTtcCents / 1.2) + const items = makeItemsForTargetHt(targetHtCents) + const amountTtcCents = computeTotalTtc(items) + + let paidAt: DateTime | null = null + let status: InvoiceStatus + let rubisEarned = 0 + + if (isCancelled) { + status = 'cancelled' + } else { + status = 'paid' + const proposed = dueDate.plus({ days: randomInt(-3, 25) }) + paidAt = proposed > today ? today.minus({ days: randomInt(0, 5) }) : proposed + rubisEarned = Math.random() < 0.7 ? randomInt(0, 2) : 0 + } + + const numero = `F${issueDate.year}-${String(config.numeroOffset + i + 1).padStart(4, '0')}` + + const pdfStorageKey = await generateAndUploadInvoicePdf(organizationId, { + seller, + client: clientInfoForPdf(client), + invoice: { + numero, + issueDate: issueDate.toJSDate(), + dueDate: dueDate.toJSDate(), + items, + }, + }) + + const invoice = await Invoice.create( + { + organizationId, + clientId: client.id, + planId: plan?.id ?? null, + numero, + amountTtcCents, + issueDate, + dueDate, + paidAt, + status, + pdfStorageKey, + rubisEarned, + notes: pickRandom(INVOICE_NOTES_POOL), + }, + { client: trx } + ) + invoices.push(invoice) + + await ActivityEvent.create( + { + organizationId, + kind: 'invoice_imported', + at: issueDate, + label: `Facture ${numero} importée`, + meta: { invoiceId: invoice.id, clientId: client.id }, + }, + { client: trx } + ) + if (status === 'paid' && paidAt) { + await ActivityEvent.create( + { + organizationId, + kind: 'invoice_paid', + at: paidAt, + label: `${client.name} a réglé ${numero}`, + meta: { invoiceId: invoice.id, clientId: client.id }, + }, + { client: trx } + ) } } - // Pattern 2 : echue-aujourdhui - if (/echue-aujourdhui/.test(filename)) { - return { - filename, - dueOffsetDays: 0, - status: 'awaiting_user_confirmation', - } - } - // Pattern 3 : en-retard-{N}j → due -N - const lateMatch = filename.match(/en-retard-(\d+)j/) - if (lateMatch) { - const days = Number(lateMatch[1]) - let status: InvoiceStatus = 'in_relance' - if (days <= 3) status = 'awaiting_user_confirmation' - if (PAID_OVERRIDES.has(filename)) status = 'paid' - else if (LITIGATION_OVERRIDES.has(filename)) status = 'litigation' - return { - filename, - dueOffsetDays: -days, - status, - } - } - return null + + const rubisEarned = invoices.reduce((s, inv) => s + inv.rubisEarned, 0) + return { clients, invoices, rubisEarned } } -/** Extrait le numéro depuis le filename (ex. `...-007.pdf` → `F2026-0007`). */ -function deriveInvoiceNumero(filename: string, fallbackYear: number): string { - const m = filename.match(/-(\d{3,})\.pdf$/) - if (!m) return `F-${fallbackYear}-${String(randomInt(1, 9999)).padStart(4, '0')}` - return `F${fallbackYear}-${m[1]!.padStart(4, '0')}` -} +// --------------------------------------------------------------------------- +// RelanceTasks — câblage des tâches de relance pour une invoice (no-enqueue) +// --------------------------------------------------------------------------- /** * Crée les RelanceTask pour une invoice — uniquement pour les statuts qui ont @@ -523,7 +785,6 @@ async function seedRelanceTasksForInvoice( ): Promise { if (!plan.steps?.length) return - // Statuts pré-check-in : aucune task à programmer. if (invoice.status === 'pending' || invoice.status === 'awaiting_user_confirmation') { return } @@ -570,231 +831,6 @@ async function seedRelanceTasksForInvoice( } } -type AssetSeedConfig = DemoSeedConfig & { assetsDir: string } - -async function seedFromAssetPdfs(config: AssetSeedConfig): Promise { - const { organizationId, plans, trx, assetsDir } = config - - const allFiles = await readdir(assetsDir) - const pdfs = allFiles.filter((f) => f.endsWith('.pdf')).sort() - const specs = pdfs - .map(parseAssetFilename) - .filter((s): s is AssetSpec => s !== null) - - if (specs.length === 0) { - // Aucun PDF parseable : on n'aurait pas dû arriver ici. - return { clients: [], invoices: [], rubisEarned: 0 } - } - - // Charge les plans avec leurs steps — on en a besoin pour seeder les - // RelanceTasks au bon `sendAt`. - const plansWithSteps = await Plan.query({ client: trx }) - .whereIn( - 'id', - plans.map((p) => p.id) - ) - .preload('steps', (q) => q.orderBy('order', 'asc')) - - // Crée tous les clients du pool — round-robin sur les factures. - const clientCount = Math.min(config.clientCount ?? 8, CLIENT_TEMPLATES.length) - const clients: Client[] = [] - for (let i = 0; i < clientCount; i++) { - clients.push(await makeClient({ organizationId, index: i, trx })) - } - - const invoices: Invoice[] = [] - const today = DateTime.utc().startOf('day') - - for (const [i, spec] of specs.entries()) { - const client = clients[i % clients.length]! - const plan = plansWithSteps[i % plansWithSteps.length] ?? null - - // Upload le PDF vers le drive (MinIO en S3, fs en fallback). - const filePath = join(assetsDir, spec.filename) - const buffer = await readFile(filePath) - const storageKey = `invoice-pdfs/${organizationId}/${randomUUID()}.pdf` - await drive.use().put(storageKey, buffer) - - // Dates : on cale la dueDate sur l'offset, on assume 30j de termes. - const dueDate = today.plus({ days: spec.dueOffsetDays }) - const issueDate = dueDate.minus({ days: 30 }) - - let paidAt: DateTime | null = null - if (spec.status === 'paid') { - // Règlement après l'échéance — entre 5 et 30j de retard côté facture. - paidAt = dueDate.plus({ days: randomInt(5, 30) }) - } - - let rubisEarned = 0 - if (spec.status === 'in_relance' || spec.status === 'awaiting_user_confirmation') { - rubisEarned = randomInt(1, 3) - } else if (spec.status === 'paid') { - rubisEarned = randomInt(1, 4) - } - - const invoice = await Invoice.create( - { - organizationId, - clientId: client.id, - planId: plan?.id ?? null, - numero: deriveInvoiceNumero(spec.filename, issueDate.year), - amountTtcCents: randomInt(25_000, 800_000), - issueDate, - dueDate, - paidAt, - status: spec.status, - pdfStorageKey: storageKey, - rubisEarned, - notes: pickRandom(INVOICE_NOTES_POOL), - }, - { client: trx } - ) - invoices.push(invoice) - await makeActivityForInvoice({ organizationId, invoice, client, trx }) - if (plan) { - await seedRelanceTasksForInvoice(invoice, plan, trx) - } - } - - const rubisEarned = invoices.reduce((s, inv) => s + inv.rubisEarned, 0) - return { clients, invoices, rubisEarned } -} - -// --------------------------------------------------------------------------- -// Recette "historique" — factures paid réparties sur N mois (alimente les -// graphes : encaissé mensuel, DSO, etc.). Pas de PDF, juste des données pour -// que les charts soient parlants. -// --------------------------------------------------------------------------- - -type HistoricalSeedConfig = { - organizationId: string - clients: Client[] - plans: Plan[] - trx: TransactionClientContract - /** Total cents que la somme des paid doit approcher. */ - targetRevenueCents: number - /** Nombre de factures à générer. */ - invoiceCount: number - /** Profondeur en mois (issueDate étalée sur cette fenêtre). */ - monthsBack: number -} - -async function seedHistoricalInvoices( - config: HistoricalSeedConfig -): Promise { - const { organizationId, clients, plans, trx, monthsBack } = config - if (clients.length === 0) return { clients, invoices: [], rubisEarned: 0 } - - // ~5% de cancelled pour la variété (factures annulées en cours de route). - const cancelledIndexes = new Set() - const cancelledCount = Math.round(config.invoiceCount * 0.05) - while (cancelledIndexes.size < cancelledCount) { - cancelledIndexes.add(randomInt(0, config.invoiceCount - 1)) - } - - // Distribution des montants — log-uniforme entre 0.3 et 2.8, puis rescale - // pour matcher le CA cible. Ça donne une queue avec quelques grosses - // factures et beaucoup de petites — réaliste pour une TPE. - const paidCount = config.invoiceCount - cancelledIndexes.size - const rawAmounts = Array.from( - { length: paidCount }, - () => 0.3 + Math.random() * 2.5 - ) - const sumRaw = rawAmounts.reduce((a, b) => a + b, 0) - const scale = config.targetRevenueCents / sumRaw - const paidAmounts = rawAmounts.map((r) => Math.max(20_000, Math.round(r * scale))) - - const invoices: Invoice[] = [] - const today = DateTime.utc().startOf('day') - let paidIdx = 0 - - for (let i = 0; i < config.invoiceCount; i++) { - const isCancelled = cancelledIndexes.has(i) - const client = clients[i % clients.length]! - // Plans optionnels — la moitié des historiques n'en avait pas (saisie - // manuelle), pour varier. - const plan = i % 2 === 0 ? (plans[i % plans.length] ?? null) : null - - // Date d'émission étalée sur monthsBack mois (avec jitter par jour). - const monthOffset = randomInt(0, monthsBack - 1) - const dayJitter = randomInt(0, 27) - const issueDate = today - .minus({ months: monthOffset }) - .startOf('month') - .plus({ days: dayJitter }) - const paymentTerm = 30 - const dueDate = issueDate.plus({ days: paymentTerm }) - - let amountTtcCents: number - let paidAt: DateTime | null = null - let status: InvoiceStatus - let rubisEarned = 0 - - if (isCancelled) { - // Cancelled : montant petit-moyen, pas de paidAt, pas de rubis. - amountTtcCents = randomInt(30_000, 200_000) - status = 'cancelled' - } else { - amountTtcCents = paidAmounts[paidIdx++]! - // Paiement entre -3j (avance) et +25j (retard) par rapport à dueDate. - paidAt = dueDate.plus({ days: randomInt(-3, 25) }) - // Mais jamais après aujourd'hui. - if (paidAt > today) paidAt = today.minus({ days: randomInt(0, 5) }) - status = 'paid' - // ~70% des paid ont déclenché 0-2 relances avant règlement. - rubisEarned = Math.random() < 0.7 ? randomInt(0, 2) : 0 - } - - const numero = `F${issueDate.year}-H${String(i + 1).padStart(4, '0')}` - - const invoice = await Invoice.create( - { - organizationId, - clientId: client.id, - planId: plan?.id ?? null, - numero, - amountTtcCents, - issueDate, - dueDate, - paidAt, - status, - pdfStorageKey: null, - rubisEarned, - notes: pickRandom(INVOICE_NOTES_POOL), - }, - { client: trx } - ) - invoices.push(invoice) - - // Activity events minimaux pour la timeline (import + paiement). - await ActivityEvent.create( - { - organizationId, - kind: 'invoice_imported', - at: issueDate, - label: `Facture ${numero} importée`, - meta: { invoiceId: invoice.id, clientId: client.id }, - }, - { client: trx } - ) - if (status === 'paid' && paidAt) { - await ActivityEvent.create( - { - organizationId, - kind: 'invoice_paid', - at: paidAt, - label: `${client.name} a réglé ${numero}`, - meta: { invoiceId: invoice.id, clientId: client.id }, - }, - { client: trx } - ) - } - } - - const rubisEarned = invoices.reduce((s, inv) => s + inv.rubisEarned, 0) - return { clients, invoices, rubisEarned } -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -807,5 +843,17 @@ function pickRandom(arr: readonly T[]): T { return arr[Math.floor(Math.random() * arr.length)]! } -// Marque l'export de randomUUID utilisée si on en a besoin ailleurs. +/** Fisher-Yates partiel — sample N éléments distincts d'un array. */ +function sampleN(arr: readonly T[], n: number): T[] { + const copy = arr.slice() + const out: T[] = [] + const k = Math.min(n, copy.length) + for (let i = 0; i < k; i++) { + const j = randomInt(i, copy.length - 1) + ;[copy[i], copy[j]] = [copy[j]!, copy[i]!] + out.push(copy[i]!) + } + return out +} + export { randomUUID } diff --git a/apps/api/database/invoice_pdf_factory.tsx b/apps/api/database/invoice_pdf_factory.tsx new file mode 100644 index 0000000..302ade2 --- /dev/null +++ b/apps/api/database/invoice_pdf_factory.tsx @@ -0,0 +1,481 @@ +/** + * Génération de PDFs de factures via `@react-pdf/renderer` — composants + * React déclaratifs, mêmes couleurs que le SPA, maintenable de bout en + * bout sans switcher de mental model. + * + * Le `renderToBuffer()` produit un Buffer uploadable + * tel quel via `drive.use().put(key, buf)`. + * + * Polices : on reste sur Helvetica intégrée (pas besoin de loader des + * fichiers TTF, ça simplifie la portabilité prod). + */ + +import { DateTime } from 'luxon' +import { + Document, + Page, + View, + Text, + StyleSheet, + renderToBuffer, +} from '@react-pdf/renderer' +import * as React from 'react' + +// --------------------------------------------------------------------------- +// Types — exposés pour factories.ts +// --------------------------------------------------------------------------- + +export type InvoiceItem = { + description: string + unit: string + quantity: number + unitPriceHtCents: number +} + +export type PdfInvoiceInput = { + seller: { + name: string + address: string + siret: string | null + tvaNumber: string | null + email: string + phone: string | null + iban: string + } + client: { + name: string + contactFirstName: string | null + contactLastName: string | null + address: string | null + siret: string | null + } + invoice: { + numero: string + issueDate: Date + dueDate: Date + items: InvoiceItem[] + note?: string | null + } +} + +// --------------------------------------------------------------------------- +// Tokens de marque (cf. CLAUDE.md → palette) +// --------------------------------------------------------------------------- + +const C = { + rubis: '#9F1239', + rubisDeep: '#771328', + rubisGlow: '#FBE4EA', + cream: '#FAF7F2', + cream2: '#F5EFE7', + ink: '#1A1410', + ink2: '#4F4640', + ink3: '#8A7F76', + line: '#E8E0D6', + white: '#FFFFFF', +} as const + +// --------------------------------------------------------------------------- +// Helpers de formatage FR +// --------------------------------------------------------------------------- + +const FR_DATE = new Intl.DateTimeFormat('fr-FR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', +}) +function formatDate(d: Date): string { + return FR_DATE.format(d) +} + +const FR_EUROS = new Intl.NumberFormat('fr-FR', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 2, +}) +function formatEuros(cents: number): string { + return FR_EUROS.format(cents / 100) +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const styles = StyleSheet.create({ + page: { + backgroundColor: C.white, + fontFamily: 'Helvetica', + fontSize: 10, + color: C.ink, + paddingBottom: 60, + }, + + // Header bandeau + header: { + backgroundColor: C.rubisDeep, + color: C.white, + paddingHorizontal: 48, + paddingVertical: 32, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + headerLeft: { flex: 1, paddingRight: 16 }, + sellerName: { + fontFamily: 'Helvetica-Bold', + fontSize: 20, + marginBottom: 10, + }, + sellerMeta: { + fontSize: 8.5, + color: C.cream2, + lineHeight: 1.5, + }, + headerRight: { width: 180, alignItems: 'flex-end' }, + factureLabel: { + fontFamily: 'Helvetica-Bold', + fontSize: 26, + marginBottom: 8, + }, + factureMeta: { + fontSize: 9, + color: C.cream2, + lineHeight: 1.5, + }, + + // Addresses + addresses: { + paddingHorizontal: 48, + paddingTop: 32, + flexDirection: 'row', + justifyContent: 'space-between', + }, + addressBlock: { flex: 1 }, + addressBlockRight: { flex: 1, alignItems: 'flex-end' }, + eyebrow: { + fontFamily: 'Helvetica-Bold', + fontSize: 8, + letterSpacing: 1, + color: C.ink3, + marginBottom: 6, + }, + clientName: { + fontFamily: 'Helvetica-Bold', + fontSize: 13, + color: C.ink, + marginBottom: 4, + }, + clientMeta: { + fontSize: 9.5, + color: C.ink2, + lineHeight: 1.4, + }, + clientSiret: { + fontSize: 8, + color: C.ink3, + marginTop: 4, + }, + dueDate: { + fontFamily: 'Helvetica-Bold', + fontSize: 15, + color: C.rubis, + marginBottom: 4, + }, + dueDateSub: { + fontSize: 9, + color: C.ink3, + }, + + // Items table + table: { + marginTop: 36, + paddingHorizontal: 48, + }, + tableHeader: { + backgroundColor: C.cream2, + flexDirection: 'row', + paddingVertical: 8, + paddingHorizontal: 10, + }, + th: { + fontFamily: 'Helvetica-Bold', + fontSize: 8.5, + letterSpacing: 0.5, + color: C.ink3, + }, + tableRow: { + flexDirection: 'row', + paddingVertical: 10, + paddingHorizontal: 10, + borderBottomWidth: 0.5, + borderBottomColor: C.line, + }, + tdDescription: { flex: 5 }, + tdQty: { flex: 1, textAlign: 'right' }, + tdUnit: { flex: 1.5, textAlign: 'right' }, + tdTotal: { flex: 2, textAlign: 'right' }, + itemDesc: { + fontSize: 10, + color: C.ink, + }, + itemUnit: { + fontSize: 8, + color: C.ink3, + marginTop: 2, + }, + + // Totals + totals: { + marginTop: 18, + paddingHorizontal: 48, + flexDirection: 'row', + justifyContent: 'flex-end', + }, + totalsBlock: { width: 240 }, + totalRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 4, + }, + totalLabel: { + fontSize: 10, + color: C.ink2, + }, + totalValue: { + fontFamily: 'Helvetica-Bold', + fontSize: 10, + color: C.ink, + }, + totalTtcRow: { + flexDirection: 'row', + justifyContent: 'space-between', + backgroundColor: C.rubisGlow, + paddingVertical: 10, + paddingHorizontal: 12, + marginTop: 8, + alignItems: 'center', + }, + totalTtcLabel: { + fontFamily: 'Helvetica-Bold', + fontSize: 11, + color: C.rubisDeep, + }, + totalTtcValue: { + fontFamily: 'Helvetica-Bold', + fontSize: 14, + color: C.rubisDeep, + }, + + // Note libre + note: { + marginTop: 28, + paddingHorizontal: 48, + fontSize: 9, + fontStyle: 'italic', + color: C.ink3, + lineHeight: 1.4, + }, + + // Footer fixe en bas de page + footer: { + position: 'absolute', + bottom: 24, + left: 48, + right: 48, + textAlign: 'center', + }, + footerLine: { + fontSize: 7.5, + color: C.ink3, + lineHeight: 1.5, + }, + footerBrand: { + fontSize: 7, + color: C.ink3, + marginTop: 4, + }, +}) + +// --------------------------------------------------------------------------- +// Composants +// --------------------------------------------------------------------------- + +function InvoiceDocument({ data }: { data: PdfInvoiceInput }) { + const totalHt = data.invoice.items.reduce( + (s, i) => s + i.quantity * i.unitPriceHtCents, + 0 + ) + const tva = Math.round(totalHt * 0.2) + const totalTtc = totalHt + tva + + const paymentTermDays = Math.round( + DateTime.fromJSDate(data.invoice.dueDate) + .startOf('day') + .diff( + DateTime.fromJSDate(data.invoice.issueDate).startOf('day'), + 'days' + ).days + ) + + return ( + + +
+ + + + {data.invoice.note ? {data.invoice.note} : null} +