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,
|
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,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) ──
|
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
|
||||||
await enrichWithNetworkMetrics(productMap);
|
await enrichWithNetworkMetrics(productMap);
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,54 @@ 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é.
|
||||||
|
*
|
||||||
|
* `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)
|
// OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -792,21 +840,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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
+46
-1
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -448,14 +450,17 @@ function buildNomMap(rows: PgNomRow[]): Map<string, PgNomRow> {
|
|||||||
* 1 requête SQL — remplace la partie stock du référentiel.
|
* 1 requête SQL — remplace la partie stock du référentiel.
|
||||||
*/
|
*/
|
||||||
export async function pgGetStockByFournisseur(codefou: string): Promise<Map<string, PgStockRow[]>> {
|
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`
|
const result = await pgNoParallel(sql`
|
||||||
SELECT
|
SELECT
|
||||||
a.codein,
|
a.codein,
|
||||||
cs.site,
|
TRIM(cs.site::text) AS site,
|
||||||
cs.stockdispo::float,
|
cs.stockdispo::float,
|
||||||
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 +477,46 @@ 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>>> {
|
||||||
|
// 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
|
// 6. Commandes en cours
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+15
-1
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user