feat: Add FF Nancy API client and status endpoint, implement grid product fetching and draft change saving, and introduce AI Copilot core logic.

This commit is contained in:
Michael committed 2026-03-18 14:31:29 +01:00
1 parent 22aa6f101a
commit 1035489db1
13 files changed
+706 -265

No files matched your search

@@ -154,6 +154,25 @@ SI AUCUNE RÈGLE MANAGER n'est définie, tu recommandes UNIQUEMENT A ou Z (jamai
lines.push(` → ${scoreRelatif >= 100 ? "✓ Au-dessus de la moyenne" : scoreRelatif >= 50 ? "⚠️ Entre 50% et 100% de la moyenne" : "✗ Très en dessous de la moyenne"}`);
lines.push("");
// Bloc stock & approvisionnement (si données disponibles)
const hasStockData = ctx.stockCoverage !== undefined || ctx.isLowStock || ctx.isDeadInventory || ctx.isCommandeEnCours;
if (hasStockData) {
lines.push(`--- STOCK & APPROVISIONNEMENT ---`);
if (ctx.stockCoverage > 0) {
lines.push(`• Couverture stock : ${ctx.stockCoverage.toFixed(1)} mois de ventes en stock`);
}
if (ctx.isLowStock) {
lines.push(`• ⚠️ Stock faible : moins d'un PCB disponible`);
}
if (ctx.isDeadInventory) {
lines.push(`• ⚠️ Stock dormant : pas de vente depuis > 90 jours`);
}
if (ctx.isCommandeEnCours) {
lines.push(`• Commande en cours : réapprovisionnement prévu`);
}
lines.push("");
}
lines.push(`Génère UNIQUEMENT le JSON :`);
return lines.join("\n");
}
@@ -127,6 +127,16 @@ export interface ProductContextProfile {
*/
isLocomotiveRayon: boolean;
// Signaux stock & approvisionnement (API FF Nancy)
/** Stock actuel ≤ PCB → moins d'un conditionnement disponible */
isLowStock: boolean;
/** Commande fournisseur en cours */
isCommandeEnCours: boolean;
/** Stock > 0 mais pas de vente depuis > 90 jours */
isDeadInventory: boolean;
/** Couverture en mois : stockActuel / (totalQuantite / 12) */
stockCoverage: number;
// Gardes-fous (issus du ScoringEngine)
isProtected: boolean;
protectionReason: string;
@@ -405,6 +415,17 @@ export class ContextProfiler {
isProtected,
protectionReason,
scoreCritique,
// Stock & approvisionnement
isLowStock: (target.stockActuel !== undefined && target.pcb !== undefined && target.pcb > 0)
? target.stockActuel <= target.pcb
: false,
isCommandeEnCours: (target.commandesEnCours ?? 0) > 0,
isDeadInventory: (target.stockTotal ?? 0) > 0 && (target.nbJoursDerniereVente ?? 0) > 90,
stockCoverage: (() => {
const avgMonthlyQty = (target.totalQuantite ?? 0) / 12;
return avgMonthlyQty > 0 ? (target.stockActuel ?? 0) / avgMonthlyQty : 0;
})(),
};
}
@@ -51,6 +51,13 @@ export interface ProductAnalysisInput {
};
/** Optional context rules for the supplier */
supplierContext?: string;
/** Données stock & approvisionnement (API FF Nancy) */
stockActuel?: number;
stockTotal?: number;
pcb?: number;
commandesEnCours?: number;
nbJoursDerniereVente?: number;
derniereVente?: string;
/**
* Fiche de contexte enrichie générée par le ContextProfiler.
+10 -109
View File
@@ -1,132 +1,33 @@
"use server";
import { db } from "@/db";
import { ventesProduits } from "@/db/schema";
import { sql, and, eq } from "drizzle-orm";
import { getFournisseursFromApi, getSitesFromApi } from "@/lib/api-ff-client";
import { getProductRows } from "./api/get-product-rows";
import type { ProductRow, GridFilters } from "@/types/grid";
/**
* Get the list of all unique suppliers from the database.
* Get the list of all suppliers from the FF Nancy API.
*/
export async function getFournisseurs() {
try {
const results = await db
.selectDistinct({
code: ventesProduits.codeFournisseur,
nom: ventesProduits.nomFournisseur,
})
.from(ventesProduits)
.orderBy(ventesProduits.nomFournisseur);
return results.map(r => ({
code: r.code ?? "UNKNOWN",
nom: r.nom ?? "Fournisseur Inconnu"
}));
} catch (error) {
console.error("Error fetching suppliers:", error);
return [];
}
return getFournisseursFromApi();
}
/**
* Get the list of all magasins (stores) from the database.
* Get the list of all stores (sites) from the FF Nancy API.
*/
export async function getMagasins() {
try {
const results = await db
.selectDistinct({
code: ventesProduits.magasin,
nom: ventesProduits.magasin, // Using code as name as no name column exists
})
.from(ventesProduits)
.orderBy(ventesProduits.magasin);
return results.map(r => ({
code: r.code,
nom: r.nom
}));
} catch (error) {
console.error("Error fetching stores:", error);
return [];
}
return getSitesFromApi();
}
/**
* Mapping global des libellés de nomenclature (Secteurs & Rayons)
* Nomenclature filter is disabled — code3 is not available from the API.
* Returns an empty hierarchy so the sidebar filter is hidden gracefully.
*/
const NOMENCLATURE_LABELS: Record<string, string> = {
// Secteurs (Niveau 1 - 2 digits)
"20": "LIQUIDES",
"30": "ÉPICERIE",
"40": "DPH",
"50": "BAZAR",
"60": "TEXTILE",
"70": "FRAIS",
// Rayons (Niveau 2 - 4 digits)
"2001": "EAUX", "2002": "JUS DE FRUITS", "2003": "SODAS / BOUILLONS", "2004": "BIERES", "2005": "VINS", "2006": "CIDRES", "2007": "APERITIFS", "2008": "ALCOOLS",
"3001": "ÉPICERIE SALÉE", "3002": "ÉPICERIE SUCRÉE", "3003": "PETIT DÉJEUNER", "3004": "CONSERVES", "3005": "PLAT CUISINES",
"4001": "HYGIÈNE", "4002": "ENTRETIEN", "4003": "PARFUMERIE",
"5001": "BRICOLAGE", "5002": "MAISON", "5003": "LOISIRS", "5004": "JARDIN",
"7001": "FRUITS ET LEGUMES", "7002": "CREMERIE", "7003": "VOLAILLE", "7004": "CHARCUTERIE", "7005": "TRAITEUR", "7006": "BOULANGERIE",
};
/**
* Get the list of all unique nomenclature levels available in the database.
*/
export async function getAvailableNomenclature(
codeFournisseur?: string,
magasin?: string
) {
try {
const conditions = [sql`${ventesProduits.code3} IS NOT NULL`];
if (codeFournisseur) {
conditions.push(eq(ventesProduits.codeFournisseur, codeFournisseur));
}
if (magasin && magasin !== "TOTAL") {
conditions.push(eq(ventesProduits.magasin, magasin));
}
const results = await db
.select({
code3: ventesProduits.code3,
libelle3: ventesProduits.libelle3,
})
.from(ventesProduits)
.where(and(...conditions))
.groupBy(ventesProduits.code3, ventesProduits.libelle3);
const hierarchy: Record<string, { label: string, children: Record<string, { label: string, children: { code: string, label: string }[] }> }> = {};
results.forEach(r => {
const c3 = r.code3 || "";
const l3 = r.libelle3 || "";
if (c3.length < 6) return;
const c1 = c3.slice(0, 2);
const c2 = c3.slice(0, 4);
if (!hierarchy[c1]) {
hierarchy[c1] = { label: NOMENCLATURE_LABELS[c1] || `Secteur ${c1}`, children: {} };
}
if (!hierarchy[c1].children[c2]) {
hierarchy[c1].children[c2] = { label: NOMENCLATURE_LABELS[c2] || `Rayon ${c2}`, children: [] };
}
hierarchy[c1].children[c2].children.push({ code: c3, label: l3 });
});
return hierarchy;
} catch (error) {
console.error("Error fetching nomenclature:", error);
return {};
}
export async function getAvailableNomenclature() {
return {};
}
/**
* Get real product data for a specific supplier and store.
* Get product data for a specific supplier and store.
*/
export async function getGridData(
codeFournisseur: string,
+172 -115
View File
@@ -1,14 +1,18 @@
"use server";
import { db } from "@/db";
import { ventesProduits } from "@/db/schema";
import { eq, and, ne, like, sql } from "drizzle-orm";
import type { ProductRow, GammeCode, GridFilters } from "@/types/grid";
import { computeProductScores } from "@/lib/score-engine";
import fs from "fs";
import path from "path";
let isSchemaInitialized = false;
import {
getArticlesByFournisseur,
getMouvementsByFournisseur,
getStockByFournisseur,
getCommandesByFournisseur,
buildLast12MonthsRange,
dateToYYYYMM,
} from "@/lib/api-ff-client";
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
interface GetProductRowsInput {
codeFournisseur: string;
@@ -17,104 +21,60 @@ interface GetProductRowsInput {
}
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
const { codeFournisseur, magasin = "TOTAL", filters } = input;
const { codeFournisseur, magasin = "TOTAL" } = input;
console.log(`\n>>> [getProductRows] RECEIVED CALL for supplier: ${codeFournisseur}, magasin: ${magasin}`);
try {
// Self-healing: Ensure codeGammeInit column exists and is populated
if (!isSchemaInitialized) {
try {
await db.execute(sql`
ALTER TABLE ventes_produits
ADD COLUMN IF NOT EXISTS code_gamme_init VARCHAR(20)
`);
await db.execute(sql`
UPDATE ventes_produits
SET code_gamme_init = code_gamme
WHERE code_gamme_init IS NULL
`);
isSchemaInitialized = true;
console.log("[Schema] codeGammeInit initialized successfully.");
} catch (err) {
console.error("[Schema] Failed to initialize codeGammeInit:", err);
}
}
// ─── Phase 1 : Articles du fournisseur ───────────────────────────────
const articles = await getArticlesByFournisseur(codeFournisseur);
console.log(`[getProductRows] ${articles.length} articles found for ${codeFournisseur}`);
// ─── Phase 2 : Mouvements (ventes + stock12m) ────────────────────────
const { dateDebut, dateFin } = buildLast12MonthsRange();
const mouvements = await getMouvementsByFournisseur(codeFournisseur, dateDebut, dateFin);
console.log(`[getProductRows] ${mouvements.length} mouvements found`);
// ─── Phase 3 : Stock & Commandes (en parallèle) ──────────────────────
const [stockMap, commandesMap] = await Promise.all([
getStockByFournisseur(articles),
getCommandesByFournisseur(codeFournisseur),
]);
// ─── Phase 4 : Fenêtre temporelle (12 mois complets) ─────────────────
// En mars 2026 → allowedPeriods = {"202503"..."202602"} (12 mois)
const now = new Date();
const allowedPeriods = new Set<string>();
for (let i = 12; i >= 0; i--) {
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
allowedPeriods.add(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
const conditions = [
eq(ventesProduits.codeFournisseur, codeFournisseur),
ne(ventesProduits.periode, "TOTAL"),
magasin !== "TOTAL" ? eq(ventesProduits.magasin, magasin) : ne(ventesProduits.magasin, "TOTAL"),
];
if (filters?.code3) {
conditions.push(eq(ventesProduits.code3, filters.code3));
} else if (filters?.code2) {
conditions.push(like(ventesProduits.code3, `${filters.code2}%`));
} else if (filters?.code1) {
conditions.push(like(ventesProduits.code3, `${filters.code1}%`));
}
const rows = await db
.select()
.from(ventesProduits)
.where(and(...conditions));
console.log(`[getProductRows] Fetching for ${codeFournisseur} (${magasin}). Found ${rows.length} rows.`);
// ─── Phase 5 : Seed productMap depuis articles ────────────────────────
const productMap = new Map<string, ProductRow>();
const storeParticipationMap = new Map<string, Map<string, Set<string>>>(); // codein -> magasin -> Set of periods
const storeParticipationMap = new Map<string, Map<string, Set<string>>>();
// stockMaxByPeriod[codein][YYYYMM] = dernier qtestock du mois
const stockLastByPeriod = new Map<string, Map<string, { qtestock: number; datemvt: string }>>();
for (const row of rows) {
const { codein, magasin, periode } = row;
const isInPeriod = allowedPeriods.has(periode);
if (isInPeriod && magasin !== "TOTAL") {
if (!storeParticipationMap.has(codein)) storeParticipationMap.set(codein, new Map());
const productStores = storeParticipationMap.get(codein)!;
if (!productStores.has(magasin)) productStores.set(magasin, new Set());
productStores.get(magasin)!.add(periode);
}
let existing = productMap.get(codein);
if (!existing) {
const c3 = row.code3 || "";
const c1 = c3.slice(0, 2);
const c2 = c3.slice(0, 4);
const NOMENCLATURE_LABELS: Record<string, string> = {
"20": "LIQUIDES", "30": "ÉPICERIE", "40": "DPH", "50": "BAZAR", "60": "TEXTILE", "70": "FRAIS",
"2001": "EAUX", "2002": "JUS DE FRUITS", "2003": "SODAS / BOUILLONS", "2004": "BIERES", "2005": "VINS", "2006": "CIDRES", "2007": "APERITIFS", "2008": "ALCOOLS",
"3001": "ÉPICERIE SALÉE", "3002": "ÉPICERIE SUCRÉE", "3003": "PETIT DÉJEUNER", "3004": "CONSERVES", "3005": "PLAT CUISINES",
"4001": "HYGIÈNE", "4002": "ENTRETIEN", "4003": "PARFUMERIE",
"5001": "BRICOLAGE", "5002": "MAISON", "5003": "LOISIRS", "5004": "JARDIN",
"7001": "FRUITS ET LEGUMES", "7002": "CREMERIE", "7003": "VOLAILLE", "7004": "CHARCUTERIE", "7005": "TRAITEUR", "7006": "BOULANGERIE",
};
existing = {
codein,
codeFournisseur: row.codeFournisseur ?? "",
nomFournisseur: row.nomFournisseur ?? "",
libelle1: row.libelle1 ?? "",
gtin: row.gtin ?? "",
reference: row.reference ?? "",
code1: c1,
libelleNiveau1: NOMENCLATURE_LABELS[c1] || `Secteur ${c1}`,
code2: c2,
libelleNiveau2: NOMENCLATURE_LABELS[c2] || `Rayon ${c2}`,
code3: c3,
libelle3: row.libelle3 ?? "",
codeGamme: (row.codeGamme as GammeCode | null),
codeGammeInit: (row.codeGammeInit as GammeCode | null) ?? (row.codeGamme as GammeCode | null),
for (const art of articles) {
if (!productMap.has(art.codein)) {
productMap.set(art.codein, {
codein: art.codein,
codeFournisseur: art.codefou ?? codeFournisseur,
nomFournisseur: art.nomfou ?? "",
libelle1: art.libelle1 ?? "",
gtin: "",
reference: "",
code1: "",
libelleNiveau1: "",
code2: "",
libelleNiveau2: "",
code3: "",
libelle3: "",
codeGamme: null,
codeGammeInit: null,
codeGammeDraft: null,
sales12m: {},
stock12m: {},
totalQuantite: 0,
totalCa: 0,
totalMarge: 0,
@@ -122,39 +82,136 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
score: 0,
workingStores: [],
aiRecommendation: null,
};
productMap.set(codein, existing);
}
if (isInPeriod) {
const qty = Math.abs(parseFloat(row.quantite?.toString() ?? "0"));
const ca = Math.abs(parseFloat(row.montantMvt?.toString() ?? "0"));
const marge = Math.abs(parseFloat(row.margeMvt?.toString() ?? "0"));
existing.sales12m[periode] = (existing.sales12m[periode] ?? 0) + qty;
existing.totalQuantite += qty;
existing.totalCa += ca;
existing.totalMarge += marge;
existing.tauxMarge = existing.totalCa > 0 ? (existing.totalMarge / existing.totalCa) * 100 : 0;
pcb: art.pcb,
prixVente: art.pv_central,
});
}
}
// ─── Phase 6 : Agréger mouvements → sales12m + stock12m ─────────────
for (const mvt of mouvements) {
const { codein, genremvt, datemvt, qte, montant, marge, qtestock, site } = mvt;
// Filtre magasin
if (magasin !== "TOTAL" && site !== magasin) continue;
const periode = dateToYYYYMM(datemvt);
const isInPeriod = allowedPeriods.has(periode);
const product = productMap.get(codein);
if (!product) continue;
// Participation magasin (pour workingStores)
if (isInPeriod && site && site !== "TOTAL") {
if (!storeParticipationMap.has(codein)) storeParticipationMap.set(codein, new Map());
const stores = storeParticipationMap.get(codein)!;
if (!stores.has(site)) stores.set(site, new Set());
stores.get(site)!.add(periode);
}
// Ventes (genremvt = 3)
if (isInPeriod && genremvt === 3) {
const qty = Math.abs(qte ?? 0);
const ca = Math.abs(montant ?? 0);
const mg = Math.abs(marge ?? 0);
product.sales12m[periode] = (product.sales12m[periode] ?? 0) + qty;
product.totalQuantite += qty;
product.totalCa += ca;
product.totalMarge += mg;
}
// Stock12m : conserver le dernier mouvement par mois (via qtestock)
// On inclut tous les mouvements (pas que les ventes) pour avoir le stock fin de mois
if (isInPeriod) {
if (!stockLastByPeriod.has(codein)) stockLastByPeriod.set(codein, new Map());
const byPeriod = stockLastByPeriod.get(codein)!;
const existing = byPeriod.get(periode);
if (!existing || datemvt >= existing.datemvt) {
byPeriod.set(periode, { qtestock, datemvt });
}
}
}
// Calcul tauxMarge + reconstruction stock12m avec carry-forward
for (const [codein, product] of productMap.entries()) {
const productStores = storeParticipationMap.get(codein);
if (productStores) {
const workingStores: string[] = [];
for (const [magasin, periods] of productStores.entries()) {
if (magasin === "TOTAL") continue;
if (periods.size >= 3) {
workingStores.push(magasin);
product.tauxMarge = product.totalCa > 0
? (product.totalMarge / product.totalCa) * 100
: 0;
// Reconstruction stock12m avec carry-forward
const byPeriod = stockLastByPeriod.get(codein);
if (byPeriod) {
const sortedPeriods = [...allowedPeriods].sort();
let lastKnownStock = 0;
for (const p of sortedPeriods) {
const entry = byPeriod.get(p);
if (entry !== undefined) {
lastKnownStock = entry.qtestock;
}
product.stock12m[p] = lastKnownStock;
}
}
}
// ─── Phase 7 : workingStores ─────────────────────────────────────────
for (const [codein, product] of productMap.entries()) {
const stores = storeParticipationMap.get(codein);
if (stores) {
const working: string[] = [];
for (const [site, periods] of stores.entries()) {
if (site !== "TOTAL" && periods.size >= 3) working.push(site);
}
product.workingStores = working.sort();
}
}
// ─── Phase 8 : Enrichir avec stockMap ────────────────────────────────
for (const [codein, product] of productMap.entries()) {
const stock = stockMap.get(codein);
if (stock) {
product.stockActuel = stock.stockActuel;
product.stockTotal = stock.stockTotal;
product.stockValeur = stock.stockValeur;
product.pa = stock.pa;
product.prixAchat = stock.pa;
product.derniereVente = stock.derniereVente || undefined;
product.derniereLivraison = stock.derniereLivraison || undefined;
product.nbJoursDerniereVente = stock.nbJoursDerniereVente;
}
const cmdQty = commandesMap.get(codein);
if (cmdQty) product.commandesEnCours = cmdQty;
}
// ─── Phase 8b : Restaurer gammes depuis dernier snapshot ─────────────
try {
const snapshots = await db
.select()
.from(sessionSnapshots)
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
.orderBy(desc(sessionSnapshots.createdAt))
.limit(1);
if (snapshots.length > 0) {
const snap = snapshots[0];
const changes = snap.changes as Record<string, { before: string | null; after: string }>;
for (const [codein, change] of Object.entries(changes)) {
const product = productMap.get(codein);
if (product && change.after) {
product.codeGamme = change.after as GammeCode;
product.codeGammeInit = change.before as GammeCode | null;
}
}
product.workingStores = workingStores.sort();
console.log(`[getProductRows] Restored ${Object.keys(changes).length} gammes from snapshot ${snap.id}`);
}
} catch (snapErr) {
console.error("[getProductRows] Failed to restore gammes from snapshot:", snapErr);
}
console.log(`- Final ProductMap Size: ${productMap.size}`);
return computeProductScores(Array.from(productMap.values()));
// ─── Phase 9 : Score composite ────────────────────────────────────────
const rows = Array.from(productMap.values());
console.log(`[getProductRows] Final: ${rows.length} products`);
return computeProductScores(rows);
} catch (error) {
console.error(`[getProductRows] Error for supplier ${codeFournisseur}:`, error);
return [];
+46 -25
View File
@@ -1,18 +1,20 @@
"use server";
import { db } from "@/db";
import { ventesProduits } from "@/db/schema";
import { eq, and, sql } from "drizzle-orm";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
const SaveDraftsSchema = z.object({
codeFournisseur: z.string(),
nomFournisseur: z.string().optional(),
magasin: z.string(),
changes: z.array(
z.object({
codein: z.string(),
magasin: z.string(),
codeGamme: z.enum(["A", "B", "C", "Z"]),
codeGammeBefore: z.string().nullable(),
codeGamme: z.string(),
})
),
});
@@ -25,29 +27,48 @@ export async function saveDraftChanges(
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
}
const { changes } = parsed.data;
const { codeFournisseur, nomFournisseur, magasin, changes } = parsed.data;
const session = await auth();
const rawUserId = (session?.user as { id?: string | number })?.id;
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
const finalUserId = userId && !isNaN(userId) ? userId : null;
try {
// Batch update all rows for given codein.
// If magasin is "TOTAL", update ALL stores for that product.
await Promise.all(
changes.map(({ codein, magasin, codeGamme }) => {
const whereClause = [eq(ventesProduits.codein, codein)];
if (magasin !== "TOTAL") {
whereClause.push(eq(ventesProduits.magasin, magasin));
}
// 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);
return db
.update(ventesProduits)
.set({
codeGamme,
codeGammeInit: sql`COALESCE(ventes_produits.code_gamme_init, ventes_produits.code_gamme)`
})
.where(and(...whereClause));
})
);
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",
});
revalidatePath("/grid");
return { success: true, saved: changes.length };
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
+3 -4
View File
@@ -30,8 +30,7 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
const searchParams = useSearchParams();
const seuilAxeFort = useScoreSettingsStore((s) => s.seuilAxeFort);
const bonusParAxe = useScoreSettingsStore((s) => s.bonusParAxe);
const scoreSettings = useScoreSettingsStore((s) => ({ weightCA: s.weightCA, weightVolume: s.weightVolume, weightMarge: s.weightMarge }));
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
@@ -60,9 +59,9 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins
// Optimisation : On évite le JSON.parse(JSON.stringify()) qui est très lourd sur 1000 lignes
// On passe directement les initialRows au moteur de score, ou on fait une copie superficielle si nécessaire.
// Le moteur de score doit idéalement retourner de nouveaux objets.
const scoredRows = computeProductScores([...initialRows], { seuilAxeFort, bonusParAxe });
const scoredRows = computeProductScores([...initialRows], scoreSettings);
setRows(scoredRows);
}, [initialRows, setRows, seuilAxeFort, bonusParAxe, isMounted]);
}, [initialRows, setRows, scoreSettings, isMounted]);
const handleSave = () => {
startTransition(async () => {
+16 -9
View File
@@ -10,7 +10,7 @@ import { GammeCode } from "@/types/grid";
* Filters changes to only include those in the provided codeins list (e.g. current supplier).
*/
export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
const { draftChanges, resetDrafts, clearDrafts } = useGridStore();
const { draftChanges, rows, resetDrafts, clearDrafts } = useGridStore();
// Only consider changes that are in the filter list (if provided)
const activeDrafts = filterCodeins
@@ -20,26 +20,33 @@ export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
const count = Object.keys(activeDrafts).length;
const save = useCallback(async () => {
const changes = Object.entries(activeDrafts).map(([codein, codeGamme]) => ({
codein,
magasin,
codeGamme: codeGamme as GammeCode,
}));
const changes = Object.entries(activeDrafts).map(([codein, codeGamme]) => {
const row = rows.find((r) => r.codein === codein);
return {
codein,
codeGammeBefore: row?.codeGammeInit ?? row?.codeGamme ?? null,
codeGamme: codeGamme as GammeCode,
};
});
if (changes.length === 0) return { success: true, saved: 0 };
const result = await saveDraftChanges({ changes });
// Derive supplier info from the first matching row
const firstRow = rows.find((r) => changes.some((c) => c.codein === r.codein));
const codeFournisseur = firstRow?.codeFournisseur ?? "";
const nomFournisseur = firstRow?.nomFournisseur ?? "";
const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes });
if (result.success) {
if (filterCodeins) {
// Clear ONLY the ones we just saved
clearDrafts(Object.keys(activeDrafts));
} else {
resetDrafts();
}
}
return result;
}, [activeDrafts, magasin, resetDrafts, clearDrafts, filterCodeins]);
}, [activeDrafts, rows, magasin, resetDrafts, clearDrafts, filterCodeins]);
return { save, hasDrafts: count > 0, count };
}