diff --git a/src/app/api/v1/nomenclatures/route.ts b/src/app/api/v1/nomenclatures/route.ts
new file mode 100644
index 0000000..7873dce
--- /dev/null
+++ b/src/app/api/v1/nomenclatures/route.ts
@@ -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}`,
+ },
+ });
+}
diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts
index 72467d1..65f793d 100644
--- a/src/app/api/v1/openapi.json/route.ts
+++ b/src/app/api/v1/openapi.json/route.ts
@@ -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",
diff --git a/src/features/admin/components/api-connection-info.tsx b/src/features/admin/components/api-connection-info.tsx
index a8e9db0..adff1ca 100644
--- a/src/features/admin/components/api-connection-info.tsx
+++ b/src/features/admin/components/api-connection-info.tsx
@@ -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 meta.complet confirme qu'il ne reste rien à lire :
+ Gros fournisseur (plusieurs dizaines de milliers d'articles) : lister d'abord les + postes de nomenclature, puis les traiter un par un. +
+