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