diff --git a/src/app/api/v1/gammes/route.ts b/src/app/api/v1/gammes/route.ts new file mode 100644 index 0000000..e3edbb7 --- /dev/null +++ b/src/app/api/v1/gammes/route.ts @@ -0,0 +1,48 @@ +import { NextRequest } from "next/server"; +import { requireApiAuth } from "@/lib/api-auth"; +import { ok, fail } from "@/lib/api-response"; +import { gammesBatchBodySchema } from "@/lib/api-schemas"; +import { affecterGammes, lireCorpsJson } from "@/lib/api-gammes"; + +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; + +/** + * POST /api/v1/gammes { "fournisseur": "…", "changes": [{ "codein": "…", "gamme": "A" }, …] } + * + * Affecte ou change la gamme de plusieurs articles d'un fournisseur en un appel — + * un seul snapshot enregistré, au lieu d'un par article avec /products/:codein/gamme. + * Tout ou rien : un article inconnu chez le fournisseur fait échouer l'appel (404). + */ +export async function POST(req: NextRequest) { + const authCtx = await requireApiAuth(req); + if (authCtx instanceof Response) return authCtx; + + const corps = await lireCorpsJson(req); + if (corps instanceof Response) return corps; + const parsed = gammesBatchBodySchema.safeParse(corps); + if (!parsed.success) { + return fail("bad_request", "Corps de requête invalide.", parsed.error.issues); + } + const { fournisseur, changes, compute } = parsed.data; + + const res = await affecterGammes({ + codeFournisseur: fournisseur, + changes, + compute, + auteur: authCtx.subject, + }); + if (res instanceof Response) return res; + + return ok(res.resultats, { + meta: { + codeFournisseur: res.codeFournisseur, + nomFournisseur: res.nomFournisseur, + demandes: res.resultats.length, + modifies: res.resultats.filter((r) => r.modifie).length, + enregistreLe: new Date().toISOString(), + }, + }); +} diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts index 2004b98..cd99026 100644 --- a/src/app/api/v1/openapi.json/route.ts +++ b/src/app/api/v1/openapi.json/route.ts @@ -1,5 +1,6 @@ import { NextRequest } from "next/server"; import { GRID_SORT_KEYS } from "@/lib/grid-store"; +import { GAMMES } from "@/lib/gammes"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -46,13 +47,20 @@ export async function GET(req: NextRequest) { }, ]; + const codesGamme = GAMMES.map((g) => g.code); + const legendeGammes = GAMMES.map((g) => `${g.code} (${g.nom})`).join(", "); + const erreur = (description: string) => ({ + description, + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, + }); + const spec = { openapi: "3.1.0", info: { title: "CollectFlow API", version: "1.0.0", description: - "Lecture des données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, " + "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" + "**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 " @@ -62,7 +70,11 @@ export async function GET(req: NextRequest) { + "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`).", + + "(`codeGammeServeur`).\n\n" + + "**Gammes** : `PUT /products/{codein}/gamme` (un article) et `POST /gammes` (plusieurs) " + + "affectent ou changent une gamme exactement comme la Grille : la nouvelle valeur apparaît " + + "aussitôt dans `codeGamme` et dans l'application, tandis que `codeGammeServeur` reste la gamme " + + "en base FF jusqu'à l'import des gammes modifiées.", }, servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }], security: [{ ApiKeyAuth: [] }], @@ -137,10 +149,9 @@ export async function GET(req: NextRequest) { type: ["string", "null"], 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).", + + "raisonner. `null` = aucune gamme. Valeurs : " + legendeGammes + ".", }, - codeGamme: { type: ["string", "null"], description: "Gamme courante, éventuellement surchargée par une modification locale non enregistrée." }, + codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications enregistrées dans CollectFlow (Grille ou API) comprises." }, totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." }, totalQuantite: { type: "number" }, totalMarge: { type: "number" }, @@ -162,6 +173,16 @@ export async function GET(req: NextRequest) { trend: { $ref: "#/components/schemas/Trend" }, }, }, + ResultatGamme: { + type: "object", + properties: { + codein: { type: "string" }, + gamme: { type: "string", enum: codesGamme, description: "Gamme désormais affectée." }, + gammePrecedente: { type: ["string", "null"], description: "Gamme courante avant l'appel." }, + codeGammeServeur: { type: ["string", "null"], description: "Gamme en base FF, non modifiée par l'API." }, + modifie: { type: "boolean", description: "false = l'article avait déjà cette gamme, rien n'a été écrit." }, + }, + }, Trend: { type: ["object", "null"], description: @@ -266,7 +287,7 @@ export async function GET(req: NextRequest) { + "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)." }, + { name: "gamme", in: "query", schema: { type: "string" }, description: `Filtre sur la gamme (${codesGamme.join(", ")}).` }, { 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 (code exact)." }, @@ -366,6 +387,118 @@ export async function GET(req: NextRequest) { }, }, }, + "/products/{codein}/gamme": { + put: { + operationId: "changerGammeProduit", + summary: "Affecter ou changer la gamme d'un produit", + description: + "Enregistre la gamme d'un article, comme la Grille. Valeurs : " + legendeGammes + ". " + + "`fournisseur` n'est requis que si l'article est référencé chez plusieurs fournisseurs " + + "(réponse 400 listant les fournisseurs) ou s'il n'a encore jamais été calculé. " + + "Pour plusieurs articles d'un même fournisseur, préférer changerGammes.", + parameters: [ + { name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["gamme"], + properties: { + gamme: { type: "string", enum: codesGamme }, + fournisseur: { type: "string", description: "Code fournisseur de l'article." }, + compute: { type: "boolean", default: true, description: "Calcule le fournisseur à la demande s'il n'a jamais été calculé." }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "Gamme enregistrée", + content: { + "application/json": { + schema: { + type: "object", + properties: { + data: { + allOf: [ + { $ref: "#/components/schemas/ResultatGamme" }, + { type: "object", properties: { codeFournisseur: { type: "string" }, nomFournisseur: { type: ["string", "null"] } } }, + ], + }, + meta: { type: "object" }, + }, + }, + }, + }, + }, + "400": erreur("Gamme invalide, ou article chez plusieurs fournisseurs sans « fournisseur »"), + "401": erreur("Clé d'API absente, invalide ou révoquée"), + "404": erreur("Article introuvable chez ce fournisseur"), + }, + }, + }, + "/gammes": { + post: { + operationId: "changerGammes", + summary: "Affecter ou changer la gamme de plusieurs produits", + description: + "Enregistre en un appel les gammes de plusieurs articles d'un même fournisseur. " + + "Tout ou rien : si un article est inconnu chez le fournisseur, rien n'est enregistré " + + "et la réponse 404 liste les articles en cause dans `error.details.inconnus`. " + + "Valeurs : " + legendeGammes + ".", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["fournisseur", "changes"], + properties: { + fournisseur: { type: "string", description: "Code fournisseur." }, + changes: { + type: "array", + minItems: 1, + maxItems: 10000, + items: { + type: "object", + required: ["codein", "gamme"], + properties: { + codein: { type: "string" }, + gamme: { type: "string", enum: codesGamme }, + }, + }, + }, + compute: { type: "boolean", default: true, description: "Calcule le fournisseur à la demande s'il n'a jamais été calculé." }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "Gammes enregistrées", + content: { + "application/json": { + schema: { + type: "object", + properties: { + data: { type: "array", items: { $ref: "#/components/schemas/ResultatGamme" } }, + meta: { type: "object" }, + }, + }, + }, + }, + }, + "400": erreur("Corps de requête invalide"), + "401": erreur("Clé d'API absente, invalide ou révoquée"), + "404": erreur("Un ou plusieurs articles introuvables chez ce fournisseur"), + }, + }, + }, "/network/{codeCentrale}": { get: { operationId: "obtenirMetriquesReseau", diff --git a/src/app/api/v1/products/[codein]/gamme/route.ts b/src/app/api/v1/products/[codein]/gamme/route.ts new file mode 100644 index 0000000..fa22a2f --- /dev/null +++ b/src/app/api/v1/products/[codein]/gamme/route.ts @@ -0,0 +1,71 @@ +import { NextRequest } from "next/server"; +import { requireApiAuth } from "@/lib/api-auth"; +import { ok, fail } from "@/lib/api-response"; +import { productGammeBodySchema } from "@/lib/api-schemas"; +import { listGridSuppliersForCodein } from "@/lib/grid-store"; +import { affecterGammes, lireCorpsJson } from "@/lib/api-gammes"; + +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; + +/** + * PUT /api/v1/products/:codein/gamme { "gamme": "A", "fournisseur"?: "…" } + * + * Affecte une gamme à un article, ou la change. Même enregistrement que la Grille : + * la nouvelle gamme est visible immédiatement dans l'application et dans `codeGamme`. + * + * `fournisseur` n'est requis que si l'article est référencé chez plusieurs + * fournisseurs, ou s'il n'apparaît pas encore dans l'instantané. + */ +export async function PUT( + req: NextRequest, + ctx: { params: Promise<{ codein: string }> }, +) { + const authCtx = await requireApiAuth(req); + if (authCtx instanceof Response) return authCtx; + + const { codein } = await ctx.params; + if (!codein) return fail("bad_request", "Paramètre 'codein' requis."); + + const corps = await lireCorpsJson(req); + if (corps instanceof Response) return corps; + const parsed = productGammeBodySchema.safeParse(corps); + if (!parsed.success) { + return fail("bad_request", "Corps de requête invalide.", parsed.error.issues); + } + const { gamme, compute } = parsed.data; + + let fournisseur = parsed.data.fournisseur; + if (!fournisseur) { + const fournisseurs = await listGridSuppliersForCodein(codein); + if (fournisseurs.length === 0) { + return fail( + "not_found", + `Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur » au corps de la requête pour que l'API calcule le lot à la demande.`, + ); + } + if (fournisseurs.length > 1) { + return fail( + "bad_request", + `Le produit « ${codein} » est référencé chez plusieurs fournisseurs : précisez « fournisseur ».`, + { fournisseurs }, + ); + } + fournisseur = fournisseurs[0].codeFournisseur; + } + + const res = await affecterGammes({ + codeFournisseur: fournisseur, + changes: [{ codein, gamme }], + compute, + auteur: authCtx.subject, + }); + if (res instanceof Response) return res; + + return ok( + { ...res.resultats[0], codeFournisseur: res.codeFournisseur, nomFournisseur: res.nomFournisseur }, + { meta: { enregistreLe: new Date().toISOString() } }, + ); +} diff --git a/src/features/admin/components/api-connection-info.tsx b/src/features/admin/components/api-connection-info.tsx index 2acca2d..175675f 100644 --- a/src/features/admin/components/api-connection-info.tsx +++ b/src/features/admin/components/api-connection-info.tsx @@ -79,6 +79,8 @@ const ENDPOINTS: Array<{ method: string; path: string; desc: string }> = [ { method: "GET", path: "/nomenclatures?fournisseur=CODE", desc: "Postes de nomenclature d'un fournisseur, avec nombre d'articles et chiffre d'affaires" }, { method: "GET", path: "/products/search?q=terme", desc: "Recherche de produits, tous fournisseurs confondus" }, { method: "GET", path: "/products/{codein}", desc: "Fiche complète d'un produit" }, + { method: "PUT", path: "/products/{codein}/gamme", desc: "Affecter ou changer la gamme d'un produit — corps : { \"gamme\": \"A\" }" }, + { method: "POST", path: "/gammes", desc: "Affecter ou changer la gamme de plusieurs produits d'un fournisseur en un appel" }, { method: "GET", path: "/network/{codeCentrale}", desc: "Ventes du réseau (Qlik) et courbe sur 12 mois" }, { method: "GET", path: "/openapi.json", desc: "Description de l'API lisible par un programme (format OpenAPI)" }, ]; @@ -203,6 +205,7 @@ export function ApiConnectionInfo() { + {`curl -X PUT -H "X-API-Key: VOTRE_CLE" -H "Content-Type: application/json" \\\n -d '{"gamme":"B"}' "${base}/products/123456/gamme"`} {`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`} {`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}

diff --git a/src/features/grid/api/enregistrer-gammes.ts b/src/features/grid/api/enregistrer-gammes.ts new file mode 100644 index 0000000..8c09861 --- /dev/null +++ b/src/features/grid/api/enregistrer-gammes.ts @@ -0,0 +1,80 @@ +import "server-only"; + +import { db } from "@/db"; +import { sessionSnapshots } from "@/db/schema"; +import { eq, desc } from "drizzle-orm"; +import { patchGridRowsCache } from "./get-product-rows"; +import { updateGridRowsGamme } from "@/lib/grid-store"; + +export interface ChangementGamme { + codein: string; + /** Gamme avant la modification (état serveur), conservée pour l'historique. */ + codeGammeBefore: string | null; + codeGamme: string; +} + +export interface EnregistrementGammes { + codeFournisseur: string; + nomFournisseur?: string | null; + magasin: string; + changes: ChangementGamme[]; + userId: number | null; + /** Libellé du snapshot, « Draft — date » par défaut. */ + label?: string; +} + +/** + * Enregistre des gammes pour un fournisseur — chemin unique de la Grille et de /api/v1. + * + * Les changements sont fusionnés par-dessus le dernier snapshot du fournisseur, + * puis reportés dans le cache mémoire de la Grille et dans l'instantané `grid_rows`. + * Lève une erreur si l'écriture du snapshot échoue. + */ +export async function enregistrerGammes(input: EnregistrementGammes): Promise { + const { codeFournisseur, nomFournisseur, magasin, changes, userId } = input; + if (changes.length === 0) return 0; + + // Charger le dernier snapshot existant pour merger les changements + const existing = await db + .select() + .from(sessionSnapshots) + .where(eq(sessionSnapshots.codeFournisseur, codeFournisseur)) + .orderBy(desc(sessionSnapshots.createdAt)) + .limit(1); + + const prevChanges: Record = + existing.length > 0 + ? (existing[0].changes as Record) + : {}; + + // Merger les nouveaux changements par-dessus l'existant + const mergedChanges = { ...prevChanges }; + for (const c of changes) { + mergedChanges[c.codein] = { + before: c.codeGammeBefore, + after: c.codeGamme, + }; + } + + // Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur + await db.insert(sessionSnapshots).values({ + userId, + codeFournisseur, + nomFournisseur: nomFournisseur ?? null, + magasin, + label: input.label ?? `Draft — ${new Date().toLocaleDateString("fr-FR")}`, + changes: mergedChanges, + summaryJson: null, + type: "snapshot", + }); + + // Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées : + // l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la + // réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer. + patchGridRowsCache(codeFournisseur, changes); + void updateGridRowsGamme(codeFournisseur, changes).catch((e) => + console.error("[enregistrerGammes] mise à jour grid_rows KO:", (e as Error).message?.slice(0, 200)), + ); + + return changes.length; +} diff --git a/src/features/grid/api/save-draft-changes.ts b/src/features/grid/api/save-draft-changes.ts index 7c10ca8..ee70b34 100644 --- a/src/features/grid/api/save-draft-changes.ts +++ b/src/features/grid/api/save-draft-changes.ts @@ -1,12 +1,8 @@ "use server"; -import { db } from "@/db"; -import { sessionSnapshots } from "@/db/schema"; -import { eq, desc } from "drizzle-orm"; import { z } from "zod"; import { verifierSession } from "@/lib/authz"; -import { patchGridRowsCache } from "./get-product-rows"; -import { updateGridRowsGamme } from "@/lib/grid-store"; +import { enregistrerGammes } from "./enregistrer-gammes"; const SaveDraftsSchema = z.object({ codeFournisseur: z.string(), @@ -39,49 +35,8 @@ export async function saveDraftChanges( const finalUserId = userId && !isNaN(userId) ? userId : null; try { - // Charger le dernier snapshot existant pour merger les changements - const existing = await db - .select() - .from(sessionSnapshots) - .where(eq(sessionSnapshots.codeFournisseur, codeFournisseur)) - .orderBy(desc(sessionSnapshots.createdAt)) - .limit(1); - - const prevChanges: Record = - existing.length > 0 - ? (existing[0].changes as Record) - : {}; - - // Merger les nouveaux changements par-dessus l'existant - const mergedChanges = { ...prevChanges }; - for (const c of changes) { - mergedChanges[c.codein] = { - before: c.codeGammeBefore, - after: c.codeGamme, - }; - } - - // Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur - await db.insert(sessionSnapshots).values({ - userId: finalUserId, - codeFournisseur, - nomFournisseur: nomFournisseur ?? null, - magasin, - label: `Draft — ${new Date().toLocaleDateString("fr-FR")}`, - changes: mergedChanges, - summaryJson: null, - type: "snapshot", - }); - - // Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées : - // l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la - // réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer. - patchGridRowsCache(codeFournisseur, changes); - void updateGridRowsGamme(codeFournisseur, changes).catch((e) => - console.error("[saveDraftChanges] mise à jour grid_rows KO:", (e as Error).message?.slice(0, 200)), - ); - - return { success: true, saved: changes.length }; + const saved = await enregistrerGammes({ codeFournisseur, nomFournisseur, magasin, changes, userId: finalUserId }); + return { success: true, saved }; } catch (err) { const msg = err instanceof Error ? err.message : "Unknown error"; return { success: false, saved: 0, error: msg }; diff --git a/src/lib/api-gammes.ts b/src/lib/api-gammes.ts new file mode 100644 index 0000000..7c0b5c5 --- /dev/null +++ b/src/lib/api-gammes.ts @@ -0,0 +1,126 @@ +/** + * CollectFlow — Affectation de gammes via l'API `/api/v1`. + * + * L'API suit exactement le chemin de la Grille (`enregistrerGammes`) : la gamme est + * enregistrée dans le snapshot du fournisseur, reportée dans l'instantané + * `grid_rows` et visible immédiatement dans l'application. Comme dans la Grille, + * rien n'est écrit dans la base FF : `codeGammeServeur` reste la gamme d'origine + * jusqu'à l'import des gammes modifiées. + * + * Tout ou rien : si un seul article est inconnu chez le fournisseur, rien n'est + * enregistré et la réponse liste les articles en cause. + */ + +import "server-only"; + +import type { NextRequest } from "next/server"; +import { fail } from "@/lib/api-response"; +import { getGridFreshness, getGridRowsGammes } from "@/lib/grid-store"; +import { getProductRows } from "@/features/grid/api/get-product-rows"; +import { enregistrerGammes, type ChangementGamme } from "@/features/grid/api/enregistrer-gammes"; + +export interface ResultatGamme { + codein: string; + /** Gamme désormais affectée. */ + gamme: string; + /** Gamme courante avant l'appel (modifications enregistrées comprises). */ + gammePrecedente: string | null; + /** Gamme présente en base FF, non modifiée par l'API. */ + codeGammeServeur: string | null; + /** `false` quand l'article avait déjà cette gamme : rien n'a été écrit pour lui. */ + modifie: boolean; +} + +export interface AffectationGammes { + codeFournisseur: string; + nomFournisseur: string | null; + resultats: ResultatGamme[]; +} + +/** Lit un corps JSON, ou renvoie une réponse 400 prête à être retournée. */ +export async function lireCorpsJson(req: NextRequest): Promise { + try { + return await req.json(); + } catch { + return fail("bad_request", "Corps de requête JSON attendu (Content-Type: application/json)."); + } +} + +/** + * Affecte des gammes à des articles d'un fournisseur. + * + * Si le fournisseur n'a encore jamais été calculé, son instantané est calculé à la + * demande (`compute`), comme sur `/grid` : sans lui, impossible de vérifier que les + * articles lui appartiennent. + */ +export async function affecterGammes(input: { + codeFournisseur: string; + changes: ReadonlyArray<{ codein: string; gamme: string }>; + compute: boolean; + /** Nom de la clé d'API ou de l'utilisateur — repris dans le libellé du snapshot. */ + auteur: string; +}): Promise { + const { codeFournisseur, compute, auteur } = input; + + // Un même article cité deux fois : la dernière valeur l'emporte. + const demandes = new Map(input.changes.map((c) => [c.codein, c.gamme])); + const codeins = [...demandes.keys()]; + + if (compute && !(await getGridFreshness(codeFournisseur))) { + console.log(`[api/v1/gammes] instantané absent pour ${codeFournisseur} — calcul à la demande`); + try { + await getProductRows({ codeFournisseur, magasin: "TOTAL" }); + } catch (e) { + console.error(`[api/v1/gammes] calcul KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(e)); + return fail("internal_error", `Le calcul de la grille a échoué pour le fournisseur « ${codeFournisseur} ».`); + } + } + + const actuelles = await getGridRowsGammes(codeFournisseur, codeins); + const inconnus = codeins.filter((c) => !actuelles.has(c)); + if (inconnus.length > 0) { + return fail( + "not_found", + `${inconnus.length} article(s) introuvable(s) chez le fournisseur « ${codeFournisseur} » : aucune gamme n'a été enregistrée.`, + { fournisseur: codeFournisseur, inconnus }, + ); + } + + const resultats: ResultatGamme[] = []; + const changements: ChangementGamme[] = []; + let nomFournisseur: string | null = null; + + for (const [codein, gamme] of demandes) { + const actuelle = actuelles.get(codein)!; + nomFournisseur ??= actuelle.nomFournisseur; + const modifie = actuelle.codeGamme !== gamme; + resultats.push({ + codein, + gamme, + gammePrecedente: actuelle.codeGamme, + codeGammeServeur: actuelle.codeGammeInit, + modifie, + }); + if (modifie) { + // Même « avant » que la Grille : l'état serveur, à défaut la gamme courante. + changements.push({ codein, codeGammeBefore: actuelle.codeGammeInit ?? actuelle.codeGamme, codeGamme: gamme }); + } + } + + try { + await enregistrerGammes({ + codeFournisseur, + nomFournisseur, + magasin: "TOTAL", + changes: changements, + userId: null, + label: `API (${auteur}) — ${new Date().toLocaleDateString("fr-FR")}`, + }); + } catch (e) { + console.error(`[api/v1/gammes] enregistrement KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(e)); + return fail("internal_error", "L'enregistrement des gammes a échoué."); + } + + console.log(`[api/v1/gammes] ${codeFournisseur} — ${changements.length} gamme(s) enregistrée(s) par ${auteur}`); + return { codeFournisseur, nomFournisseur, resultats }; +} diff --git a/src/lib/api-schemas.ts b/src/lib/api-schemas.ts index 2b815ea..7e0af74 100644 --- a/src/lib/api-schemas.ts +++ b/src/lib/api-schemas.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { GRID_SORT_KEYS, type GridSortKey } from "@/lib/grid-store"; +import { GAMMES, type CodeGamme } from "@/lib/gammes"; const SORT_KEYS = GRID_SORT_KEYS as readonly string[]; @@ -116,6 +117,30 @@ export const productDetailSchema = z.object({ compute: z.enum(["0", "1"]).default("1"), }); +const CODES_GAMME = GAMMES.map((g) => g.code) as [CodeGamme, ...CodeGamme[]]; +const gammeShape = z.enum(CODES_GAMME, { + message: `Gamme inconnue. Valeurs acceptées : ${CODES_GAMME.join(", ")}`, +}); + +/** `PUT /api/v1/products/:codein/gamme` — affecte ou change la gamme d'un article. */ +export const productGammeBodySchema = z.object({ + gamme: gammeShape, + /** Obligatoire seulement si l'article est référencé chez plusieurs fournisseurs. */ + fournisseur: z.string().min(1).optional(), + /** `true` (défaut) : calcule l'instantané du fournisseur s'il n'existe pas encore. */ + compute: z.boolean().default(true), +}); + +/** `POST /api/v1/gammes` — affecte des gammes à plusieurs articles d'un fournisseur. */ +export const gammesBatchBodySchema = z.object({ + fournisseur: z.string().min(1, "Champ 'fournisseur' requis"), + changes: z + .array(z.object({ codein: z.string().min(1), gamme: gammeShape })) + .min(1, "Au moins un changement attendu") + .max(10_000, "10 000 changements au maximum par appel"), + compute: z.boolean().default(true), +}); + /** * Projection de champs : `?fields=codein,libelle1,totalCa`. * diff --git a/src/lib/grid-store.ts b/src/lib/grid-store.ts index 5707110..db29e82 100644 --- a/src/lib/grid-store.ts +++ b/src/lib/grid-store.ts @@ -314,6 +314,55 @@ export async function getGridRowByCodein( }; } +/** Fournisseurs chez lesquels un article figure dans l'instantané. */ +export async function listGridSuppliersForCodein( + codein: string, +): Promise> { + return db + .select({ codeFournisseur: gridRows.codeFournisseur, nomFournisseur: gridRows.nomFournisseur }) + .from(gridRows) + .where(eq(gridRows.codein, codein)) + .orderBy(asc(gridRows.codeFournisseur)); +} + +export interface GammeInstantane { + /** Gamme courante, modifications enregistrées comprises. */ + codeGamme: string | null; + /** Gamme serveur au moment du calcul de la grille. */ + codeGammeInit: string | null; + nomFournisseur: string | null; +} + +/** + * Gammes d'articles d'un fournisseur dans l'instantané. Un codein absent de la + * carte n'appartient pas à ce fournisseur (ou n'a pas encore été calculé). + */ +export async function getGridRowsGammes( + codeFournisseur: string, + codeins: readonly string[], +): Promise> { + const out = new Map(); + const CHUNK = 5000; // bien en deçà des 65 535 paramètres liés de PostgreSQL + for (let i = 0; i < codeins.length; i += CHUNK) { + const found = await db + .select({ + codein: gridRows.codein, + codeGamme: gridRows.codeGamme, + codeGammeInit: gridRows.codeGammeInit, + nomFournisseur: gridRows.nomFournisseur, + }) + .from(gridRows) + .where(and( + eq(gridRows.codeFournisseur, codeFournisseur), + inArray(gridRows.codein, codeins.slice(i, i + CHUNK)), + )); + for (const r of found) { + out.set(r.codein, { codeGamme: r.codeGamme, codeGammeInit: r.codeGammeInit, nomFournisseur: r.nomFournisseur }); + } + } + return out; +} + /** * Indique si un fournisseur a déjà été calculé au moins une fois. * Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long.