+
+
r.modifie).length,
+ aValider: res.resultats.filter((r) => r.statut === "a_valider").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 cd99026..3b2588d 100644
--- a/src/app/api/v1/openapi.json/route.ts
+++ b/src/app/api/v1/openapi.json/route.ts
@@ -72,9 +72,11 @@ export async function GET(req: NextRequest) {
+ "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée "
+ "(`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.",
+ + "**proposent** une gamme. La proposition reste **à valider** : elle apparaît dans la Grille "
+ + "comme une modification non enregistrée, pour voir quels produits changent de gamme, et "
+ + "n'est appliquée qu'une fois validée par un utilisateur (« Enregistrer »). D'ici là, "
+ + "`codeGamme` est inchangée et la proposition figure dans `gammeAValider`. Une fois validée, "
+ + "`codeGammeServeur` reste la gamme en base FF jusqu'à l'import des gammes modifiées.",
},
servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }],
security: [{ ApiKeyAuth: [] }],
@@ -151,7 +153,8 @@ export async function GET(req: NextRequest) {
"Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour "
+ "raisonner. `null` = aucune gamme. Valeurs : " + legendeGammes + ".",
},
- codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications enregistrées dans CollectFlow (Grille ou API) comprises." },
+ codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications validées dans CollectFlow comprises." },
+ gammeAValider: { type: ["string", "null"], description: "Gamme proposée par l'API et pas encore validée dans la Grille. `null` = aucune proposition en attente." },
totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
totalQuantite: { type: "number" },
totalMarge: { type: "number" },
@@ -177,10 +180,11 @@ export async function GET(req: NextRequest) {
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." },
+ gamme: { type: "string", enum: codesGamme, description: "Gamme proposée." },
+ gammePrecedente: { type: ["string", "null"], description: "Gamme enregistrée au moment de 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." },
+ modifie: { type: "boolean", description: "true = proposition déposée, à valider dans la Grille. false = l'article a déjà cette gamme (une proposition en attente pour lui est retirée)." },
+ statut: { type: "string", enum: ["a_valider", "inchangee"], description: "`a_valider` : en attente de validation dans la Grille." },
},
},
Trend: {
@@ -390,9 +394,10 @@ export async function GET(req: NextRequest) {
"/products/{codein}/gamme": {
put: {
operationId: "changerGammeProduit",
- summary: "Affecter ou changer la gamme d'un produit",
+ summary: "Proposer la gamme d'un produit (à valider)",
description:
- "Enregistre la gamme d'un article, comme la Grille. Valeurs : " + legendeGammes + ". "
+ "Propose une gamme pour un article. Elle reste à valider dans la Grille, où elle apparaît "
+ + "comme une modification non enregistrée. 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.",
@@ -417,7 +422,7 @@ export async function GET(req: NextRequest) {
},
responses: {
"200": {
- description: "Gamme enregistrée",
+ description: "Gamme proposée, à valider",
content: {
"application/json": {
schema: {
@@ -444,10 +449,10 @@ export async function GET(req: NextRequest) {
"/gammes": {
post: {
operationId: "changerGammes",
- summary: "Affecter ou changer la gamme de plusieurs produits",
+ summary: "Proposer la gamme de plusieurs produits (à valider)",
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é "
+ "Propose en un appel les gammes de plusieurs articles d'un même fournisseur, à valider "
+ + "dans la Grille. Tout ou rien : si un article est inconnu chez le fournisseur, rien n'est proposé "
+ "et la réponse 404 liste les articles en cause dans `error.details.inconnus`. "
+ "Valeurs : " + legendeGammes + ".",
requestBody: {
@@ -480,7 +485,7 @@ export async function GET(req: NextRequest) {
},
responses: {
"200": {
- description: "Gammes enregistrées",
+ description: "Gammes proposées, à valider",
content: {
"application/json": {
schema: {
diff --git a/src/app/api/v1/products/[codein]/gamme/route.ts b/src/app/api/v1/products/[codein]/gamme/route.ts
index fa22a2f..926ffc3 100644
--- a/src/app/api/v1/products/[codein]/gamme/route.ts
+++ b/src/app/api/v1/products/[codein]/gamme/route.ts
@@ -13,8 +13,9 @@ 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`.
+ * Propose une gamme pour un article. La proposition reste **à valider** : elle
+ * apparaît dans la Grille comme une modification non enregistrée, et `codeGamme`
+ * ne change qu'une fois validée (« Enregistrer » dans la Grille).
*
* `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é.
diff --git a/src/db/schema.ts b/src/db/schema.ts
index dec4ba0..a480879 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -1,4 +1,4 @@
-import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean } from "drizzle-orm/pg-core";
+import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean, primaryKey } from "drizzle-orm/pg-core";
export const ventesProduits = pgTable("ventes_produits", {
id: serial("id").primaryKey(),
@@ -283,3 +283,26 @@ export const aiSupplierContext = pgTable("ai_supplier_context", {
/** When it was last updated */
updatedAt: timestamp("updated_at").defaultNow(),
});
+
+/**
+ * Gammes proposées par l'API `/api/v1`, en attente de validation.
+ *
+ * L'API n'enregistre plus directement : chaque proposition attend ici qu'un
+ * utilisateur l'examine dans la Grille, où elle apparaît comme une modification
+ * non enregistrée. « Enregistrer » la valide (snapshot + `grid_rows`), « Annuler
+ * les modifications » la rejette ; dans les deux cas la ligne est supprimée.
+ * Une seule proposition par article et fournisseur : la dernière l'emporte.
+ */
+export const gammesAValider = pgTable("gammes_a_valider", {
+ codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
+ codein: varchar("codein", { length: 20 }).notNull(),
+ /** Gamme proposée. */
+ gamme: varchar("gamme", { length: 20 }).notNull(),
+ /** Gamme enregistrée au moment de la proposition. */
+ gammePrecedente: varchar("gamme_precedente", { length: 20 }),
+ /** Nom de la clé d'API à l'origine de la proposition. */
+ auteur: varchar("auteur", { length: 100 }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+}, (table) => [
+ primaryKey({ columns: [table.codeFournisseur, table.codein] }),
+]);
diff --git a/src/features/admin/components/api-connection-info.tsx b/src/features/admin/components/api-connection-info.tsx
index 175675f..7aee6bd 100644
--- a/src/features/admin/components/api-connection-info.tsx
+++ b/src/features/admin/components/api-connection-info.tsx
@@ -79,8 +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: "PUT", path: "/products/{codein}/gamme", desc: "Proposer la gamme d'un produit, à valider dans la Révision d'assortiment — corps : { \"gamme\": \"A\" }" },
+ { method: "POST", path: "/gammes", desc: "Proposer la gamme de plusieurs produits d'un fournisseur en un appel, à valider dans la Révision d'assortiment" },
{ 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)" },
];
diff --git a/src/features/grid/api/gammes-a-valider-actions.ts b/src/features/grid/api/gammes-a-valider-actions.ts
new file mode 100644
index 0000000..0a7a8f9
--- /dev/null
+++ b/src/features/grid/api/gammes-a-valider-actions.ts
@@ -0,0 +1,37 @@
+"use server";
+
+import { z } from "zod";
+import { verifierSession } from "@/lib/authz";
+import { listerGammesAValider, supprimerGammesAValider, type GammeAValider } from "@/lib/gammes-a-valider";
+
+/** Gammes proposées par l'API pour un fournisseur, en attente de validation. */
+export async function getGammesAValider(
+ codeFournisseur: string,
+): Promise<{ success: boolean; propositions: GammeAValider[]; error?: string }> {
+ const acces = await verifierSession();
+ if (!acces.ok) return { success: false, propositions: [], error: acces.message };
+ try {
+ return { success: true, propositions: await listerGammesAValider(String(codeFournisseur)) };
+ } catch (err) {
+ return { success: false, propositions: [], error: err instanceof Error ? err.message : "Unknown error" };
+ }
+}
+
+const RejetSchema = z.object({
+ codeFournisseur: z.string().min(1),
+ codeins: z.array(z.string()),
+});
+
+/** Rejette des propositions de l'API (« Annuler les modifications » dans la Grille). */
+export async function rejeterGammesAValider(raw: unknown): Promise<{ success: boolean; error?: string }> {
+ const acces = await verifierSession();
+ if (!acces.ok) return { success: false, error: acces.message };
+ const parsed = RejetSchema.safeParse(raw);
+ if (!parsed.success) return { success: false, error: "Validation failed: " + parsed.error.message };
+ try {
+ await supprimerGammesAValider(parsed.data.codeFournisseur, parsed.data.codeins);
+ return { success: true };
+ } catch (err) {
+ return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
+ }
+}
diff --git a/src/features/grid/api/save-draft-changes.ts b/src/features/grid/api/save-draft-changes.ts
index ee70b34..1c432cd 100644
--- a/src/features/grid/api/save-draft-changes.ts
+++ b/src/features/grid/api/save-draft-changes.ts
@@ -3,6 +3,7 @@
import { z } from "zod";
import { verifierSession } from "@/lib/authz";
import { enregistrerGammes } from "./enregistrer-gammes";
+import { supprimerGammesAValider } from "@/lib/gammes-a-valider";
const SaveDraftsSchema = z.object({
codeFournisseur: z.string(),
@@ -15,6 +16,11 @@ const SaveDraftsSchema = z.object({
codeGamme: z.string(),
})
),
+ /**
+ * Propositions de l'API examinées dans la Grille : validées (présentes dans
+ * `changes`) ou écartées. Elles quittent la file « à valider ».
+ */
+ gammesAValiderTraitees: z.array(z.string()).optional(),
});
export async function saveDraftChanges(
@@ -28,7 +34,7 @@ export async function saveDraftChanges(
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
}
- const { codeFournisseur, nomFournisseur, magasin, changes } = parsed.data;
+ const { codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees = [] } = parsed.data;
const rawUserId = acces.utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
@@ -36,6 +42,7 @@ export async function saveDraftChanges(
try {
const saved = await enregistrerGammes({ codeFournisseur, nomFournisseur, magasin, changes, userId: finalUserId });
+ await supprimerGammesAValider(codeFournisseur, gammesAValiderTraitees);
return { success: true, saved };
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
diff --git a/src/features/grid/components/floating-summary-bar.tsx b/src/features/grid/components/floating-summary-bar.tsx
index aa3b854..a1426e5 100644
--- a/src/features/grid/components/floating-summary-bar.tsx
+++ b/src/features/grid/components/floating-summary-bar.tsx
@@ -8,6 +8,7 @@ import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
import { ChevronDown, Download, FileSpreadsheet, FileText, History, Loader2, RotateCcw, Save, Table2 } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
import { saveSnapshot } from "@/features/snapshots/api/save-snapshot";
+import { rejeterGammesAValider } from "@/features/grid/api/gammes-a-valider-actions";
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
import {
DropdownMenu,
@@ -62,7 +63,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
const [isPending, startTransition] = useTransition();
const [enCours, setEnCours] = useState(null);
- const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL");
+ const { save, hasDrafts, count, nbAValider } = useSaveDrafts(activeMagasin || "TOTAL");
const supplierCode = filterFournisseur || rows[0]?.codeFournisseur;
const lastQlikUpdate = useMemo(() => {
@@ -77,7 +78,9 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
startTransition(async () => {
try {
const result = await save();
- if (result.success) {
+ if (result.success && result.saved === 0) {
+ toast.info("Propositions de l'API écartées.");
+ } else if (result.success) {
toast.succes(`${result.saved} gamme${result.saved > 1 ? "s" : ""} enregistrée${result.saved > 1 ? "s" : ""}.`);
} else {
toast.erreur(`Enregistrement impossible : ${result.error ?? "erreur inconnue"}`);
@@ -89,16 +92,36 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
};
const handleReset = async () => {
+ const pluriel = count > 1 ? "s" : "";
+ const message = count > 0
+ ? `Les ${count} gamme${pluriel} modifiée${pluriel} et non enregistrée${pluriel} reviendront à leur valeur précédente.`
+ : "";
const ok = await confirmer({
titre: "Annuler les modifications ?",
- message: `Les ${count} gamme${count > 1 ? "s" : ""} modifiée${count > 1 ? "s" : ""} et non enregistrée${count > 1 ? "s" : ""} reviendront à leur valeur précédente.`,
+ message: nbAValider > 0
+ ? `${message} ${nbAValider > 1 ? `Les ${nbAValider} gammes proposées` : "La gamme proposée"} par l'API ${nbAValider > 1 ? "seront rejetées" : "sera rejetée"}.`.trim()
+ : message,
libelleConfirmer: "Annuler les modifications",
danger: true,
});
- if (ok) {
- resetDrafts();
- toast.info("Modifications annulées.");
+ if (!ok) return;
+ if (nbAValider > 0) {
+ const { gammesAValider, rowsByCodein, retirerGammesAValider } = useGridStore.getState();
+ const codeins = Object.keys(gammesAValider).filter((c) => rowsByCodein[c]);
+ const codeFournisseur = filterFournisseur || rows[0]?.codeFournisseur;
+ try {
+ const res = codeFournisseur
+ ? await rejeterGammesAValider({ codeFournisseur, codeins })
+ : { success: false, error: "fournisseur inconnu" };
+ if (!res.success) throw new Error(res.error);
+ retirerGammesAValider(codeins);
+ } catch (err) {
+ toast.erreur(`Les propositions de l'API n'ont pas pu être rejetées : ${messageErreur(err)}`);
+ return;
+ }
}
+ resetDrafts();
+ toast.info("Modifications annulées.");
};
/**
@@ -256,6 +279,14 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
+ {nbAValider > 0 && (
+
+ {nbAValider} gamme{nbAValider > 1 ? "s" : ""} proposée{nbAValider > 1 ? "s" : ""} par l'API à valider
+
+ )}
{hasDrafts && (
diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx
index 3a02738..34bf1ca 100644
--- a/src/features/grid/components/grid-client.tsx
+++ b/src/features/grid/components/grid-client.tsx
@@ -7,6 +7,7 @@ import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolba
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
import { useStorePatch } from "@/features/grid/hooks/use-store-patch";
+import { getGammesAValider } from "@/features/grid/api/gammes-a-valider-actions";
import type { ProductRow } from "@/types/grid";
import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -231,6 +232,26 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
return () => controller.abort();
}, [codeFournisseur, refreshRequest, setRows, isMounted]);
+ // Gammes proposées par l'API : relues une fois les lignes du fournisseur
+ // chargées (ou retrouvées en mémoire), puis affichées comme modifications
+ // non enregistrées, à valider.
+ const lignesChargeesLe = useGridStore((s) => (s.rowsMeta?.key === rowsKeyFor(codeFournisseur) ? s.rowsMeta.loadedAt : null));
+ useEffect(() => {
+ if (!isMounted || lignesChargeesLe === null) return;
+ let annule = false;
+ getGammesAValider(codeFournisseur)
+ .then((res) => {
+ if (annule) return;
+ if (!res.success) {
+ console.error("[grid] gammes à valider KO:", res.error);
+ return;
+ }
+ useGridStore.getState().chargerGammesAValider(res.propositions);
+ })
+ .catch((e) => console.error("[grid] gammes à valider KO:", e));
+ return () => { annule = true; };
+ }, [codeFournisseur, lignesChargeesLe, isMounted]);
+
// Compléments de l'API FF pour le magasin choisi, appliqués à leur arrivée.
useStorePatch(codeFournisseur, isMounted);
diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx
index e76b6cc..1f9c071 100644
--- a/src/features/grid/components/heatmap-grid.tsx
+++ b/src/features/grid/components/heatmap-grid.tsx
@@ -244,6 +244,9 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
const isDraft = useGridStore((s) => s.draftChanges[codein] !== undefined);
const effectiveGamme = useGridStore((s) => s.draftChanges[codein] ?? row.codeGamme);
const setDraftGamme = useGridStore((s) => s.setDraftGamme);
+ // Proposition de l'API affichée telle quelle : à valider.
+ const proposition = useGridStore((s) => s.gammesAValider[codein]);
+ const proposeeParApi = isDraft && proposition !== undefined && proposition.gamme === effectiveGamme;
const isModified = row.codeGamme !== row.codeGammeInit && row.codeGammeInit !== null;
const displayValue = (!effectiveGamme || effectiveGamme.trim() === "") ? "Aucune" : effectiveGamme;
@@ -267,6 +270,14 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
isDraft={isDraft}
onChange={(g: GammeCode) => setDraftGamme(codein, g)}
/>
+ {proposeeParApi && (
+
+ API
+
+ )}
{isModified && !isDraft && (
diff --git a/src/features/grid/hooks/use-save-drafts.ts b/src/features/grid/hooks/use-save-drafts.ts
index 29725e0..5cdc49f 100644
--- a/src/features/grid/hooks/use-save-drafts.ts
+++ b/src/features/grid/hooks/use-save-drafts.ts
@@ -23,9 +23,15 @@ export function useSaveDrafts(magasin: string) {
for (const codein in s.draftChanges) if (s.rowsByCodein[codein]) n++;
return n;
});
+ /** Propositions de l'API en attente sur les lignes chargées. */
+ const nbAValider = useGridStore((s) => {
+ let n = 0;
+ for (const codein in s.gammesAValider) if (s.rowsByCodein[codein]) n++;
+ return n;
+ });
const save = useCallback(async () => {
- const { draftChanges, rowsByCodein, applyDraftsToRows, clearDrafts } = useGridStore.getState();
+ const { draftChanges, rowsByCodein, rows, gammesAValider, applyDraftsToRows, clearDrafts, retirerGammesAValider } = useGridStore.getState();
const activeDrafts = modificationsActives(draftChanges, rowsByCodein);
// Index par code article : `rows.find` dans la boucle coûtait
// (modifications × lignes) comparaisons sur un gros fournisseur.
@@ -37,15 +43,18 @@ export function useSaveDrafts(magasin: string) {
codeGamme: codeGamme as GammeCode,
};
});
+ // Propositions de l'API examinées : enregistrées si elles sont restées en
+ // modification, écartées sinon. Dans les deux cas, elles sont traitées.
+ const gammesAValiderTraitees = Object.keys(gammesAValider).filter((codein) => rowsByCodein[codein]);
- if (changes.length === 0) return { success: true, saved: 0 };
+ if (changes.length === 0 && gammesAValiderTraitees.length === 0) return { success: true, saved: 0 };
// Fournisseur déduit de la première ligne modifiée
- const firstRow = rowsByCodein[changes[0].codein];
+ const firstRow = rowsByCodein[changes[0]?.codein ?? gammesAValiderTraitees[0]] ?? rows[0];
const codeFournisseur = firstRow?.codeFournisseur ?? "";
const nomFournisseur = firstRow?.nomFournisseur ?? "";
- const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes });
+ const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees });
if (result.success) {
// Report des valeurs enregistrées sur row.codeGamme AVANT de vider les
@@ -53,9 +62,10 @@ export function useSaveDrafts(magasin: string) {
// codeGammeInit) reste juste.
applyDraftsToRows(activeDrafts);
clearDrafts(Object.keys(activeDrafts));
+ retirerGammesAValider(gammesAValiderTraitees);
}
return result;
}, [magasin]);
- return { save, hasDrafts: count > 0, count };
+ return { save, hasDrafts: count > 0 || nbAValider > 0, count, nbAValider };
}
diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts
index ebe4eee..2579754 100644
--- a/src/features/grid/store/use-grid-store.ts
+++ b/src/features/grid/store/use-grid-store.ts
@@ -15,6 +15,14 @@ export interface RowsMeta {
patchedStores: string[];
}
+/** Gamme proposée par l'API, en attente de validation (cf. lib/gammes-a-valider). */
+export interface GammeProposee {
+ gamme: string;
+ auteur: string | null;
+ /** Date ISO de la proposition. */
+ proposeeLe: string;
+}
+
/** Clé des lignes chargées pour un fournisseur (toujours « tous magasins »). */
export const rowsKeyFor = (codeFournisseur: string) => `${codeFournisseur}:TOTAL`;
@@ -25,6 +33,20 @@ interface GridState {
rowsByCodein: Record
;
/** Draft edits: codein → new GammeCode */
draftChanges: Record;
+ /**
+ * Propositions de l'API en attente pour le fournisseur affiché (codein →
+ * proposition). Non persisté : relu sur le serveur à chaque chargement.
+ */
+ gammesAValider: Record;
+ /**
+ * Range les propositions de l'API et les reporte en modifications non
+ * enregistrées — sauf là où l'utilisateur a déjà sa propre modification, et
+ * sauf celles déjà connues (une proposition écartée à la main ne revient pas
+ * à chaque retour sur la Grille).
+ */
+ chargerGammesAValider: (propositions: ReadonlyArray<{ codein: string } & GammeProposee>) => void;
+ /** Retire des propositions traitées (validées ou rejetées). */
+ retirerGammesAValider: (codeins: string[]) => void;
filters: GridFilters;
summary: GridSummary;
displayDensity: "compact" | "normal" | "comfortable";
@@ -141,6 +163,7 @@ export const useGridStore = create()(
rowsByCodein: {},
activeMagasin: "TOTAL",
draftChanges: {},
+ gammesAValider: {},
filters: {
magasin: null,
codeFournisseur: null,
@@ -211,6 +234,28 @@ export const useGridStore = create()(
}, 80);
},
+ chargerGammesAValider: (propositions) => {
+ const { rowsByCodein, draftChanges, gammesAValider: connues } = get();
+ const gammesAValider: Record = {};
+ const nouvelles: Record = {};
+ for (const p of propositions) {
+ const row = rowsByCodein[p.codein];
+ if (!row) continue;
+ gammesAValider[p.codein] = { gamme: p.gamme, auteur: p.auteur, proposeeLe: p.proposeeLe };
+ const dejaConnue = connues[p.codein]?.proposeeLe === p.proposeeLe;
+ if (!dejaConnue && draftChanges[p.codein] === undefined && p.gamme !== row.codeGamme) {
+ nouvelles[p.codein] = p.gamme as GammeCode;
+ }
+ }
+ set({ gammesAValider });
+ if (Object.keys(nouvelles).length > 0) get().batchSetDraftGamme(nouvelles);
+ },
+ retirerGammesAValider: (codeins) => {
+ const gammesAValider = { ...get().gammesAValider };
+ codeins.forEach((id) => delete gammesAValider[id]);
+ set({ gammesAValider });
+ },
+
resetDrafts: () => {
set({ draftChanges: {} });
if (summaryTimer) clearTimeout(summaryTimer);
diff --git a/src/lib/api-enrich.ts b/src/lib/api-enrich.ts
index b99e52e..143ca9d 100644
--- a/src/lib/api-enrich.ts
+++ b/src/lib/api-enrich.ts
@@ -22,6 +22,7 @@ import "server-only";
import type { ProductRow } from "@/types/grid";
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
import { pgGetGammesByCodeins } from "@/lib/pg-ff-client";
+import { gammesAValiderParArticle } from "@/lib/gammes-a-valider";
// Module de la Grille : l'API doit renvoyer exactement la tendance affichée dans
// l'application, pas une seconde implémentation qui divergerait.
import { computeNetworkTrend, trendLabel } from "@/features/grid/lib/network-trend";
@@ -51,6 +52,11 @@ export interface EnrichedProductRow extends ProductRow {
* `null` si l'article n'a aucune gamme en base.
*/
codeGammeServeur: string | null;
+ /**
+ * Gamme proposée par l'API et pas encore validée dans la Grille.
+ * `null` = aucune proposition en attente.
+ */
+ gammeAValider: string | null;
/** Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas. */
network: NetworkMetricCached | null;
/**
@@ -73,7 +79,7 @@ export async function enrichRows(rows: ProductRow[]): Promise r.codeCentrale).filter((c): c is string => Boolean(c)))];
const codeins = [...new Set(rows.map((r) => r.codein).filter(Boolean))];
- const [network, gammes] = await Promise.all([
+ const [network, gammes, aValider] = await Promise.all([
codesCentraux.length
? getNetworkMetricsByCodeCentrale(codesCentraux).catch((e) => {
console.error("[api-enrich] métriques réseau KO:", (e as Error).message?.slice(0, 160));
@@ -86,6 +92,10 @@ export async function enrichRows(rows: ProductRow[]): Promise();
})
: Promise.resolve(new Map()),
+ gammesAValiderParArticle(codeins).catch((e) => {
+ console.error("[api-enrich] gammes à valider KO:", (e as Error).message?.slice(0, 160));
+ return new Map();
+ }),
]);
return rows.map((row) => {
@@ -97,6 +107,7 @@ export async function enrichRows(rows: ProductRow[]): Promise;
compute: boolean;
- /** Nom de la clé d'API ou de l'utilisateur — repris dans le libellé du snapshot. */
+ /** Nom de la clé d'API ou de l'utilisateur — affiché dans la Grille avec la proposition. */
auteur: string;
}): Promise {
const { codeFournisseur, compute, auteur } = input;
@@ -87,7 +95,8 @@ export async function affecterGammes(input: {
}
const resultats: ResultatGamme[] = [];
- const changements: ChangementGamme[] = [];
+ const propositions: PropositionGamme[] = [];
+ const annules: string[] = [];
let nomFournisseur: string | null = null;
for (const [codein, gamme] of demandes) {
@@ -100,27 +109,19 @@ export async function affecterGammes(input: {
gammePrecedente: actuelle.codeGamme,
codeGammeServeur: actuelle.codeGammeInit,
modifie,
+ statut: modifie ? "a_valider" : "inchangee",
});
- 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 });
- }
+ if (modifie) propositions.push({ codein, gamme, gammePrecedente: actuelle.codeGamme });
+ else annules.push(codein);
}
try {
- await enregistrerGammes({
- codeFournisseur,
- nomFournisseur,
- magasin: "TOTAL",
- changes: changements,
- userId: null,
- label: `API (${auteur}) — ${new Date().toLocaleDateString("fr-FR")}`,
- });
+ await proposerGammes({ codeFournisseur, propositions, annules, auteur });
} 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.error(`[api/v1/gammes] proposition KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(e));
+ return fail("internal_error", "L'enregistrement des gammes à valider a échoué.");
}
- console.log(`[api/v1/gammes] ${codeFournisseur} — ${changements.length} gamme(s) enregistrée(s) par ${auteur}`);
+ console.log(`[api/v1/gammes] ${codeFournisseur} — ${propositions.length} gamme(s) à valider proposée(s) par ${auteur}`);
return { codeFournisseur, nomFournisseur, resultats };
}
diff --git a/src/lib/gammes-a-valider.ts b/src/lib/gammes-a-valider.ts
new file mode 100644
index 0000000..4ba91d1
--- /dev/null
+++ b/src/lib/gammes-a-valider.ts
@@ -0,0 +1,104 @@
+/**
+ * CollectFlow — Gammes proposées par l'API, en attente de validation.
+ *
+ * L'API `/api/v1` ne change plus une gamme directement : elle dépose une
+ * proposition, que la Grille affiche comme une modification non enregistrée.
+ * L'utilisateur voit ainsi quels produits changent de gamme, puis valide
+ * (« Enregistrer ») ou rejette (« Annuler les modifications »).
+ */
+
+import "server-only";
+
+import { and, eq, inArray, sql } from "drizzle-orm";
+import { db } from "@/db";
+import { gammesAValider } from "@/db/schema";
+
+export interface PropositionGamme {
+ codein: string;
+ /** Gamme proposée. */
+ gamme: string;
+ /** Gamme enregistrée au moment de la proposition. */
+ gammePrecedente: string | null;
+}
+
+export interface GammeAValider extends PropositionGamme {
+ auteur: string | null;
+ /** Date ISO de la proposition. */
+ proposeeLe: string;
+}
+
+/**
+ * Dépose des propositions (la dernière l'emporte pour un même article) et retire
+ * celles des articles `annules` — redemander la gamme déjà enregistrée annule la
+ * proposition en attente.
+ */
+export async function proposerGammes(input: {
+ codeFournisseur: string;
+ propositions: PropositionGamme[];
+ annules: string[];
+ auteur: string;
+}): Promise {
+ const { codeFournisseur, propositions, annules, auteur } = input;
+ await db.transaction(async (tx) => {
+ if (annules.length > 0) {
+ await tx
+ .delete(gammesAValider)
+ .where(and(eq(gammesAValider.codeFournisseur, codeFournisseur), inArray(gammesAValider.codein, annules)));
+ }
+ if (propositions.length > 0) {
+ const maintenant = new Date();
+ await tx
+ .insert(gammesAValider)
+ .values(propositions.map((p) => ({
+ codeFournisseur,
+ codein: p.codein,
+ gamme: p.gamme,
+ gammePrecedente: p.gammePrecedente,
+ auteur: auteur.slice(0, 100),
+ createdAt: maintenant,
+ })))
+ .onConflictDoUpdate({
+ target: [gammesAValider.codeFournisseur, gammesAValider.codein],
+ set: {
+ gamme: sql`excluded.gamme`,
+ gammePrecedente: sql`excluded.gamme_precedente`,
+ auteur: sql`excluded.auteur`,
+ createdAt: sql`excluded.created_at`,
+ },
+ });
+ }
+ });
+}
+
+/** Propositions en attente pour un fournisseur. */
+export async function listerGammesAValider(codeFournisseur: string): Promise {
+ const rows = await db
+ .select()
+ .from(gammesAValider)
+ .where(eq(gammesAValider.codeFournisseur, codeFournisseur));
+ return rows.map((r) => ({
+ codein: r.codein,
+ gamme: r.gamme,
+ gammePrecedente: r.gammePrecedente,
+ auteur: r.auteur,
+ proposeeLe: r.createdAt.toISOString(),
+ }));
+}
+
+/** Propositions en attente pour une liste d'articles, indexées par `fournisseur|codein`. */
+export async function gammesAValiderParArticle(codeins: string[]): Promise