diff --git a/src/app/api/v1/grid/route.ts b/src/app/api/v1/grid/route.ts index ce27dcf..5409d5b 100644 --- a/src/app/api/v1/grid/route.ts +++ b/src/app/api/v1/grid/route.ts @@ -83,6 +83,7 @@ export async function GET(req: NextRequest) { code1: q.code1, code2: q.code2, code3: q.code3, + nomenclature: q.nomenclature, search: q.search, sort: toSortKey(q.sort), order: q.order, diff --git a/src/app/api/v1/nomenclatures/route.ts b/src/app/api/v1/nomenclatures/route.ts index 7873dce..c760406 100644 --- a/src/app/api/v1/nomenclatures/route.ts +++ b/src/app/api/v1/nomenclatures/route.ts @@ -16,10 +16,11 @@ export const dynamic = "force-dynamic"; * C'est la porte d'entrée pour traiter un gros fournisseur : certains dépassent * 130 000 articles, et tout récupérer d'un coup n'a de sens ni pour une IA ni * pour une application. On liste d'abord les postes — quelques dizaines de - * lignes — puis on interroge `/grid?fournisseur=…&code1=…` poste par poste. + * lignes — puis on interroge `/grid?fournisseur=…&nomenclature=…` poste par poste. * - * `niveau=2` accepte `parent` (un code1) et `niveau=3` un `parent` (un code2), - * pour descendre l'arborescence sans tout charger. + * Les codes sont hiérarchiques par préfixe (6 chiffres, de 31xxxx à 40xxxx) : + * niveau 1 = « 32 », niveau 2 = « 3202 », niveau 3 = « 320211 ». `parent` accepte + * un préfixe pour descendre l'arborescence sans tout charger. */ export async function GET(req: NextRequest) { const authCtx = await requireApiAuth(req); @@ -54,8 +55,11 @@ export async function GET(req: NextRequest) { nbArticles, snapshotRowCount: freshness.rowCount, snapshotComputedAt: freshness.computedAt, - /** Le paramètre de /grid à utiliser pour filtrer sur un poste de ce niveau. */ - filtreGrid: `code${niveau}`, + /** + * Le paramètre de /grid à utiliser avec les `code` renvoyés ci-dessus. + * `nomenclature` filtre par préfixe et fonctionne aux trois niveaux. + */ + filtreGrid: "nomenclature", }, }); } diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts index 65f793d..2004b98 100644 --- a/src/app/api/v1/openapi.json/route.ts +++ b/src/app/api/v1/openapi.json/route.ts @@ -220,7 +220,7 @@ export async function GET(req: NextRequest) { parameters: [ { name: "fournisseur", in: "query", required: true, schema: { type: "string" } }, { name: "niveau", in: "query", schema: { type: "integer", enum: [1, 2, 3], default: 1 }, description: "1 = univers, 2 = famille, 3 = sous-famille." }, - { name: "parent", in: "query", schema: { type: "string" }, description: "Restreint à un poste parent : un code1 si niveau=2, un code2 si niveau=3." }, + { name: "parent", in: "query", schema: { type: "string" }, description: "Préfixe du poste parent, ex. « 32 » pour lister ses familles." }, ], responses: { "200": { @@ -269,7 +269,8 @@ export async function GET(req: NextRequest) { { name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme (A, B, C, D, Z)." }, { name: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." }, { name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." }, - { name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3." }, + { name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3 (code exact)." }, + { name: "nomenclature", in: "query", schema: { type: "string" }, description: "Préfixe de nomenclature renvoyé par listerNomenclaturesFournisseur : 32, 3202 ou 320211. À préférer à code1/code2, qui peuvent être vides." }, { name: "search", in: "query", schema: { type: "string" }, description: "Filtre texte : libellé, codein, GTIN, référence ou code centrale." }, { name: "compute", diff --git a/src/features/admin/components/api-connection-info.tsx b/src/features/admin/components/api-connection-info.tsx index adff1ca..0fae02c 100644 --- a/src/features/admin/components/api-connection-info.tsx +++ b/src/features/admin/components/api-connection-info.tsx @@ -54,7 +54,8 @@ const PARAMS: Array<{ name: string; desc: string }> = [ { name: "page, limit", desc: "Pagination, sans plafond. Sur /grid, omettre limit renvoie TOUT le fournisseur en un appel ; ailleurs le défaut est 100" }, { name: "sort, order", desc: "Tri, ex. sort=totalCa&order=desc" }, { name: "search", desc: "Libellé, codein, GTIN, référence ou code centrale" }, - { name: "gamme, code1..code3", desc: "Filtres sur la gamme et la nomenclature" }, + { name: "nomenclature", desc: "Préfixe de nomenclature : 32 (univers), 3202 (famille), 320211 (sous-famille)" }, + { name: "gamme, code1..code3", desc: "Filtres sur la gamme et les codes exacts de nomenclature" }, { name: "fields", desc: "Champs à conserver, séparés par des virgules — allège fortement la réponse" }, { name: "enrich", desc: "1 par défaut : métriques Qlik + gamme serveur relues à l'appel. 0 pour s'en dispenser" }, { name: "compute", desc: "1 par défaut : calcule le fournisseur s'il n'a jamais été ouvert (premier appel plus lent). 0 pour échouer vite" }, @@ -133,7 +134,7 @@ export function ApiConnectionInfo() { postes de nomenclature, puis les traiter un par un.

{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/nomenclatures?fournisseur=D005&niveau=1"`} - {`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=D005&code1=32"`} + {`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=D005&nomenclature=32"`} {/* Branchement d'une IA externe (ChatGPT) */} diff --git a/src/lib/api-schemas.ts b/src/lib/api-schemas.ts index 1401b52..2b815ea 100644 --- a/src/lib/api-schemas.ts +++ b/src/lib/api-schemas.ts @@ -46,6 +46,12 @@ export const gridQuerySchema = z.object({ code1: z.string().min(1).optional(), code2: z.string().min(1).optional(), code3: z.string().min(1).optional(), + /** + * Préfixe de nomenclature (« 32 », « 3202 », « 320211 »), tel que renvoyé par + * /api/v1/nomenclatures. À préférer à code1/code2, qui peuvent être vides + * quand la hiérarchie n'a pas pu être remontée depuis la base FF. + */ + nomenclature: z.string().min(1).max(20).regex(/^[0-9]+$/, "La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)").optional(), search: z.string().min(1).optional(), /** * `1` (défaut) : si le fournisseur n'a pas encore d'instantané, l'API le calcule diff --git a/src/lib/grid-store.ts b/src/lib/grid-store.ts index d4a7082..2394bfb 100644 --- a/src/lib/grid-store.ts +++ b/src/lib/grid-store.ts @@ -44,6 +44,8 @@ export interface GridQuery { code1?: string; code2?: string; code3?: string; + /** Préfixe de nomenclature (« 32 », « 3202 », « 320211 ») — filtre sur code3. */ + nomenclature?: string; /** Recherche libre : libellé, codein, GTIN, référence, code centrale. */ search?: string; sort?: GridSortKey; @@ -153,6 +155,10 @@ function buildWhere(q: GridQuery): SQL | undefined { if (q.code1) clauses.push(eq(gridRows.code1, q.code1)); if (q.code2) clauses.push(eq(gridRows.code2, q.code2)); if (q.code3) clauses.push(eq(gridRows.code3, q.code3)); + // Filtre par **préfixe** de nomenclature : « 32 », « 3202 » ou « 320211 ». + // C'est celui à utiliser avec /api/v1/nomenclatures, car il ne dépend pas de + // code1/code2 — parfois vides quand la hiérarchie n'a pas pu être remontée. + if (q.nomenclature) clauses.push(sql`${gridRows.code3} like ${q.nomenclature + "%"}`); const search = q.search?.trim(); if (search) { const pattern = `%${search}%`; @@ -292,13 +298,23 @@ export interface GridNomenclature { totalQuantite: number; } +/** Longueur du code pour chaque niveau : « 32 » → « 3202 » → « 320211 ». */ +export const NOMENCLATURE_LONGUEURS: Record<1 | 2 | 3, number> = { 1: 2, 2: 4, 3: 6 }; + /** * Répartition des articles d'un fournisseur par niveau de nomenclature. * * Sans cela, un appelant qui veut découper un gros fournisseur par nomenclature - * est coincé : les filtres `code1..3` existent sur `/grid`, mais rien ne lui dit - * **quelles** valeurs existent — il devrait tout télécharger pour les découvrir, - * ce qui annule le bénéfice du découpage. + * est coincé : le filtre existe sur `/grid`, mais rien ne lui dit **quelles** + * valeurs existent — il devrait tout télécharger pour les découvrir, ce qui + * annule le bénéfice du découpage. + * + * Les niveaux sont dérivés par **préfixe de `code3`** (codes hiérarchiques à 6 + * chiffres, de 31xxxx à 40xxxx), et non des colonnes `code1`/`code2`. Celles-ci + * ne sont renseignées que si `pgGetNomenclatureByFournisseur` a su remonter la + * hiérarchie, ce qui dépend d'une détection heuristique de la colonne parent de + * la table `nomenclature` ; quand elle échoue, seul `code3` est rempli. Le + * préfixe, lui, est toujours disponible. * * Tout est agrégé en SQL : la réponse fait quelques dizaines de lignes, même pour * un fournisseur de 130 000 articles. @@ -308,20 +324,23 @@ export async function listGridNomenclatures( niveau: 1 | 2 | 3, parent?: string, ): Promise { - const codeCol = niveau === 1 ? gridRows.code1 : niveau === 2 ? gridRows.code2 : gridRows.code3; - // Les libellés ne sont pas dénormalisés en colonnes : on les lit dans le payload. + const longueur = NOMENCLATURE_LONGUEURS[niveau]; + const codeExpr = sql`left(${gridRows.code3}, ${longueur})`; + // Les libellés n'ont pas de colonne dédiée : on les lit dans le payload. + // Aux niveaux 1 et 2 ils peuvent être absents (même cause que ci-dessus) ; + // le code et les compteurs, eux, restent justes. const libelleKey = niveau === 1 ? "libelleNiveau1" : niveau === 2 ? "libelleNiveau2" : "libelle3"; - const parentCol = niveau === 2 ? gridRows.code1 : niveau === 3 ? gridRows.code2 : null; const clauses: SQL[] = [ eq(gridRows.codeFournisseur, codeFournisseur), - sql`${codeCol} is not null and ${codeCol} <> ''`, + sql`${gridRows.code3} is not null and length(${gridRows.code3}) >= ${longueur}`, ]; - if (parent && parentCol) clauses.push(eq(parentCol, parent)); + // `parent` est lui aussi un préfixe : « 32 » pour descendre au niveau 2. + if (parent) clauses.push(sql`${gridRows.code3} like ${parent + "%"}`); const rows = await db .select({ - code: codeCol, + code: codeExpr, libelle: sql`max(${gridRows.payload} ->> ${libelleKey})`, nbArticles: sql`count(*)::int`, totalCa: sql`coalesce(sum(${gridRows.totalCa}), 0)::float`, @@ -329,7 +348,7 @@ export async function listGridNomenclatures( }) .from(gridRows) .where(and(...clauses)) - .groupBy(codeCol) + .groupBy(codeExpr) .orderBy(desc(sql`coalesce(sum(${gridRows.totalCa}), 0)`)); return rows.map((r) => ({