From c9e1934aae9613f324c152d96b8776b33ae07c16 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:44:48 +0000 Subject: [PATCH] Grille : le prix de vente vient de cube_pv, pas d'article_infosup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La colonne PV s'affichait vide sur toutes les lignes. Le schéma du SaaS Postgres (dépôt Apiflow, postgres/init.sql) explique pourquoi : la seule source lue jusqu'ici, `article_infosup.prix_vente_mini`, est une borne de PARAMÉTRAGE — un prix plancher, à côté de `prix_vente_maxi` et `pv_conseille` — et non le prix pratiqué. Le prix de vente vit dans `cube_pv (artnoid, site, pv)`, pendant exact du `cube_pa` déjà utilisé pour l'achat, rafraîchi chaque nuit depuis le cube MSSQL `Cube_PV`. `cube_stock` porte le même PV et sert de filet : il ne couvre que les articles ayant une ligne de stock, mais il est déjà interrogé par la Grille, une colonne de plus dans le SELECT suffit. Le prix est propre à chaque MAGASIN. La colonne suit donc le magasin consulté ; en « tous magasins » elle montre le prix commun, et s'ils divergent le plus élevé assorti d'un « ≠ » et de l'infobulle qui donne les deux. Une moyenne afficherait un prix qu'aucune caisse ne pratique, et retenir silencieusement l'un des deux ferait passer le prix d'un magasin pour celui des deux. L'en-tête devient « PV / Magasin » : « PV central » décrivait la fiche article, ce n'est plus la source. Vérifié sur build de production, prix identiques, divergents et absents : en « tous magasins » 14,90 € ≠ avec l'infobulle « Frouard (Nancy) : 13,90 € · Houdemont : 14,90 € » ; sur Frouard 13,90 €, sur Houdemont 14,90 €, sans marqueur ; « - » quand aucune source n'a de prix. La route de diagnostic compte désormais les trois sources côte à côte, de quoi confirmer sur la base réelle laquelle est renseignée. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR --- src/app/api/diag/prix-vente/route.ts | 30 +++++++++++ src/features/grid/api/get-product-rows.ts | 33 +++++++++++- src/features/grid/components/heatmap-grid.tsx | 53 ++++++++++++++++--- src/lib/pg-ff-client.ts | 38 +++++++++++++ src/types/grid.ts | 16 +++++- 5 files changed, 160 insertions(+), 10 deletions(-) diff --git a/src/app/api/diag/prix-vente/route.ts b/src/app/api/diag/prix-vente/route.ts index eeef0b7..b8dc6cf 100644 --- a/src/app/api/diag/prix-vente/route.ts +++ b/src/app/api/diag/prix-vente/route.ts @@ -81,6 +81,36 @@ export async function GET(req: NextRequest) { } } + // ── 2 bis. Les trois sources de prix, côte à côte ─────────────────────── + // `cube_pv` est la source retenue (pendant de `cube_pa` pour l'achat) ; + // `cube_stock.pv` sert de filet ; `article_infosup` ferme la marche. + if (codefou) { + try { + const r = await db.execute(sql` + WITH art AS ( + SELECT DISTINCT a.no_id, a.codein + FROM artfou1 af + JOIN articles a ON a.no_id = af.art_no_id + WHERE af.code = ${codefou} AND a.codein IS NOT NULL + ) + SELECT + (SELECT COUNT(*)::int FROM art) AS articles, + (SELECT COUNT(DISTINCT cpv.artnoid)::int FROM cube_pv cpv + JOIN art ON art.no_id = cpv.artnoid WHERE cpv.pv > 0) AS avec_cube_pv, + (SELECT COUNT(DISTINCT cs.artnoid)::int FROM cube_stock cs + JOIN art ON art.no_id = cs.artnoid WHERE cs.pv > 0) AS avec_cube_stock_pv, + (SELECT COUNT(*)::int FROM article_infosup ai + JOIN art ON art.no_id = ai.artnoid WHERE ai.prix_vente_mini > 0) AS avec_prix_vente_mini + `); + diagnostic.sources_de_prix = { + commentaire: "nombre d'articles du fournisseur ayant un prix > 0 dans chaque source", + ...(r.rows[0] as Record), + }; + } catch (e) { + diagnostic.sources_de_prix = { erreur: (e as Error).message?.slice(0, 200) }; + } + } + // ── 3. Colonnes candidates du schéma, et leur remplissage réel ────────── // C'est la réponse à « le prix est peut-être ailleurs » : une colonne bien // nommée mais vide ne sert à rien, seul le comptage tranche. diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 477f741..a87d0cb 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,34 @@ 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); + if (s.site && Number.isFinite(pv) && pv > 0) depuisStock[s.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..6444024 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -93,6 +93,40 @@ 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é. + * + * Sur un magasin donné, c'est SON prix. En « tous magasins », c'est 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 parSite = row.prixVenteByStore; + + if (activeMagasin !== "TOTAL") { + const pv = parSite?.[activeMagasin]; + return { valeur: pv ?? null, divergent: false, detail: null }; + } + + const entrees = Object.entries(parSite ?? {}); + 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))); + const detail = entrees + .sort(([a], [b]) => a.localeCompare(b)) + .map(([site, pv]) => `${SITE_LABELS[site]?.nom ?? site} : ${fmtEuro2(pv)}`) + .join(" · "); + + return { + valeur: Math.max(...entrees.map(([, pv]) => pv)), + divergent: distincts.size > 1, + detail, + }; +} + // ========================================================================= // OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires) // ========================================================================= @@ -792,21 +826,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..fbdeefd 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; } @@ -456,6 +458,7 @@ export async function pgGetStockByFournisseur(codefou: string): Promise>> { + const result = await pgNoParallel(sql` + SELECT + a.codein, + cpv.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