fix(api): nomenclatures dérivées du préfixe de code3, pas de code1/code2

Les codes de nomenclature FF font 6 chiffres et sont hiérarchiques par préfixe
(31xxxx à 40xxxx) : « 32 » univers, « 3202 » famille, « 320211 » sous-famille.

L'endpoint groupait sur les colonnes code1/code2, qui 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 et l'endpoint aurait renvoyé une liste
vide aux niveaux 1 et 2, sans rien signaler.

Les niveaux sont désormais dérivés de left(code3, 2|4|6), toujours disponible.

En conséquence, /grid gagne un filtre `nomenclature` par préfixe, qui fonctionne
aux trois niveaux et ne dépend pas non plus de code1/code2 : c'est lui que
meta.filtreGrid désigne. Les filtres code1..code3 exacts restent inchangés.

Les libellés viennent toujours du payload et peuvent être null aux niveaux 1 et
2 pour la même raison ; codes et compteurs, eux, sont exacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
Claude committed 2026-08-07 06:08:02 +00:00
1 parent 60aa2ce120
commit 0a57a9a707
6 files changed
+51 -19

No files matched your search

+1
View File
@@ -83,6 +83,7 @@ export async function GET(req: NextRequest) {
code1: q.code1, code1: q.code1,
code2: q.code2, code2: q.code2,
code3: q.code3, code3: q.code3,
nomenclature: q.nomenclature,
search: q.search, search: q.search,
sort: toSortKey(q.sort), sort: toSortKey(q.sort),
order: q.order, order: q.order,
+9 -5
View File
@@ -16,10 +16,11 @@ export const dynamic = "force-dynamic";
* C'est la porte d'entrée pour traiter un gros fournisseur : certains dépassent * 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 * 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 * 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), * Les codes sont hiérarchiques par préfixe (6 chiffres, de 31xxxx à 40xxxx) :
* pour descendre l'arborescence sans tout charger. * 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) { export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req); const authCtx = await requireApiAuth(req);
@@ -54,8 +55,11 @@ export async function GET(req: NextRequest) {
nbArticles, nbArticles,
snapshotRowCount: freshness.rowCount, snapshotRowCount: freshness.rowCount,
snapshotComputedAt: freshness.computedAt, 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",
}, },
}); });
} }
+3 -2
View File
@@ -220,7 +220,7 @@ export async function GET(req: NextRequest) {
parameters: [ parameters: [
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" } }, { 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: "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: { responses: {
"200": { "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: "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: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." },
{ name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." }, { 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: "search", in: "query", schema: { type: "string" }, description: "Filtre texte : libellé, codein, GTIN, référence ou code centrale." },
{ {
name: "compute", name: "compute",
@@ -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: "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: "sort, order", desc: "Tri, ex. sort=totalCa&order=desc" },
{ name: "search", desc: "Libellé, codein, GTIN, référence ou code centrale" }, { 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: "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: "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" }, { 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. postes de nomenclature, puis les traiter un par un.
</p> </p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/nomenclatures?fournisseur=D005&niveau=1"`}</CodeLine> <CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/nomenclatures?fournisseur=D005&niveau=1"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=D005&code1=32"`}</CodeLine> <CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=D005&nomenclature=32"`}</CodeLine>
</div> </div>
{/* Branchement d'une IA externe (ChatGPT) */} {/* Branchement d'une IA externe (ChatGPT) */}
+6
View File
@@ -46,6 +46,12 @@ export const gridQuerySchema = z.object({
code1: z.string().min(1).optional(), code1: z.string().min(1).optional(),
code2: z.string().min(1).optional(), code2: z.string().min(1).optional(),
code3: 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(), search: z.string().min(1).optional(),
/** /**
* `1` (défaut) : si le fournisseur n'a pas encore d'instantané, l'API le calcule * `1` (défaut) : si le fournisseur n'a pas encore d'instantané, l'API le calcule
+29 -10
View File
@@ -44,6 +44,8 @@ export interface GridQuery {
code1?: string; code1?: string;
code2?: string; code2?: string;
code3?: 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. */ /** Recherche libre : libellé, codein, GTIN, référence, code centrale. */
search?: string; search?: string;
sort?: GridSortKey; sort?: GridSortKey;
@@ -153,6 +155,10 @@ function buildWhere(q: GridQuery): SQL | undefined {
if (q.code1) clauses.push(eq(gridRows.code1, q.code1)); if (q.code1) clauses.push(eq(gridRows.code1, q.code1));
if (q.code2) clauses.push(eq(gridRows.code2, q.code2)); if (q.code2) clauses.push(eq(gridRows.code2, q.code2));
if (q.code3) clauses.push(eq(gridRows.code3, q.code3)); 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(); const search = q.search?.trim();
if (search) { if (search) {
const pattern = `%${search}%`; const pattern = `%${search}%`;
@@ -292,13 +298,23 @@ export interface GridNomenclature {
totalQuantite: number; 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. * Répartition des articles d'un fournisseur par niveau de nomenclature.
* *
* Sans cela, un appelant qui veut découper un gros fournisseur par 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 * est coincé : le filtre existe sur `/grid`, mais rien ne lui dit **quelles**
* **quelles** valeurs existent — il devrait tout télécharger pour les découvrir, * valeurs existent — il devrait tout télécharger pour les découvrir, ce qui
* ce qui annule le bénéfice du découpage. * 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 * Tout est agrégé en SQL : la réponse fait quelques dizaines de lignes, même pour
* un fournisseur de 130 000 articles. * un fournisseur de 130 000 articles.
@@ -308,20 +324,23 @@ export async function listGridNomenclatures(
niveau: 1 | 2 | 3, niveau: 1 | 2 | 3,
parent?: string, parent?: string,
): Promise<GridNomenclature[]> { ): Promise<GridNomenclature[]> {
const codeCol = niveau === 1 ? gridRows.code1 : niveau === 2 ? gridRows.code2 : gridRows.code3; const longueur = NOMENCLATURE_LONGUEURS[niveau];
// Les libellés ne sont pas dénormalisés en colonnes : on les lit dans le payload. const codeExpr = sql<string>`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 libelleKey = niveau === 1 ? "libelleNiveau1" : niveau === 2 ? "libelleNiveau2" : "libelle3";
const parentCol = niveau === 2 ? gridRows.code1 : niveau === 3 ? gridRows.code2 : null;
const clauses: SQL[] = [ const clauses: SQL[] = [
eq(gridRows.codeFournisseur, codeFournisseur), 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 const rows = await db
.select({ .select({
code: codeCol, code: codeExpr,
libelle: sql<string | null>`max(${gridRows.payload} ->> ${libelleKey})`, libelle: sql<string | null>`max(${gridRows.payload} ->> ${libelleKey})`,
nbArticles: sql<number>`count(*)::int`, nbArticles: sql<number>`count(*)::int`,
totalCa: sql<number>`coalesce(sum(${gridRows.totalCa}), 0)::float`, totalCa: sql<number>`coalesce(sum(${gridRows.totalCa}), 0)::float`,
@@ -329,7 +348,7 @@ export async function listGridNomenclatures(
}) })
.from(gridRows) .from(gridRows)
.where(and(...clauses)) .where(and(...clauses))
.groupBy(codeCol) .groupBy(codeExpr)
.orderBy(desc(sql`coalesce(sum(${gridRows.totalCa}), 0)`)); .orderBy(desc(sql`coalesce(sum(${gridRows.totalCa}), 0)`));
return rows.map((r) => ({ return rows.map((r) => ({