Grille : le prix de vente vient de cube_pv, pas d'article_infosup

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
This commit is contained in:
Claude committed 2026-08-14 06:12:25 +00:00
1 parent 9729b00dc7
commit c9e1934aae
5 files changed
+160 -10

No files matched your search

+30
View File
@@ -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<string, unknown>),
};
} 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.
+32 -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,34 @@ 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);
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);
+45 -8
View File
@@ -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: () => <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>
);
},
+38
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;
}
@@ -456,6 +458,7 @@ export async function pgGetStockByFournisseur(codefou: string): Promise<Map<stri
cs.qte::float,
cs.valstock::float,
cs.prmp::float,
cs.pv::float,
cs.dernierevente::text,
cs.dernierereception::text
FROM cube_stock cs
@@ -472,6 +475,41 @@ 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>>> {
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<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