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 :

{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001"`} +

+ Gros fournisseur (plusieurs dizaines de milliers d'articles) : lister d'abord les + 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"`} {/* Branchement d'une IA externe (ChatGPT) */} diff --git a/src/lib/api-schemas.ts b/src/lib/api-schemas.ts index 21c067e..1401b52 100644 --- a/src/lib/api-schemas.ts +++ b/src/lib/api-schemas.ts @@ -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(), /** diff --git a/src/lib/grid-store.ts b/src/lib/grid-store.ts index a2c5c70..d4a7082 100644 --- a/src/lib/grid-store.ts +++ b/src/lib/grid-store.ts @@ -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 { + 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`max(${gridRows.payload} ->> ${libelleKey})`, + nbArticles: sql`count(*)::int`, + totalCa: sql`coalesce(sum(${gridRows.totalCa}), 0)::float`, + totalQuantite: sql`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, + })); +}