mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 01:36:43 +02:00
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:
1 parent
22aa6f101a
commit
1035489db1
13 files changed
+706
-265
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<FfSyncStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Section title="API FF Nancy" subtitle="Source des données produits — synchronisation nuit depuis SQL Server (données J-1)">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={fetchStatus} disabled={loading} className="apple-btn-secondary h-9 px-4">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Vérifier le statut
|
||||
</button>
|
||||
{error && <span className="text-[12px] text-red-400">{error}</span>}
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
||||
Dernière sync globale : <span className="font-mono font-medium">{new Date(status.lastSync).toLocaleString("fr-FR")}</span>
|
||||
</p>
|
||||
{status.tables?.length > 0 && (
|
||||
<div className="rounded-lg overflow-hidden border border-[var(--border-subtle)]">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="bg-[var(--surface-tertiary)]">
|
||||
<th className="text-left px-3 py-2 font-medium text-[var(--text-secondary)]">Table</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Sync</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Lignes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{status.tables.map((t) => (
|
||||
<tr key={t.nom} className="border-t border-[var(--border-subtle)]">
|
||||
<td className="px-3 py-2 font-mono text-[var(--text-primary)]">{t.nom}</td>
|
||||
<td className="px-3 py-2 text-right text-[var(--text-secondary)]">
|
||||
{new Date(t.derniereSync).toLocaleString("fr-FR")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-[var(--text-muted)]">
|
||||
{t.nbLignes?.toLocaleString("fr-FR") ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
@@ -155,7 +226,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* PostgreSQL */}
|
||||
<Section title="PostgreSQL" subtitle="Connexion à votre instance Docker ou distante">
|
||||
<Section title="PostgreSQL — Auth & Historique" subtitle="Connexion pour les comptes utilisateurs, snapshots et contexte IA. Les données produits proviennent de l'API FF Nancy.">
|
||||
<Field label="Hôte / Nom du container" hint="Ex: localhost, 192.168.1.10, ou nom du service Docker (ex: postgres, db)">
|
||||
<input
|
||||
type="text"
|
||||
@@ -243,6 +314,9 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* API FF Nancy */}
|
||||
<FfApiStatusSection />
|
||||
|
||||
{/* OpenRouter */}
|
||||
<Section title="IA Copilot — OpenRouter" subtitle="Clé API pour les analyses de gammes par intelligence artificielle">
|
||||
<Field label="Clé API" hint="Disponible sur openrouter.ai/keys — commence par sk-or-...">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<T>(
|
||||
buildUrl: (page: number) => string,
|
||||
extractItems: (data: unknown) => T[],
|
||||
pageSize = 500
|
||||
): Promise<T[]> {
|
||||
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<FfArticle[]> {
|
||||
return fetchAllPages<FfArticle>(
|
||||
(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<FfMouvement[]> {
|
||||
return fetchAllPages<FfMouvement>(
|
||||
(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<Map<string, AggregatedStock>> {
|
||||
const result = new Map<string, AggregatedStock>();
|
||||
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<Map<string, number>> {
|
||||
const result = new Map<string, number>();
|
||||
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<FfSyncStatus | null> {
|
||||
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" },
|
||||
];
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -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<string, number>;
|
||||
/** End-of-month stock levels, indexed by YYYYMM key (reconstructed from movements qtestock) */
|
||||
stock12m: Record<string, number>;
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user