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>
860 lines
29 KiB
TypeScript
860 lines
29 KiB
TypeScript
/**
|
||
* Factories — créent des entités réalistes (FR, format Rubis) pour
|
||
* peupler une org de démo.
|
||
*
|
||
* 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 type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
||
import drive from '@adonisjs/drive/services/main'
|
||
|
||
import Client from '#models/client'
|
||
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)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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: '3 impasse des Artisans, 33000 Bordeaux',
|
||
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: '17 rue des Lilas, 44000 Nantes',
|
||
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,
|
||
]
|
||
|
||
/**
|
||
* 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 },
|
||
]
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<PdfInvoiceInput['seller']> {
|
||
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<string> {
|
||
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<number> {
|
||
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 = {
|
||
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']
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Activity events
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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
|
||
|
||
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 — orchestration globale du seed démo
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
*/
|
||
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). */
|
||
const TARGET_ANNUAL_REVENUE_CENTS = 40_000_000 // 400 000 €
|
||
/** Nombre de factures historiques à générer (paid sur les 12 derniers mois). */
|
||
const HISTORICAL_INVOICE_COUNT = 200
|
||
|
||
export async function seedDemoOrg(config: DemoSeedConfig): Promise<DemoSeedResult> {
|
||
const { organizationId, plans, trx } = 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'))
|
||
|
||
// 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)
|
||
const historicalTarget = Math.max(
|
||
TARGET_ANNUAL_REVENUE_CENTS - paidInActionable,
|
||
20_000_000 // garde-fou : au moins 200 K€ pour avoir des graphes lisibles
|
||
)
|
||
const historical = await seedHistoricalInvoices({
|
||
organizationId,
|
||
clients,
|
||
plans: plansWithSteps,
|
||
trx,
|
||
seller,
|
||
targetRevenueCents: historicalTarget,
|
||
invoiceCount: HISTORICAL_INVOICE_COUNT,
|
||
monthsBack: 12,
|
||
numeroOffset: actionable.invoices.length,
|
||
})
|
||
|
||
return {
|
||
clients,
|
||
invoices: [...actionable.invoices, ...historical.invoices],
|
||
rubisEarned: actionable.rubisEarned + historical.rubisEarned,
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Phase 1 — actionnables (recipe 27 factures)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type ActionableSeedConfig = {
|
||
organizationId: string
|
||
clients: Client[]
|
||
plans: Array<Plan & { steps?: Array<{ id: string; offsetDays: number; order: number }> }>
|
||
trx: TransactionClientContract
|
||
seller: PdfInvoiceInput['seller']
|
||
}
|
||
|
||
async function seedActionableInvoices(
|
||
config: ActionableSeedConfig
|
||
): Promise<DemoSeedResult> {
|
||
const { organizationId, clients, plans, trx, seller } = config
|
||
const invoices: Invoice[] = []
|
||
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 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)
|
||
return { clients, invoices, rubisEarned }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Phase 2 — historique (200 paid sur 12 mois pour alimenter les graphes)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type HistoricalSeedConfig = {
|
||
organizationId: string
|
||
clients: Client[]
|
||
plans: Array<Plan & { steps?: Array<{ id: string; offsetDays: number; order: number }> }>
|
||
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<DemoSeedResult> {
|
||
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<number>()
|
||
const cancelledCount = Math.round(config.invoiceCount * 0.05)
|
||
while (cancelledIndexes.size < cancelledCount) {
|
||
cancelledIndexes.add(randomInt(0, config.invoiceCount - 1))
|
||
}
|
||
|
||
// 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))
|
||
)
|
||
|
||
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]!
|
||
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 <b>${numero}</b> 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: `<b>${client.name}</b> 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 }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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
|
||
* effectivement passé un check-in et donc déclenché des relances. Les statuts
|
||
* `pending` et `awaiting_user_confirmation` n'en ont volontairement pas : tant
|
||
* que l'user n'a pas répondu à un check-in, AUCUNE relance n'est programmée.
|
||
*
|
||
* - pending → 0 task (en attente du tout premier check-in)
|
||
* - awaiting_user_confirmation → 0 task (check-in en cours, en attente)
|
||
* - in_relance / litigation → sent si passée, scheduled sinon
|
||
* - paid → sent jusqu'à paidAt, cancelled au-delà
|
||
*
|
||
* Pas d'enqueue BullMQ — sinon les jobs orphelins polluent Redis.
|
||
*/
|
||
async function seedRelanceTasksForInvoice(
|
||
invoice: Invoice,
|
||
plan: Plan & { steps?: Array<{ id: string; offsetDays: number; order: number }> },
|
||
trx: TransactionClientContract
|
||
): Promise<void> {
|
||
if (!plan.steps?.length) return
|
||
|
||
if (invoice.status === 'pending' || invoice.status === 'awaiting_user_confirmation') {
|
||
return
|
||
}
|
||
|
||
const now = DateTime.utc()
|
||
const paidAt = invoice.paidAt
|
||
const sortedSteps = plan.steps.slice().sort((a, b) => a.order - b.order)
|
||
|
||
for (const step of sortedSteps) {
|
||
const sendAt = invoice.dueDate.plus({ days: step.offsetDays })
|
||
|
||
let status: 'scheduled' | 'sent' | 'cancelled'
|
||
let sentAt: DateTime | null = null
|
||
|
||
if (invoice.status === 'paid' && paidAt) {
|
||
if (sendAt <= paidAt) {
|
||
status = 'sent'
|
||
sentAt = sendAt
|
||
} else {
|
||
status = 'cancelled'
|
||
}
|
||
} else {
|
||
// in_relance, litigation
|
||
if (sendAt <= now) {
|
||
status = 'sent'
|
||
sentAt = sendAt
|
||
} else {
|
||
status = 'scheduled'
|
||
}
|
||
}
|
||
|
||
await RelanceTask.create(
|
||
{
|
||
organizationId: invoice.organizationId,
|
||
invoiceId: invoice.id,
|
||
planStepId: step.id,
|
||
sendAt,
|
||
status,
|
||
sentAt,
|
||
queueJobId: null,
|
||
},
|
||
{ client: trx }
|
||
)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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)]!
|
||
}
|
||
|
||
/** Fisher-Yates partiel — sample N éléments distincts d'un array. */
|
||
function sampleN<T>(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 }
|