diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 477f741..b59f4fc 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -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 { 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 { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }), pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map(); }), pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map(); }), + pgGetPrixVenteByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetPrixVenteByFournisseur ERROR:", e); return new Map>(); }), ]); console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`); @@ -374,6 +377,35 @@ async function buildProductRows(input: GetProductRowsInput): Promise 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 = {}; + 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); diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index bb73b16..3582e4c 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -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: () =>
PV central
Article
, + header: () =>
PV
Magasin
, size: 88, cell: ({ row }: { row: { original: ProductRow } }) => { - const val = row.original.prixVente; + const { valeur, divergent, detail } = prixVenteAffiche(row.original, activeMagasin); return (
- {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 && ≠}
); }, diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 052bfeb..8b1580a 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -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 { * 1 requête SQL — remplace la partie stock du référentiel. */ export async function pgGetStockByFournisseur(codefou: string): Promise> { + // `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>> { + // 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>(); + 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 // --------------------------------------------------------------------------- diff --git a/src/types/grid.ts b/src/types/grid.ts index fea77bd..07107d2 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -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; stockActuel?: number; // Stock dispo agrégé tous sites stockTotal?: number; // Qte en stock agrégé stockValeur?: number; // Valeur stock agrégée