mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-21 22:18:08 +02:00
feat: Implement product heatmap grid with comprehensive data aggregation and PostgreSQL client for product rows.
This commit is contained in:
1 parent
bba6fe05e8
commit
ffa257fda1
5 files changed
+73
-37
No files matched your search
@@ -125,10 +125,11 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
|||||||
const siteByPeriod = bySite.get(row.site)!;
|
const siteByPeriod = bySite.get(row.site)!;
|
||||||
if (!siteByPeriod.has(periode)) siteByPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 });
|
if (!siteByPeriod.has(periode)) siteByPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 });
|
||||||
const sp = siteByPeriod.get(periode)!;
|
const sp = siteByPeriod.get(periode)!;
|
||||||
sp.qty += Number(row.qte_vendue) || 0;
|
sp.qty += Number(row.qte_vendue) || 0;
|
||||||
sp.ca += Number(row.ca_ht) || 0;
|
sp.ca += Number(row.ca_ht) || 0;
|
||||||
sp.marge += Number(row.marge) || 0;
|
sp.marge += Number(row.marge) || 0;
|
||||||
sp.stock = Math.max(sp.stock, Number(row.stock_fin_mois) || 0);
|
sp.stock = Number(row.stock_fin_mois) || 0; // SQL donne déjà le dernier mouvement
|
||||||
|
sp.receptions += Number(row.qte_recue) || 0;
|
||||||
|
|
||||||
// Suivi magasins actifs (pour workingStores) : 1 vente suffit
|
// Suivi magasins actifs (pour workingStores) : 1 vente suffit
|
||||||
if (Number(row.qte_vendue) > 0) {
|
if (Number(row.qte_vendue) > 0) {
|
||||||
@@ -165,19 +166,28 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
|||||||
// Breakdowns par site pour le switch client-side
|
// Breakdowns par site pour le switch client-side
|
||||||
const siteMap = mensuelBySite.get(codein);
|
const siteMap = mensuelBySite.get(codein);
|
||||||
if (siteMap) {
|
if (siteMap) {
|
||||||
product.sales12mByStore = {};
|
product.sales12mByStore = {};
|
||||||
product.stock12mByStore = {};
|
product.stock12mByStore = {};
|
||||||
product.caByStore = {};
|
product.receptions12mByStore = {};
|
||||||
product.quantiteByStore = {};
|
product.caByStore = {};
|
||||||
product.margeByStore = {};
|
product.quantiteByStore = {};
|
||||||
|
product.margeByStore = {};
|
||||||
for (const [site, siteByPeriod] of siteMap.entries()) {
|
for (const [site, siteByPeriod] of siteMap.entries()) {
|
||||||
product.sales12mByStore[site] = {};
|
product.sales12mByStore[site] = {};
|
||||||
product.stock12mByStore[site] = {};
|
product.stock12mByStore[site] = {};
|
||||||
|
product.receptions12mByStore![site] = {};
|
||||||
let sQty = 0, sCa = 0, sMarge = 0;
|
let sQty = 0, sCa = 0, sMarge = 0;
|
||||||
|
let lastSiteStock = 0;
|
||||||
for (const periode of sortedPeriods) {
|
for (const periode of sortedPeriods) {
|
||||||
const sp = siteByPeriod.get(periode);
|
const sp = siteByPeriod.get(periode);
|
||||||
product.sales12mByStore[site][periode] = sp?.qty ?? 0;
|
product.sales12mByStore[site][periode] = sp?.qty ?? 0;
|
||||||
product.stock12mByStore[site][periode] = sp?.stock ?? 0;
|
product.receptions12mByStore![site][periode] = sp?.receptions ?? 0;
|
||||||
|
if (sp) {
|
||||||
|
product.stock12mByStore[site][periode] = sp.stock;
|
||||||
|
lastSiteStock = sp.stock;
|
||||||
|
} else {
|
||||||
|
product.stock12mByStore[site][periode] = lastSiteStock; // carry-forward
|
||||||
|
}
|
||||||
sQty += sp?.qty ?? 0;
|
sQty += sp?.qty ?? 0;
|
||||||
sCa += sp?.ca ?? 0;
|
sCa += sp?.ca ?? 0;
|
||||||
sMarge += sp?.marge ?? 0;
|
sMarge += sp?.marge ?? 0;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import React from "react";
|
|||||||
interface HeatmapCellProps {
|
interface HeatmapCellProps {
|
||||||
value: number | null;
|
value: number | null;
|
||||||
tooltipStock?: number | null;
|
tooltipStock?: number | null;
|
||||||
|
tooltipReceptions?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHeatmapClass(value: number | null): string {
|
function getHeatmapClass(value: number | null): string {
|
||||||
@@ -14,15 +15,16 @@ function getHeatmapClass(value: number | null): string {
|
|||||||
return "heatmap-zero";
|
return "heatmap-zero";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeatmapCell = React.memo(({ value, tooltipStock }: HeatmapCellProps) => {
|
export const HeatmapCell = React.memo(({ value, tooltipStock, tooltipReceptions }: HeatmapCellProps) => {
|
||||||
const heatClass = getHeatmapClass(value);
|
const heatClass = getHeatmapClass(value);
|
||||||
const display = value === null || value === 0 ? "0" : Math.round(Math.abs(value)).toString();
|
const display = value === null || value === 0 ? "0" : Math.round(Math.abs(value)).toString();
|
||||||
|
|
||||||
const titleParts: string[] = [];
|
const titleParts: string[] = [];
|
||||||
if (value !== null && value !== 0) titleParts.push(`${Math.abs(value).toLocaleString("fr-FR")} unités`);
|
if (tooltipReceptions != null && tooltipReceptions > 0)
|
||||||
else titleParts.push("Aucune vente");
|
titleParts.push(`Entrées : +${Math.round(tooltipReceptions).toLocaleString("fr-FR")}`);
|
||||||
if (tooltipStock != null) titleParts.push(`Stock fin de mois : ${tooltipStock.toLocaleString("fr-FR")}`);
|
if (tooltipStock != null)
|
||||||
const title = titleParts.join(" · ");
|
titleParts.push(`Stock fin de mois : ${Math.round(tooltipStock).toLocaleString("fr-FR")}`);
|
||||||
|
const title = titleParts.length > 0 ? titleParts.join(" · ") : (value ? `${Math.abs(value).toLocaleString("fr-FR")} unités` : "Aucune vente");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -463,7 +463,10 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
const stock = activeMagasin === "TOTAL"
|
const stock = activeMagasin === "TOTAL"
|
||||||
? (row.original.stock12m[monthKey] ?? null)
|
? (row.original.stock12m[monthKey] ?? null)
|
||||||
: (row.original.stock12mByStore?.[activeMagasin]?.[monthKey] ?? null);
|
: (row.original.stock12mByStore?.[activeMagasin]?.[monthKey] ?? null);
|
||||||
return <HeatmapCell value={qty} tooltipStock={stock} />;
|
const receptions = activeMagasin === "TOTAL"
|
||||||
|
? (row.original.receptions12m[monthKey] ?? null)
|
||||||
|
: (row.original.receptions12mByStore?.[activeMagasin]?.[monthKey] ?? null);
|
||||||
|
return <HeatmapCell value={qty} tooltipStock={stock} tooltipReceptions={receptions} />;
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
@@ -571,7 +574,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
globalFilterFn: (row, columnId, filterValue) => {
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
const search = String(filterValue).toLowerCase();
|
const search = String(filterValue).toLowerCase();
|
||||||
const libelle = String(row.original.libelle1 || "").toLowerCase();
|
const libelle = String(row.original.libelle1 || "").toLowerCase();
|
||||||
const code = String(row.original.codein || "").toLowerCase();
|
const code = String(row.original.codein || "").toLowerCase();
|
||||||
|
|||||||
+35
-16
@@ -166,24 +166,43 @@ export async function pgGetMensuelByFournisseur(
|
|||||||
// IMPORTANT : utiliser DISTINCT sur artfou1 pour éviter les doublons de mouvements.
|
// IMPORTANT : utiliser DISTINCT sur artfou1 pour éviter les doublons de mouvements.
|
||||||
// Un article peut avoir plusieurs lignes artfou1 pour le même fournisseur (pcb/ref différents),
|
// Un article peut avoir plusieurs lignes artfou1 pour le même fournisseur (pcb/ref différents),
|
||||||
// ce qui multiplierait chaque mouvement et gonflerait les totaux.
|
// ce qui multiplierait chaque mouvement et gonflerait les totaux.
|
||||||
|
//
|
||||||
|
// stock_fin_mois = qtestock du DERNIER mouvement du mois (tri par datmvt DESC).
|
||||||
|
// MAX(qtestock) donnait le stock de début de mois (avant les premières ventes), ce qui était faux.
|
||||||
const result = await pgNoParallel(sql`
|
const result = await pgNoParallel(sql`
|
||||||
|
WITH base AS (
|
||||||
|
SELECT
|
||||||
|
a.codein,
|
||||||
|
m.site,
|
||||||
|
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
|
||||||
|
m.qtemvt,
|
||||||
|
m.mntmvtttc,
|
||||||
|
m.margemvt,
|
||||||
|
m.genremvt,
|
||||||
|
m.qtestock,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
|
||||||
|
ORDER BY m.datmvt DESC
|
||||||
|
) AS rn_last
|
||||||
|
FROM mvtart m
|
||||||
|
JOIN articles a ON a.no_id = m.artnoid
|
||||||
|
JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af
|
||||||
|
ON af.art_no_id = a.no_id
|
||||||
|
WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
||||||
|
AND m.site IN ('292', '579')
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
a.codein,
|
codein,
|
||||||
m.site,
|
site,
|
||||||
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
|
mois,
|
||||||
SUM(CASE WHEN m.genremvt = 3 THEN -m.qtemvt ELSE 0 END)::float AS qte_vendue,
|
SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue,
|
||||||
SUM(CASE WHEN m.genremvt = 3 THEN -m.mntmvtttc ELSE 0 END)::float AS ca_ht,
|
SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht,
|
||||||
SUM(CASE WHEN m.genremvt = 3 THEN m.margemvt ELSE 0 END)::float AS marge,
|
SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge,
|
||||||
MAX(m.qtestock)::float AS stock_fin_mois,
|
MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois,
|
||||||
SUM(CASE WHEN m.genremvt IN (1, 2) THEN -m.qtemvt ELSE 0 END)::float AS qte_recue
|
SUM(CASE WHEN genremvt IN (1, 2) THEN -qtemvt ELSE 0 END)::float AS qte_recue
|
||||||
FROM mvtart m
|
FROM base
|
||||||
JOIN articles a ON a.no_id = m.artnoid
|
GROUP BY codein, site, mois
|
||||||
JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af
|
ORDER BY codein, site, mois
|
||||||
ON af.art_no_id = a.no_id
|
|
||||||
WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
|
||||||
AND m.site IN ('292', '579')
|
|
||||||
GROUP BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
|
|
||||||
ORDER BY a.codein, m.site, mois
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return result.rows as unknown as PgMensuelRow[];
|
return result.rows as unknown as PgMensuelRow[];
|
||||||
|
|||||||
+3
-1
@@ -54,8 +54,10 @@ export interface ProductRow {
|
|||||||
receptions12m: Record<string, number>;
|
receptions12m: Record<string, number>;
|
||||||
/** Per-store breakdown (site → YYYYMM → qty) — always present even in TOTAL mode */
|
/** Per-store breakdown (site → YYYYMM → qty) — always present even in TOTAL mode */
|
||||||
sales12mByStore?: Record<string, Record<string, number>>;
|
sales12mByStore?: Record<string, Record<string, number>>;
|
||||||
/** Per-store stock breakdown (site → YYYYMM → stock) */
|
/** Per-store stock breakdown (site → YYYYMM → stock fin de mois) */
|
||||||
stock12mByStore?: Record<string, Record<string, number>>;
|
stock12mByStore?: Record<string, Record<string, number>>;
|
||||||
|
/** Per-store reception breakdown (site → YYYYMM → qté reçue) */
|
||||||
|
receptions12mByStore?: Record<string, Record<string, number>>;
|
||||||
/** Per-store CA totals */
|
/** Per-store CA totals */
|
||||||
caByStore?: Record<string, number>;
|
caByStore?: Record<string, number>;
|
||||||
/** Per-store quantity totals */
|
/** Per-store quantity totals */
|
||||||
|
|||||||
Reference in new issue
Block a user