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:
parent
cfd3680bb4
commit
6de2711aa8
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user