mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Merge pull request #12 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Claude/grille modal ventes stock qlxg9i
This commit is contained in:
4 files changed
+153
-11
No files matched your search
@@ -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,35 @@ 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);
|
||||
const site = String(s.site ?? "").trim();
|
||||
if (site && Number.isFinite(pv) && pv > 0) depuisStock[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,54 @@ 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é.
|
||||
*
|
||||
* `cube_pv` a une clé `(artnoid, site)`, mais notre base n'y met en pratique
|
||||
* qu'une ligne par article : le prix n'est pas ventilé par magasin. La colonne
|
||||
* doit donc rester lisible dans les deux cas de figure —
|
||||
*
|
||||
* · magasin consulté tarifé → SON prix ;
|
||||
* · prix unique, non ventilé → ce prix vaut pour tous les magasins ;
|
||||
* · plusieurs prix mais aucun pour ce magasin → rien, plutôt que celui du
|
||||
* voisin ;
|
||||
* · en « tous magasins » : 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 entrees = Object.entries(row.prixVenteByStore ?? {});
|
||||
|
||||
// Aucun prix ventilé : reste le PV de la fiche article, s'il existe.
|
||||
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)));
|
||||
|
||||
if (activeMagasin !== "TOTAL") {
|
||||
const pv = row.prixVenteByStore?.[activeMagasin];
|
||||
if (pv != null) return { valeur: pv, divergent: false, detail: null };
|
||||
if (distincts.size === 1) {
|
||||
return { valeur: entrees[0][1], divergent: false, detail: "prix unique, non ventilé par magasin" };
|
||||
}
|
||||
return { valeur: null, divergent: false, detail: null };
|
||||
}
|
||||
|
||||
const detail = entrees.length > 1
|
||||
? entrees
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([site, pv]) => `${SITE_LABELS[site]?.nom ?? site} : ${fmtEuro2(pv)}`)
|
||||
.join(" · ")
|
||||
: null;
|
||||
|
||||
return {
|
||||
valeur: Math.max(...entrees.map(([, pv]) => pv)),
|
||||
divergent: distincts.size > 1,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires)
|
||||
// =========================================================================
|
||||
@@ -792,21 +840,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>
|
||||
);
|
||||
},
|
||||
|
||||
+46
-1
@@ -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;
|
||||
}
|
||||
@@ -448,14 +450,17 @@ function buildNomMap(rows: PgNomRow[]): Map<string, PgNomRow> {
|
||||
* 1 requête SQL — remplace la partie stock du référentiel.
|
||||
*/
|
||||
export async function pgGetStockByFournisseur(codefou: string): Promise<Map<string, PgStockRow[]>> {
|
||||
// `site` est élagué pour la même raison que dans pgGetPrixVenteByFournisseur :
|
||||
// il sert de clé de recherche (derniereLivraisonByStore, prix de repli).
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT
|
||||
a.codein,
|
||||
cs.site,
|
||||
TRIM(cs.site::text) AS site,
|
||||
cs.stockdispo::float,
|
||||
cs.qte::float,
|
||||
cs.valstock::float,
|
||||
cs.prmp::float,
|
||||
cs.pv::float,
|
||||
cs.dernierevente::text,
|
||||
cs.dernierereception::text
|
||||
FROM cube_stock cs
|
||||
@@ -472,6 +477,46 @@ 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>>> {
|
||||
// Le site sert de CLÉ de recherche côté Grille (prixVenteByStore["292"]), d'où
|
||||
// l'élagage : la synchronisation Apiflow écrit `cube_pv.site` brut, sans passer
|
||||
// par le `safeStr` qu'elle applique à `cube_stock`. Une colonne MSSQL de largeur
|
||||
// fixe remonterait complétée d'espaces, et la recherche échouerait en silence —
|
||||
// le prix resterait visible en « tous magasins » et vide magasin par magasin.
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT
|
||||
a.codein,
|
||||
TRIM(cpv.site::text) AS 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