feat(api): import OCR (batch + drafts) avec MockOcrProvider

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).
This commit is contained in:
ordinarthur 2026-05-06 14:51:37 +02:00
parent 27cfa9ac13
commit c7714e3e8a
24 changed files with 1040 additions and 61 deletions

View File

@ -0,0 +1,200 @@
import ImportBatch from '#models/import_batch'
import Invoice from '#models/invoice'
import Plan from '#models/plan'
import ImportBatchTransformer, {
serializeDraft,
} from '#transformers/import_batch_transformer'
import InvoiceTransformer from '#transformers/invoice_transformer'
import {
uploadValidator,
validateDraftValidator,
} from '#validators/import_batch'
import { resolveClient } from '#services/resolve_client'
import { createImportBatchFromFilenames } from '#services/import_batch'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
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 serializeBatch(b: ImportBatch) {
return new ImportBatchTransformer(b).toObject()
}
async function loadBatchOrFail(organizationId: string, id: string): Promise<ImportBatch> {
const batch = await ImportBatch.query()
.where('organization_id', organizationId)
.where('id', id)
.preload('drafts', (q) => q.orderBy('created_at', 'asc'))
.first()
if (!batch) {
throw new Exception('Batch introuvable', { status: 404, code: 'not_found' })
}
return batch
}
export default class ImportBatchesController {
/**
* POST /invoices/upload démarre un batch OCR.
*
* V1 mock : accepte un body JSON `{ filenames: [...] }` (pas de fichier
* réel). Le service appelle le MockOcrProvider qui invente des champs
* plausibles. Quand on aura Mistral, on basculera sur multipart.
*/
async upload({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const { filenames } = await request.validateUsing(uploadValidator)
const batch = await createImportBatchFromFilenames(organizationId, filenames)
return response.status(201).json({ data: serializeBatch(batch) })
}
/**
* GET /invoices/import-batch/:id état courant d'un batch.
* Le SPA poll cet endpoint pendant la review (drafts pending validated/skipped).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const batch = await loadBatchOrFail(organizationId, params.id)
return response.json({ data: serializeBatch(batch) })
}
/**
* POST /invoices/import-batch/:id/drafts/:draftId/validate
*
* L'utilisateur valide un draft → on crée l'Invoice avec les champs
* éventuellement édités. Même logique de résolution client que POST
* /invoices (clientId match nom création + email requis).
*/
async validateDraft({ auth, params, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const fields = await request.validateUsing(validateDraftValidator)
const batch = await loadBatchOrFail(organizationId, params.id)
const draft = batch.drafts.find((d) => d.id === params.draftId)
if (!draft) {
throw new Exception('Brouillon introuvable', { status: 404, code: 'not_found' })
}
if (draft.status !== 'pending') {
throw new Exception('Brouillon déjà traité', {
status: 409,
code: 'draft_already_processed',
})
}
const invoice = await db.transaction(async (trx) => {
const result = await resolveClient(
organizationId,
{
clientId: fields.clientId,
clientName: fields.clientName,
clientEmail: fields.clientEmail,
},
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
// Plan : si 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
}
const created = await 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 import OCR (cf. CLAUDE.md → glossaire)
pdfStorageKey: draft.pdfStorageKey,
notes: null,
paidAt: null,
},
{ client: trx }
)
draft.useTransaction(trx)
draft.status = 'validated'
draft.edited = {
clientId: client.id,
clientName: client.name,
clientEmail: client.email,
numero: fields.numero,
amountTtcCents: fields.amountTtcCents,
issueDate: fields.issueDate,
dueDate: fields.dueDate,
planId,
}
draft.invoiceId = created.id
await draft.save()
return created
})
await invoice.load('client')
await invoice.load('plan')
return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() })
}
/**
* POST /invoices/import-batch/:id/drafts/:draftId/skip
* Marque un brouillon comme skippé (l'utilisateur ne veut pas le valider).
*/
async skipDraft({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const batch = await loadBatchOrFail(organizationId, params.id)
const draft = batch.drafts.find((d) => d.id === params.draftId)
if (!draft) {
throw new Exception('Brouillon introuvable', { status: 404, code: 'not_found' })
}
if (draft.status === 'validated') {
throw new Exception('Brouillon déjà validé', {
status: 409,
code: 'draft_already_processed',
})
}
if (draft.status !== 'skipped') {
draft.status = 'skipped'
await draft.save()
}
return response.json({ data: serializeDraft(draft) })
}
/**
* DELETE /invoices/import-batch/:id annule le batch entier.
* CASCADE supprime les drafts. Les invoices validées (si y'en a déjà)
* restent intactes, le FK draft.invoice_id est SET NULL côté ImportDraft.
*/
async destroy({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const batch = await loadBatchOrFail(organizationId, params.id)
await batch.delete()
return response.status(204).send('')
}
}

View File

@ -1,5 +1,4 @@
import Invoice from '#models/invoice' import Invoice from '#models/invoice'
import Client from '#models/client'
import Plan from '#models/plan' import Plan from '#models/plan'
import InvoiceTransformer from '#transformers/invoice_transformer' import InvoiceTransformer from '#transformers/invoice_transformer'
import { import {
@ -10,7 +9,7 @@ import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions' import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db' import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database' import { resolveClient } from '#services/resolve_client'
const PAGE_SIZE = 50 const PAGE_SIZE = 50
@ -36,56 +35,6 @@ function serializeInvoice(i: Invoice) {
return new InvoiceTransformer(i).toObject() return new InvoiceTransformer(i).toObject()
} }
/**
* Résolution client à la création de facture.
*
* Priorité :
* 1. clientId fourni utilise tel quel (combobox a sélectionné une fiche).
* 2. match par nom (case-insensitive) sur les clients existants.
* 3. création à la volée email REQUIS (sans email pas de relance possible).
*/
async function resolveClient(
organizationId: string,
fields: {
clientId?: string
clientName: string
clientEmail?: string | null
},
trx: TransactionClientContract
): Promise<Client | { errorCode: 'client_email_required' }> {
if (fields.clientId) {
const c = await Client.query({ client: trx })
.where('organization_id', organizationId)
.where('id', fields.clientId)
.first()
if (c) return c
}
const matched = await Client.query({ client: trx })
.where('organization_id', organizationId)
.whereILike('name', fields.clientName)
.first()
if (matched) return matched
// Création à la volée : email obligatoire.
if (!fields.clientEmail) {
return { errorCode: 'client_email_required' }
}
return Client.create(
{
organizationId,
name: fields.clientName,
email: fields.clientEmail,
phone: null,
address: null,
siret: null,
notes: null,
},
{ client: trx }
)
}
/** /**
* Construit la timeline d'une facture en composant les étapes du plan * Construit la timeline d'une facture en composant les étapes du plan
* avec l'état courant (V1 simplifié les RelanceTask viendront plus tard). * avec l'état courant (V1 simplifié les RelanceTask viendront plus tard).
@ -305,15 +254,14 @@ export default class InvoicesController {
const fields = await request.validateUsing(createInvoiceValidator) const fields = await request.validateUsing(createInvoiceValidator)
const invoice = await db.transaction(async (trx) => { const invoice = await db.transaction(async (trx) => {
const clientOrError = await resolveClient(organizationId, fields, trx) const result = await resolveClient(organizationId, fields, trx)
if ('errorCode' in clientOrError) { if ('errorCode' in result) {
// Throw with explicit shape pour l'exception handler
throw new Exception( throw new Exception(
'Email du client requis — Rubis en a besoin pour envoyer les relances.', 'Email du client requis — Rubis en a besoin pour envoyer les relances.',
{ status: 422, code: clientOrError.errorCode } { status: 422, code: result.errorCode }
) )
} }
const client = clientOrError const client = result.client
// Vérification plan (s'il est fourni, doit appartenir à l'org). // Vérification plan (s'il est fourni, doit appartenir à l'org).
let planId: string | null = null let planId: string | null = null

View File

@ -0,0 +1,13 @@
import { ImportBatchSchema } from '#database/schema'
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
import ImportDraft from '#models/import_draft'
export default class ImportBatch extends ImportBatchSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
@hasMany(() => ImportDraft, { foreignKey: 'batchId' })
declare drafts: HasMany<typeof ImportDraft>
}

View File

@ -0,0 +1,13 @@
import { ImportDraftSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import ImportBatch from '#models/import_batch'
import Invoice from '#models/invoice'
export default class ImportDraft extends ImportDraftSchema {
@belongsTo(() => ImportBatch, { foreignKey: 'batchId' })
declare batch: BelongsTo<typeof ImportBatch>
@belongsTo(() => Invoice, { foreignKey: 'invoiceId' })
declare invoice: BelongsTo<typeof Invoice>
}

View File

@ -0,0 +1,117 @@
import db from '@adonisjs/lucid/services/db'
import ImportBatch from '#models/import_batch'
import ImportDraft from '#models/import_draft'
import Client from '#models/client'
import Plan from '#models/plan'
import { getOcrProvider } from '#services/ocr/index'
import type { OcrResult } from '#services/ocr/ocr_provider'
export type DraftFields = {
clientId: string | null
clientName: string
clientEmail: string | null
numero: string
amountTtcCents: number
issueDate: string
dueDate: string
planId: string | null
}
export type DraftConfidence = Partial<{
clientId: number
clientName: number
clientEmail: number
numero: number
amountTtcCents: number
issueDate: number
dueDate: number
planId: number
}>
/**
* Compose les champs `extracted` (DraftFields) + `confidence` à partir
* du résultat OCR brut. Tente un match client immédiat (case-insensitive
* sur le nom) pour pré-remplir clientId l'utilisateur n'a rien à faire
* dans le combobox si ça matche.
*/
async function buildDraftFromOcr(
organizationId: string,
ocr: OcrResult,
defaultPlanId: string | null
): Promise<{ extracted: DraftFields; confidence: DraftConfidence }> {
const matchedClient = await Client.query()
.where('organization_id', organizationId)
.whereILike('name', ocr.fields.clientName.value)
.first()
return {
extracted: {
clientId: matchedClient?.id ?? null,
clientName: matchedClient?.name ?? ocr.fields.clientName.value,
clientEmail: matchedClient?.email ?? ocr.fields.clientEmail.value,
numero: ocr.fields.numero.value,
amountTtcCents: ocr.fields.amountTtcCents.value,
issueDate: ocr.fields.issueDate.value,
dueDate: ocr.fields.dueDate.value,
planId: defaultPlanId,
},
confidence: {
clientName: matchedClient ? 1 : ocr.fields.clientName.confidence,
clientEmail: ocr.fields.clientEmail.confidence,
numero: ocr.fields.numero.confidence,
amountTtcCents: ocr.fields.amountTtcCents.confidence,
issueDate: ocr.fields.issueDate.confidence,
dueDate: ocr.fields.dueDate.confidence,
planId: 1,
},
}
}
/**
* Crée un batch + N drafts à partir d'une liste de filenames (V1 mock).
* Quand on aura le vrai upload multipart + MinIO, cette fonction prendra
* `Array<{ filename, storageKey }>` à la place.
*/
export async function createImportBatchFromFilenames(
organizationId: string,
filenames: string[]
): Promise<ImportBatch> {
const ocr = getOcrProvider()
// Plan par défaut = premier `is_default` de l'org (provisionné au signup).
const defaultPlan = await Plan.query()
.where('organization_id', organizationId)
.where('is_default', true)
.orderBy('name', 'asc')
.first()
return db.transaction(async (trx) => {
const batch = await ImportBatch.create({ organizationId }, { client: trx })
for (const filename of filenames) {
const result = await ocr.extract({ storageKey: null, filename })
const { extracted, confidence } = await buildDraftFromOcr(
organizationId,
result,
defaultPlan?.id ?? null
)
await ImportDraft.create(
{
batchId: batch.id,
filename,
pdfStorageKey: null,
extracted,
edited: { ...extracted },
confidence,
status: 'pending',
invoiceId: null,
},
{ client: trx }
)
}
await batch.load('drafts')
return batch
})
}

View File

@ -0,0 +1,22 @@
import env from '#start/env'
import type { OcrProvider } from '#services/ocr/ocr_provider'
import { MockOcrProvider } from '#services/ocr/mock_ocr_provider'
/**
* Résout l'implémentation OCR à utiliser selon OCR_PROVIDER.
*
* - `mock` (default) : MockOcrProvider, données plausibles depuis filename.
* - `mistral` : à brancher (cf. ADR-020). Pour l'instant on fallback sur mock
* avec un warning pour ne pas casser le boot quand la clé n'est pas posée.
*/
export function getOcrProvider(): OcrProvider {
const provider = env.get('OCR_PROVIDER', 'mock')
if (provider === 'mistral') {
// TODO: implémenter MistralOcrProvider quand la clé API est dispo.
// En attendant, on log et on fallback sur mock.
console.warn(
'[ocr] OCR_PROVIDER=mistral mais MistralOcrProvider pas implémenté — fallback sur mock'
)
}
return new MockOcrProvider()
}

View File

@ -0,0 +1,84 @@
import type { OcrProvider, OcrResult } from '#services/ocr/ocr_provider'
const SAMPLE_CLIENT_NAMES = [
'Boulangerie Martin SARL',
'Atelier Durand',
'Cabinet Rousseau',
'Garage Lemoine',
'Studio Lefèvre',
'Pharmacie Bertrand',
'Imprimerie Moreau',
] as const
function rand<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!
}
function randomAmountCents(): number {
// entre 80 € et 8 000 €, multiple de 50 cts pour rester crédible
return Math.floor(((Math.random() * 7920 + 80) * 100) / 50) * 50
}
function numeroFromFilename(filename: string): string {
const match = filename.match(/(\d{2,5})/u)
const yr = new Date().getFullYear()
const seq = match?.[1] ?? Math.floor(Math.random() * 9000 + 1000).toString()
return `F-${yr}-${seq.padStart(4, '0')}`
}
function isoDaysFromNow(days: number): string {
const d = new Date()
d.setDate(d.getDate() + days)
d.setHours(9, 0, 0, 0)
return d.toISOString()
}
function slugify(s: string): string {
return s
.toLowerCase()
.replace(/sarl|sa|sas/giu, '')
.replace(/[^a-z]+/giu, '-')
.replace(/^-+|-+$/gu, '')
}
/**
* Implémentation OCR mock : génère des champs plausibles depuis le filename
* + injecte volontairement quelques confidences basses (~30 %) pour signaler
* "champ douteux" dans l'UI de review.
*
* Aucun appel réseau, aucun PDF téléchargé. Quand Mistral arrive, on swap
* cette classe via le container Adonis sans toucher au reste.
*/
export class MockOcrProvider implements OcrProvider {
async extract(input: {
storageKey: string | null
filename: string
}): Promise<OcrResult> {
const clientName = rand(SAMPLE_CLIENT_NAMES)
// 30 % de chance d'avoir un email douteux (low confidence) — déclenche
// la pastille "à vérifier" dans la UI de review.
const emailLowConf = Math.random() < 0.3
const email = emailLowConf ? null : `compta@${slugify(clientName)}.fr`
return {
fields: {
clientName: { value: clientName, confidence: 0.95 },
clientEmail: {
value: email,
confidence: emailLowConf ? 0.42 : 0.88,
},
numero: { value: numeroFromFilename(input.filename), confidence: 0.97 },
amountTtcCents: { value: randomAmountCents(), confidence: 0.93 },
issueDate: {
value: isoDaysFromNow(-15 - Math.floor(Math.random() * 10)),
confidence: 0.9,
},
dueDate: {
value: isoDaysFromNow(15 + Math.floor(Math.random() * 20)),
confidence: emailLowConf ? 0.65 : 0.92,
},
},
rawProviderResponse: { provider: 'mock', filename: input.filename },
}
}
}

View File

@ -0,0 +1,33 @@
/**
* Interface OCR abstraction switchable (cf. backend.md §11.1).
*
* Implémentations :
* - MockOcrProvider : retour plausible depuis le filename, pour les démos
* et le dev sans Mistral. C'est le default en V1 (OCR_PROVIDER=mock).
* - MistralOcrProvider : à venir (ADR-020), appel API Mistral avec PDF
* téléchargé depuis MinIO.
*/
export interface OcrProvider {
extract(input: { storageKey: string | null; filename: string }): Promise<OcrResult>
}
export type OcrFieldName =
| 'clientName'
| 'clientEmail'
| 'numero'
| 'amountTtcCents'
| 'issueDate'
| 'dueDate'
export type OcrResult = {
fields: {
clientName: { value: string; confidence: number }
clientEmail: { value: string | null; confidence: number }
numero: { value: string; confidence: number }
amountTtcCents: { value: number; confidence: number }
issueDate: { value: string; confidence: number } // ISO 8601
dueDate: { value: string; confidence: number }
}
/** Trace brute du provider — utile pour debug / re-process / audit. */
rawProviderResponse?: unknown
}

View File

@ -0,0 +1,61 @@
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import Client from '#models/client'
export type ResolveClientInput = {
clientId?: string | null
clientName: string
clientEmail?: string | null
}
export type ResolveClientResult =
| { client: Client; created: boolean }
| { errorCode: 'client_email_required' }
/**
* Résolution client à la création de facture / validation d'import OCR.
*
* Priorité (mêmes règles côté API que côté MSW) :
* 1. `clientId` fourni + existant dans l'org utilise tel quel.
* 2. Match par nom (case-insensitive) sur les clients de l'org.
* 3. Création à la volée `clientEmail` REQUIS, sinon
* `{ errorCode: 'client_email_required' }`.
*
* Le contrôleur appelant transforme l'erreur en HTTP 422 avec le code stable.
*/
export async function resolveClient(
organizationId: string,
fields: ResolveClientInput,
trx: TransactionClientContract
): Promise<ResolveClientResult> {
if (fields.clientId) {
const c = await Client.query({ client: trx })
.where('organization_id', organizationId)
.where('id', fields.clientId)
.first()
if (c) return { client: c, created: false }
}
const matched = await Client.query({ client: trx })
.where('organization_id', organizationId)
.whereILike('name', fields.clientName)
.first()
if (matched) return { client: matched, created: false }
if (!fields.clientEmail) {
return { errorCode: 'client_email_required' }
}
const created = await Client.create(
{
organizationId,
name: fields.clientName,
email: fields.clientEmail,
phone: null,
address: null,
siret: null,
notes: null,
},
{ client: trx }
)
return { client: created, created: true }
}

View File

@ -0,0 +1,33 @@
import type ImportBatch from '#models/import_batch'
import type ImportDraft from '#models/import_draft'
import { BaseTransformer } from '@adonisjs/core/transformers'
function serializeDraft(d: ImportDraft) {
return {
id: d.id,
filename: d.filename,
pdfStorageKey: d.pdfStorageKey,
extracted: d.extracted,
edited: d.edited,
confidence: d.confidence,
status: d.status,
invoiceId: d.invoiceId,
createdAt: d.createdAt.toISO()!,
updatedAt: d.updatedAt?.toISO() ?? d.createdAt.toISO()!,
}
}
export default class ImportBatchTransformer extends BaseTransformer<ImportBatch> {
toObject() {
const b = this.resource
return {
id: b.id,
organizationId: b.organizationId,
drafts: (b.drafts ?? []).map(serializeDraft),
createdAt: b.createdAt.toISO()!,
updatedAt: b.updatedAt?.toISO() ?? b.createdAt.toISO()!,
}
}
}
export { serializeDraft }

View File

@ -0,0 +1,29 @@
import vine from '@vinejs/vine'
/**
* POST /invoices/upload V1 mock.
*
* Accepte un tableau de filenames (pas de fichiers réels). Quand on
* branchera Mistral + MinIO, on switchera sur multipart `files[]` avec
* upload effectif des PDFs. Le contrat côté SPA reste le même.
*/
export const uploadValidator = vine.create({
filenames: vine.array(vine.string().minLength(1).maxLength(500)).minLength(1).maxLength(20),
})
/**
* POST /invoices/import-batch/:id/drafts/:draftId/validate.
*
* Le SPA envoie les `edited` finaux (peut différer de `extracted` si
* l'utilisateur a corrigé). On les normalise puis on crée l'invoice.
*/
export const validateDraftValidator = vine.create({
clientId: vine.string().uuid().nullable(),
clientName: vine.string().minLength(1).maxLength(120),
clientEmail: vine.string().email().nullable(),
numero: vine.string().minLength(1).maxLength(50),
amountTtcCents: vine.number().min(1),
issueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
dueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
planId: vine.string().uuid().nullable(),
})

View File

@ -0,0 +1,26 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'import_batches'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table
.uuid('organization_id')
.notNullable()
.references('id')
.inTable('organizations')
.onDelete('CASCADE')
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
table.index(['organization_id'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,55 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'import_drafts'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table
.uuid('batch_id')
.notNullable()
.references('id')
.inTable('import_batches')
.onDelete('CASCADE')
table.string('filename', 500).notNullable()
// Clé MinIO du PDF source. Null en V1 mock (pas de fichier réel).
table.string('pdf_storage_key', 500).nullable()
// Champs extraits par l'OCR + version éditée par l'utilisateur +
// confiance par champ (0-1). jsonb pour les 3 — flexibles, indexables
// si besoin via GIN.
table.jsonb('extracted').notNullable()
table.jsonb('edited').notNullable()
table.jsonb('confidence').notNullable().defaultTo('{}')
table
.enum('status', ['pending', 'validated', 'skipped'], {
useNative: true,
enumName: 'import_draft_status',
})
.notNullable()
.defaultTo('pending')
// Si validé, on lie l'invoice créée. SET NULL si l'invoice est purgée
// (cas très limite, mais propre).
table
.uuid('invoice_id')
.nullable()
.references('id')
.inTable('invoices')
.onDelete('SET NULL')
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
table.index(['batch_id'])
})
}
async down() {
this.schema.dropTable(this.tableName)
this.schema.raw('DROP TYPE IF EXISTS import_draft_status')
}
}

View File

@ -57,6 +57,46 @@ export class ClientSchema extends BaseModel {
declare updatedAt: DateTime | null declare updatedAt: DateTime | null
} }
export class ImportBatchSchema extends BaseModel {
static $columns = ['createdAt', 'id', 'organizationId', 'updatedAt'] as const
$columns = ImportBatchSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: string
@column()
declare organizationId: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class ImportDraftSchema extends BaseModel {
static $columns = ['batchId', 'confidence', 'createdAt', 'edited', 'extracted', 'filename', 'id', 'invoiceId', 'pdfStorageKey', 'status', 'updatedAt'] as const
$columns = ImportDraftSchema.$columns
@column()
declare batchId: string
@column()
declare confidence: Partial<{ clientId: number; clientName: number; clientEmail: number; numero: number; amountTtcCents: number; issueDate: number; dueDate: number; planId: number }>
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare edited: { clientId: string | null; clientName: string; clientEmail: string | null; numero: string; amountTtcCents: number; issueDate: string; dueDate: string; planId: string | null }
@column()
declare extracted: { clientId: string | null; clientName: string; clientEmail: string | null; numero: string; amountTtcCents: number; issueDate: string; dueDate: string; planId: string | null }
@column()
declare filename: string
@column({ isPrimary: true })
declare id: string
@column()
declare invoiceId: string | null
@column()
declare pdfStorageKey: string | null
@column()
declare status: 'pending' | 'validated' | 'skipped'
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class InvoiceSchema extends BaseModel { export class InvoiceSchema extends BaseModel {
static $columns = ['amountTtcCents', 'clientId', 'createdAt', 'dueDate', 'id', 'issueDate', 'notes', 'numero', 'organizationId', 'paidAt', 'pdfStorageKey', 'planId', 'rubisEarned', 'status', 'updatedAt'] as const static $columns = ['amountTtcCents', 'clientId', 'createdAt', 'dueDate', 'id', 'issueDate', 'notes', 'numero', 'organizationId', 'paidAt', 'pdfStorageKey', 'planId', 'rubisEarned', 'status', 'updatedAt'] as const
$columns = InvoiceSchema.$columns $columns = InvoiceSchema.$columns

View File

@ -2,7 +2,7 @@ import { type SchemaRules } from '@adonisjs/lucid/types/schema_generator'
/** /**
* Override de types pour les colonnes que Lucid n'arrive pas à inférer * Override de types pour les colonnes que Lucid n'arrive pas à inférer
* depuis l'introspection PG (ex. ENUMs natifs tapés `any` par défaut). * depuis l'introspection PG (ex. ENUMs natifs et jsonb tapés `any`).
*/ */
export default { export default {
tables: { tables: {
@ -21,5 +21,28 @@ export default {
}, },
}, },
}, },
import_drafts: {
columns: {
status: {
tsType: "'pending' | 'validated' | 'skipped'",
},
// jsonb des champs extraits par l'OCR. La forme est garantie par
// le validator Vine côté entrée et par le service côté seed.
extracted: {
tsType:
"{ clientId: string | null; clientName: string; clientEmail: string | null; numero: string; amountTtcCents: number; issueDate: string; dueDate: string; planId: string | null }",
},
edited: {
tsType:
"{ clientId: string | null; clientName: string; clientEmail: string | null; numero: string; amountTtcCents: number; issueDate: string; dueDate: string; planId: string | null }",
},
// Confiance par champ — 0..1. Partial parce que l'OCR n'a pas
// toujours toutes les confiances.
confidence: {
tsType:
"Partial<{ clientId: number; clientName: number; clientEmail: number; numero: number; amountTtcCents: number; issueDate: number; dueDate: number; planId: number }>",
},
},
},
}, },
} satisfies SchemaRules } satisfies SchemaRules

View File

@ -84,14 +84,43 @@ router
.use(middleware.auth()) .use(middleware.auth())
/** /**
* Invoices auth requise. Note : /counts doit être déclaré AVANT * Invoices auth requise. Ordre IMPORTANT : les routes statiques
* /:id (sinon `:id` matche "counts" et le param.id devient la string). * (/upload, /counts, /import-batch/...) sont déclarées AVANT /:id
* sinon `:id` matche les segments littéraux.
*/ */
router router
.group(() => { .group(() => {
router.get('', [controllers.Invoices, 'index']).as('index') router.get('', [controllers.Invoices, 'index']).as('index')
router.post('', [controllers.Invoices, 'store']).as('store') router.post('', [controllers.Invoices, 'store']).as('store')
router.get('counts', [controllers.Invoices, 'counts']).as('counts') router.get('counts', [controllers.Invoices, 'counts']).as('counts')
// OCR / Import batch (cf. ImportBatchesController)
router.post('upload', [controllers.ImportBatches, 'upload']).as('upload')
router
.get('import-batch/:id', [controllers.ImportBatches, 'show'])
.as('import-batch.show')
.where('id', router.matchers.uuid())
router
.post('import-batch/:id/drafts/:draftId/validate', [
controllers.ImportBatches,
'validateDraft',
])
.as('import-batch.draft.validate')
.where('id', router.matchers.uuid())
.where('draftId', router.matchers.uuid())
router
.post('import-batch/:id/drafts/:draftId/skip', [
controllers.ImportBatches,
'skipDraft',
])
.as('import-batch.draft.skip')
.where('id', router.matchers.uuid())
.where('draftId', router.matchers.uuid())
router
.delete('import-batch/:id', [controllers.ImportBatches, 'destroy'])
.as('import-batch.destroy')
.where('id', router.matchers.uuid())
router.get(':id', [controllers.Invoices, 'show']).as('show').where('id', router.matchers.uuid()) router.get(':id', [controllers.Invoices, 'show']).as('show').where('id', router.matchers.uuid())
router router
.post(':id/mark-paid', [controllers.Invoices, 'markPaid']) .post(':id/mark-paid', [controllers.Invoices, 'markPaid'])

View File

@ -0,0 +1,68 @@
meta {
name: 01 Upload (mock)
type: http
seq: 1
}
post {
url: {{baseUrl}}/api/v1/invoices/upload
body: json
auth: inherit
}
body:json {
{
"filenames": [
"facture-martin-042.pdf",
"atelier-durand-2026-039.pdf",
"studio-lefevre-12.pdf"
]
}
}
script:post-response {
if (res.getStatus() === 201) {
const batch = res.getBody().data;
bru.setEnvVar("batchId", batch.id);
if (batch.drafts && batch.drafts.length > 0) {
// Premier draft pending pour les requêtes "validate" / "skip"
const firstPending = batch.drafts.find(d => d.status === "pending") || batch.drafts[0];
bru.setEnvVar("draftId", firstPending.id);
}
}
}
tests {
test("201 Created", function () {
expect(res.getStatus()).to.equal(201);
});
test("3 drafts créés", function () {
expect(res.getBody().data.drafts).to.have.lengthOf(3);
});
test("Chaque draft a extracted + edited + confidence", function () {
const d = res.getBody().data.drafts[0];
expect(d).to.have.property("extracted");
expect(d).to.have.property("edited");
expect(d).to.have.property("confidence");
expect(d.status).to.equal("pending");
});
}
docs {
POST /api/v1/invoices/upload
V1 mock : on envoie un body JSON `{ filenames: [...] }` (pas de fichier
réel). Le service crée un ImportBatch + 1 ImportDraft par filename, en
appelant le `MockOcrProvider` qui invente des champs plausibles depuis
le nom du fichier.
Quand Mistral sera branché : on basculera sur multipart `files[]` avec
upload effectif vers MinIO. Le contrat de réponse reste identique.
Capture `batchId` et `draftId` (le 1er pending) pour les requêtes
suivantes.
Validation :
- 1 à 20 filenames
- Chaque filename ≤ 500 chars
}

View File

@ -0,0 +1,28 @@
meta {
name: 02 Get batch
type: http
seq: 2
}
get {
url: {{baseUrl}}/api/v1/invoices/import-batch/{{batchId}}
body: none
auth: inherit
}
tests {
test("200 OK", function () {
expect(res.getStatus()).to.equal(200);
});
test("drafts triés par created_at asc", function () {
expect(res.getBody().data.drafts).to.be.an("array");
});
}
docs {
GET /api/v1/invoices/import-batch/:id
Retourne l'état courant du batch et de ses drafts. Le SPA poll cet
endpoint pendant la review pour suivre la progression (drafts qui
passent de `pending` à `validated` ou `skipped`).
}

View File

@ -0,0 +1,59 @@
meta {
name: 03 Validate draft
type: http
seq: 3
}
post {
url: {{baseUrl}}/api/v1/invoices/import-batch/{{batchId}}/drafts/{{draftId}}/validate
body: json
auth: inherit
}
body:json {
{
"clientId": null,
"clientName": "Atelier Durand",
"clientEmail": "compta@atelier-durand.fr",
"numero": "F-2026-0039",
"amountTtcCents": 360000,
"issueDate": "2026-04-01T09:00:00.000Z",
"dueDate": "2026-05-01T09:00:00.000Z",
"planId": null
}
}
script:post-response {
if (res.getStatus() === 201) {
bru.setEnvVar("invoiceId", res.getBody().data.id);
}
}
tests {
test("201 Created", function () {
expect(res.getStatus()).to.equal(201);
});
test("invoice rubisEarned = 1 (bonus import)", function () {
expect(res.getBody().data.rubisEarned).to.equal(1);
});
}
docs {
POST /api/v1/invoices/import-batch/:id/drafts/:draftId/validate
L'utilisateur a (potentiellement édité puis) validé un draft → on crée
l'Invoice avec les champs envoyés.
Résolution client identique à POST /invoices (cf. service
`resolveClient`) :
1. clientId fourni → utilise tel quel
2. match par nom (case-insensitive) sur les clients existants
3. création à la volée → clientEmail REQUIS sinon 422 `client_email_required`
Le draft passe `pending` → `validated` et capture l'`invoiceId`.
Erreurs :
- 404 not_found (batch ou draft inexistant)
- 409 draft_already_processed (déjà validated/skipped)
- 422 client_email_required (création de client sans email)
}

View File

@ -0,0 +1,36 @@
meta {
name: 04 Skip draft
type: http
seq: 4
}
post {
url: {{baseUrl}}/api/v1/invoices/import-batch/{{batchId}}/drafts/{{draftId}}/skip
body: none
auth: inherit
}
tests {
test("200 OK", function () {
expect(res.getStatus()).to.equal(200);
});
test("status = skipped", function () {
expect(res.getBody().data.status).to.equal("skipped");
});
}
docs {
POST /api/v1/invoices/import-batch/:id/drafts/:draftId/skip
Marque le brouillon `skipped` (l'utilisateur ne veut pas le créer).
Idempotent — refait le call sur un draft déjà skipped retourne 200
avec le draft tel quel.
Erreur : 409 draft_already_processed si le draft est déjà `validated`
(on ne peut pas skip une facture qui existe déjà).
⚠️ Cette requête utilise le même `draftId` que la précédente. Si tu
viens de valider ce draft (statut `validated`), tu auras un 409. Pour
tester le skip, mets un autre `draftId` (regarde la réponse de
"01 Upload (mock)" et copie l'id d'un autre draft).
}

View File

@ -0,0 +1,30 @@
meta {
name: 05 Cancel batch
type: http
seq: 5
}
delete {
url: {{baseUrl}}/api/v1/invoices/import-batch/{{batchId}}
body: none
auth: inherit
}
tests {
test("204 No Content", function () {
expect(res.getStatus()).to.equal(204);
});
}
docs {
DELETE /api/v1/invoices/import-batch/:id
Annule le batch entier. CASCADE en DB → tous les drafts du batch sont
supprimés.
Les invoices déjà créées via Validate restent en place (le FK
draft.invoice_id est en SET NULL côté schema).
Cas typique d'usage : l'utilisateur clique "Tout annuler" depuis
l'écran de review OCR avant d'avoir validé quoi que ce soit.
}

View File

@ -0,0 +1,26 @@
meta {
name: Imports
seq: 7
}
auth {
mode: bearer
}
auth:bearer {
token: {{token}}
}
docs {
## Import OCR — batch + drafts
Flow :
1. POST /invoices/upload (V1 mock : `{ filenames: [...] }`) → crée un batch + N drafts. Le `MockOcrProvider` invente des champs plausibles depuis le filename.
2. GET /invoices/import-batch/:id → état courant du batch (le SPA poll pendant la review).
3. Pour chaque draft `pending` :
- POST .../drafts/:draftId/validate → crée l'Invoice (résolution client identique à POST /invoices) → marque le draft `validated`
- POST .../drafts/:draftId/skip → marque le draft `skipped`
4. DELETE /invoices/import-batch/:id → annule le batch entier (cascade sur les drafts ; les invoices déjà créées restent).
⚠️ V1 : OCR_PROVIDER=mock. Mistral arrive en commit séparé (cf. ADR-020).
}

View File

@ -30,7 +30,8 @@ Définies dans `environments/local.bru`. Les valeurs **vides** (token, userId, e
| `organizationId` | rempli après Signup/Login | (info, debug) | | `organizationId` | rempli après Signup/Login | (info, debug) |
| `clientId` | rempli après Create client | détail/update client, création facture | | `clientId` | rempli après Create client | détail/update client, création facture |
| `planSlug` | en dur (`standard-30j`) | détail/update plan | | `planSlug` | en dur (`standard-30j`) | détail/update plan |
| `invoiceId` | rempli après Create invoice | détail/mark-paid | | `invoiceId` | rempli après Create invoice OU Validate draft | détail/mark-paid |
| `batchId` / `draftId` | remplis après Upload (mock) | Get batch / Validate / Skip / Cancel |
## Parcours recommandé (premier run) ## Parcours recommandé (premier run)
@ -44,6 +45,9 @@ Définies dans `environments/local.bru`. Les valeurs **vides** (token, userId, e
8. **Invoices → 06 Mark paid** (encaisse + bonus rubis) 8. **Invoices → 06 Mark paid** (encaisse + bonus rubis)
9. **Organizations → 01 Get my org** (vérifie `rubisCount` incrémenté) 9. **Organizations → 01 Get my org** (vérifie `rubisCount` incrémenté)
10. **Clients → 02 List with stats** (vérifie les compteurs) 10. **Clients → 02 List with stats** (vérifie les compteurs)
11. **Imports → 01 Upload (mock)** (capture `batchId` + `draftId`)
12. **Imports → 02 Get batch** (review des drafts pending)
13. **Imports → 03 Validate draft** (transforme le draft en facture)
## Reset entre runs ## Reset entre runs

View File

@ -9,4 +9,6 @@ vars {
clientId: clientId:
planSlug: standard-30j planSlug: standard-30j
invoiceId: invoiceId:
batchId:
draftId:
} }