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 ────────── // ── 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 // 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. // nommée mais vide ne sert à rien, seul le comptage tranche.
+32 -1
View File
@@ -9,6 +9,7 @@ import {
pgGetNomenclatureByFournisseur, pgGetNomenclatureByFournisseur,
pgGetStockByFournisseur, pgGetStockByFournisseur,
pgGetCommandesByFournisseur, pgGetCommandesByFournisseur,
pgGetPrixVenteByFournisseur,
type PgStockRow, type PgStockRow,
} from "@/lib/pg-ff-client"; } from "@/lib/pg-ff-client";
import { db } from "@/db"; import { db } from "@/db";
@@ -124,7 +125,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
try { try {
const { dateDebut, dateFin } = buildLast12MonthsRange(); 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. // Remplace des centaines/milliers d'appels HTTP per-article.
const [ const [
articles, articles,
@@ -133,6 +134,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
nomMap, nomMap,
stockMap, stockMap,
commandesMap, commandesMap,
prixVenteMap,
] = await Promise.all([ ] = await Promise.all([
pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }), pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }),
pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur 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(); }), 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[]>(); }), 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>(); }), 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`); 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) ── // ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
await enrichWithNetworkMetrics(productMap); await enrichWithNetworkMetrics(productMap);
+45 -8
View File
@@ -93,6 +93,40 @@ function prixMoyenReseau(row: ProductRow): number | null {
const fmtEuro2 = (v: number) => const fmtEuro2 = (v: number) =>
v.toLocaleString("fr-FR", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 }); 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) // OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires)
// ========================================================================= // =========================================================================
@@ -792,21 +826,24 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
}, },
{ {
id: "prixVente", id: "prixVente",
accessorFn: (row: ProductRow) => valeurTriable(row.prixVente), accessorFn: (row: ProductRow) => valeurTriable(prixVenteAffiche(row, activeMagasin).valeur),
sortUndefined: "last" as const, 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, size: 88,
cell: ({ row }: { row: { original: ProductRow } }) => { cell: ({ row }: { row: { original: ProductRow } }) => {
const val = row.original.prixVente; const { valeur, divergent, detail } = prixVenteAffiche(row.original, activeMagasin);
return ( return (
<div <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)" }} style={{ color: "var(--text-primary)" }}
title={val != null title={valeur != null
? "Prix de vente central de la fiche article (base Nancy)" ? `Prix de vente en base (cube_pv)${detail ? ` — ${detail}` : ""}`
: "Aucun prix de vente central sur la fiche article"} : "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> </div>
); );
}, },
+38
View File
@@ -72,6 +72,8 @@ export interface PgStockRow {
qte: number; qte: number;
valstock: number; valstock: number;
prmp: number; prmp: number;
/** Prix de vente du site — cube_stock.pv, filet de secours si cube_pv est vide. */
pv?: number;
dernierevente?: string; dernierevente?: string;
dernierereception?: string; dernierereception?: string;
} }
@@ -456,6 +458,7 @@ export async function pgGetStockByFournisseur(codefou: string): Promise<Map<stri
cs.qte::float, cs.qte::float,
cs.valstock::float, cs.valstock::float,
cs.prmp::float, cs.prmp::float,
cs.pv::float,
cs.dernierevente::text, cs.dernierevente::text,
cs.dernierereception::text cs.dernierereception::text
FROM cube_stock cs FROM cube_stock cs
@@ -472,6 +475,41 @@ export async function pgGetStockByFournisseur(codefou: string): Promise<Map<stri
return map; 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 // 6. Commandes en cours
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+15 -1
View File
@@ -89,7 +89,21 @@ export interface ProductRow {
/** Données stock & approvisionnement (API FF Nancy) */ /** Données stock & approvisionnement (API FF Nancy) */
pcb?: number; // Conditionnement (pack size) pcb?: number; // Conditionnement (pack size)
prixAchat?: number; // PA / PRMP 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 stockActuel?: number; // Stock dispo agrégé tous sites
stockTotal?: number; // Qte en stock agrégé stockTotal?: number; // Qte en stock agrégé
stockValeur?: number; // Valeur stock agrégée stockValeur?: number; // Valeur stock agrégée