diff --git a/apps/api/app/controllers/import_batches_controller.ts b/apps/api/app/controllers/import_batches_controller.ts new file mode 100644 index 0000000..0e53891 --- /dev/null +++ b/apps/api/app/controllers/import_batches_controller.ts @@ -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 { + 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('') + } +} diff --git a/apps/api/app/controllers/invoices_controller.ts b/apps/api/app/controllers/invoices_controller.ts index 66f314d..f9f7abc 100644 --- a/apps/api/app/controllers/invoices_controller.ts +++ b/apps/api/app/controllers/invoices_controller.ts @@ -1,5 +1,4 @@ import Invoice from '#models/invoice' -import Client from '#models/client' import Plan from '#models/plan' import InvoiceTransformer from '#transformers/invoice_transformer' import { @@ -10,7 +9,7 @@ import type { HttpContext } from '@adonisjs/core/http' import { Exception } from '@adonisjs/core/exceptions' import db from '@adonisjs/lucid/services/db' import { DateTime } from 'luxon' -import type { TransactionClientContract } from '@adonisjs/lucid/types/database' +import { resolveClient } from '#services/resolve_client' const PAGE_SIZE = 50 @@ -36,56 +35,6 @@ function serializeInvoice(i: Invoice) { 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 { - 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 * 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 invoice = await db.transaction(async (trx) => { - const clientOrError = await resolveClient(organizationId, fields, trx) - if ('errorCode' in clientOrError) { - // Throw with explicit shape pour l'exception handler + const result = await resolveClient(organizationId, fields, trx) + if ('errorCode' in result) { throw new Exception( 'Email du client requis — Rubis en a besoin pour envoyer les relances.', - { status: 422, code: 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). let planId: string | null = null diff --git a/apps/api/app/models/import_batch.ts b/apps/api/app/models/import_batch.ts new file mode 100644 index 0000000..0a7c524 --- /dev/null +++ b/apps/api/app/models/import_batch.ts @@ -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 + + @hasMany(() => ImportDraft, { foreignKey: 'batchId' }) + declare drafts: HasMany +} diff --git a/apps/api/app/models/import_draft.ts b/apps/api/app/models/import_draft.ts new file mode 100644 index 0000000..0f87119 --- /dev/null +++ b/apps/api/app/models/import_draft.ts @@ -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 + + @belongsTo(() => Invoice, { foreignKey: 'invoiceId' }) + declare invoice: BelongsTo +} diff --git a/apps/api/app/services/import_batch.ts b/apps/api/app/services/import_batch.ts new file mode 100644 index 0000000..f422457 --- /dev/null +++ b/apps/api/app/services/import_batch.ts @@ -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 { + 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 + }) +} diff --git a/apps/api/app/services/ocr/index.ts b/apps/api/app/services/ocr/index.ts new file mode 100644 index 0000000..f3a155e --- /dev/null +++ b/apps/api/app/services/ocr/index.ts @@ -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() +} diff --git a/apps/api/app/services/ocr/mock_ocr_provider.ts b/apps/api/app/services/ocr/mock_ocr_provider.ts new file mode 100644 index 0000000..d750aa5 --- /dev/null +++ b/apps/api/app/services/ocr/mock_ocr_provider.ts @@ -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(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 { + 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 }, + } + } +} diff --git a/apps/api/app/services/ocr/ocr_provider.ts b/apps/api/app/services/ocr/ocr_provider.ts new file mode 100644 index 0000000..de1c3e9 --- /dev/null +++ b/apps/api/app/services/ocr/ocr_provider.ts @@ -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 +} + +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 +} diff --git a/apps/api/app/services/resolve_client.ts b/apps/api/app/services/resolve_client.ts new file mode 100644 index 0000000..29bdf96 --- /dev/null +++ b/apps/api/app/services/resolve_client.ts @@ -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 { + 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 } +} diff --git a/apps/api/app/transformers/import_batch_transformer.ts b/apps/api/app/transformers/import_batch_transformer.ts new file mode 100644 index 0000000..4eea97a --- /dev/null +++ b/apps/api/app/transformers/import_batch_transformer.ts @@ -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 { + 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 } diff --git a/apps/api/app/validators/import_batch.ts b/apps/api/app/validators/import_batch.ts new file mode 100644 index 0000000..311bf26 --- /dev/null +++ b/apps/api/app/validators/import_batch.ts @@ -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(), +}) diff --git a/apps/api/database/migrations/1778080000600_create_import_batches_table.ts b/apps/api/database/migrations/1778080000600_create_import_batches_table.ts new file mode 100644 index 0000000..de76146 --- /dev/null +++ b/apps/api/database/migrations/1778080000600_create_import_batches_table.ts @@ -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) + } +} diff --git a/apps/api/database/migrations/1778080000700_create_import_drafts_table.ts b/apps/api/database/migrations/1778080000700_create_import_drafts_table.ts new file mode 100644 index 0000000..2f475d0 --- /dev/null +++ b/apps/api/database/migrations/1778080000700_create_import_drafts_table.ts @@ -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') + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index 5d76aaa..8b49d89 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -57,6 +57,46 @@ export class ClientSchema extends BaseModel { 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 { static $columns = ['amountTtcCents', 'clientId', 'createdAt', 'dueDate', 'id', 'issueDate', 'notes', 'numero', 'organizationId', 'paidAt', 'pdfStorageKey', 'planId', 'rubisEarned', 'status', 'updatedAt'] as const $columns = InvoiceSchema.$columns diff --git a/apps/api/database/schema_rules.ts b/apps/api/database/schema_rules.ts index 3f41692..ac9ba5d 100644 --- a/apps/api/database/schema_rules.ts +++ b/apps/api/database/schema_rules.ts @@ -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 - * 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 { 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 diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index 75e5552..5bbdd88 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -84,14 +84,43 @@ router .use(middleware.auth()) /** - * Invoices — auth requise. Note : /counts doit être déclaré AVANT - * /:id (sinon `:id` matche "counts" et le param.id devient la string). + * Invoices — auth requise. Ordre IMPORTANT : les routes statiques + * (/upload, /counts, /import-batch/...) sont déclarées AVANT /:id + * sinon `:id` matche les segments littéraux. */ router .group(() => { router.get('', [controllers.Invoices, 'index']).as('index') router.post('', [controllers.Invoices, 'store']).as('store') 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 .post(':id/mark-paid', [controllers.Invoices, 'markPaid']) diff --git a/bruno/06-Imports/01 Upload (mock).bru b/bruno/06-Imports/01 Upload (mock).bru new file mode 100644 index 0000000..7f675ce --- /dev/null +++ b/bruno/06-Imports/01 Upload (mock).bru @@ -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 +} diff --git a/bruno/06-Imports/02 Get batch.bru b/bruno/06-Imports/02 Get batch.bru new file mode 100644 index 0000000..c20cca1 --- /dev/null +++ b/bruno/06-Imports/02 Get batch.bru @@ -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`). +} diff --git a/bruno/06-Imports/03 Validate draft.bru b/bruno/06-Imports/03 Validate draft.bru new file mode 100644 index 0000000..94870c3 --- /dev/null +++ b/bruno/06-Imports/03 Validate draft.bru @@ -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) +} diff --git a/bruno/06-Imports/04 Skip draft.bru b/bruno/06-Imports/04 Skip draft.bru new file mode 100644 index 0000000..634430b --- /dev/null +++ b/bruno/06-Imports/04 Skip draft.bru @@ -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). +} diff --git a/bruno/06-Imports/05 Cancel batch.bru b/bruno/06-Imports/05 Cancel batch.bru new file mode 100644 index 0000000..50d7fc5 --- /dev/null +++ b/bruno/06-Imports/05 Cancel batch.bru @@ -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. +} diff --git a/bruno/06-Imports/folder.bru b/bruno/06-Imports/folder.bru new file mode 100644 index 0000000..c44245c --- /dev/null +++ b/bruno/06-Imports/folder.bru @@ -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). +} diff --git a/bruno/README.md b/bruno/README.md index 2ba164c..3b00d96 100644 --- a/bruno/README.md +++ b/bruno/README.md @@ -30,7 +30,8 @@ Définies dans `environments/local.bru`. Les valeurs **vides** (token, userId, e | `organizationId` | rempli après Signup/Login | (info, debug) | | `clientId` | rempli après Create client | détail/update client, création facture | | `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) @@ -44,6 +45,9 @@ Définies dans `environments/local.bru`. Les valeurs **vides** (token, userId, e 8. **Invoices → 06 Mark paid** (encaisse + bonus rubis) 9. **Organizations → 01 Get my org** (vérifie `rubisCount` incrémenté) 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 diff --git a/bruno/environments/local.bru b/bruno/environments/local.bru index ad3ecb4..c7cffc1 100644 --- a/bruno/environments/local.bru +++ b/bruno/environments/local.bru @@ -9,4 +9,6 @@ vars { clientId: planSlug: standard-30j invoiceId: + batchId: + draftId: }