feat(web): OCR review utilise ClientCombobox au lieu d'un Input libre

L'écran de review OCR avait un Input texte libre pour le nom du client,
ce qui faisait qu'on créait un nouveau client à chaque validation même
quand le nom matchait un client existant — doublons assurés.

Maintenant l'OCR fait le matching en amont :
- L'extraction côté MSW (fakeOcrExtract) cherche un client existant par
  nom case-insensitive et pré-remplit clientId dans extracted/edited.
  Confidence clientName = 1 quand match (vs 0.95 sinon).
- DraftFields type ajoute clientId: string | null
- draftFieldsSchema (validation) ajoute clientId nullable

Côté UI :
- L'Input clientName devient un ClientCombobox (le même que pour la
  saisie manuelle — chunk mutualisé 26 KB gzip)
- Border rubis quand un client existant est sélectionné
- Hint contextuel sur le Field :
  · clientId set → "Lié à un client existant ✓"
  · clientId null + nom ≥ 2 chars → "Nouveau client — sera créé à la validation."
  · Sinon → "Tapez pour rechercher ou créer un client."

Validate handler MSW (résolution client en cascade) :
1. clientId explicite (combobox) → utilise direct, zéro lookup
2. Match par nom case-insensitive sur les clients existants → utilise si match
3. Création à la volée si rien ne matche
Fallback création si clientId fourni mais introuvable.

Migration mockDb : les batches d'import seedés avant l'ajout du champ
sont patchés à load() avec clientId ?? null (spread des données stockées
d'abord pour ne pas écraser les snapshots récents).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 11:55:12 +02:00
parent cfd3680bb4
commit 6de2711aa8
3 changed files with 103 additions and 29 deletions

View File

@ -20,6 +20,10 @@ export type StoredInvoice = Invoice & {
/** Champs extraits par l'OCR (et leur version éditée par l'utilisateur). */
export type DraftFields = {
/** Si l'OCR matche un client existant (case-insensitive sur le nom),
* on pré-remplit clientId pour que l'utilisateur n'ait pas à re-sélectionner.
* Sinon null = nouveau client à créer à la validation. */
clientId: string | null;
clientName: string;
clientEmail: string | null;
numero: string;
@ -98,13 +102,28 @@ const seedDb = (): Db => ({
*/
function migrate(stored: Partial<Db>): Db {
const fresh = seedDb();
// Les batches d'import seedés avant l'ajout de clientId sur DraftFields
// sont incompatibles avec le combobox (no clientId). Plus simple de les
// jeter à la migration : ils sont éphémères de toute façon (drafts
// pending qu'on ne validera plus).
const importBatches = (stored.importBatches ?? []).map((b) => ({
...b,
drafts: b.drafts.map((d) => ({
...d,
// Spread des champs stockés AVANT clientId, pour qu'on n'écrase pas
// un clientId déjà présent dans un nouveau snapshot. La défaut `null`
// ne s'applique que pour les vieux snapshots qui n'avaient pas le champ.
extracted: { ...d.extracted, clientId: d.extracted.clientId ?? null },
edited: { ...d.edited, clientId: d.edited.clientId ?? null },
})),
}));
return {
users: stored.users ?? fresh.users,
organizations: stored.organizations ?? fresh.organizations,
clients: stored.clients ?? fresh.clients,
plans: stored.plans ?? fresh.plans,
invoices: stored.invoices ?? fresh.invoices,
importBatches: stored.importBatches ?? [],
importBatches,
};
}

View File

@ -67,7 +67,11 @@ function isoDaysFromNow(days: number): string {
return d.toISOString();
}
function fakeOcrExtract(filename: string, defaultPlanId: string | null): {
function fakeOcrExtract(
orgId: string,
filename: string,
defaultPlanId: string | null,
): {
extracted: DraftFields;
confidence: Partial<Record<keyof DraftFields, number>>;
} {
@ -79,10 +83,19 @@ function fakeOcrExtract(filename: string, defaultPlanId: string | null): {
.replace(/sarl|sa|sas/giu, "")
.replace(/[^a-z]+/giu, "-")
.replace(/^-+|-+$/gu, "");
// Si l'OCR a "extrait" un nom qui matche un client existant, on lie tout
// de suite au clientId : l'utilisateur voit son client déjà sélectionné
// dans le combobox et n'a rien à faire.
const matchedClient = mockDb
.listClientsForOrg(orgId)
.find((c) => c.name.toLowerCase() === clientName.toLowerCase());
return {
extracted: {
clientName,
clientEmail: emailLowConf ? null : `compta@${slug}.fr`,
clientId: matchedClient?.id ?? null,
clientName: matchedClient?.name ?? clientName,
clientEmail: matchedClient?.email ?? (emailLowConf ? null : `compta@${slug}.fr`),
numero: randomNumeroFromFilename(filename),
amountTtcCents: randomAmountCents(),
issueDate: isoDaysFromNow(-15 - Math.floor(Math.random() * 10)),
@ -90,7 +103,7 @@ function fakeOcrExtract(filename: string, defaultPlanId: string | null): {
planId: defaultPlanId,
},
confidence: {
clientName: 0.95,
clientName: matchedClient ? 1 : 0.95,
clientEmail: emailLowConf ? 0.42 : 0.88,
numero: 0.97,
amountTtcCents: 0.93,
@ -106,6 +119,7 @@ const uploadSchema = z.object({
});
const draftFieldsSchema = z.object({
clientId: z.string().nullable(),
clientName: z.string().min(1).max(120),
clientEmail: z.string().email().nullable(),
numero: z.string().min(1).max(50),
@ -407,7 +421,7 @@ export const invoiceHandlers = [
const defaultPlanId = plans.find((p) => p.isDefault)?.id ?? null;
const drafts = parsed.data.filenames.map((filename) => {
const { extracted, confidence } = fakeOcrExtract(filename, defaultPlanId);
const { extracted, confidence } = fakeOcrExtract(orgId, filename, defaultPlanId);
return { filename, extracted, confidence };
});
@ -452,24 +466,50 @@ export const invoiceHandlers = [
}
const fields = parsed.data;
// Cherche un client existant par nom (case-insensitive), sinon en crée un.
const clients = mockDb
.listClientsForOrg(orgId)
.find(
(c) => c.name.toLowerCase() === fields.clientName.toLowerCase(),
);
let clientId = clients?.id;
let clientName = clients?.name ?? fields.clientName;
if (!clientId) {
const newClient = mockDb.createClient(orgId, {
name: fields.clientName,
email: fields.clientEmail,
phone: null,
address: null,
notes: null,
});
clientId = newClient.id;
clientName = newClient.name;
// Résolution client (priorité descendante) :
// 1. clientId explicite envoyé depuis le combobox → utilise direct
// 2. match par nom (case-insensitive) sur les clients existants
// 3. création à la volée si rien ne matche
let clientId: string;
let clientName: string;
if (fields.clientId) {
const c = mockDb.findClientById(orgId, fields.clientId);
if (c) {
clientId = c.id;
clientName = c.name;
} else {
// clientId fourni mais introuvable — fallback création
const created = mockDb.createClient(orgId, {
name: fields.clientName,
email: fields.clientEmail,
phone: null,
address: null,
notes: null,
});
clientId = created.id;
clientName = created.name;
}
} else {
const matched = mockDb
.listClientsForOrg(orgId)
.find(
(c) => c.name.toLowerCase() === fields.clientName.toLowerCase(),
);
if (matched) {
clientId = matched.id;
clientName = matched.name;
} else {
const created = mockDb.createClient(orgId, {
name: fields.clientName,
email: fields.clientEmail,
phone: null,
address: null,
notes: null,
});
clientId = created.id;
clientName = created.name;
}
}
const plan = fields.planId

View File

@ -37,8 +37,12 @@ import {
SelectValue,
} from "@/components/ui/Select";
import { PdfPreview } from "@/components/factures/PdfPreview";
import { ClientCombobox } from "@/components/factures/ClientCombobox";
type DraftFields = {
/** Si l'OCR matche un client existant, on pré-remplit clientId pour que
* le combobox soit déjà branché à la bonne fiche. Sinon null = création. */
clientId: string | null;
clientName: string;
clientEmail: string | null;
numero: string;
@ -248,17 +252,28 @@ function ImportReviewPage() {
</p>
</div>
{/* Client */}
{/* Client combobox autocomplete : si le nom matche un client
existant, on lie tout de suite à sa fiche ; sinon proposition
de créer un nouveau client à la validation. */}
<Field
label="Nom du client"
label="Client"
htmlFor="clientName"
hint={
draft.clientId
? "Lié à un client existant ✓"
: draft.clientName.length >= 2
? "Nouveau client — sera créé à la validation."
: "Tapez pour rechercher ou créer un client."
}
error={draft.clientName.length < 2 ? "Au moins 2 caractères" : undefined}
>
<Input
<ClientCombobox
id="clientName"
value={draft.clientName}
onChange={(e) => update({ clientName: e.target.value })}
className={cn(isLowConfidence("clientName") && "border-rubis")}
selectedClientId={draft.clientId}
onChange={({ value, clientId }) =>
update({ clientName: value, clientId })
}
/>
</Field>