diff --git a/src/app/(dashboard)/grid/page.tsx b/src/app/(dashboard)/grid/page.tsx index c6f8c5b..3a064b9 100644 --- a/src/app/(dashboard)/grid/page.tsx +++ b/src/app/(dashboard)/grid/page.tsx @@ -26,7 +26,7 @@ export default async function GridPage({ searchParams }: GridPageProps) { const [fournisseurs, magasins, nomenclature] = await Promise.all([ getFournisseurs(), getMagasins(), - getAvailableNomenclature(codeFournisseur, magasin) + getAvailableNomenclature() ]); // 2. If no supplier selected, show compact selection UI diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index b86cce2..5f03ff0 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -36,6 +36,77 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr ); } +interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; } +interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; } + +function FfApiStatusSection() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchStatus = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/ff-status"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setStatus(await res.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur inconnue"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchStatus(); }, [fetchStatus]); + + return ( +
+
+ + {error && {error}} +
+ + {status && ( +
+

+ Dernière sync globale : {new Date(status.lastSync).toLocaleString("fr-FR")} +

+ {status.tables?.length > 0 && ( +
+ + + + + + + + + + {status.tables.map((t) => ( + + + + + + ))} + +
TableSyncLignes
{t.nom} + {new Date(t.derniereSync).toLocaleString("fr-FR")} + + {t.nbLignes?.toLocaleString("fr-FR") ?? "—"} +
+
+ )} +
+ )} +
+ ); +} + export default function SettingsPage() { const { resolvedTheme, setTheme } = useTheme(); const isDark = resolvedTheme === "dark"; @@ -155,7 +226,7 @@ export default function SettingsPage() { {/* PostgreSQL */} -
+
+ {/* API FF Nancy */} + + {/* OpenRouter */}
diff --git a/src/app/api/ff-status/route.ts b/src/app/api/ff-status/route.ts new file mode 100644 index 0000000..838e1b4 --- /dev/null +++ b/src/app/api/ff-status/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { getSyncStatus } from "@/lib/api-ff-client"; + +export async function GET() { + const status = await getSyncStatus(); + if (!status) { + return NextResponse.json({ error: "API FF Nancy non disponible" }, { status: 503 }); + } + return NextResponse.json(status); +} diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index 8369f74..6c89d7c 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -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"); } diff --git a/src/features/ai-copilot/business/context-profiler.ts b/src/features/ai-copilot/business/context-profiler.ts index bc5cc20..2a081be 100644 --- a/src/features/ai-copilot/business/context-profiler.ts +++ b/src/features/ai-copilot/business/context-profiler.ts @@ -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; + })(), }; } diff --git a/src/features/ai-copilot/models/ai-analysis.types.ts b/src/features/ai-copilot/models/ai-analysis.types.ts index 53b9262..753d732 100644 --- a/src/features/ai-copilot/models/ai-analysis.types.ts +++ b/src/features/ai-copilot/models/ai-analysis.types.ts @@ -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. diff --git a/src/features/grid/actions.ts b/src/features/grid/actions.ts index 3a16d27..eb791d3 100644 --- a/src/features/grid/actions.ts +++ b/src/features/grid/actions.ts @@ -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 = { - // 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 }> = {}; - - 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, diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 23bcc68..0c51cb5 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -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 { - 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(); - 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(); - const storeParticipationMap = new Map>>(); // codein -> magasin -> Set of periods + const storeParticipationMap = new Map>>(); + // stockMaxByPeriod[codein][YYYYMM] = dernier qtestock du mois + const stockLastByPeriod = new Map>(); - 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 = { - "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 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; + 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 []; diff --git a/src/features/grid/api/save-draft-changes.ts b/src/features/grid/api/save-draft-changes.ts index bb00a21..b125b3b 100644 --- a/src/features/grid/api/save-draft-changes.ts +++ b/src/features/grid/api/save-draft-changes.ts @@ -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 = + existing.length > 0 + ? (existing[0].changes as Record) + : {}; + + // 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"; diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx index c37c152..71914b8 100644 --- a/src/features/grid/components/grid-client.tsx +++ b/src/features/grid/components/grid-client.tsx @@ -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([]); 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 () => { diff --git a/src/features/grid/hooks/use-save-drafts.ts b/src/features/grid/hooks/use-save-drafts.ts index 447ab33..d955746 100644 --- a/src/features/grid/hooks/use-save-drafts.ts +++ b/src/features/grid/hooks/use-save-drafts.ts @@ -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 }; } diff --git a/src/lib/api-ff-client.ts b/src/lib/api-ff-client.ts new file mode 100644 index 0000000..57f6cd7 --- /dev/null +++ b/src/lib/api-ff-client.ts @@ -0,0 +1,312 @@ +/** + * CollectFlow — API FF Nancy Client + * + * Client HTTP centralisé pour l'API REST https://api.ffnancy.fr + * Sync nuit SQL Server → données fraîches J-1 + * + * Stratégie mouvements : appeler SANS filtre genremvt (tous types) + * - genremvt = 3 → alimenter sales12m (ventes) + * - Tous types → lire qtestock pour reconstruire stock12m + */ + +const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; + +// --------------------------------------------------------------------------- +// Types API (shapes des réponses brutes) +// --------------------------------------------------------------------------- + +export interface FfFournisseur { + codefou: string; + nomfou: string; +} + +export interface FfArticle { + codein: string; + libelle1: string; + codefou: string; + nomfou?: string; + pcb?: number; // conditionnement + pv_central?: number; // prix de vente central + noid?: number; // SQL Server internal ID +} + +export interface FfMouvement { + codein: string; + genremvt: number; // 3 = vente client + datemvt: string; // ISO date (YYYY-MM-DD) + qte: number; // quantité du mouvement + montant: number; // montant HT + marge?: number; // marge (si disponible) + qtestock: number; // stock APRÈS ce mouvement — clé pour stock12m + site: string; // code magasin / site +} + +export interface FfStock { + codein: string; + site?: string; + stockdispo: number; + qte: number; + valstock: number; + prmp?: number; // PRMP (prix de revient moyen pondéré) + pv?: number; // prix de vente + dernieresventes?: string; + dernierereception?: string; + nbjoursdernsventes?: number; +} + +export interface FfCommande { + codein: string; + qtecde: number; +} + +export interface FfSyncStatus { + lastSync: string; + tables: { nom: string; derniereSync: string; nbLignes?: number }[]; +} + +/** Stock agrégé sur tous les sites pour un codein */ +export interface AggregatedStock { + stockActuel: number; // sum stockdispo + stockTotal: number; // sum qte + stockValeur: number; // sum valstock + pa: number; // prmp (premier site non-zéro) + prixVente: number; // pv premier site + derniereVente: string; // max date tous sites + derniereLivraison: string; // max dernierereception tous sites + nbJoursDerniereVente: number; // min tous sites +} + +// --------------------------------------------------------------------------- +// Pagination générique +// --------------------------------------------------------------------------- + +async function fetchAllPages( + buildUrl: (page: number) => string, + extractItems: (data: unknown) => T[], + pageSize = 500 +): Promise { + const all: T[] = []; + let page = 1; + + while (true) { + const url = buildUrl(page); + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) { + console.error(`[api-ff] HTTP ${res.status} — ${url}`); + break; + } + const data = await res.json(); + const items = extractItems(data); + all.push(...items); + if (items.length < pageSize) break; + page++; + } + + return all; +} + +// --------------------------------------------------------------------------- +// Helpers date +// --------------------------------------------------------------------------- + +/** Retourne la fenêtre des 12 mois complets précédant le mois en cours. + * En mars 2026 → { dateDebut: "2025-03-01", dateFin: "2026-02-28" } + */ +export function buildLast12MonthsRange(): { dateDebut: string; dateFin: string } { + const now = new Date(); + // Fin : dernier jour du mois précédent le mois courant + const endMonth = new Date(now.getFullYear(), now.getMonth(), 0); + // Début : 12 mois avant le mois courant + const startMonth = new Date(now.getFullYear(), now.getMonth() - 12, 1); + + return { + dateDebut: startMonth.toISOString().slice(0, 10), + dateFin: endMonth.toISOString().slice(0, 10), + }; +} + +/** "2025-03-15" → "202503" */ +export function dateToYYYYMM(isoDate: string): string { + return isoDate.slice(0, 7).replace("-", ""); +} + +// --------------------------------------------------------------------------- +// Fournisseurs +// --------------------------------------------------------------------------- + +export async function getFournisseursFromApi( + search?: string +): Promise<{ code: string; nom: string }[]> { + try { + const url = search + ? `${FF_API_BASE}/api/fournisseurs?search=${encodeURIComponent(search)}&limit=500` + : `${FF_API_BASE}/api/fournisseurs?limit=500`; + + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + + // L'API peut renvoyer { items: [...] } ou directement [...] + const list: FfFournisseur[] = Array.isArray(data) ? data : (data.items ?? data.data ?? []); + return list.map(f => ({ code: f.codefou, nom: f.nomfou })); + } catch (err) { + console.error("[api-ff] getFournisseursFromApi error:", err); + return []; + } +} + +// --------------------------------------------------------------------------- +// Articles +// --------------------------------------------------------------------------- + +export async function getArticlesByFournisseur( + codefou: string +): Promise { + return fetchAllPages( + (page) => `${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&page=${page}&limit=500`, + (data) => Array.isArray(data) ? data : ((data as any).items ?? (data as any).data ?? []), + 500 + ); +} + +// --------------------------------------------------------------------------- +// Mouvements (ventes + stock12m) +// --------------------------------------------------------------------------- + +export async function getMouvementsByFournisseur( + codefou: string, + dateDebut: string, + dateFin: string +): Promise { + return fetchAllPages( + (page) => + `${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&datedeb=${dateDebut}&datefin=${dateFin}&page=${page}&limit=1000`, + (data) => Array.isArray(data) ? data : ((data as any).items ?? (data as any).data ?? []), + 1000 + ); +} + +// --------------------------------------------------------------------------- +// Stock (agrégé tous sites) +// --------------------------------------------------------------------------- + +export async function getStockByFournisseur( + articles: FfArticle[] +): Promise> { + const result = new Map(); + const BATCH_SIZE = 10; + + for (let i = 0; i < articles.length; i += BATCH_SIZE) { + const batch = articles.slice(i, i + BATCH_SIZE); + await Promise.all( + batch.map(async (art) => { + try { + const res = await fetch( + `${FF_API_BASE}/api/stocks/articles?codein=${encodeURIComponent(art.codein)}`, + { cache: "no-store" } + ); + if (!res.ok) return; + const data = await res.json(); + const sites: FfStock[] = Array.isArray(data) ? data : ((data as any).items ?? (data as any).data ?? []); + if (sites.length === 0) return; + + const agg: AggregatedStock = { + stockActuel: 0, + stockTotal: 0, + stockValeur: 0, + pa: 0, + prixVente: 0, + derniereVente: "", + derniereLivraison: "", + nbJoursDerniereVente: 9999, + }; + + for (const s of sites) { + agg.stockActuel += s.stockdispo ?? 0; + agg.stockTotal += s.qte ?? 0; + agg.stockValeur += s.valstock ?? 0; + if (!agg.pa && s.prmp) agg.pa = s.prmp; + if (!agg.prixVente && s.pv) agg.prixVente = s.pv; + if (s.dernieresventes && s.dernieresventes > agg.derniereVente) { + agg.derniereVente = s.dernieresventes; + } + if (s.dernierereception && s.dernierereception > agg.derniereLivraison) { + agg.derniereLivraison = s.dernierereception; + } + if (s.nbjoursdernsventes !== undefined && s.nbjoursdernsventes < agg.nbJoursDerniereVente) { + agg.nbJoursDerniereVente = s.nbjoursdernsventes; + } + } + if (agg.nbJoursDerniereVente === 9999) agg.nbJoursDerniereVente = 0; + + result.set(art.codein, agg); + } catch (err) { + console.error(`[api-ff] getStock error for ${art.codein}:`, err); + } + }) + ); + } + + return result; +} + +// --------------------------------------------------------------------------- +// Commandes en cours +// --------------------------------------------------------------------------- + +export async function getCommandesByFournisseur( + codefou: string +): Promise> { + const result = new Map(); + try { + const res = await fetch( + `${FF_API_BASE}/api/commandes/articles?codefou=${encodeURIComponent(codefou)}`, + { cache: "no-store" } + ); + if (!res.ok) return result; + const data = await res.json(); + const list: FfCommande[] = Array.isArray(data) ? data : ((data as any).items ?? (data as any).data ?? []); + for (const c of list) { + result.set(c.codein, (result.get(c.codein) ?? 0) + (c.qtecde ?? 0)); + } + } catch (err) { + console.error("[api-ff] getCommandesByFournisseur error:", err); + } + return result; +} + +// --------------------------------------------------------------------------- +// Statut de synchronisation +// --------------------------------------------------------------------------- + +export async function getSyncStatus(): Promise { + try { + const res = await fetch(`${FF_API_BASE}/api/sync/status`, { cache: "no-store" }); + if (!res.ok) return null; + return await res.json(); + } catch (err) { + console.error("[api-ff] getSyncStatus error:", err); + return null; + } +} + +// --------------------------------------------------------------------------- +// Sites (magasins) +// --------------------------------------------------------------------------- + +export async function getSitesFromApi(): Promise<{ code: string; nom: string }[]> { + try { + const res = await fetch(`${FF_API_BASE}/api/sites`, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const list = Array.isArray(data) ? data : ((data as any).items ?? (data as any).data ?? []); + return list.map((s: any) => ({ code: s.code ?? s.codesite ?? s.site, nom: s.nom ?? s.libelle ?? s.code })); + } catch (err) { + console.error("[api-ff] getSitesFromApi error:", err); + // Fallback : 2 sites connus de FF Nancy + return [ + { code: "NANCY1", nom: "FF Nancy 1" }, + { code: "NANCY2", nom: "FF Nancy 2" }, + ]; + } +} diff --git a/src/types/grid.ts b/src/types/grid.ts index 294d8b9..cbbe0d1 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -1,6 +1,5 @@ /** * CollectFlow - Core Grid Types - * Maps to the `ventes_produits` table via Drizzle ORM (camelCase). */ /** One row in the data grid, representing a product at a store for one period. */ @@ -49,6 +48,8 @@ export interface ProductRow { codeGammeDraft: GammeCode | null; /** 12-month sales quantities, indexed by YYYYMM key */ sales12m: Record; + /** End-of-month stock levels, indexed by YYYYMM key (reconstructed from movements qtestock) */ + stock12m: Record; totalQuantite: number; totalCa: number; totalMarge: number; @@ -57,6 +58,18 @@ export interface ProductRow { score: number; workingStores: string[]; aiRecommendation?: string | null; + /** Données stock & approvisionnement (API FF Nancy) */ + pcb?: number; // Conditionnement (pack size) + prixAchat?: number; // PA / PRMP + prixVente?: number; // PV central + stockActuel?: number; // Stock dispo agrégé tous sites + stockTotal?: number; // Qte en stock agrégé + stockValeur?: number; // Valeur stock agrégée + pa?: number; // PRMP (alias prixAchat) + derniereVente?: string; // ISO date de dernière vente + derniereLivraison?: string; // ISO date de dernière réception + nbJoursDerniereVente?: number; // Nb jours sans vente + commandesEnCours?: number; // Qté en commande fournisseur /** Moyennes de comparaison pour l'IA */ avgQtyFournisseur?: number; avgQtyRayon?: number;