Migrations :
- import_batches (uuid id, organization_id FK CASCADE)
- import_drafts (uuid id, batch_id FK CASCADE, filename, pdf_storage_key nullable, extracted/edited/confidence en jsonb, status ENUM PG natif pending/validated/skipped, invoice_id FK SET NULL)
Schema rules : tape précisément extracted/edited/confidence (sinon `any`) + status enum.
Services :
- OcrProvider : interface (storageKey + filename → champs avec confiance par champ)
- MockOcrProvider : génère des champs plausibles depuis le filename (numero parsed via regex, montants random multiples de 50cts, dates ISO décalées) + 30 % de cas avec emails à confiance basse pour simuler la review UX
- getOcrProvider() : sélectionne via OCR_PROVIDER env var (default mock, mistral en attente d'ADR-020)
- createImportBatchFromFilenames : compose extracted/edited/confidence par draft, tente un match client immédiat (case-insensitive sur le nom) pour pré-remplir clientId
- resolveClient extrait dans un service partagé (3 priorités : clientId → match nom → création + email requis), réutilisé par invoices_controller et import_batches_controller
Endpoints (auth + scope par organization) :
- POST /invoices/upload : V1 mock body { filenames[] }, 201 → ImportBatch avec ses drafts. Multipart upload réel quand Mistral arrivera, contrat de réponse identique.
- GET /invoices/import-batch/:id : poll pendant la review
- POST /invoices/import-batch/:id/drafts/:draftId/validate : crée Invoice (résolution client) + draft.status=validated + draft.invoiceId
- POST .../drafts/:draftId/skip : draft.status=skipped (idempotent)
- DELETE /invoices/import-batch/:id : CASCADE drop drafts, les invoices validées restent
Routes : ordre soigné — /upload, /counts, /import-batch/* AVANT /:id pour éviter le shadowing.
Bruno : nouveau dossier 06-Imports avec 5 requêtes documentées + capture batchId/draftId dans l'env local. README mis à jour avec le parcours étendu (étapes 11-13).
341 lines
9.8 KiB
TypeScript
341 lines
9.8 KiB
TypeScript
import Invoice from '#models/invoice'
|
|
import Plan from '#models/plan'
|
|
import InvoiceTransformer from '#transformers/invoice_transformer'
|
|
import {
|
|
createInvoiceValidator,
|
|
listInvoicesValidator,
|
|
} from '#validators/invoice'
|
|
import type { HttpContext } from '@adonisjs/core/http'
|
|
import { Exception } from '@adonisjs/core/exceptions'
|
|
import db from '@adonisjs/lucid/services/db'
|
|
import { DateTime } from 'luxon'
|
|
import { resolveClient } from '#services/resolve_client'
|
|
|
|
const PAGE_SIZE = 50
|
|
|
|
// Priorité d'affichage côté liste : ce qui est actionnable d'abord.
|
|
const STATUS_PRIORITY: Record<string, number> = {
|
|
awaiting_user_confirmation: 0,
|
|
in_relance: 1,
|
|
pending: 2,
|
|
litigation: 3,
|
|
paid: 4,
|
|
cancelled: 5,
|
|
}
|
|
|
|
function requireOrgId(auth: HttpContext['auth']): string {
|
|
const user = auth.getUserOrFail()
|
|
if (!user.organizationId) {
|
|
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
|
|
}
|
|
return user.organizationId
|
|
}
|
|
|
|
function serializeInvoice(i: Invoice) {
|
|
return new InvoiceTransformer(i).toObject()
|
|
}
|
|
|
|
/**
|
|
* Construit la timeline d'une facture en composant les étapes du plan
|
|
* avec l'état courant (V1 simplifié — les RelanceTask viendront plus tard).
|
|
*
|
|
* - étapes dont sendDay <= aujourd'hui : 'past' (envoyées)
|
|
* - étape actuelle (la prochaine future) : 'current'
|
|
* - étapes futures : 'future'
|
|
*/
|
|
function buildTimeline(invoice: Invoice): Array<{
|
|
id: string
|
|
state: 'past' | 'current' | 'future'
|
|
when: string
|
|
what: string
|
|
}> {
|
|
const events: Array<{
|
|
id: string
|
|
state: 'past' | 'current' | 'future'
|
|
when: string
|
|
what: string
|
|
}> = [
|
|
{
|
|
id: `${invoice.id}__issued`,
|
|
state: 'past',
|
|
when: `${formatShortDate(invoice.issueDate)} · facture émise`,
|
|
what: 'Importée',
|
|
},
|
|
]
|
|
|
|
if (
|
|
invoice.plan?.steps?.length &&
|
|
invoice.status !== 'paid' &&
|
|
invoice.status !== 'cancelled'
|
|
) {
|
|
const dueMs = invoice.dueDate.toMillis()
|
|
const nowMs = DateTime.now().toMillis()
|
|
let currentSet = false
|
|
|
|
for (const step of invoice.plan.steps.slice().sort((a, b) => a.order - b.order)) {
|
|
const sendMs = dueMs + step.offsetDays * 24 * 60 * 60 * 1000
|
|
const stepDate = DateTime.fromMillis(sendMs)
|
|
const labelStep = `J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} — Étape ${step.order + 1}`
|
|
|
|
let state: 'past' | 'current' | 'future'
|
|
if (sendMs < nowMs) state = 'past'
|
|
else if (!currentSet) {
|
|
state = 'current'
|
|
currentSet = true
|
|
} else state = 'future'
|
|
|
|
events.push({
|
|
id: `${invoice.id}__step_${step.order}`,
|
|
state,
|
|
when: `${formatShortDate(stepDate)} · ${labelStep}`,
|
|
what:
|
|
state === 'past'
|
|
? `Email envoyé · "${step.subject.replace('{{numero}}', invoice.numero)}"`
|
|
: `Email programmé · "${step.subject.replace('{{numero}}', invoice.numero)}"`,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (invoice.status === 'paid' && invoice.paidAt) {
|
|
events.push({
|
|
id: `${invoice.id}__paid`,
|
|
state: 'past',
|
|
when: `${formatShortDate(invoice.paidAt)} · facture encaissée`,
|
|
what: 'Marquée encaissée — relances stoppées',
|
|
})
|
|
}
|
|
|
|
return events
|
|
}
|
|
|
|
function formatShortDate(d: DateTime): string {
|
|
return d.toFormat('dd/LL/yyyy')
|
|
}
|
|
|
|
export default class InvoicesController {
|
|
/**
|
|
* GET /invoices?status=&q=&clientId=&page=
|
|
*/
|
|
async index({ auth, request, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
const filters = await request.validateUsing(listInvoicesValidator)
|
|
|
|
const query = Invoice.query()
|
|
.where('organization_id', organizationId)
|
|
.preload('client')
|
|
.preload('plan')
|
|
|
|
if (filters.status && filters.status !== 'all') {
|
|
query.where('status', filters.status)
|
|
}
|
|
if (filters.clientId) {
|
|
query.where('client_id', filters.clientId)
|
|
}
|
|
if (filters.q) {
|
|
const q = filters.q.toLowerCase()
|
|
query.where((b) => {
|
|
b.whereILike('numero', `%${q}%`).orWhereExists((sub) => {
|
|
sub
|
|
.from('clients')
|
|
.whereColumn('clients.id', 'invoices.client_id')
|
|
.whereILike('clients.name', `%${q}%`)
|
|
})
|
|
})
|
|
}
|
|
|
|
const invoices = await query.exec()
|
|
|
|
// Tri : actionnable d'abord (status priority), puis échéance croissante.
|
|
invoices.sort((a, b) => {
|
|
const dp = (STATUS_PRIORITY[a.status] ?? 99) - (STATUS_PRIORITY[b.status] ?? 99)
|
|
if (dp !== 0) return dp
|
|
return a.dueDate.toMillis() - b.dueDate.toMillis()
|
|
})
|
|
|
|
// Pagination simple en V1 (cf. backend.md §6 — cursor-based plus tard).
|
|
const page = filters.page ?? 1
|
|
const total = invoices.length
|
|
const sliced = invoices.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
|
|
|
return response.json({
|
|
data: sliced.map(serializeInvoice),
|
|
meta: { total, page },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* GET /invoices/counts — compteurs par statut pour les chips dashboard.
|
|
*/
|
|
async counts({ auth, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
|
|
const rows = await db
|
|
.from('invoices')
|
|
.where('organization_id', organizationId)
|
|
.select('status')
|
|
.count('* as count')
|
|
.groupBy('status')
|
|
|
|
const counts = {
|
|
all: 0,
|
|
pending: 0,
|
|
in_relance: 0,
|
|
awaiting_user_confirmation: 0,
|
|
paid: 0,
|
|
litigation: 0,
|
|
cancelled: 0,
|
|
}
|
|
for (const r of rows) {
|
|
const c = Number(r.count)
|
|
counts.all += c
|
|
const s = r.status as keyof typeof counts
|
|
if (s in counts) counts[s] = c
|
|
}
|
|
|
|
return response.json({ data: counts })
|
|
}
|
|
|
|
/**
|
|
* GET /invoices/:id — détail enrichi (client + plan + timeline).
|
|
*/
|
|
async show({ auth, params, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
|
|
const invoice = await Invoice.query()
|
|
.where('organization_id', organizationId)
|
|
.where('id', params.id)
|
|
.preload('client')
|
|
.preload('plan', (q) => q.preload('steps'))
|
|
.first()
|
|
|
|
if (!invoice) {
|
|
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
|
|
}
|
|
|
|
const data = serializeInvoice(invoice)
|
|
return response.json({
|
|
data: {
|
|
...data,
|
|
client: invoice.client && {
|
|
id: invoice.client.id,
|
|
name: invoice.client.name,
|
|
email: invoice.client.email,
|
|
phone: invoice.client.phone,
|
|
address: invoice.client.address,
|
|
siret: invoice.client.siret,
|
|
},
|
|
plan: invoice.plan && {
|
|
id: invoice.plan.id,
|
|
slug: invoice.plan.slug,
|
|
name: invoice.plan.name,
|
|
steps: (invoice.plan.steps ?? [])
|
|
.slice()
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((s) => ({
|
|
id: s.id,
|
|
order: s.order,
|
|
offsetDays: s.offsetDays,
|
|
tone: s.tone,
|
|
subject: s.subject,
|
|
body: s.body,
|
|
requiresManualValidation: s.requiresManualValidation,
|
|
})),
|
|
},
|
|
timeline: buildTimeline(invoice),
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* POST /invoices — saisie manuelle.
|
|
*/
|
|
async store({ auth, request, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
const fields = await request.validateUsing(createInvoiceValidator)
|
|
|
|
const invoice = await db.transaction(async (trx) => {
|
|
const result = await resolveClient(organizationId, fields, trx)
|
|
if ('errorCode' in result) {
|
|
throw new Exception(
|
|
'Email du client requis — Rubis en a besoin pour envoyer les relances.',
|
|
{ status: 422, code: result.errorCode }
|
|
)
|
|
}
|
|
const client = result.client
|
|
|
|
// Vérification plan (s'il est fourni, doit appartenir à l'org).
|
|
let planId: string | null = null
|
|
if (fields.planId) {
|
|
const plan = await Plan.query({ client: trx })
|
|
.where('organization_id', organizationId)
|
|
.where('id', fields.planId)
|
|
.first()
|
|
if (plan) planId = plan.id
|
|
}
|
|
|
|
return Invoice.create(
|
|
{
|
|
organizationId,
|
|
clientId: client.id,
|
|
planId,
|
|
numero: fields.numero,
|
|
amountTtcCents: fields.amountTtcCents,
|
|
issueDate: DateTime.fromISO(fields.issueDate),
|
|
dueDate: DateTime.fromISO(fields.dueDate),
|
|
status: 'pending',
|
|
rubisEarned: 1, // bonus saisie initiale (cf. CLAUDE.md → glossaire)
|
|
pdfStorageKey: null,
|
|
notes: null,
|
|
paidAt: null,
|
|
},
|
|
{ client: trx }
|
|
)
|
|
})
|
|
|
|
await invoice.load('client')
|
|
await invoice.load('plan')
|
|
|
|
return response.status(201).json({ data: serializeInvoice(invoice) })
|
|
}
|
|
|
|
/**
|
|
* POST /invoices/:id/mark-paid
|
|
* Marque encaissée + bonus +1 rubis (à la fois sur invoice.rubisEarned
|
|
* et sur organization.rubisCount).
|
|
*/
|
|
async markPaid({ auth, params, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
|
|
const invoice = await Invoice.query()
|
|
.where('organization_id', organizationId)
|
|
.where('id', params.id)
|
|
.preload('client')
|
|
.preload('plan')
|
|
.first()
|
|
|
|
if (!invoice) {
|
|
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
|
|
}
|
|
if (invoice.status === 'paid') {
|
|
// Idempotent : déjà payée, on renvoie l'état courant sans bumper.
|
|
return response.json({ data: serializeInvoice(invoice) })
|
|
}
|
|
|
|
await db.transaction(async (trx) => {
|
|
invoice.useTransaction(trx)
|
|
invoice.status = 'paid'
|
|
invoice.paidAt = DateTime.now()
|
|
invoice.rubisEarned = invoice.rubisEarned + 1
|
|
await invoice.save()
|
|
|
|
// Bump du compteur agrégé sur l'organisation
|
|
await trx
|
|
.from('organizations')
|
|
.where('id', organizationId)
|
|
.increment('rubis_count', 1)
|
|
})
|
|
|
|
return response.json({ data: serializeInvoice(invoice) })
|
|
}
|
|
}
|