feat(api): factories de démo + commande seed:demo --email pour peupler une org
Factories réutilisables (database/factories.ts) : - makeClient — un client à partir de 8 templates FR (Boulangerie Martin, Maçonnerie Dupont, etc.) avec contact/SIRET/adresse réalistes - makeInvoice — une facture avec status driving les dates et le rubis earned (pending = future, in_relance = échue récente, paid = paidAt cohérent, etc.) - makeActivityForInvoice — events alignés sur le statut (import/relance/paid) - seedDemoOrg — recette V1 : 8 clients + 15 factures réparties sur 5 statuts (5 paid sur 6 mois, 4 in_relance, 2 awaiting_user_confirmation, 3 pending, 1 litigation) → fait vivre dashboard, factures et DSO Commande Ace seed:demo - Args : --email <email> (obligatoire), --reset (wipe avant), --orgName - Flow : trouve user, configure son org (nom + bucket), provision les 4 plans par défaut (idempotent), seed la data, met à jour rubis_count - Pose une signature email par défaut sur le user si vide - Tout en transaction : pas d'état inconsistant si une étape plante Usage : node ace seed:demo --email arthurbarre.js@gmail.com node ace seed:demo --email ... --reset --orgName="Maçonnerie Dupont" Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4113cb56d3
commit
32fcb02108
147
apps/api/commands/seed_demo.ts
Normal file
147
apps/api/commands/seed_demo.ts
Normal file
@ -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 <user-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<string, number> = {}
|
||||||
|
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.')
|
||||||
|
}
|
||||||
|
}
|
||||||
382
apps/api/database/factories.ts
Normal file
382
apps/api/database/factories.ts
Normal file
@ -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<Client> {
|
||||||
|
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<Invoice> {
|
||||||
|
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<void> {
|
||||||
|
const { invoice, client, trx } = input
|
||||||
|
|
||||||
|
// Import — toujours
|
||||||
|
await ActivityEvent.create(
|
||||||
|
{
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
kind: 'invoice_imported',
|
||||||
|
at: invoice.issueDate,
|
||||||
|
label: `Facture <b>${invoice.numero}</b> 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 à <b>${client.name}</b>`,
|
||||||
|
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: `<b>${client.name}</b> 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<DemoSeedResult> {
|
||||||
|
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<T>(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 }
|
||||||
Loading…
x
Reference in New Issue
Block a user