All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 58s
- Dockerfile.api : copie `assets/test-invoices/` dans l'image (les 27
PDFs servent au seed démo, ~80KB, négligeable).
- factories.ts : ajout d'un 3e candidat de chemin pour couvrir le
contexte prod où la commande tourne depuis `/app/apps/api/build`.
Permet de peupler une org démo en prod via :
kubectl -n rubis exec -it deploy/rubis-api -- \\
sh -c 'cd /app/apps/api/build && node ace.js seed:demo \\
--email <user> --reset --org-name="<nom>"'
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
812 lines
26 KiB
TypeScript
812 lines
26 KiB
TypeScript
/**
|
|
* 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 { readdir, readFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { existsSync } from 'node:fs'
|
|
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'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 },
|
|
]
|
|
|
|
/** 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
|
|
|
|
// Phase 1 — Actionnables : si on a des PDFs réels dans assets/test-invoices,
|
|
// on les utilise (mix pending/in_relance/paid/litigation, avec preview PDF).
|
|
// Sinon on tombe sur la recipe synthétique.
|
|
const assetsDir = resolveTestInvoicesDir()
|
|
const actionable: DemoSeedResult = assetsDir
|
|
? await seedFromAssetPdfs({ ...config, assetsDir })
|
|
: await seedSyntheticActionable(config)
|
|
|
|
// Phase 2 — Historique : ~200 factures `paid` réparties sur 12 mois pour
|
|
// alimenter les graphes (encaissé mensuel, DSO, etc.) et donner un CA
|
|
// annuel d'environ 400 K€.
|
|
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: actionable.clients,
|
|
plans,
|
|
trx,
|
|
targetRevenueCents: historicalTarget,
|
|
invoiceCount: HISTORICAL_INVOICE_COUNT,
|
|
monthsBack: 12,
|
|
})
|
|
|
|
return {
|
|
clients: actionable.clients,
|
|
invoices: [...actionable.invoices, ...historical.invoices],
|
|
rubisEarned: actionable.rubisEarned + historical.rubisEarned,
|
|
}
|
|
}
|
|
|
|
/** Recipe synthétique fallback — utilisée si pas de PDFs dans assets/. */
|
|
async function seedSyntheticActionable(
|
|
config: DemoSeedConfig
|
|
): Promise<DemoSeedResult> {
|
|
const { organizationId, plans, trx } = config
|
|
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 }))
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Recette "PDFs réels" — utilise assets/test-invoices/*.pdf
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Localise le dossier `assets/test-invoices`. La résolution couvre 3 contextes :
|
|
* - dev local : cwd = `apps/api/` → `../../assets/test-invoices`
|
|
* - prod (init) : cwd = `/app/apps/api` → `../../assets/test-invoices`
|
|
* - prod (build) : cwd = `/app/apps/api/build` → `../../../assets/test-invoices`
|
|
*/
|
|
function resolveTestInvoicesDir(): string | null {
|
|
const candidates = [
|
|
join(process.cwd(), '..', '..', 'assets', 'test-invoices'),
|
|
join(process.cwd(), '..', '..', '..', 'assets', 'test-invoices'),
|
|
join(process.cwd(), 'assets', 'test-invoices'),
|
|
]
|
|
for (const c of candidates) {
|
|
if (existsSync(c)) return c
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Mappe le suffixe descriptif d'un nom de fichier vers (status, dueOffset).
|
|
* Filenames :
|
|
* - facture-pas-en-retard-echeance-{N}j-XXX.pdf → due dans +N jours
|
|
* - facture-echue-aujourdhui-XXX.pdf → due aujourd'hui
|
|
* - facture-en-retard-{N}j-XXX.pdf → due il y a N jours
|
|
*
|
|
* Pour certaines factures très en retard, on simule un règlement tardif
|
|
* (paid) ou une mise en demeure (litigation) — ça donne un mix réaliste.
|
|
*/
|
|
type AssetSpec = {
|
|
filename: string
|
|
/** Décalage de la dueDate par rapport à aujourd'hui (en jours). */
|
|
dueOffsetDays: number
|
|
status: InvoiceStatus
|
|
}
|
|
|
|
/** Quelques numéros qu'on bascule en paid / litigation pour le mix démo. */
|
|
const PAID_OVERRIDES = new Set([
|
|
'facture-en-retard-30j-017.pdf',
|
|
'facture-en-retard-45j-019.pdf',
|
|
'facture-en-retard-60j-022.pdf',
|
|
'facture-en-retard-90j-024.pdf',
|
|
'facture-en-retard-120j-026.pdf',
|
|
])
|
|
const LITIGATION_OVERRIDES = new Set([
|
|
'facture-en-retard-120j-025.pdf',
|
|
'facture-en-retard-180j-027.pdf',
|
|
])
|
|
|
|
function parseAssetFilename(filename: string): AssetSpec | null {
|
|
// Pattern 1 : pas-en-retard-echeance-{N}j → due in +N
|
|
const futureMatch = filename.match(/pas-en-retard-echeance-(\d+)j/)
|
|
if (futureMatch) {
|
|
return {
|
|
filename,
|
|
dueOffsetDays: Number(futureMatch[1]),
|
|
status: 'pending',
|
|
}
|
|
}
|
|
// Pattern 2 : echue-aujourdhui
|
|
if (/echue-aujourdhui/.test(filename)) {
|
|
return {
|
|
filename,
|
|
dueOffsetDays: 0,
|
|
status: 'awaiting_user_confirmation',
|
|
}
|
|
}
|
|
// Pattern 3 : en-retard-{N}j → due -N
|
|
const lateMatch = filename.match(/en-retard-(\d+)j/)
|
|
if (lateMatch) {
|
|
const days = Number(lateMatch[1])
|
|
let status: InvoiceStatus = 'in_relance'
|
|
if (days <= 3) status = 'awaiting_user_confirmation'
|
|
if (PAID_OVERRIDES.has(filename)) status = 'paid'
|
|
else if (LITIGATION_OVERRIDES.has(filename)) status = 'litigation'
|
|
return {
|
|
filename,
|
|
dueOffsetDays: -days,
|
|
status,
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Extrait le numéro depuis le filename (ex. `...-007.pdf` → `F2026-0007`). */
|
|
function deriveInvoiceNumero(filename: string, fallbackYear: number): string {
|
|
const m = filename.match(/-(\d{3,})\.pdf$/)
|
|
if (!m) return `F-${fallbackYear}-${String(randomInt(1, 9999)).padStart(4, '0')}`
|
|
return `F${fallbackYear}-${m[1]!.padStart(4, '0')}`
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
|
|
// Statuts pré-check-in : aucune task à programmer.
|
|
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 }
|
|
)
|
|
}
|
|
}
|
|
|
|
type AssetSeedConfig = DemoSeedConfig & { assetsDir: string }
|
|
|
|
async function seedFromAssetPdfs(config: AssetSeedConfig): Promise<DemoSeedResult> {
|
|
const { organizationId, plans, trx, assetsDir } = config
|
|
|
|
const allFiles = await readdir(assetsDir)
|
|
const pdfs = allFiles.filter((f) => f.endsWith('.pdf')).sort()
|
|
const specs = pdfs
|
|
.map(parseAssetFilename)
|
|
.filter((s): s is AssetSpec => s !== null)
|
|
|
|
if (specs.length === 0) {
|
|
// Aucun PDF parseable : on n'aurait pas dû arriver ici.
|
|
return { clients: [], invoices: [], rubisEarned: 0 }
|
|
}
|
|
|
|
// Charge les plans avec leurs steps — on en a besoin pour seeder les
|
|
// RelanceTasks au bon `sendAt`.
|
|
const plansWithSteps = await Plan.query({ client: trx })
|
|
.whereIn(
|
|
'id',
|
|
plans.map((p) => p.id)
|
|
)
|
|
.preload('steps', (q) => q.orderBy('order', 'asc'))
|
|
|
|
// Crée tous les clients du pool — round-robin sur les factures.
|
|
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 }))
|
|
}
|
|
|
|
const invoices: Invoice[] = []
|
|
const today = DateTime.utc().startOf('day')
|
|
|
|
for (const [i, spec] of specs.entries()) {
|
|
const client = clients[i % clients.length]!
|
|
const plan = plansWithSteps[i % plansWithSteps.length] ?? null
|
|
|
|
// Upload le PDF vers le drive (MinIO en S3, fs en fallback).
|
|
const filePath = join(assetsDir, spec.filename)
|
|
const buffer = await readFile(filePath)
|
|
const storageKey = `invoice-pdfs/${organizationId}/${randomUUID()}.pdf`
|
|
await drive.use().put(storageKey, buffer)
|
|
|
|
// Dates : on cale la dueDate sur l'offset, on assume 30j de termes.
|
|
const dueDate = today.plus({ days: spec.dueOffsetDays })
|
|
const issueDate = dueDate.minus({ days: 30 })
|
|
|
|
let paidAt: DateTime | null = null
|
|
if (spec.status === 'paid') {
|
|
// Règlement après l'échéance — entre 5 et 30j de retard côté facture.
|
|
paidAt = dueDate.plus({ days: randomInt(5, 30) })
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
const invoice = await Invoice.create(
|
|
{
|
|
organizationId,
|
|
clientId: client.id,
|
|
planId: plan?.id ?? null,
|
|
numero: deriveInvoiceNumero(spec.filename, issueDate.year),
|
|
amountTtcCents: randomInt(25_000, 800_000),
|
|
issueDate,
|
|
dueDate,
|
|
paidAt,
|
|
status: spec.status,
|
|
pdfStorageKey: storageKey,
|
|
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 }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Recette "historique" — factures paid réparties sur N mois (alimente les
|
|
// graphes : encaissé mensuel, DSO, etc.). Pas de PDF, juste des données pour
|
|
// que les charts soient parlants.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type HistoricalSeedConfig = {
|
|
organizationId: string
|
|
clients: Client[]
|
|
plans: Plan[]
|
|
trx: TransactionClientContract
|
|
/** Total cents que la somme des paid doit approcher. */
|
|
targetRevenueCents: number
|
|
/** Nombre de factures à générer. */
|
|
invoiceCount: number
|
|
/** Profondeur en mois (issueDate étalée sur cette fenêtre). */
|
|
monthsBack: number
|
|
}
|
|
|
|
async function seedHistoricalInvoices(
|
|
config: HistoricalSeedConfig
|
|
): Promise<DemoSeedResult> {
|
|
const { organizationId, clients, plans, trx, monthsBack } = config
|
|
if (clients.length === 0) return { clients, invoices: [], rubisEarned: 0 }
|
|
|
|
// ~5% de cancelled pour la variété (factures annulées en cours de route).
|
|
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 des montants — log-uniforme entre 0.3 et 2.8, puis rescale
|
|
// pour matcher le CA cible. Ça donne une queue avec quelques grosses
|
|
// factures et beaucoup de petites — réaliste pour une TPE.
|
|
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 paidAmounts = 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]!
|
|
// Plans optionnels — la moitié des historiques n'en avait pas (saisie
|
|
// manuelle), pour varier.
|
|
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 paymentTerm = 30
|
|
const dueDate = issueDate.plus({ days: paymentTerm })
|
|
|
|
let amountTtcCents: number
|
|
let paidAt: DateTime | null = null
|
|
let status: InvoiceStatus
|
|
let rubisEarned = 0
|
|
|
|
if (isCancelled) {
|
|
// Cancelled : montant petit-moyen, pas de paidAt, pas de rubis.
|
|
amountTtcCents = randomInt(30_000, 200_000)
|
|
status = 'cancelled'
|
|
} else {
|
|
amountTtcCents = paidAmounts[paidIdx++]!
|
|
// Paiement entre -3j (avance) et +25j (retard) par rapport à dueDate.
|
|
paidAt = dueDate.plus({ days: randomInt(-3, 25) })
|
|
// Mais jamais après aujourd'hui.
|
|
if (paidAt > today) paidAt = today.minus({ days: randomInt(0, 5) })
|
|
status = 'paid'
|
|
// ~70% des paid ont déclenché 0-2 relances avant règlement.
|
|
rubisEarned = Math.random() < 0.7 ? randomInt(0, 2) : 0
|
|
}
|
|
|
|
const numero = `F${issueDate.year}-H${String(i + 1).padStart(4, '0')}`
|
|
|
|
const invoice = await Invoice.create(
|
|
{
|
|
organizationId,
|
|
clientId: client.id,
|
|
planId: plan?.id ?? null,
|
|
numero,
|
|
amountTtcCents,
|
|
issueDate,
|
|
dueDate,
|
|
paidAt,
|
|
status,
|
|
pdfStorageKey: null,
|
|
rubisEarned,
|
|
notes: pickRandom(INVOICE_NOTES_POOL),
|
|
},
|
|
{ client: trx }
|
|
)
|
|
invoices.push(invoice)
|
|
|
|
// Activity events minimaux pour la timeline (import + paiement).
|
|
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 }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 }
|