mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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:
5 files changed
+160
-10
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user