diff --git a/src/app/api/v1/grid/route.ts b/src/app/api/v1/grid/route.ts index 066c57a..8534cdc 100644 --- a/src/app/api/v1/grid/route.ts +++ b/src/app/api/v1/grid/route.ts @@ -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, }, }, ); diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts index db01084..0b341db 100644 --- a/src/app/api/v1/openapi.json/route.ts +++ b/src/app/api/v1/openapi.json/route.ts @@ -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" } } } }, }, }, }, diff --git a/src/app/api/v1/products/[codein]/route.ts b/src/app/api/v1/products/[codein]/route.ts index 6e48105..d52cb5f 100644 --- a/src/app/api/v1/products/[codein]/route.ts +++ b/src/app/api/v1/products/[codein]/route.ts @@ -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.`, ); } diff --git a/src/lib/api-schemas.ts b/src/lib/api-schemas.ts index a167aba..7d9fec2 100644 --- a/src/lib/api-schemas.ts +++ b/src/lib/api-schemas.ts @@ -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"), }); /**