diff --git a/src/app/(dashboard)/hit-parade/client.tsx b/src/app/(dashboard)/hit-parade/client.tsx
index 8e3dc65..ec4b18a 100644
--- a/src/app/(dashboard)/hit-parade/client.tsx
+++ b/src/app/(dashboard)/hit-parade/client.tsx
@@ -28,6 +28,43 @@ interface Props {
pivotted: HitParadePivotRow[];
}
+function ColHeader({
+ label,
+ sortable,
+ group,
+ sortKey,
+ sortDir,
+ onSort,
+}: {
+ label: string;
+ sortable?: SortKey;
+ group?: "292" | "579" | "total";
+ sortKey: SortKey;
+ sortDir: SortDir;
+ onSort: (key: SortKey) => void;
+}) {
+ const isActive = sortable && sortKey === sortable;
+ const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : "";
+ const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700";
+ return (
+
onSort(sortable) : undefined}
+ className={[
+ "px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none",
+ sortable ? "cursor-pointer" : "",
+ isActive ? activeBg : `${groupBg} text-gray-600`,
+ ].join(" ")}
+ >
+ {label}
+ {sortable && (
+
+ {isActive ? (sortDir === "desc" ? "↓" : "↑") : ↕}
+
+ )}
+ |
+ );
+}
+
export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
const router = useRouter();
const [sortKey, setSortKey] = useState(DEFAULT_SORT);
@@ -73,6 +110,32 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
const isSorted = sortKey !== DEFAULT_SORT || sortDir !== DEFAULT_DIR;
+ const filtered = useMemo(() =>
+ pivotted.filter(r =>
+ (filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) &&
+ (filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature)
+ ),
+ [pivotted, filterFournisseur, filterNomenclature]
+ );
+
+ const sorted = useMemo(() =>
+ [...filtered].sort((a, b) => {
+ const diff = a[sortKey] - b[sortKey];
+ return sortDir === "desc" ? -diff : diff;
+ }),
+ [filtered, sortKey, sortDir]
+ );
+
+ const totals = useMemo(() => sorted.reduce(
+ (acc, r) => ({
+ qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292,
+ qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579,
+ qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal,
+ stock292: acc.stock292 + r.stock292, stock579: acc.stock579 + r.stock579, stockTotal: acc.stockTotal + r.stockTotal,
+ }),
+ { qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
+ ), [sorted]);
+
function exportToExcel() {
const headers = [
"Code", "Désignation", "Fournisseur", "Nomenclature",
@@ -115,56 +178,6 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
XLSX.writeFile(wb, `hit-parade_${dateDebut}_${dateFin}.xlsx`);
}
- const filtered = useMemo(() =>
- pivotted.filter(r =>
- (filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) &&
- (filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature)
- ),
- [pivotted, filterFournisseur, filterNomenclature]
- );
-
- const sorted = useMemo(() =>
- [...filtered].sort((a, b) => {
- const diff = a[sortKey] - b[sortKey];
- return sortDir === "desc" ? -diff : diff;
- }),
- [filtered, sortKey, sortDir]
- );
-
- const totals = useMemo(() => sorted.reduce(
- (acc, r) => ({
- qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292,
- qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579,
- qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal,
- stock292: acc.stock292 + r.stock292, stock579: acc.stock579 + r.stock579, stockTotal: acc.stockTotal + r.stockTotal,
- }),
- { qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
- ), [sorted]);
-
- function ColHeader({ label, sortable, k, group }: { label: string; sortable?: SortKey; k: string; group?: "292" | "579" | "total" }) {
- const isActive = sortable && sortKey === sortable;
- const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : "";
- const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700";
- return (
- handleSort(sortable) : undefined}
- className={[
- "px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none",
- sortable ? "cursor-pointer" : "",
- isActive ? activeBg : `${groupBg} text-gray-600`,
- ].join(" ")}
- >
- {label}
- {sortable && (
-
- {isActive ? (sortDir === "desc" ? "↓" : "↑") : ↕}
-
- )}
- |
- );
- }
-
return (
{/* Barre de contrôles */}
@@ -277,22 +290,22 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
-
-
+
+
| % Marge |
-
-
-
+
+
+
% Marge |
-
-
-
+
+
+
% Marge |
-
+
- {sorted.map((row, idx) => (
+ {sorted.map((row) => (
| {row.codein} |
{row.libelle.trim()} |
diff --git a/src/app/(dashboard)/hit-parade/page.tsx b/src/app/(dashboard)/hit-parade/page.tsx
index ce2909e..b207512 100644
--- a/src/app/(dashboard)/hit-parade/page.tsx
+++ b/src/app/(dashboard)/hit-parade/page.tsx
@@ -42,14 +42,17 @@ function pivotHitParade(rows: HitParadeRow[]): HitParadePivotRow[] {
});
}
const entry = map.get(row.codein)!;
- if (row.site === "292") {
- entry.qte292 = row.qte_vendue;
- entry.ca292 = row.ca_ttc;
- entry.marge292 = row.marge;
- } else if (row.site === "579") {
- entry.qte579 = row.qte_vendue;
- entry.ca579 = row.ca_ttc;
- entry.marge579 = row.marge;
+ // Accumulation (+=) et non affectation : si la requête renvoyait plusieurs
+ // lignes pour un même (codein, site), aucune valeur ne serait écrasée.
+ const site = String(row.site).trim();
+ if (site === "292") {
+ entry.qte292 += row.qte_vendue;
+ entry.ca292 += row.ca_ttc;
+ entry.marge292 += row.marge;
+ } else if (site === "579") {
+ entry.qte579 += row.qte_vendue;
+ entry.ca579 += row.ca_ttc;
+ entry.marge579 += row.marge;
}
}
diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts
index 102bb76..725257f 100644
--- a/src/features/grid/api/get-product-rows.ts
+++ b/src/features/grid/api/get-product-rows.ts
@@ -391,8 +391,10 @@ async function reconcileSelectedStoreFromMensuelApi(
for (const entry of entries) {
const period = entry.mois.replace("-", "");
if (!sortedPeriods.includes(period)) continue;
- const qty = Math.abs(Number(entry.ventes?.qte_vendue ?? 0) || 0);
- const ca = Math.abs(Number(entry.ventes?.ca_ht ?? 0) || 0);
+ // qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
+ // les nie au lieu de Math.abs pour que les retours restent déduits.
+ const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0);
+ const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0);
const marge = Number(entry.ventes?.marge ?? 0) || 0;
const stock = Number(entry.stock_fin_mois ?? 0) || 0;
const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0;
diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts
index ecbfeb4..530530d 100644
--- a/src/lib/pg-ff-client.ts
+++ b/src/lib/pg-ff-client.ts
@@ -674,6 +674,10 @@ export interface CaByNomenclatureRow {
/**
* Retourne le CA TTC par fournisseur et site pour deux mois donnés (mois et mois N-1).
* Attribution via artfou1.preference = true (fournisseur principal de l'article).
+ * CA NET : SUM(-mntmvtttc) — les retours clients sont déduits (même convention
+ * que pgGetMensuelByFournisseur / pgGetHitParade). Fournisseur préféré résolu
+ * via une sous-requête artfou1 dédupliquée (DISTINCT ON) : un seul code par
+ * article, aucune duplication possible des mouvements dans la somme.
*/
export async function pgGetCaByFournisseur(
mois: string,
@@ -685,10 +689,15 @@ export async function pgGetCaByFournisseur(
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS nom,
m.site,
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
- ABS(SUM(m.mntmvtttc))::float AS ca_ttc
+ SUM(-m.mntmvtttc)::float AS ca_ttc
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
- LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
+ LEFT JOIN (
+ SELECT DISTINCT ON (art_no_id) art_no_id, code
+ FROM artfou1
+ WHERE preference = 1
+ ORDER BY art_no_id, code
+ ) af ON af.art_no_id = a.no_id
LEFT JOIN fouident fi ON fi.code = af.code
WHERE (TO_CHAR(m.datmvt, 'YYYY-MM') = ${mois} OR TO_CHAR(m.datmvt, 'YYYY-MM') = ${moisN1})
AND m.site IN ('292', '579')
@@ -703,6 +712,7 @@ export async function pgGetCaByFournisseur(
/**
* Retourne le CA TTC par nomenclature et site pour deux mois donnés.
+ * CA NET : SUM(-mntmvtttc) — retours clients déduits (convention commune).
*/
export async function pgGetCaByNomenclature(
mois: string,
@@ -714,7 +724,7 @@ export async function pgGetCaByNomenclature(
COALESCE(n.libelle, 'Sans nomenclature')::text AS libelle,
m.site,
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
- ABS(SUM(m.mntmvtttc))::float AS ca_ttc
+ SUM(-m.mntmvtttc)::float AS ca_ttc
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
@@ -750,48 +760,77 @@ export interface HitParadeRow {
/**
* Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC, marge et stock par site.
- * Le stock est intégré via un LEFT JOIN — pas de requête séparée, pas de tableau de paramètres.
+ *
+ * L'agrégation des ventes se fait UNIQUEMENT sur mvtart × articles (jointure 1:1),
+ * exactement comme pgGetMensuelByFournisseur (le calcul canonique de la Grille) :
+ * qte = SUM(-qtemvt), ca = SUM(-mntmvtttc), marge = SUM(margemvt) pour genremvt = 3
+ * (ventes stockées en négatif — vérifié sur les mouvements réels de l'API).
+ * Les attributs (libellé, fournisseur préféré, nomenclature) et le stock sont
+ * joints APRÈS l'agrégation, par codein : aucune jointure ne peut dupliquer les
+ * mouvements et fausser les sommes, et le LATERAL fournisseur ne s'exécute que
+ * par article vendu (et non par ligne de mouvement).
+ *
+ * Filtre mntmvtttc <> 0 : mvtart contient des lignes genremvt=3 de quantité
+ * sans montant (corrections/régularisations, ex: codein 487673 site 579 —
+ * 38 affiché au lieu de 24 réels, CA exact). Une vente réelle a toujours un
+ * montant TTC non nul ; les endpoints officiels de l'API les excluent aussi.
*/
export async function pgGetHitParade(dateDebut: string, dateFin: string): Promise {
const result = await pgNoParallel(sql`
WITH ventes AS (
SELECT
- a.no_id AS art_no_id,
- a.codein::text AS codein,
- a.libelle1::text AS libelle,
- COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
- COALESCE(n.code, '')::text AS nomenclature_code,
- COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature,
+ TRIM(a.codein::text) AS codein,
m.site,
- SUM(ABS(m.qtemvt))::float AS qte_vendue,
- ABS(SUM(m.mntmvtttc))::float AS ca_ttc,
+ SUM(-m.qtemvt)::float AS qte_vendue,
+ SUM(-m.mntmvtttc)::float AS ca_ttc,
SUM(m.margemvt)::float AS marge
FROM mvtart m
- JOIN articles a ON a.no_id = m.artnoid
- LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
- LEFT JOIN fouident fi ON fi.code = af.code
- LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
+ JOIN articles a ON a.no_id = m.artnoid
WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
AND m.site IN ('292', '579')
AND m.genremvt = 3
- GROUP BY a.no_id, a.codein, a.libelle1, fi.nom, af.code, n.code, n.libelle, m.site
+ AND m.mntmvtttc IS NOT NULL
+ AND m.mntmvtttc <> 0
+ AND a.codein IS NOT NULL
+ GROUP BY TRIM(a.codein::text), m.site
+ ),
+ attrs AS (
+ SELECT DISTINCT ON (TRIM(a.codein::text))
+ TRIM(a.codein::text) AS codein,
+ a.libelle1::text AS libelle,
+ COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
+ COALESCE(n.code, '')::text AS nomenclature_code,
+ COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature
+ FROM articles a
+ LEFT JOIN LATERAL (
+ SELECT af1.code
+ FROM artfou1 af1
+ WHERE af1.art_no_id = a.no_id AND af1.preference = 1
+ ORDER BY af1.code
+ LIMIT 1
+ ) af ON TRUE
+ LEFT JOIN fouident fi ON fi.code = af.code
+ LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
+ WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes)
+ ORDER BY TRIM(a.codein::text), a.no_id DESC
),
stock_agg AS (
SELECT
- cs.artnoid,
+ TRIM(a2.codein::text) AS codein,
SUM(CASE WHEN cs.site = '292' THEN cs.qte ELSE 0 END)::float AS stock292,
SUM(CASE WHEN cs.site = '579' THEN cs.qte ELSE 0 END)::float AS stock579,
SUM(cs.qte)::float AS stockTotal
FROM cube_stock cs
- WHERE cs.artnoid IN (SELECT DISTINCT art_no_id FROM ventes)
- GROUP BY cs.artnoid
+ JOIN articles a2 ON a2.no_id = cs.artnoid
+ WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes)
+ GROUP BY TRIM(a2.codein::text)
)
SELECT
v.codein,
- v.libelle,
- v.fournisseur,
- v.nomenclature_code,
- v.nomenclature,
+ COALESCE(ar.libelle, '') AS libelle,
+ COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur,
+ COALESCE(ar.nomenclature_code, '') AS nomenclature_code,
+ COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature,
v.site,
v.qte_vendue,
v.ca_ttc,
@@ -800,7 +839,8 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis
COALESCE(s.stock579, 0) AS stock579,
COALESCE(s.stockTotal, 0) AS "stockTotal"
FROM ventes v
- LEFT JOIN stock_agg s ON s.artnoid = v.art_no_id
+ LEFT JOIN attrs ar ON ar.codein = v.codein
+ LEFT JOIN stock_agg s ON s.codein = v.codein
ORDER BY v.ca_ttc DESC
`);
@@ -929,7 +969,12 @@ export async function pgGetStockNegatif(site?: string): Promise 0