mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-24 07:28:10 +02:00
API : affecter ou changer la gamme d'un produit
Deux endpoints d'écriture dans /api/v1 :
- PUT /products/{codein}/gamme { gamme, fournisseur? } pour un article ;
- POST /gammes { fournisseur, changes[] } pour plusieurs articles d'un
fournisseur, en tout ou rien.
Ils passent par le même enregistrement que la Grille (extrait de
saveDraftChanges dans enregistrerGammes) : snapshot du fournisseur, cache
de la Grille et instantané grid_rows. Gamme validée contre la liste des
gammes (A, B, C, Y, Z) ; un article déjà dans la gamme demandée n'est pas
réécrit. Spec OpenAPI et page de connexion à l'API mises à jour ; la
légende des gammes de la spec, qui citait une gamme D inexistante, suit
désormais lib/gammes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEXT61tGbsXHUDNBeghCXQ
This commit is contained in:
9 files changed
+544
-54
No files matched your search
@@ -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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { GRID_SORT_KEYS } from "@/lib/grid-store";
|
import { GRID_SORT_KEYS } from "@/lib/grid-store";
|
||||||
|
import { GAMMES } from "@/lib/gammes";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
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 = {
|
const spec = {
|
||||||
openapi: "3.1.0",
|
openapi: "3.1.0",
|
||||||
info: {
|
info: {
|
||||||
title: "CollectFlow API",
|
title: "CollectFlow API",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
description:
|
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"
|
+ "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 "
|
+ "**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 "
|
+ "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"
|
+ "alimenté par une synchronisation séparée.\n\n"
|
||||||
+ "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques "
|
+ "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 "
|
+ "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" }],
|
servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }],
|
||||||
security: [{ ApiKeyAuth: [] }],
|
security: [{ ApiKeyAuth: [] }],
|
||||||
@@ -137,10 +149,9 @@ export async function GET(req: NextRequest) {
|
|||||||
type: ["string", "null"],
|
type: ["string", "null"],
|
||||||
description:
|
description:
|
||||||
"Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour "
|
"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), "
|
+ "raisonner. `null` = aucune gamme. Valeurs : " + legendeGammes + ".",
|
||||||
+ "C (performance), D (saisonnier/niche), Z (sortie).",
|
|
||||||
},
|
},
|
||||||
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." },
|
totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
|
||||||
totalQuantite: { type: "number" },
|
totalQuantite: { type: "number" },
|
||||||
totalMarge: { type: "number" },
|
totalMarge: { type: "number" },
|
||||||
@@ -162,6 +173,16 @@ export async function GET(req: NextRequest) {
|
|||||||
trend: { $ref: "#/components/schemas/Trend" },
|
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: {
|
Trend: {
|
||||||
type: ["object", "null"],
|
type: ["object", "null"],
|
||||||
description:
|
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.",
|
+ "ou n'a aucun article — le signaler plutôt que de conclure à une panne.",
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" }, description: "Code fournisseur (voir listerFournisseurs)." },
|
{ 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: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." },
|
||||||
{ name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." },
|
{ 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)." },
|
{ 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}": {
|
"/network/{codeCentrale}": {
|
||||||
get: {
|
get: {
|
||||||
operationId: "obtenirMetriquesReseau",
|
operationId: "obtenirMetriquesReseau",
|
||||||
|
|||||||
@@ -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() } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: "/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/search?q=terme", desc: "Recherche de produits, tous fournisseurs confondus" },
|
||||||
{ method: "GET", path: "/products/{codein}", desc: "Fiche complète d'un produit" },
|
{ 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: "/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)" },
|
{ method: "GET", path: "/openapi.json", desc: "Description de l'API lisible par un programme (format OpenAPI)" },
|
||||||
];
|
];
|
||||||
@@ -203,6 +205,7 @@ export function ApiConnectionInfo() {
|
|||||||
</Repli>
|
</Repli>
|
||||||
|
|
||||||
<Repli titre="Exemples d'appels">
|
<Repli titre="Exemples d'appels">
|
||||||
|
<CodeLine>{`curl -X PUT -H "X-API-Key: VOTRE_CLE" -H "Content-Type: application/json" \\\n -d '{"gamme":"B"}' "${base}/products/123456/gamme"`}</CodeLine>
|
||||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`}</CodeLine>
|
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`}</CodeLine>
|
||||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
|
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
|
||||||
<p className="pt-1 text-[13px] text-[var(--text-secondary)]">
|
<p className="pt-1 text-[13px] text-[var(--text-secondary)]">
|
||||||
|
|||||||
@@ -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<number> {
|
||||||
|
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<string, { before: string | null; after: string }> =
|
||||||
|
existing.length > 0
|
||||||
|
? (existing[0].changes as Record<string, { before: string | null; after: string }>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { db } from "@/db";
|
|
||||||
import { sessionSnapshots } from "@/db/schema";
|
|
||||||
import { eq, desc } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { verifierSession } from "@/lib/authz";
|
import { verifierSession } from "@/lib/authz";
|
||||||
import { patchGridRowsCache } from "./get-product-rows";
|
import { enregistrerGammes } from "./enregistrer-gammes";
|
||||||
import { updateGridRowsGamme } from "@/lib/grid-store";
|
|
||||||
|
|
||||||
const SaveDraftsSchema = z.object({
|
const SaveDraftsSchema = z.object({
|
||||||
codeFournisseur: z.string(),
|
codeFournisseur: z.string(),
|
||||||
@@ -39,49 +35,8 @@ export async function saveDraftChanges(
|
|||||||
const finalUserId = userId && !isNaN(userId) ? userId : null;
|
const finalUserId = userId && !isNaN(userId) ? userId : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Charger le dernier snapshot existant pour merger les changements
|
const saved = await enregistrerGammes({ codeFournisseur, nomFournisseur, magasin, changes, userId: finalUserId });
|
||||||
const existing = await db
|
return { success: true, saved };
|
||||||
.select()
|
|
||||||
.from(sessionSnapshots)
|
|
||||||
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
|
|
||||||
.orderBy(desc(sessionSnapshots.createdAt))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const prevChanges: Record<string, { before: string | null; after: string }> =
|
|
||||||
existing.length > 0
|
|
||||||
? (existing[0].changes as Record<string, { before: string | null; after: string }>)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
// 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 };
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||||
return { success: false, saved: 0, error: msg };
|
return { success: false, saved: 0, error: msg };
|
||||||
|
|||||||
@@ -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<unknown | Response> {
|
||||||
|
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<AffectationGammes | Response> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { GRID_SORT_KEYS, type GridSortKey } from "@/lib/grid-store";
|
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[];
|
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"),
|
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`.
|
* Projection de champs : `?fields=codein,libelle1,totalCa`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -314,6 +314,55 @@ export async function getGridRowByCodein(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fournisseurs chez lesquels un article figure dans l'instantané. */
|
||||||
|
export async function listGridSuppliersForCodein(
|
||||||
|
codein: string,
|
||||||
|
): Promise<Array<{ codeFournisseur: string; nomFournisseur: string | null }>> {
|
||||||
|
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<Map<string, GammeInstantane>> {
|
||||||
|
const out = new Map<string, GammeInstantane>();
|
||||||
|
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.
|
* 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.
|
* Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long.
|
||||||
|
|||||||
Reference in new issue
Block a user