Merge pull request #12 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i

Claude/grille modal ventes stock qlxg9i
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-14 08:16:49 +02:00
commit dc7f6bbf9f
4 files changed
+153 -11

No files matched your search

+33 -1
View File
@@ -9,6 +9,7 @@ import {
pgGetNomenclatureByFournisseur,
pgGetStockByFournisseur,
pgGetCommandesByFournisseur,
pgGetPrixVenteByFournisseur,
type PgStockRow,
} from "@/lib/pg-ff-client";
import { db } from "@/db";
@@ -124,7 +125,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
try {
const { dateDebut, dateFin } = buildLast12MonthsRange();
// ─── Phase 1 : 6 requêtes SQL en parallèle ────────────────────────────
// ─── Phase 1 : 7 requêtes SQL en parallèle ────────────────────────────
// Remplace des centaines/milliers d'appels HTTP per-article.
const [
articles,
@@ -133,6 +134,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
nomMap,
stockMap,
commandesMap,
prixVenteMap,
] = await Promise.all([
pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }),
pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur ERROR:", e); return []; }),
@@ -140,6 +142,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }),
pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map<string, PgStockRow[]>(); }),
pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map<string, number>(); }),
pgGetPrixVenteByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetPrixVenteByFournisseur ERROR:", e); return new Map<string, Record<string, number>>(); }),
]);
console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`);
@@ -374,6 +377,35 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
}
}
// ─── Phase 7b : Prix de vente (cube_pv, sinon le PV du cube de stock) ─
//
// `article_infosup.prix_vente_mini`, renseigné en Phase 3, n'est qu'une
// borne de paramétrage : elle est vide en base et laissait la colonne PV
// entièrement creuse. Le vrai prix vit dans `cube_pv`, par article ET par
// site. On garde le détail par magasin — deux magasins peuvent ne pas
// pratiquer le même prix, et l'afficher vaut mieux que le moyenner.
for (const product of productMap.values()) {
const parSite = prixVenteMap.get(product.codein);
if (parSite && Object.keys(parSite).length > 0) {
product.prixVenteByStore = parSite;
} else {
// Filet de secours : le cube de stock porte le même PV, mais
// seulement pour les articles qui ont une ligne de stock.
const depuisStock: Record<string, number> = {};
for (const s of stockMap.get(product.codein) ?? []) {
const pv = Number(s.pv);
const site = String(s.site ?? "").trim();
if (site && Number.isFinite(pv) && pv > 0) depuisStock[site] = pv;
}
if (Object.keys(depuisStock).length > 0) product.prixVenteByStore = depuisStock;
}
const prix = Object.values(product.prixVenteByStore ?? {});
// Le plus élevé quand les magasins divergent : une moyenne inventerait
// un prix qu'aucune caisse ne pratique. L'écart est signalé à l'écran.
if (prix.length > 0) product.prixVente = Math.max(...prix);
}
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
await enrichWithNetworkMetrics(productMap);
+59 -8
View File
@@ -93,6 +93,54 @@ function prixMoyenReseau(row: ProductRow): number | null {
const fmtEuro2 = (v: number) =>
v.toLocaleString("fr-FR", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
/**
* Prix de vente à montrer pour le magasin consulté.
*
* `cube_pv` a une clé `(artnoid, site)`, mais notre base n'y met en pratique
* qu'une ligne par article : le prix n'est pas ventilé par magasin. La colonne
* doit donc rester lisible dans les deux cas de figure —
*
* · magasin consulté tarifé → SON prix ;
* · prix unique, non ventilé → ce prix vaut pour tous les magasins ;
* · plusieurs prix mais aucun pour ce magasin → rien, plutôt que celui du
* voisin ;
* · en « tous magasins » : le prix commun, et s'ils divergent le plus élevé
* assorti d'un signal. Une cellule ne peut afficher qu'un nombre, mais
* taire l'écart ferait passer le prix d'un magasin pour celui des deux.
*/
function prixVenteAffiche(row: ProductRow, activeMagasin: string): { valeur: number | null; divergent: boolean; detail: string | null } {
const entrees = Object.entries(row.prixVenteByStore ?? {});
// Aucun prix ventilé : reste le PV de la fiche article, s'il existe.
if (entrees.length === 0) return { valeur: row.prixVente ?? null, divergent: false, detail: null };
// Comparaison au centime : deux flottants issus du même prix peuvent différer
// dans les décimales lointaines sans que le prix affiché change.
const distincts = new Set(entrees.map(([, pv]) => Math.round(pv * 100)));
if (activeMagasin !== "TOTAL") {
const pv = row.prixVenteByStore?.[activeMagasin];
if (pv != null) return { valeur: pv, divergent: false, detail: null };
if (distincts.size === 1) {
return { valeur: entrees[0][1], divergent: false, detail: "prix unique, non ventilé par magasin" };
}
return { valeur: null, divergent: false, detail: null };
}
const detail = entrees.length > 1
? entrees
.sort(([a], [b]) => a.localeCompare(b))
.map(([site, pv]) => `${SITE_LABELS[site]?.nom ?? site} : ${fmtEuro2(pv)}`)
.join(" · ")
: null;
return {
valeur: Math.max(...entrees.map(([, pv]) => pv)),
divergent: distincts.size > 1,
detail,
};
}
// =========================================================================
// OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires)
// =========================================================================
@@ -792,21 +840,24 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
},
{
id: "prixVente",
accessorFn: (row: ProductRow) => valeurTriable(row.prixVente),
accessorFn: (row: ProductRow) => valeurTriable(prixVenteAffiche(row, activeMagasin).valeur),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">PV central<br/><span className="text-[9px] opacity-60">Article</span></div>,
header: () => <div className="text-center w-full">PV<br/><span className="text-[9px] opacity-60">Magasin</span></div>,
size: 88,
cell: ({ row }: { row: { original: ProductRow } }) => {
const val = row.original.prixVente;
const { valeur, divergent, detail } = prixVenteAffiche(row.original, activeMagasin);
return (
<div
className="text-center tabular-nums text-[12px] font-bold"
className="text-center tabular-nums text-[12px] font-bold flex items-center justify-center gap-0.5"
style={{ color: "var(--text-primary)" }}
title={val != null
? "Prix de vente central de la fiche article (base Nancy)"
: "Aucun prix de vente central sur la fiche article"}
title={valeur != null
? `Prix de vente en base (cube_pv)${detail ? ` — ${detail}` : ""}`
: "Aucun prix de vente en base pour cet article"}
>
{val != null ? fmtEuro2(val) : "-"}
{valeur != null ? fmtEuro2(valeur) : "-"}
{/* Prix différents d'un magasin à l'autre : le signaler, la
cellule ne peut en afficher qu'un. */}
{divergent && <span className="text-[10px] font-black" style={{ color: "var(--accent-warning)" }}>≠</span>}
</div>
);
},
+46 -1
View File
@@ -72,6 +72,8 @@ export interface PgStockRow {
qte: number;
valstock: number;
prmp: number;
/** Prix de vente du site — cube_stock.pv, filet de secours si cube_pv est vide. */
pv?: number;
dernierevente?: string;
dernierereception?: string;
}
@@ -448,14 +450,17 @@ function buildNomMap(rows: PgNomRow[]): Map<string, PgNomRow> {
* 1 requête SQL — remplace la partie stock du référentiel.
*/
export async function pgGetStockByFournisseur(codefou: string): Promise<Map<string, PgStockRow[]>> {
// `site` est élagué pour la même raison que dans pgGetPrixVenteByFournisseur :
// il sert de clé de recherche (derniereLivraisonByStore, prix de repli).
const result = await pgNoParallel(sql`
SELECT
a.codein,
cs.site,
TRIM(cs.site::text) AS site,
cs.stockdispo::float,
cs.qte::float,
cs.valstock::float,
cs.prmp::float,
cs.pv::float,
cs.dernierevente::text,
cs.dernierereception::text
FROM cube_stock cs
@@ -472,6 +477,46 @@ export async function pgGetStockByFournisseur(codefou: string): Promise<Map<stri
return map;
}
/**
* Prix de vente par article et par site (`cube_pv`).
*
* Pendant exact de `cube_pa` pour l'achat, rafraîchi chaque nuit depuis le cube
* MSSQL `Cube_PV`. C'est LA source du prix de vente : `article_infosup`, seule
* table lue jusqu'ici pour cette information, ne porte qu'un prix plancher de
* paramétrage (`prix_vente_mini`) qui reste vide en pratique.
*
* Retour : codein → { site → PV }. Les prix nuls sont écartés — un article non
* tarifé n'est pas un article à 0 €.
*/
export async function pgGetPrixVenteByFournisseur(codefou: string): Promise<Map<string, Record<string, number>>> {
// Le site sert de CLÉ de recherche côté Grille (prixVenteByStore["292"]), d'où
// l'élagage : la synchronisation Apiflow écrit `cube_pv.site` brut, sans passer
// par le `safeStr` qu'elle applique à `cube_stock`. Une colonne MSSQL de largeur
// fixe remonterait complétée d'espaces, et la recherche échouerait en silence —
// le prix resterait visible en « tous magasins » et vide magasin par magasin.
const result = await pgNoParallel(sql`
SELECT
a.codein,
TRIM(cpv.site::text) AS site,
cpv.pv::float AS pv
FROM cube_pv cpv
JOIN articles a ON a.no_id = cpv.artnoid
JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af
ON af.art_no_id = a.no_id
WHERE cpv.pv IS NOT NULL AND cpv.pv > 0
`);
const map = new Map<string, Record<string, number>>();
for (const row of result.rows as { codein: string; site: string; pv: number }[]) {
if (!row.codein || !row.site) continue;
const parSite = map.get(row.codein) ?? {};
parSite[String(row.site)] = Number(row.pv);
map.set(row.codein, parSite);
}
console.log(`[pg-ff] Prix de vente : ${map.size} articles tarifés pour ${codefou}`);
return map;
}
// ---------------------------------------------------------------------------
// 6. Commandes en cours
// ---------------------------------------------------------------------------
+15 -1
View File
@@ -89,7 +89,21 @@ export interface ProductRow {
/** Données stock & approvisionnement (API FF Nancy) */
pcb?: number; // Conditionnement (pack size)
prixAchat?: number; // PA / PRMP
prixVente?: number; // PV central
/**
* Prix de vente pratiqué, en euros.
*
* Source par ordre de préférence : `cube_pv` (prix de vente par article et
* par site, pendant de `cube_pa` pour l'achat), puis le PV porté par
* `cube_stock`, puis `article_infosup.prix_vente_mini` — cette dernière
* n'est qu'une borne de paramétrage, vide en pratique.
*
* Quand les magasins ne pratiquent pas le même prix, cette valeur est la
* plus élevée : `prixVenteByStore` porte le détail, et l'affichage signale
* la divergence plutôt que d'inventer une moyenne que personne n'encaisse.
*/
prixVente?: number;
/** Prix de vente par magasin (site → PV) — `cube_pv`. */
prixVenteByStore?: Record<string, number>;
stockActuel?: number; // Stock dispo agrégé tous sites
stockTotal?: number; // Qte en stock agrégé
stockValeur?: number; // Valeur stock agrégée