feat(api): inventaire des nomenclatures, pour découper un gros fournisseur

Les filtres code1/code2/code3 existaient déjà sur /grid, mais un appelant ne
pouvait pas savoir quelles valeurs existent chez un fournisseur : il aurait dû
tout télécharger pour les découvrir, ce qui annulait l'intérêt du découpage.

GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=… renvoie les
postes avec leur nombre d'articles et leur CA, agrégés en SQL — quelques
dizaines de lignes, même pour un fournisseur de 130 000 articles. `parent`
permet de descendre l'arborescence sans tout charger, et meta.filtreGrid
indique le paramètre de /grid correspondant au niveau demandé.

Le parcours devient : lister les postes, puis /grid?fournisseur=…&code1=… poste
par poste. Bien plus praticable pour une IA que 132 000 lignes d'un bloc, et
plus pertinent métier que le découpage par gamme.

Les libellés sont lus dans le payload jsonb (ils n'ont pas de colonne dédiée).

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:00:54 +00:00
1 parent 8fe294924b
commit 60aa2ce120
5 files changed
+191

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { nomenclaturesQuerySchema } from "@/lib/api-schemas";
import { listGridNomenclatures, getGridFreshness } from "@/lib/grid-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=…
*
* Répartition des articles d'un fournisseur par poste de nomenclature, avec le
* nombre d'articles et le CA de chacun.
*
* 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.
*
* `niveau=2` accepte `parent` (un code1) et `niveau=3` un `parent` (un code2),
* pour descendre l'arborescence sans tout charger.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const parsed = nomenclaturesQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const { fournisseur, niveau, parent } = parsed.data;
// Même distinction que /grid : « pas encore calculé » n'est pas « vide ».
const freshness = await getGridFreshness(fournisseur);
if (!freshness) {
return fail(
"not_ready",
`Aucun instantané pour le fournisseur « ${fournisseur} ». Appelez /api/v1/grid?fournisseur=${fournisseur} une fois pour le calculer.`,
{ fournisseur },
);
}
const postes = await listGridNomenclatures(fournisseur, niveau, parent);
const nbArticles = postes.reduce((s, p) => s + p.nbArticles, 0);
return ok(postes, {
meta: {
fournisseur,
niveau,
parent: parent ?? null,
nbPostes: postes.length,
/** Total couvert par les postes listés — à rapprocher de snapshotRowCount. */
nbArticles,
snapshotRowCount: freshness.rowCount,
snapshotComputedAt: freshness.computedAt,
/** Le paramètre de /grid à utiliser pour filtrer sur un poste de ce niveau. */
filtreGrid: `code${niveau}`,
},
});
}
+47
View File
@@ -207,6 +207,53 @@ export async function GET(req: NextRequest) {
},
},
},
"/nomenclatures": {
get: {
operationId: "listerNomenclaturesFournisseur",
summary: "Postes de nomenclature d'un fournisseur",
description:
"Répartition des articles d'un fournisseur par poste de nomenclature, avec le nombre "
+ "d'articles et le CA de chacun. **À utiliser avant /grid sur un gros fournisseur** : "
+ "certains dépassent 130 000 articles. On liste les postes (quelques dizaines de lignes), "
+ "puis on interroge /grid poste par poste avec le filtre indiqué par `meta.filtreGrid` "
+ "(code1, code2 ou code3).",
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." },
],
responses: {
"200": {
description: "Postes de nomenclature",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: {
type: "array",
items: {
type: "object",
properties: {
code: { type: "string", description: "À passer au filtre code1/code2/code3 de /grid." },
libelle: { type: ["string", "null"] },
nbArticles: { type: "integer" },
totalCa: { type: "number" },
totalQuantite: { type: "number" },
},
},
},
meta: { type: "object" },
},
},
},
},
},
"202": { description: "Fournisseur pas encore calculé", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
"/grid": {
get: {
operationId: "listerProduitsFournisseur",
@@ -43,6 +43,7 @@ function CodeLine({ children, copy }: { children: string; copy?: string }) {
const ENDPOINTS: Array<{ method: string; path: string; desc: string }> = [
{ method: "GET", path: "/fournisseurs", desc: "Fournisseurs, avec la fraîcheur de leur instantané" },
{ method: "GET", path: "/grid?fournisseur=CODE", desc: "Lignes de grille d'un fournisseur" },
{ method: "GET", path: "/nomenclatures?fournisseur=CODE", desc: "Postes de nomenclature d'un fournisseur, avec nb d'articles et CA" },
{ method: "GET", path: "/products/search?q=terme", desc: "Recherche transversale, tous fournisseurs" },
{ method: "GET", path: "/products/{codein}", desc: "Fiche complète d'un produit" },
{ method: "GET", path: "/network/{codeCentrale}", desc: "Métriques réseau Qlik + courbe 12 mois" },
@@ -127,6 +128,12 @@ export function ApiConnectionInfo() {
et <code className="font-mono">meta.complet</code> confirme qu&apos;il ne reste rien à lire :
</p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001"`}</CodeLine>
<p className="text-[11px] pt-1" style={{ color: "var(--text-muted)" }}>
Gros fournisseur (plusieurs dizaines de milliers d&apos;articles) : lister d&apos;abord les
postes de nomenclature, puis les traiter un par un.
</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}/grid?fournisseur=D005&code1=32"`}</CodeLine>
</div>
{/* Branchement d'une IA externe (ChatGPT) */}
+13
View File
@@ -88,6 +88,19 @@ export const fournisseursQuerySchema = z.object({
withData: z.enum(["0", "1"]).optional(),
});
/** `/api/v1/nomenclatures` — inventaire des postes d'un fournisseur. */
export const nomenclaturesQuerySchema = z.object({
fournisseur: z.string().min(1, "Paramètre 'fournisseur' requis"),
/**
* 1 = univers, 2 = famille, 3 = sous-famille (selon la nomenclature FF).
* Le `transform` ne fait que redonner le type littéral : les bornes ont déjà
* été validées juste avant, la valeur ne peut être que 1, 2 ou 3.
*/
niveau: z.coerce.number().int().min(1).max(3).default(1).transform((n) => n as 1 | 2 | 3),
/** Restreint à un poste parent : un code1 pour niveau=2, un code2 pour niveau=3. */
parent: z.string().min(1).optional(),
});
export const productDetailSchema = z.object({
fournisseur: z.string().min(1).optional(),
/**
+63
View File
@@ -277,3 +277,66 @@ export async function listGridSuppliers(): Promise<
computedAt: r.computedAt ? new Date(r.computedAt).toISOString() : null,
}));
}
// ---------------------------------------------------------------------------
// Inventaire des nomenclatures d'un fournisseur
// ---------------------------------------------------------------------------
/** Un poste de nomenclature, avec son poids — de quoi décider par où découper. */
export interface GridNomenclature {
code: string;
/** Libellé du poste, lu dans le payload (il n'a pas de colonne dédiée). */
libelle: string | null;
nbArticles: number;
totalCa: number;
totalQuantite: number;
}
/**
* 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.
*
* Tout est agrégé en SQL : la réponse fait quelques dizaines de lignes, même pour
* un fournisseur de 130 000 articles.
*/
export async function listGridNomenclatures(
codeFournisseur: string,
niveau: 1 | 2 | 3,
parent?: string,
): Promise<GridNomenclature[]> {
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 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} <> ''`,
];
if (parent && parentCol) clauses.push(eq(parentCol, parent));
const rows = await db
.select({
code: codeCol,
libelle: sql<string | null>`max(${gridRows.payload} ->> ${libelleKey})`,
nbArticles: sql<number>`count(*)::int`,
totalCa: sql<number>`coalesce(sum(${gridRows.totalCa}), 0)::float`,
totalQuantite: sql<number>`coalesce(sum(${gridRows.totalQuantite}), 0)::float`,
})
.from(gridRows)
.where(and(...clauses))
.groupBy(codeCol)
.orderBy(desc(sql`coalesce(sum(${gridRows.totalCa}), 0)`));
return rows.map((r) => ({
code: String(r.code ?? ""),
libelle: r.libelle ?? null,
nbArticles: Number(r.nbArticles) || 0,
totalCa: Number(r.totalCa) || 0,
totalQuantite: Number(r.totalQuantite) || 0,
}));
}