diff --git a/apps/api/commands/seed_demo.ts b/apps/api/commands/seed_demo.ts new file mode 100644 index 0000000..0717c71 --- /dev/null +++ b/apps/api/commands/seed_demo.ts @@ -0,0 +1,147 @@ +import { BaseCommand, args, flags } from '@adonisjs/core/ace' +import type { CommandOptions } from '@adonisjs/core/types/ace' +import db from '@adonisjs/lucid/services/db' + +import User from '#models/user' +import Organization from '#models/organization' +import Plan from '#models/plan' +import Client from '#models/client' +import Invoice from '#models/invoice' +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' + +/** + * Peuple l'org d'un user existant avec des données de démo réalistes — + * pour visualiser dashboard, factures, plans en conditions réelles. + * + * node ace seed:demo --email arthurbarre.js@gmail.com + * node ace seed:demo --email ... --reset # wipe avant + */ +export default class SeedDemo extends BaseCommand { + static commandName = 'seed:demo' + static description = "Peuple l'organisation d'un user existant avec des données de démo (clients, factures, activité)" + + static options: CommandOptions = { + startApp: true, + } + + @args.string({ description: 'Email du user dont on peuple l\'org', required: false }) + declare email: string | undefined + + @flags.boolean({ + description: + "Supprime les clients/factures/activité existants de l'org avant le seed", + default: false, + }) + declare reset: boolean + + @flags.string({ + description: "Nom à donner à l'organisation (ex. 'Maçonnerie Dupont'). Si vide, on ne touche pas.", + }) + declare orgName?: string + + async run() { + const email = this.email ?? this.parsed.flags.email + if (!email) { + this.logger.error('Argument requis : --email ') + this.exitCode = 1 + return + } + + const user = await User.findBy('email', String(email).toLowerCase()) + if (!user) { + this.logger.error(`User introuvable : ${email}`) + this.exitCode = 1 + return + } + this.logger.info(`User trouvé : ${user.fullName ?? user.email} (${user.id})`) + + if (!user.organizationId) { + this.logger.error('Le user n\'a pas d\'organization rattachée — flow signup pas terminé ?') + this.exitCode = 1 + return + } + + await db.transaction(async (trx) => { + const org = await Organization.findOrFail(user.organizationId!, { client: trx }) + + if (this.reset) { + this.logger.warning('--reset : suppression des clients/factures/activity existants…') + // Ordre : enfants d'abord pour respecter les FK + await CheckinTask.query({ client: trx }) + .whereIn( + 'invoice_id', + db.from('invoices').where('organization_id', org.id).select('id') + ) + .delete() + await RelanceTask.query({ client: trx }) + .whereIn( + 'invoice_id', + db.from('invoices').where('organization_id', org.id).select('id') + ) + .delete() + 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() + } + + // Configure l'org : nom (si fourni) + bucket volume mensuel + const targetName = + this.orgName ?? + (org.name && org.name.length > 0 ? org.name : `Maison ${user.fullName?.split(' ')[1] ?? 'Démo'}`) + org.useTransaction(trx) + org.name = targetName + if (!org.monthlyVolumeBucket) { + org.monthlyVolumeBucket = '20-50' + } + // Reset rubisCount, on le rechargera en fonction des factures seedées. + org.rubisCount = 0 + await org.save() + this.logger.info(`Org configurée : "${org.name}"`) + + // Plans : provision si manquants (idempotent) + await provisionDefaultPlans(org.id, trx) + const plans = await Plan.query({ client: trx }).where('organization_id', org.id) + this.logger.info(`Plans disponibles : ${plans.length} (${plans.map((p) => p.name).join(', ')})`) + + // Seed la data + const result = await seedDemoOrg({ + organizationId: org.id, + plans, + trx, + }) + + // Met à jour le compteur rubis de l'org en fonction du seed + await trx + .from('organizations') + .where('id', org.id) + .update({ rubis_count: result.rubisEarned }) + + this.logger.success( + `Seed terminé : ${result.clients.length} clients · ${result.invoices.length} factures · ${result.rubisEarned} rubis` + ) + + // Petit récap par statut + const byStatus: Record = {} + for (const inv of result.invoices) { + byStatus[inv.status] = (byStatus[inv.status] ?? 0) + 1 + } + for (const [status, count] of Object.entries(byStatus)) { + this.logger.info(` · ${status} : ${count}`) + } + + // User : signature par défaut si vide (utile pour les previews/tests email) + if (!user.signature) { + user.useTransaction(trx) + user.signature = `Cordialement,\n${user.fullName ?? 'L\'équipe'}\n${org.name}` + await user.save() + this.logger.info('Signature email par défaut posée sur le user.') + } + }) + + this.logger.success('Done.') + } +} diff --git a/apps/api/database/factories.ts b/apps/api/database/factories.ts new file mode 100644 index 0000000..9c2bb0c --- /dev/null +++ b/apps/api/database/factories.ts @@ -0,0 +1,382 @@ +/** + * 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 }