/** * Factories — créent des entités réalistes (FR, format Rubis) pour * peupler une org de démo ou alimenter des tests. * * Pas de framework lourd type @adonisjs/lucid factories : des fonctions * pures, idempotentes, qu'on compose dans une commande Ace ou un test. */ import { DateTime } from 'luxon' import { randomUUID } from 'node:crypto' import type { TransactionClientContract } from '@adonisjs/lucid/types/database' import Client from '#models/client' import Invoice from '#models/invoice' import ActivityEvent from '#models/activity_event' import type Plan from '#models/plan' // --------------------------------------------------------------------------- // Sources de données déterministes (pas de Faker — moins de deps, plus stable) // --------------------------------------------------------------------------- const CLIENT_TEMPLATES: Array<{ name: string firstName: string | null lastName: string | null emailDomain: string phone: string | null address: string | null siret: string | null }> = [ { name: 'Boulangerie Martin SARL', firstName: 'Marie', lastName: 'Martin', emailDomain: 'boulangerie-martin.fr', phone: '+33 1 23 45 67 89', address: '12 rue du Pain, 75011 Paris', siret: '82345678900012', }, { name: 'Maçonnerie Dupont & Fils', firstName: 'Jean', lastName: 'Dupont', emailDomain: 'maconnerie-dupont.fr', phone: '+33 4 78 56 12 34', address: '45 chemin des Carrières, 69100 Villeurbanne', siret: '53412987600028', }, { name: 'Atelier Durand', firstName: null, lastName: null, emailDomain: 'atelier-durand.fr', phone: null, address: null, siret: null, }, { name: 'Cabinet Rousseau Conseil', firstName: 'Julien', lastName: 'Rousseau', emailDomain: 'cabinet-rousseau.fr', phone: '+33 4 56 78 90 12', address: '8 place de la République, 69002 Lyon', siret: '53412987600101', }, { name: 'Garage Lemoine', firstName: 'Pierre', lastName: 'Lemoine', emailDomain: 'garage-lemoine.fr', phone: '+33 2 99 87 65 43', address: '23 boulevard de la Liberté, 35000 Rennes', siret: '78912345600054', }, { name: 'Studio Lefèvre', firstName: 'Camille', lastName: 'Lefèvre', emailDomain: 'studio-lefevre.com', phone: null, address: null, siret: null, }, { name: 'Restaurant Le Beauvoir', firstName: 'Sophie', lastName: 'Beauvoir', emailDomain: 'le-beauvoir.fr', phone: '+33 1 45 22 33 44', address: '15 rue Mouffetard, 75005 Paris', siret: '12345678900078', }, { name: 'Imprimerie Henri & Fils', firstName: 'Henri', lastName: 'Petit', emailDomain: 'imprimerie-henri.fr', phone: '+33 5 61 78 90 23', address: '7 avenue Jean Jaurès, 31000 Toulouse', siret: '45123789600041', }, ] const INVOICE_NOTES_POOL = [ 'Prestation conseil — janvier', 'Travaux de rénovation second œuvre', 'Photographe événementiel — mariage', 'Maintenance trimestrielle', 'Livraison matières premières', 'Audit comptable annuel', null, null, ] // --------------------------------------------------------------------------- // Factories // --------------------------------------------------------------------------- export type ClientFactoryInput = { organizationId: string trx?: TransactionClientContract /** Index 0..N dans CLIENT_TEMPLATES, sinon valeurs random. */ index?: number } export async function makeClient(input: ClientFactoryInput): Promise { const tpl = input.index !== undefined ? CLIENT_TEMPLATES[input.index % CLIENT_TEMPLATES.length]! : CLIENT_TEMPLATES[Math.floor(Math.random() * CLIENT_TEMPLATES.length)]! const inboxName = tpl.firstName ? `${tpl.firstName.toLowerCase()}@${tpl.emailDomain}` : `compta@${tpl.emailDomain}` return Client.create( { organizationId: input.organizationId, name: tpl.name, email: inboxName, contactFirstName: tpl.firstName, contactLastName: tpl.lastName, phone: tpl.phone, address: tpl.address, siret: tpl.siret, notes: null, }, { client: input.trx } ) } 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 } ) } export type ActivityFactoryInput = { organizationId: string invoice: Invoice client: Client trx?: TransactionClientContract } /** * Pour chaque facture, génère les events réalistes : * - invoice_imported (toujours, à issueDate) * - relance_sent N fois si status in_relance/awaiting (entre issueDate et now) * - invoice_paid si status paid */ export async function makeActivityForInvoice( input: ActivityFactoryInput ): Promise { const { invoice, client, trx } = input // Import — toujours await ActivityEvent.create( { organizationId: input.organizationId, kind: 'invoice_imported', at: invoice.issueDate, label: `Facture ${invoice.numero} importée`, meta: { invoiceId: invoice.id, clientId: client.id }, }, { client: trx } ) if (invoice.status === 'in_relance' || invoice.status === 'awaiting_user_confirmation') { const relanceCount = randomInt(1, 3) for (let i = 0; i < relanceCount; i++) { const sentAt = invoice.dueDate.plus({ days: 3 + i * 7 }) if (sentAt > DateTime.utc()) break await ActivityEvent.create( { organizationId: input.organizationId, kind: 'relance_sent', at: sentAt, label: `Relance J+${3 + i * 7} envoyée à ${client.name}`, meta: { invoiceId: invoice.id, clientId: client.id, planStepOrder: i, }, }, { client: trx } ) } } if (invoice.status === 'paid' && invoice.paidAt) { await ActivityEvent.create( { organizationId: input.organizationId, kind: 'invoice_paid', at: invoice.paidAt, label: `${client.name} a réglé ${invoice.numero}`, meta: { invoiceId: invoice.id, clientId: client.id }, }, { client: trx } ) } } // --------------------------------------------------------------------------- // Recettes — combine les factories pour produire une org démo cohérente // --------------------------------------------------------------------------- export type DemoSeedConfig = { organizationId: string /** Plans déjà provisionnés dans l'org (pour piocher des planId). */ plans: Plan[] trx: TransactionClientContract /** Combien de clients (1..N templates dispo). Défaut 8. */ clientCount?: number } export type DemoSeedResult = { clients: Client[] invoices: Invoice[] rubisEarned: number } /** * 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) */ 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 }, ] export async function seedDemoOrg(config: DemoSeedConfig): Promise { const { organizationId, plans, trx } = config const clientCount = Math.min(config.clientCount ?? 8, CLIENT_TEMPLATES.length) // Crée les clients const clients: Client[] = [] for (let i = 0; i < clientCount; i++) { clients.push(await makeClient({ organizationId, index: i, trx })) } // Crée les factures (round-robin sur les clients + plans) const invoices: Invoice[] = [] for (const [i, recipe] of INVOICE_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, }) invoices.push(invoice) await makeActivityForInvoice({ organizationId, invoice, client, trx, }) } const rubisEarned = invoices.reduce((s, inv) => s + inv.rubisEarned, 0) return { clients, invoices, rubisEarned } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function randomInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min } 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. export { randomUUID }