mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Merge pull request #23 from R0m1k3/claude/loving-fermi-qdh0qy
Gammes de l'API à valider dans la Grille, analyses pleine largeur, référence dans Meilleures ventes
This commit is contained in:
21 files changed
+413
-73
No files matched your search
@@ -212,6 +212,20 @@ async function main() {
|
||||
`);
|
||||
console.log("[DB Init] Table sync_fournisseurs is verified/created.");
|
||||
|
||||
// Gammes proposées par l'API /api/v1 : en attente de validation dans la Grille.
|
||||
await tempPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS "gammes_a_valider" (
|
||||
"code_fournisseur" varchar(20) NOT NULL,
|
||||
"codein" varchar(20) NOT NULL,
|
||||
"gamme" varchar(20) NOT NULL,
|
||||
"gamme_precedente" varchar(20),
|
||||
"auteur" varchar(100),
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("code_fournisseur", "codein")
|
||||
);
|
||||
`);
|
||||
console.log("[DB Init] Table gammes_a_valider is verified/created.");
|
||||
|
||||
await tempPool.end();
|
||||
console.log("[DB Init] Initialization successful. Exiting.");
|
||||
process.exit(0);
|
||||
|
||||
@@ -141,7 +141,7 @@ export default async function AnalyticsPage(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-2xl">
|
||||
<div className="w-full min-w-0">
|
||||
<PageHeader
|
||||
icon={CalendarRange}
|
||||
title="Ventes par mois"
|
||||
|
||||
@@ -134,6 +134,14 @@ const COLONNES: DataColumn<Ligne>[] = [
|
||||
footer: () => "Total",
|
||||
className: "whitespace-nowrap",
|
||||
},
|
||||
{
|
||||
id: "reference",
|
||||
header: "Référence",
|
||||
hint: "Référence fournisseur de l'article",
|
||||
sortValue: r => r.reference,
|
||||
cell: r => (r.reference ? <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.reference}</span> : vide),
|
||||
className: "whitespace-nowrap",
|
||||
},
|
||||
{
|
||||
id: "libelle",
|
||||
header: "Désignation",
|
||||
@@ -170,7 +178,7 @@ const FILTRES: DataFilter<Ligne>[] = [
|
||||
{ id: "famille", label: "Famille", valueOf: famille, allLabel: "Toutes les familles" },
|
||||
];
|
||||
|
||||
const recherche = (r: Ligne) => [r.codein, r.libelle];
|
||||
const recherche = (r: Ligne) => [r.codein, r.reference, r.libelle];
|
||||
|
||||
interface Props {
|
||||
dateDebut: string;
|
||||
@@ -204,21 +212,22 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
feuille: "Meilleures ventes",
|
||||
fichier: `meilleures-ventes_${dateDebut}_${dateFin}`,
|
||||
entetes: [
|
||||
"Code", "Désignation", "Fournisseur", "Nomenclature",
|
||||
"Code", "Référence", "Désignation", "Fournisseur", "Nomenclature",
|
||||
...GROUPES.flatMap(g => [
|
||||
`Qté ${nomExport(g)}`, `CA TTC ${nomExport(g)}`, `% Marge ${nomExport(g)}`, `Stock ${nomExport(g)}`,
|
||||
]),
|
||||
],
|
||||
largeurs: [12, 40, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])],
|
||||
largeurs: [12, 18, 40, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])],
|
||||
lignes: rows.map(r => [
|
||||
r.codein,
|
||||
r.reference,
|
||||
r.libelle.trim(),
|
||||
r.fournisseur,
|
||||
r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "",
|
||||
...GROUPES.flatMap(g => [r[g.qte], r[g.ca], pct(r[g.ca], r[g.marge]), r[g.stock]]),
|
||||
]),
|
||||
totaux: [
|
||||
`TOTAL — ${rows.length} articles`, "", "", "",
|
||||
`TOTAL — ${rows.length} articles`, "", "", "", "",
|
||||
...GROUPES.flatMap(g => {
|
||||
const ca = somme(rows, g.ca);
|
||||
return [somme(rows, g.qte), ca, pct(ca, somme(rows, g.marge)), somme(rows, g.stock)];
|
||||
@@ -266,7 +275,7 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
columns={COLONNES}
|
||||
rowKey={r => r.codein}
|
||||
searchIn={recherche}
|
||||
searchPlaceholder="Code ou désignation…"
|
||||
searchPlaceholder="Code, référence ou désignation…"
|
||||
filters={FILTRES}
|
||||
pageSize={100}
|
||||
unite="produits"
|
||||
|
||||
@@ -13,6 +13,8 @@ export const metadata: Metadata = { title: "Meilleures ventes" };
|
||||
export interface HitParadePivotRow {
|
||||
codein: string;
|
||||
libelle: string;
|
||||
/** Référence fournisseur de l'article. */
|
||||
reference: string;
|
||||
fournisseur: string;
|
||||
nomenclature_code: string;
|
||||
nomenclature: string;
|
||||
@@ -38,6 +40,7 @@ function pivotHitParade(rows: HitParadeRow[]): HitParadePivotRow[] {
|
||||
map.set(row.codein, {
|
||||
codein: row.codein,
|
||||
libelle: row.libelle,
|
||||
reference: row.reference ?? "",
|
||||
fournisseur: row.fournisseur,
|
||||
nomenclature_code: row.nomenclature_code,
|
||||
nomenclature: row.nomenclature,
|
||||
@@ -100,7 +103,7 @@ export default async function HitParadePage(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-2xl">
|
||||
<div className="w-full min-w-0">
|
||||
<PageHeader
|
||||
icon={Trophy}
|
||||
title="Meilleures ventes"
|
||||
|
||||
@@ -120,7 +120,7 @@ export default async function PublicitesPage(props: {
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-2xl">
|
||||
<div className="w-full min-w-0">
|
||||
<PageHeader
|
||||
icon={Megaphone}
|
||||
title="Publicités"
|
||||
|
||||
@@ -12,8 +12,8 @@ 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.
|
||||
* Propose la gamme de plusieurs articles d'un fournisseur en un appel. Les
|
||||
* propositions restent **à valider** dans la Grille (cf. lib/api-gammes).
|
||||
* Tout ou rien : un article inconnu chez le fournisseur fait échouer l'appel (404).
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -42,6 +42,7 @@ export async function POST(req: NextRequest) {
|
||||
nomFournisseur: res.nomFournisseur,
|
||||
demandes: res.resultats.length,
|
||||
modifies: res.resultats.filter((r) => r.modifie).length,
|
||||
aValider: res.resultats.filter((r) => r.statut === "a_valider").length,
|
||||
enregistreLe: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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é.
|
||||
|
||||
+24
-1
@@ -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] }),
|
||||
]);
|
||||
@@ -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)" },
|
||||
];
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string | null>(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
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{nbAValider > 0 && (
|
||||
<span
|
||||
className="rounded-lg border px-2.5 py-1 text-[13px] font-medium border-[var(--accent-warning)] text-[var(--accent-warning)]"
|
||||
title="Gammes proposées par l'API : elles apparaissent comme des modifications non enregistrées. « Enregistrer » les valide, « Annuler » les rejette."
|
||||
>
|
||||
{nbAValider} gamme{nbAValider > 1 ? "s" : ""} proposée{nbAValider > 1 ? "s" : ""} par l'API à valider
|
||||
</span>
|
||||
)}
|
||||
{hasDrafts && (
|
||||
<Button variant="ghost" onClick={handleReset} disabled={isPending}>
|
||||
<RotateCcw /> Annuler
|
||||
@@ -265,9 +296,11 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Save />}
|
||||
{isPending
|
||||
? "Enregistrement…"
|
||||
: hasDrafts
|
||||
: count > 0
|
||||
? `Enregistrer ${count} modification${count > 1 ? "s" : ""}`
|
||||
: "Aucune modification"}
|
||||
: hasDrafts
|
||||
? `Écarter ${nbAValider > 1 ? "les propositions" : "la proposition"} de l'API`
|
||||
: "Aucune modification"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<div
|
||||
className="absolute -right-2 -top-2 z-10 rounded px-1 text-[9px] font-bold leading-[14px] text-white shadow-sm bg-[var(--accent-warning)]"
|
||||
title={`Proposée par l'API${proposition.auteur ? ` (${proposition.auteur})` : ""} le ${new Date(proposition.proposeeLe).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })} — à valider`}
|
||||
>
|
||||
API
|
||||
</div>
|
||||
)}
|
||||
{isModified && !isDraft && (
|
||||
<div className="absolute -right-2 -top-2 bg-emerald-500 text-white rounded-full p-0.5 shadow-sm z-10 animate-in zoom-in-50" title="Modifié et validé">
|
||||
<Check className="w-2.5 h-2.5" />
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string, ProductRow>;
|
||||
/** Draft edits: codein → new GammeCode */
|
||||
draftChanges: Record<string, GammeCode>;
|
||||
/**
|
||||
* Propositions de l'API en attente pour le fournisseur affiché (codein →
|
||||
* proposition). Non persisté : relu sur le serveur à chaque chargement.
|
||||
*/
|
||||
gammesAValider: Record<string, GammeProposee>;
|
||||
/**
|
||||
* 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<GridState>()(
|
||||
rowsByCodein: {},
|
||||
activeMagasin: "TOTAL",
|
||||
draftChanges: {},
|
||||
gammesAValider: {},
|
||||
filters: {
|
||||
magasin: null,
|
||||
codeFournisseur: null,
|
||||
@@ -211,6 +234,28 @@ export const useGridStore = create<GridState>()(
|
||||
}, 80);
|
||||
},
|
||||
|
||||
chargerGammesAValider: (propositions) => {
|
||||
const { rowsByCodein, draftChanges, gammesAValider: connues } = get();
|
||||
const gammesAValider: Record<string, GammeProposee> = {};
|
||||
const nouvelles: Record<string, GammeCode> = {};
|
||||
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);
|
||||
|
||||
+12
-1
@@ -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<EnrichedProductRow
|
||||
const codesCentraux = [...new Set(rows.map((r) => 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<EnrichedProductRow
|
||||
return new Map<string, string>();
|
||||
})
|
||||
: Promise.resolve(new Map<string, string>()),
|
||||
gammesAValiderParArticle(codeins).catch((e) => {
|
||||
console.error("[api-enrich] gammes à valider KO:", (e as Error).message?.slice(0, 160));
|
||||
return new Map<string, string>();
|
||||
}),
|
||||
]);
|
||||
|
||||
return rows.map((row) => {
|
||||
@@ -97,6 +107,7 @@ export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow
|
||||
const enriched: EnrichedProductRow = {
|
||||
...row,
|
||||
codeGammeServeur: gammeServeur,
|
||||
gammeAValider: aValider.get(`${row.codeFournisseur}|${row.codein}`) ?? null,
|
||||
network: metrics,
|
||||
trend: null, // renseignée plus bas, une fois la série mensuelle connue
|
||||
};
|
||||
|
||||
+29
-28
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* 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.
|
||||
* L'API n'enregistre pas la gamme : elle la **propose**. La proposition est
|
||||
* rangée dans `gammes_a_valider` et apparaît dans la Grille comme une
|
||||
* modification non enregistrée — on y voit donc quels produits changent de gamme
|
||||
* avant de valider (« Enregistrer », même chemin que toute modification de la
|
||||
* Grille) ou de rejeter (« Annuler les modifications »). Tant qu'elle n'est pas
|
||||
* validée, `codeGamme` reste inchangée et la proposition est exposée dans
|
||||
* `gammeAValider`.
|
||||
*
|
||||
* 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.
|
||||
* proposé et la réponse liste les articles en cause.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
@@ -17,18 +19,24 @@ 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";
|
||||
import { proposerGammes, type PropositionGamme } from "@/lib/gammes-a-valider";
|
||||
|
||||
export interface ResultatGamme {
|
||||
codein: string;
|
||||
/** Gamme désormais affectée. */
|
||||
/** Gamme proposée. */
|
||||
gamme: string;
|
||||
/** Gamme courante avant l'appel (modifications enregistrées comprises). */
|
||||
/** Gamme enregistrée au moment de l'appel (modifications validé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. */
|
||||
/**
|
||||
* `true` = la gamme change : proposition déposée, à valider dans la Grille.
|
||||
* `false` = l'article a déjà cette gamme : rien à valider (une proposition
|
||||
* antérieure en attente pour lui est retirée).
|
||||
*/
|
||||
modifie: boolean;
|
||||
/** `a_valider` quand `modifie`, sinon `inchangee`. */
|
||||
statut: "a_valider" | "inchangee";
|
||||
}
|
||||
|
||||
export interface AffectationGammes {
|
||||
@@ -47,7 +55,7 @@ export async function lireCorpsJson(req: NextRequest): Promise<unknown | Respons
|
||||
}
|
||||
|
||||
/**
|
||||
* Affecte des gammes à des articles d'un fournisseur.
|
||||
* Propose des gammes pour des articles d'un fournisseur (à valider dans la Grille).
|
||||
*
|
||||
* 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
|
||||
@@ -57,7 +65,7 @@ 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. */
|
||||
/** Nom de la clé d'API ou de l'utilisateur — affiché dans la Grille avec la proposition. */
|
||||
auteur: string;
|
||||
}): Promise<AffectationGammes | Response> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<GammeAValider[]> {
|
||||
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<Map<string, string>> {
|
||||
if (codeins.length === 0) return new Map();
|
||||
const rows = await db
|
||||
.select({ codeFournisseur: gammesAValider.codeFournisseur, codein: gammesAValider.codein, gamme: gammesAValider.gamme })
|
||||
.from(gammesAValider)
|
||||
.where(inArray(gammesAValider.codein, codeins));
|
||||
return new Map(rows.map((r) => [`${r.codeFournisseur}|${r.codein}`, r.gamme]));
|
||||
}
|
||||
|
||||
/** Retire des propositions, une fois validées ou rejetées dans la Grille. */
|
||||
export async function supprimerGammesAValider(codeFournisseur: string, codeins: string[]): Promise<void> {
|
||||
if (codeins.length === 0) return;
|
||||
await db
|
||||
.delete(gammesAValider)
|
||||
.where(and(eq(gammesAValider.codeFournisseur, codeFournisseur), inArray(gammesAValider.codein, codeins)));
|
||||
}
|
||||
@@ -880,6 +880,8 @@ export async function pgGetCaByNomenclature(
|
||||
export interface HitParadeRow {
|
||||
codein: string;
|
||||
libelle: string;
|
||||
/** Référence fournisseur de l'article (artfou1.reference du fournisseur principal). */
|
||||
reference: string;
|
||||
fournisseur: string;
|
||||
nomenclature_code: string;
|
||||
nomenclature: string;
|
||||
@@ -929,12 +931,13 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis
|
||||
SELECT DISTINCT ON (TRIM(a.codein::text))
|
||||
TRIM(a.codein::text) AS codein,
|
||||
a.libelle1::text AS libelle,
|
||||
COALESCE(TRIM(af.reference::text), '') AS reference,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||||
COALESCE(n.code, '')::text AS nomenclature_code,
|
||||
COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature
|
||||
FROM articles a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT af1.code
|
||||
SELECT af1.code, af1.reference
|
||||
FROM artfou1 af1
|
||||
WHERE af1.art_no_id = a.no_id AND af1.preference = 1
|
||||
ORDER BY af1.code
|
||||
@@ -959,6 +962,7 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis
|
||||
SELECT
|
||||
v.codein,
|
||||
COALESCE(ar.libelle, '') AS libelle,
|
||||
COALESCE(ar.reference, '') AS reference,
|
||||
COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur,
|
||||
COALESCE(ar.nomenclature_code, '') AS nomenclature_code,
|
||||
COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature,
|
||||
|
||||
Reference in new issue
Block a user