Chaque facture seedée a maintenant son propre PDF dont le contenu
matche exactement les meta DB (vendeur = org du user, client = client
réel, numéro / dates / montant cohérents). Plus de réutilisation
round-robin de PDFs disque non-cohérents.
Stack :
- @react-pdf/renderer : composants React déclaratifs, StyleSheet
inspiré du SPA (mêmes tokens couleur Rubis), même mental model que
le frontend.
- InvoiceDocument décomposé en sous-composants Header / Addresses /
ItemsTable / Totals / Footer pour itération facile.
- Items générés depuis un pool B2B (conseil, dev, audit, formation,
livraison, photo, …) avec quantités/prix unitaires qui s'ajustent
pour que la somme matche le total TTC stocké.
Le command `seed:demo --reset` :
- wipe les invoice-pdfs/{orgId}/* sur MinIO (paginé)
- re-génère 227 PDFs (27 actionnables + 200 historiques)
- CA cumulé paid ≈ 400 K€ pile sur la cible
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
161 lines
5.6 KiB
TypeScript
161 lines
5.6 KiB
TypeScript
import { BaseCommand, 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, wipeOrgInvoicePdfs } 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,
|
|
}
|
|
|
|
@flags.string({
|
|
description: "Email du user dont on peuple l'org",
|
|
required: true,
|
|
})
|
|
declare email: string
|
|
|
|
@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. 'Arthur Barré'). Si vide, on garde le nom existant ou fallback.",
|
|
})
|
|
declare orgName?: string
|
|
|
|
async run() {
|
|
const email = this.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()
|
|
|
|
// 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
|
|
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.')
|
|
}
|
|
}
|