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