feat(api): /api/v1/grid calcule le fournisseur à la demande

L'API lisait uniquement la table grid_rows, écrite en effet de bord quand un
humain ouvre la page Grille. Un fournisseur jamais ouvert renvoyait donc
202 not_ready : une app ou un agent externe ne pouvait consulter que ce qui
avait déjà été parcouru dans l'interface, ce qui vide l'API de son intérêt.

/api/v1/grid déclenche désormais getProductRows() quand l'instantané manque.
Le calcul est déjà protégé en amont (cache 10 min + verrou anti-concurrence)
et persiste l'instantané, donc seul le premier appel paie le coût ; les
suivants repassent par le chemin SQL rapide.

- compute=1 (défaut) : calcul à la demande, meta.computedOnDemand signale
  quand il a eu lieu. compute=0 : comportement strict d'avant.
- Fournisseur inconnu ou sans article : 404 explicite, distinct d'une panne
  (500) et de « pas encore calculé » (202).
- /api/v1/products/{codein} fait de même lorsque `fournisseur` est fourni,
  le calcul se faisant par lot fournisseur.
- openapi.json mis à jour : c'est ce document que lit un agent externe, il
  annonçait « aucun endpoint ne déclenche de recalcul ».

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-06 10:29:26 +00:00
1 parent 54d51e8eab
commit 3a825866d2
4 files changed
+106 -20

No files matched your search

+52 -11
View File
@@ -4,19 +4,27 @@ import { ok, fail, buildPagination } from "@/lib/api-response";
import { gridQuerySchema, pickFields, toSortKey } from "@/lib/api-schemas";
import { queryGridRows, getGridFreshness } from "@/lib/grid-store";
import { enrichRows } from "@/lib/api-enrich";
import { getProductRows } from "@/features/grid/api/get-product-rows";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Le calcul à la demande (premier appel sur un fournisseur inconnu) enchaîne
// plusieurs requêtes SQL sur la base miroir FF : on laisse de la marge.
export const maxDuration = 300;
/**
* GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=
* GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=&compute=
*
* Lignes de grille d'un fournisseur, filtrées / triées / paginées **en SQL** depuis
* l'instantané persisté (`grid_rows`).
*
* Cet endpoint ne déclenche **jamais** `getProductRows()` et ne contacte jamais Qlik :
* si le fournisseur n'a pas encore d'instantané, il répond `202 not_ready` plutôt que
* d'imposer un calcul de plusieurs secondes à l'appelant.
* Si le fournisseur n'a pas encore d'instantané, l'endpoint le **calcule à la demande**
* (`compute=1`, défaut) : sans cela, un appelant externe ne pourrait consulter que les
* fournisseurs déjà ouverts dans la Grille par un humain. Le calcul est protégé par le
* cache et le verrou anti-concurrence de `getProductRows()`, et il persiste l'instantané
* au passage — les appels suivants repassent donc par le chemin rapide.
*
* `compute=0` restaure le comportement strict : échec immédiat en `202 not_ready`.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
@@ -28,14 +36,45 @@ export async function GET(req: NextRequest) {
}
const q = parsed.data;
// Distingue « fournisseur jamais calculé » (202) de « filtres sans résultat » (200 vide).
const freshness = await getGridFreshness(q.fournisseur);
// Distingue « fournisseur jamais calculé » de « filtres sans résultat » (200 vide).
let freshness = await getGridFreshness(q.fournisseur);
let computedOnDemand = false;
if (!freshness) {
return fail(
"not_ready",
`Aucun instantané pour le fournisseur « ${q.fournisseur} ». Ouvrez-le une fois dans la Grille pour le calculer.`,
{ fournisseur: q.fournisseur },
);
if (q.compute === "0") {
return fail(
"not_ready",
`Aucun instantané pour le fournisseur « ${q.fournisseur} ». Relancez sans « compute=0 » pour le calculer à la demande.`,
{ fournisseur: q.fournisseur },
);
}
console.log(`[api/v1/grid] instantané absent pour ${q.fournisseur} — calcul à la demande (via ${authCtx.via})`);
try {
// magasin TOTAL : c'est la seule variante persistée par getProductRows,
// et ProductRow embarque déjà les ventilations par magasin.
await getProductRows({ codeFournisseur: q.fournisseur, magasin: "TOTAL" });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`[api/v1/grid] calcul à la demande KO pour ${q.fournisseur}:`, msg);
return fail(
"internal_error",
`Le calcul de la grille a échoué pour le fournisseur « ${q.fournisseur} ».`,
{ fournisseur: q.fournisseur },
);
}
freshness = await getGridFreshness(q.fournisseur);
if (!freshness) {
// Calcul réussi mais aucune ligne : le code fournisseur n'existe pas, ou
// il n'a aucun article. À distinguer d'une panne.
return fail(
"not_found",
`Aucun article pour le fournisseur « ${q.fournisseur} ». Vérifiez le code auprès de /api/v1/fournisseurs.`,
{ fournisseur: q.fournisseur },
);
}
computedOnDemand = true;
}
const result = await queryGridRows({
@@ -64,6 +103,8 @@ export async function GET(req: NextRequest) {
snapshotComputedAt: freshness.computedAt,
snapshotRowCount: freshness.rowCount,
enrichi: q.enrich === "1",
/** true = l'instantané n'existait pas et vient d'être calculé par cet appel. */
computedOnDemand,
},
},
);
+20 -7
View File
@@ -54,10 +54,12 @@ export async function GET(req: NextRequest) {
description:
"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"
+ "**N'importe quel fournisseur peut être interrogé directement.** Les réponses sont servies "
+ "depuis un instantané persisté ; si le fournisseur demandé n'en a pas encore, `/grid` le calcule "
+ "à la demande. Ce premier appel prend alors plusieurs secondes, les suivants sont immédiats. "
+ "Passez `compute=0` pour refuser ce calcul et obtenir un `202 not_ready` immédiat.\n\n"
+ "**Aucun endpoint ne contacte Qlik en direct** : les métriques réseau viennent d'un cache "
+ "alimenté par une synchronisation séparée.\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`).",
@@ -190,8 +192,10 @@ export async function GET(req: NextRequest) {
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.",
+ "réseau. Fonctionne pour **n'importe quel** fournisseur : si ses données n'ont jamais été "
+ "calculées, l'API les calcule à la demande (premier appel de quelques secondes, "
+ "`meta.computedOnDemand = true`). Un `404` signifie que le code fournisseur n'existe pas "
+ "ou n'a aucun article — le signaler plutôt que de conclure à une panne.",
parameters: [
{ 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)." },
@@ -199,6 +203,14 @@ export async function GET(req: NextRequest) {
{ 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." },
{
name: "compute",
in: "query",
schema: { type: "string", enum: ["0", "1"], default: "1" },
description:
"1 (défaut) : calcule les données du fournisseur si elles n'existent pas encore. "
+ "0 : refuse le calcul et répond immédiatement 202 not_ready.",
},
...sortParams,
...paginationParams,
],
@@ -218,9 +230,10 @@ export async function GET(req: NextRequest) {
},
},
},
"202": { description: "Données pas encore calculées pour ce fournisseur", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"202": { description: "Données pas encore calculées (uniquement avec compute=0)", 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" } } } },
"404": { description: "Fournisseur inconnu ou sans article", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
+20 -2
View File
@@ -4,9 +4,12 @@ import { ok, fail } from "@/lib/api-response";
import { productDetailSchema } from "@/lib/api-schemas";
import { getGridRowByCodein } from "@/lib/grid-store";
import { enrichRows } from "@/lib/api-enrich";
import { getProductRows } from "@/features/grid/api/get-product-rows";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes.
export const maxDuration = 300;
/**
* GET /api/v1/products/:codein?fournisseur=…
@@ -33,11 +36,26 @@ export async function GET(
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const found = await getGridRowByCodein(codein, parsed.data.fournisseur);
let found = await getGridRowByCodein(codein, parsed.data.fournisseur);
// Absent de l'instantané : on peut le calculer, mais seulement si l'appelant a
// précisé le fournisseur — le calcul se fait par lot fournisseur, pas par article.
if (!found && parsed.data.compute === "1" && parsed.data.fournisseur) {
console.log(`[api/v1/products] ${codein} absent — calcul du lot ${parsed.data.fournisseur} à la demande`);
try {
await getProductRows({ codeFournisseur: parsed.data.fournisseur, magasin: "TOTAL" });
found = await getGridRowByCodein(codein, parsed.data.fournisseur);
} catch (e) {
console.error(`[api/v1/products] calcul KO pour ${parsed.data.fournisseur}:`, e instanceof Error ? e.message : String(e));
}
}
if (!found) {
return fail(
"not_found",
`Aucun instantané pour le produit « ${codein} ». Le fournisseur a-t-il déjà été ouvert dans la Grille ?`,
parsed.data.fournisseur
? `Produit « ${codein} » introuvable chez le fournisseur « ${parsed.data.fournisseur} ».`
: `Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur=… » pour que l'API calcule le lot à la demande.`,
);
}
+14
View File
@@ -41,6 +41,15 @@ export const gridQuerySchema = z.object({
code2: z.string().min(1).optional(),
code3: 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
* à la demande au lieu de répondre `not_ready`. C'est ce qui permet à une app ou
* à un agent externe d'interroger n'importe quel fournisseur sans qu'un humain
* ait ouvert la Grille avant. Le premier appel est alors plus lent (plusieurs
* secondes) ; les suivants sont servis depuis l'instantané.
* `0` : comportement strict, échoue vite en `not_ready`.
*/
compute: z.enum(["0", "1"]).default("1"),
...sortShape,
...paginationShape,
});
@@ -62,6 +71,11 @@ export const fournisseursQuerySchema = z.object({
export const productDetailSchema = z.object({
fournisseur: z.string().min(1).optional(),
/**
* `1` (défaut) : calcule l'instantané à la demande si le produit n'y figure pas
* encore. Nécessite `fournisseur`, le calcul se faisant par lot fournisseur.
*/
compute: z.enum(["0", "1"]).default("1"),
});
/**