mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +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 { 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",
|
||||
|
||||
@@ -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: "/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() {
|
||||
</Repli>
|
||||
|
||||
<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}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
|
||||
<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";
|
||||
|
||||
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<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 };
|
||||
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 };
|
||||
|
||||
@@ -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 { 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`.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
* Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long.
|
||||
|
||||
Reference in new issue
Block a user