mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 17:57:09 +02:00
feat(api): API prête pour une IA externe type ChatGPT
Adapte l'API CollectFlow aux exigences des Actions ChatGPT et garantit que les données soient réellement disponibles pour un consommateur externe. Schéma OpenAPI : - openapi.json devient PUBLIC (sans clé). Il ne décrit que la structure, sans aucune donnée, or ChatGPT importe le schéma par URL avant que la clé ne soit configurée : l'exiger rendait l'import impossible. - servers[0].url est désormais ABSOLUE (une URL relative est rejetée à l'import), construite depuis l'hôte appelant ou COLLECTFLOW_PUBLIC_URL. - operationId sur chaque opération (requis par les Actions), schémas de réponse typés, et descriptions rédigées pour le modèle : quelle gamme utiliser pour raisonner, pourquoi préférer caParMagasinReseau au CA brut, que faire d'un 202 not_ready. Disponibilité des données : - Nouveau préchauffage /api/admin/grid-warmup + bouton dans Paramètres. Sans lui, l'API ne sert que les fournisseurs déjà ouverts à la main dans la Grille — une IA externe n'aurait presque rien vu. Le job calcule tous les fournisseurs séquentiellement (paralléliser saturerait PostgreSQL), saute ceux à jour depuis moins de 24 h et suit son avancement. Documentation : - Paramètres → marche à suivre pas à pas pour brancher un GPT (import du schéma, auth par clé personnalisée X-API-Key), et mention de COLLECTFLOW_PUBLIC_URL quand le domaine public diffère. L'assistant interne et son API api.ffnancy.fr ne sont pas touchés. Vérifié sur PostgreSQL local : schéma servi sans clé en 200, URL absolue, 5 operationId, auth apiKey/X-API-Key, surcharge COLLECTFLOW_PUBLIC_URL effective, 5 endpoints en 200 avec la clé, 401 JSON sans clé, et recherche transversale renvoyant bien plusieurs fournisseurs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
5 files changed
+529
-91
No files matched your search
@@ -0,0 +1,144 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
import { listGridSuppliers } from "@/lib/grid-store";
|
||||
|
||||
// Le préchauffage parcourt tous les fournisseurs : c'est long par nature. On rend la
|
||||
// main immédiatement et le client interroge GET pour suivre l'avancement.
|
||||
export const maxDuration = 300;
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Préchauffage de l'instantané de grille (`grid_rows`).
|
||||
*
|
||||
* L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est
|
||||
* ouverte dans l'application. Sans préchauffage, un consommateur externe (ChatGPT) ne
|
||||
* verrait donc que les fournisseurs déjà consultés à la main.
|
||||
*
|
||||
* Ce job calcule chaque fournisseur une fois, séquentiellement — la persistance se fait
|
||||
* en effet de bord de `getProductRows()`. Séquentiel volontairement : le calcul est
|
||||
* lourd en SQL, le paralléliser saturerait PostgreSQL.
|
||||
*/
|
||||
type WarmupStatus = "idle" | "running" | "success" | "error";
|
||||
|
||||
interface WarmupJob {
|
||||
status: WarmupStatus;
|
||||
total: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
currentFournisseur?: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
lastErrors: string[];
|
||||
}
|
||||
|
||||
let job: WarmupJob | null = null;
|
||||
|
||||
function publicJob(j: WarmupJob | null) {
|
||||
if (!j) {
|
||||
return { status: "idle" as const, total: 0, done: 0, skipped: 0, failed: 0, lastErrors: [] };
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runWarmup(j: WarmupJob, staleHours: number): Promise<void> {
|
||||
try {
|
||||
const fournisseurs = await pgGetFournisseurs();
|
||||
// Les fournisseurs déjà calculés récemment sont sautés : un second passage
|
||||
// coûte alors quasiment rien, ce qui rend le bouton rejouable sans crainte.
|
||||
const existing = await listGridSuppliers();
|
||||
const freshLimit = Date.now() - staleHours * 3600_000;
|
||||
const fresh = new Set(
|
||||
existing
|
||||
.filter((s) => s.computedAt && new Date(s.computedAt).getTime() > freshLimit)
|
||||
.map((s) => s.codeFournisseur),
|
||||
);
|
||||
|
||||
j.total = fournisseurs.length;
|
||||
console.log(`[grid-warmup] ${fournisseurs.length} fournisseurs, ${fresh.size} déjà à jour (< ${staleHours}h)`);
|
||||
|
||||
for (const f of fournisseurs) {
|
||||
if (fresh.has(f.code)) {
|
||||
j.skipped++;
|
||||
continue;
|
||||
}
|
||||
j.currentFournisseur = `${f.code} — ${f.nom}`;
|
||||
try {
|
||||
await getProductRows({ codeFournisseur: f.code, magasin: "TOTAL", forceRefresh: true });
|
||||
j.done++;
|
||||
} catch (e) {
|
||||
j.failed++;
|
||||
const msg = `${f.code}: ${e instanceof Error ? e.message.slice(0, 120) : String(e)}`;
|
||||
// On garde une trace des derniers échecs sans faire exploser la mémoire.
|
||||
if (j.lastErrors.length < 10) j.lastErrors.push(msg);
|
||||
console.error("[grid-warmup]", msg);
|
||||
}
|
||||
}
|
||||
|
||||
j.currentFournisseur = undefined;
|
||||
j.status = "success";
|
||||
j.finishedAt = new Date().toISOString();
|
||||
console.log(`[grid-warmup] terminé — ${j.done} calculés, ${j.skipped} à jour, ${j.failed} en échec`);
|
||||
} catch (e) {
|
||||
j.status = "error";
|
||||
j.error = e instanceof Error ? e.message : String(e);
|
||||
j.finishedAt = new Date().toISOString();
|
||||
console.error("[grid-warmup] échec global:", j.error);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/admin/grid-warmup — avancement du préchauffage. */
|
||||
export async function GET() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/grid-warmup?staleHours=24
|
||||
* Démarre le préchauffage en arrière-plan et répond immédiatement.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
if (job?.status === "running") {
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
const raw = Number(req.nextUrl.searchParams.get("staleHours"));
|
||||
const staleHours = Number.isFinite(raw) ? Math.min(720, Math.max(0, raw)) : 24;
|
||||
|
||||
job = {
|
||||
status: "running",
|
||||
total: 0,
|
||||
done: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastErrors: [],
|
||||
};
|
||||
const current = job;
|
||||
console.log(`[grid-warmup] démarré (staleHours=${staleHours})`);
|
||||
|
||||
void runWarmup(current, staleHours).catch((e) => {
|
||||
current.status = "error";
|
||||
current.error = e instanceof Error ? e.message : String(e);
|
||||
current.finishedAt = new Date().toISOString();
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
@@ -1,41 +1,48 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { GRID_SORT_KEYS } from "@/lib/grid-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/openapi.json
|
||||
* GET /api/v1/openapi.json — spécification OpenAPI 3.1 de l'API CollectFlow.
|
||||
*
|
||||
* Spécification lisible par machine de l'API CollectFlow. Permet aux outils externes
|
||||
* — et à l'assistant IA — de découvrir les endpoints sans documentation manuscrite.
|
||||
* Authentifiée comme le reste de l'API : la spec décrit une surface interne.
|
||||
* **Volontairement accessible sans authentification.** Le document ne décrit que la
|
||||
* structure de l'API (chemins, paramètres, schémas) et ne contient aucune donnée
|
||||
* métier ; or ChatGPT et les autres plateformes d'agents importent le schéma par URL,
|
||||
* avant même que la clé ne soit configurée. Exiger une clé ici rendrait l'import
|
||||
* impossible.
|
||||
*
|
||||
* `servers` est construit depuis l'hôte appelant (ou COLLECTFLOW_PUBLIC_URL) : les
|
||||
* Actions ChatGPT exigent une URL **absolue**, une URL relative étant rejetée.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
const configured = process.env.COLLECTFLOW_PUBLIC_URL?.trim().replace(/\/+$/, "");
|
||||
const origin = configured || req.nextUrl.origin;
|
||||
|
||||
const paginationParams = [
|
||||
{ name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 } },
|
||||
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 500, default: 100 } },
|
||||
{ name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 }, description: "Numéro de page." },
|
||||
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 500, default: 100 }, description: "Nombre de lignes par page (500 maximum)." },
|
||||
];
|
||||
const sortParams = [
|
||||
{ name: "sort", in: "query", schema: { type: "string", enum: GRID_SORT_KEYS }, description: "Colonne de tri." },
|
||||
{ name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"], default: "desc" } },
|
||||
{ name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"], default: "desc" }, description: "Sens du tri." },
|
||||
{
|
||||
name: "fields",
|
||||
in: "query",
|
||||
schema: { type: "string" },
|
||||
description: "Champs de ProductRow à conserver, séparés par des virgules (allège fortement la réponse). `codein` est toujours inclus.",
|
||||
description:
|
||||
"Colonnes à retourner, séparées par des virgules (ex. « codein,libelle1,totalCa »). "
|
||||
+ "À utiliser systématiquement : une ligne complète contient les séries mensuelles et les "
|
||||
+ "ventilations par magasin, ce qui est très volumineux. `codein` est toujours inclus.",
|
||||
},
|
||||
{
|
||||
name: "enrich",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["0", "1"], default: "1" },
|
||||
description:
|
||||
"1 (défaut) : relit les métriques réseau Qlik (`network`) et la gamme serveur (`codeGammeServeur`) au moment de l'appel. "
|
||||
+ "0 : sert l'instantané brut, légèrement plus rapide.",
|
||||
"1 (défaut) : relit les métriques réseau Qlik (`network`) et la gamme serveur "
|
||||
+ "(`codeGammeServeur`) au moment de l'appel. 0 : sert l'instantané brut.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -45,22 +52,21 @@ export async function GET(req: NextRequest) {
|
||||
title: "CollectFlow API",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"Lecture de la grille CollectFlow (ventes 12 mois, stock, marges, gammes, métriques réseau Qlik) "
|
||||
+ "et recherche de produits.\n\n"
|
||||
+ "**Aucun endpoint ne déclenche de recalcul ni d'appel à Qlik.** Toutes les réponses proviennent "
|
||||
+ "de données déjà persistées : l'instantané de grille (`grid_rows`), rempli quand la Grille est "
|
||||
+ "ouverte dans l'application, et le cache des métriques réseau (`qlik_network_metrics`). "
|
||||
+ "Un fournisseur jamais ouvert renvoie donc `202 not_ready` au lieu d'imposer une attente.\n\n"
|
||||
+ "Deux informations sont relues à chaque appel car elles évoluent indépendamment du calcul de "
|
||||
+ "la grille : les **métriques réseau Qlik** (`network`, `null` quand il n'y en a pas) et la "
|
||||
+ "**gamme serveur non modifiée** (`codeGammeServeur`). Passer `enrich=0` pour s'en dispenser.",
|
||||
"Lecture des données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, "
|
||||
+ "gammes et métriques du réseau Qlik (~270 magasins Foir'Fouille), plus la recherche de produits.\n\n"
|
||||
+ "**Aucun endpoint ne déclenche de recalcul ni d'appel à Qlik.** Les réponses proviennent de "
|
||||
+ "données déjà persistées : l'instantané de grille, écrit quand la Grille est calculée dans "
|
||||
+ "l'application, et le cache des métriques réseau. Un fournisseur dont l'instantané n'existe pas "
|
||||
+ "encore renvoie `202 not_ready`.\n\n"
|
||||
+ "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques "
|
||||
+ "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée "
|
||||
+ "(`codeGammeServeur`).",
|
||||
},
|
||||
servers: [{ url: "/api/v1" }],
|
||||
security: [{ ApiKeyAuth: [] }, { BearerAuth: [] }],
|
||||
servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }],
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
|
||||
BearerAuth: { type: "http", scheme: "bearer" },
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
@@ -74,7 +80,6 @@ export async function GET(req: NextRequest) {
|
||||
enum: ["unauthorized", "forbidden", "bad_request", "not_found", "not_ready", "internal_error"],
|
||||
},
|
||||
message: { type: "string" },
|
||||
details: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -88,49 +93,62 @@ export async function GET(req: NextRequest) {
|
||||
totalPages: { type: "integer" },
|
||||
},
|
||||
},
|
||||
ProductRow: {
|
||||
Fournisseur: {
|
||||
type: "object",
|
||||
description: "Ligne de grille. Voir src/types/grid.ts pour la liste complète des champs.",
|
||||
properties: {
|
||||
codein: { type: "string", description: "Identifiant interne FF Nancy." },
|
||||
code: { type: "string", description: "Code fournisseur, à passer au paramètre `fournisseur`." },
|
||||
nom: { type: ["string", "null"] },
|
||||
hasSnapshot: { type: "boolean", description: "false = données pas encore disponibles pour ce fournisseur." },
|
||||
rowCount: { type: "integer" },
|
||||
computedAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
},
|
||||
NetworkMetrics: {
|
||||
type: ["object", "null"],
|
||||
description: "Métriques du réseau Qlik. `null` quand le produit n'a pas de données réseau.",
|
||||
properties: {
|
||||
caReseau: { type: "number" },
|
||||
qteReseau: { type: "number" },
|
||||
nbMagasinsReseau: { type: "integer", description: "Nombre de magasins du réseau vendant le produit (sur ~270)." },
|
||||
caParMagasinReseau: { type: "number", description: "CA moyen par magasin : normalise la présence, préférable au CA brut pour comparer." },
|
||||
margePctReseau: { type: "number", description: "Taux de marge, ratio brut (0.32 = 32 %)." },
|
||||
qteByMonth: { type: ["object", "null"], description: "Quantités vendues par mois, clés « YYYY-MM »." },
|
||||
fetchedAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
},
|
||||
Product: {
|
||||
type: "object",
|
||||
description: "Ligne de grille : un article chez un fournisseur.",
|
||||
properties: {
|
||||
codein: { type: "string", description: "Identifiant interne de l'article." },
|
||||
codeFournisseur: { type: "string" },
|
||||
nomFournisseur: { type: "string" },
|
||||
libelle1: { type: "string" },
|
||||
libelle1: { type: "string", description: "Libellé de l'article." },
|
||||
gtin: { type: "string" },
|
||||
codeCentrale: { type: "string", description: "Clé de jointure avec Qlik (format 10000XXXXXX)." },
|
||||
codeGamme: { type: ["string", "null"], description: "Gamme courante — peut être surchargée par un snapshot de session." },
|
||||
reference: { type: "string" },
|
||||
codeCentrale: { type: "string", description: "Code centrale — clé de jointure avec le réseau Qlik." },
|
||||
codeGammeServeur: {
|
||||
type: ["string", "null"],
|
||||
description: "Gamme **non modifiée**, telle qu'elle existe sur le serveur PostgreSQL. Relue à chaque appel (sauf `enrich=0`). `null` = aucune gamme en base.",
|
||||
description:
|
||||
"Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour "
|
||||
+ "raisonner. `null` = aucune gamme. Valeurs : A (pilier), B (bonne rotation), "
|
||||
+ "C (performance), D (saisonnier/niche), Z (sortie).",
|
||||
},
|
||||
codeGammeInit: { type: ["string", "null"], description: "Alias historique de `codeGammeServeur`, maintenu aligné." },
|
||||
network: {
|
||||
type: ["object", "null"],
|
||||
description: "Métriques réseau Qlik en cache, `null` si le produit n'en a pas.",
|
||||
properties: {
|
||||
caReseau: { type: "number" },
|
||||
qteReseau: { type: "number" },
|
||||
nbMagasinsReseau: { type: "integer" },
|
||||
caParMagasinReseau: { type: "number" },
|
||||
margePctReseau: { type: "number", description: "Ratio brut Qlik (0.32 = 32 %)." },
|
||||
qteByMonth: { type: ["object", "null"], description: "Quantités par mois, clés YYYY-MM." },
|
||||
fetchedAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
},
|
||||
totalCa: { type: "number" },
|
||||
codeGamme: { type: ["string", "null"], description: "Gamme courante, éventuellement surchargée par une modification locale non enregistrée." },
|
||||
totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
|
||||
totalQuantite: { type: "number" },
|
||||
totalMarge: { type: "number" },
|
||||
tauxMarge: { type: "number" },
|
||||
stockActuel: { type: "number" },
|
||||
sales12m: { type: "object", description: "Quantités vendues par mois, clés YYYYMM." },
|
||||
stock12m: { type: "object", description: "Stock fin de mois, clés YYYYMM." },
|
||||
sales12mByStore: { type: "object", description: "Ventes par magasin puis par mois." },
|
||||
caReseau: { type: "number" },
|
||||
qteReseau: { type: "number" },
|
||||
nbMagasinsReseau: { type: "integer" },
|
||||
caParMagasinReseau: { type: "number" },
|
||||
margePctReseau: { type: "number", description: "Ratio brut Qlik (0.32 = 32 %)." },
|
||||
qteReseauByMonth: { type: ["object", "null"], description: "Quantités réseau par mois, clés YYYY-MM." },
|
||||
pcb: { type: "number", description: "Conditionnement (nombre d'unités par colis)." },
|
||||
prixAchat: { type: "number" },
|
||||
prixVente: { type: "number" },
|
||||
derniereVente: { type: "string", format: "date" },
|
||||
nbJoursDerniereVente: { type: "integer" },
|
||||
sales12m: { type: "object", description: "Quantités vendues par mois, clés « YYYYMM »." },
|
||||
stock12m: { type: "object", description: "Stock de fin de mois, clés « YYYYMM »." },
|
||||
sales12mByStore: { type: "object", description: "Ventes par magasin (292, 579) puis par mois." },
|
||||
network: { $ref: "#/components/schemas/NetworkMetrics" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -138,73 +156,162 @@ export async function GET(req: NextRequest) {
|
||||
paths: {
|
||||
"/fournisseurs": {
|
||||
get: {
|
||||
summary: "Liste des fournisseurs, avec la fraîcheur de leur instantané de grille",
|
||||
operationId: "listerFournisseurs",
|
||||
summary: "Lister les fournisseurs",
|
||||
description:
|
||||
"Renvoie les fournisseurs et indique, via `hasSnapshot`, lesquels ont des données "
|
||||
+ "immédiatement disponibles. À appeler en premier quand le code fournisseur est inconnu.",
|
||||
parameters: [
|
||||
{ name: "search", in: "query", schema: { type: "string" } },
|
||||
{
|
||||
name: "withData",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["0", "1"] },
|
||||
description: "1 = uniquement les fournisseurs déjà présents dans l'instantané.",
|
||||
},
|
||||
{ name: "search", in: "query", schema: { type: "string" }, description: "Filtre sur le nom ou le code." },
|
||||
{ name: "withData", in: "query", schema: { type: "string", enum: ["0", "1"] }, description: "1 = uniquement ceux dont les données sont disponibles." },
|
||||
],
|
||||
responses: { "200": { description: "OK" }, "401": { description: "Non authentifié" } },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Liste des fournisseurs",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: "#/components/schemas/Fournisseur" } },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/grid": {
|
||||
get: {
|
||||
summary: "Lignes de grille d'un fournisseur",
|
||||
description: "Répond `202 not_ready` si le fournisseur n'a pas encore d'instantané.",
|
||||
operationId: "listerProduitsFournisseur",
|
||||
summary: "Lister les produits d'un fournisseur",
|
||||
description:
|
||||
"Lignes de grille d'un fournisseur : ventes 12 mois, stock, marges, gammes et métriques "
|
||||
+ "réseau. Renvoie `202 not_ready` si les données de ce fournisseur n'ont pas encore été "
|
||||
+ "calculées — le signaler, plutôt que de conclure à l'absence de produits.",
|
||||
parameters: [
|
||||
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" } },
|
||||
{ name: "gamme", in: "query", schema: { type: "string" } },
|
||||
{ name: "code1", in: "query", schema: { type: "string" } },
|
||||
{ name: "code2", in: "query", schema: { type: "string" } },
|
||||
{ name: "code3", in: "query", schema: { type: "string" } },
|
||||
{ name: "search", in: "query", schema: { type: "string" }, description: "Libellé, codein, GTIN, référence ou code centrale." },
|
||||
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" }, description: "Code fournisseur (voir listerFournisseurs)." },
|
||||
{ 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: "search", in: "query", schema: { type: "string" }, description: "Filtre texte : libellé, codein, GTIN, référence ou code centrale." },
|
||||
...sortParams,
|
||||
...paginationParams,
|
||||
],
|
||||
responses: {
|
||||
"200": { description: "OK" },
|
||||
"202": { description: "Instantané pas encore calculé" },
|
||||
"400": { description: "Paramètres invalides" },
|
||||
"401": { description: "Non authentifié" },
|
||||
"200": {
|
||||
description: "Produits du fournisseur",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
|
||||
pagination: { $ref: "#/components/schemas/Pagination" },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"202": { description: "Données pas encore calculées pour ce fournisseur", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/products/search": {
|
||||
get: {
|
||||
summary: "Recherche transversale de produits, tous fournisseurs confondus",
|
||||
operationId: "rechercherProduits",
|
||||
summary: "Rechercher des produits, tous fournisseurs confondus",
|
||||
description:
|
||||
"Recherche sur le libellé, le codein, le GTIN, la référence et le code centrale, sans "
|
||||
+ "avoir à connaître le fournisseur. À utiliser dès qu'un produit est désigné par son nom.",
|
||||
parameters: [
|
||||
{ name: "q", in: "query", required: true, schema: { type: "string", maxLength: 120 } },
|
||||
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Restreint la recherche à un fournisseur." },
|
||||
{ name: "gamme", in: "query", schema: { type: "string" } },
|
||||
{ name: "q", in: "query", required: true, schema: { type: "string", maxLength: 120 }, description: "Termes recherchés." },
|
||||
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Restreint à un fournisseur." },
|
||||
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme." },
|
||||
...sortParams,
|
||||
...paginationParams,
|
||||
],
|
||||
responses: { "200": { description: "OK" }, "401": { description: "Non authentifié" } },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Produits correspondants",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
|
||||
pagination: { $ref: "#/components/schemas/Pagination" },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/products/{codein}": {
|
||||
get: {
|
||||
operationId: "obtenirProduit",
|
||||
summary: "Fiche complète d'un produit",
|
||||
description: "Toutes les données d'un article : séries mensuelles, ventilation par magasin, stock, marges, gammes et métriques réseau.",
|
||||
parameters: [
|
||||
{ name: "codein", in: "path", required: true, schema: { type: "string" } },
|
||||
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Lève l'ambiguïté d'un article multi-fournisseurs." },
|
||||
{ name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." },
|
||||
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs." },
|
||||
],
|
||||
responses: { "200": { description: "OK" }, "404": { description: "Inconnu de l'instantané" } },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Fiche produit",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { data: { $ref: "#/components/schemas/Product" }, meta: { type: "object" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": { description: "Produit absent de l'instantané", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/network/{codeCentrale}": {
|
||||
get: {
|
||||
summary: "Métriques réseau Qlik en cache + courbe 12 mois",
|
||||
parameters: [{ name: "codeCentrale", in: "path", required: true, schema: { type: "string" } }],
|
||||
responses: { "200": { description: "OK" }, "404": { description: "Absent du cache réseau" } },
|
||||
operationId: "obtenirMetriquesReseau",
|
||||
summary: "Métriques du réseau Qlik pour un produit",
|
||||
description: "Performances du produit sur l'ensemble du réseau Foir'Fouille (~270 magasins) et courbe des quantités sur 12 mois glissants.",
|
||||
parameters: [
|
||||
{ name: "codeCentrale", in: "path", required: true, schema: { type: "string" }, description: "Code centrale du produit (format 10000XXXXXX)." },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Métriques réseau",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { data: { $ref: "#/components/schemas/NetworkMetrics" }, meta: { type: "object" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": { description: "Aucune donnée réseau pour ce code centrale", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return Response.json(spec);
|
||||
// CORS ouvert en lecture : le schéma est public et peut être chargé par un outil tiers.
|
||||
return Response.json(spec, { headers: { "Access-Control-Allow-Origin": "*" } });
|
||||
}
|
||||
Reference in new issue
Block a user