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:
parent
27cfa9ac13
commit
c7714e3e8a
200
apps/api/app/controllers/import_batches_controller.ts
Normal file
200
apps/api/app/controllers/import_batches_controller.ts
Normal 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('')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
13
apps/api/app/models/import_batch.ts
Normal file
13
apps/api/app/models/import_batch.ts
Normal 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>
|
||||||
|
}
|
||||||
13
apps/api/app/models/import_draft.ts
Normal file
13
apps/api/app/models/import_draft.ts
Normal 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>
|
||||||
|
}
|
||||||
117
apps/api/app/services/import_batch.ts
Normal file
117
apps/api/app/services/import_batch.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
22
apps/api/app/services/ocr/index.ts
Normal file
22
apps/api/app/services/ocr/index.ts
Normal 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()
|
||||||
|
}
|
||||||
84
apps/api/app/services/ocr/mock_ocr_provider.ts
Normal file
84
apps/api/app/services/ocr/mock_ocr_provider.ts
Normal 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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/api/app/services/ocr/ocr_provider.ts
Normal file
33
apps/api/app/services/ocr/ocr_provider.ts
Normal 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
|
||||||
|
}
|
||||||
61
apps/api/app/services/resolve_client.ts
Normal file
61
apps/api/app/services/resolve_client.ts
Normal 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 }
|
||||||
|
}
|
||||||
33
apps/api/app/transformers/import_batch_transformer.ts
Normal file
33
apps/api/app/transformers/import_batch_transformer.ts
Normal 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 }
|
||||||
29
apps/api/app/validators/import_batch.ts
Normal file
29
apps/api/app/validators/import_batch.ts
Normal 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(),
|
||||||
|
})
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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'])
|
||||||
|
|||||||
68
bruno/06-Imports/01 Upload (mock).bru
Normal file
68
bruno/06-Imports/01 Upload (mock).bru
Normal 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
|
||||||
|
}
|
||||||
28
bruno/06-Imports/02 Get batch.bru
Normal file
28
bruno/06-Imports/02 Get batch.bru
Normal 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`).
|
||||||
|
}
|
||||||
59
bruno/06-Imports/03 Validate draft.bru
Normal file
59
bruno/06-Imports/03 Validate draft.bru
Normal 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)
|
||||||
|
}
|
||||||
36
bruno/06-Imports/04 Skip draft.bru
Normal file
36
bruno/06-Imports/04 Skip draft.bru
Normal 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).
|
||||||
|
}
|
||||||
30
bruno/06-Imports/05 Cancel batch.bru
Normal file
30
bruno/06-Imports/05 Cancel batch.bru
Normal 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.
|
||||||
|
}
|
||||||
26
bruno/06-Imports/folder.bru
Normal file
26
bruno/06-Imports/folder.bru
Normal 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).
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -9,4 +9,6 @@ vars {
|
|||||||
clientId:
|
clientId:
|
||||||
planSlug: standard-30j
|
planSlug: standard-30j
|
||||||
invoiceId:
|
invoiceId:
|
||||||
|
batchId:
|
||||||
|
draftId:
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user