From d733755659699d2835df6f74f8a37bee8c850c99 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 15:55:43 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix(hit-parade):=20ventes=20nettes=20par=20?= =?UTF-8?q?magasin=20=E2=80=94=20corrige=20les=20chiffres=20fauss=C3=A9s?= =?UTF-8?q?=20(579)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La requête Hit Parade divergeait du calcul canonique de la Grille : - SUM(ABS(qtemvt)) additionnait les retours clients comme des ventes au lieu de les déduire → quantités et CA gonflés sur le magasin ayant des retours/avoirs sur la période (visible sur 579). - ABS(SUM(mntmvtttc)) inversait le signe d'un CA net négatif. - GROUP BY no_id + jointure artfou1 preference=1 non dédupliquée pouvait produire plusieurs lignes par (codein, site), écrasées par le pivot. Corrections : - Hit Parade : ventes nettes SUM(-qtemvt) / SUM(-mntmvtttc), agrégation par (codein, site), fournisseur préféré via LATERAL ... LIMIT 1, stock agrégé par codein ; pivot en accumulation (+=) par sécurité. - Analytics (même classe de bug) : pgGetCaByFournisseur et pgGetCaByNomenclature passent en CA net ; jointure fournisseur dédupliquée (LATERAL LIMIT 1) pour éviter le double comptage. - Gestion de stock : pgGetStockNegatif et pgGetSansVente6Mois dédupliquent le fournisseur préféré (LATERAL LIMIT 1) pour éviter les lignes en double. Audit complet du mapping par magasin (292/579) côté client : aucun champ inversé (hit-parade, analytics, dashboard, grid, heatmap). Build OK. --- src/app/(dashboard)/hit-parade/page.tsx | 19 ++++--- src/lib/pg-ff-client.ts | 73 +++++++++++++++++++------ 2 files changed, 66 insertions(+), 26 deletions(-) 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/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index ecbfeb4..1ca1af1 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 LATERAL ... LIMIT 1 pour éviter la duplication des mouvements si un + * article a plusieurs lignes artfou1 avec preference = 1. */ export async function pgGetCaByFournisseur( mois: string, @@ -685,10 +689,16 @@ 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 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 WHERE (TO_CHAR(m.datmvt, 'YYYY-MM') = ${mois} OR TO_CHAR(m.datmvt, 'YYYY-MM') = ${moisN1}) AND m.site IN ('292', '579') @@ -703,6 +713,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 +725,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 @@ -751,40 +762,54 @@ 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. + * + * Ventes NETTES, alignées sur pgGetMensuelByFournisseur (genremvt = 3) : + * -SUM(qtemvt) / -SUM(mntmvtttc) — les retours clients (mouvements positifs) + * sont déduits au lieu d'être additionnés comme des ventes (l'ancien + * SUM(ABS(qtemvt)) gonflait les chiffres du magasin ayant des retours). + * Agrégation par (codein, site) — garantit 1 ligne par produit et par magasin + * même si un codein correspond à plusieurs no_id. Le fournisseur préféré est + * résolu via LATERAL ... LIMIT 1 pour éviter toute duplication de jointure. */ 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, + MIN(a.libelle1::text) AS libelle, + MIN(COALESCE(fi.nom, af.code, 'Sans fournisseur')::text) AS fournisseur, + MIN(COALESCE(n.code, '')::text) AS nomenclature_code, + MIN(COALESCE(n.libelle, 'Sans nomenclature')::text) AS nomenclature, 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 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 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 + GROUP BY a.codein, m.site ), stock_agg AS ( SELECT - cs.artnoid, + 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 a2.codein::text IN (SELECT codein FROM ventes) + GROUP BY a2.codein ) SELECT v.codein, @@ -800,7 +825,7 @@ 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 stock_agg s ON s.codein = v.codein ORDER BY v.ca_ttc DESC `); @@ -929,7 +954,13 @@ export async function pgGetStockNegatif(site?: string): Promise 0 From b7de347a99a0904131ec6113ab43e5aafe8a437b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 20:28:03 +0000 Subject: [PATCH 2/4] fix(grid,hit-parade): convention de signe nette + lint React Compiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Grille (fallback API mensuel) : Math.abs remplacé par la négation sur qte_vendue/ca_ht (négatifs côté API) — les retours clients restent déduits au lieu d'être inversés en positif, même convention que le SQL. - Hit Parade (client) : ColHeader extrait hors du composant (erreur 'Cannot create components during render'), useMemo déplacés avant exportToExcel pour préserver la mémoïsation React Compiler, variable idx inutilisée supprimée. ESLint 0 erreur, build OK. --- src/app/(dashboard)/hit-parade/client.tsx | 133 ++++++++++++---------- src/features/grid/api/get-product-rows.ts | 6 +- 2 files changed, 77 insertions(+), 62 deletions(-) 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/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; From 159508132cfe9266b72205f97d96274eefcd03e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 20:49:12 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(hit-parade):=20agr=C3=A9gation=20isol?= =?UTF-8?q?=C3=A9e=20sur=20mvtart=C3=97articles=20=E2=80=94=20corrige=20le?= =?UTF-8?q?s=20chiffres=20faux=20des=202=20magasins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La version précédente agrégeait les ventes avec les jointures fournisseur (LATERAL par ligne de mouvement + fouident) dans la même passe : si fouident contient des codes dupliqués, chaque mouvement était compté plusieurs fois → chiffres faux sur les deux magasins, et le LATERAL s'exécutait par mouvement (très lent). Restructuration, validée sur 12 000 mouvements réels de l'API chargés dans un PostgreSQL 16 local : - CTE ventes : mvtart JOIN articles uniquement (1:1), SUM(-qtemvt) / SUM(-mntmvtttc) / SUM(margemvt) par (codein, site) — le calcul canonique de la Grille (pgGetMensuelByFournisseur), signes vérifiés sur les mouvements réels (ventes négatives, genremvt=3). - CTE attrs : libellé/fournisseur/nomenclature joints APRÈS agrégation, dédupliqués par DISTINCT ON — aucune jointure ne peut plus fausser les sommes (totaux invariants aux doublons artfou1/fouident injectés). - Même principe pour pgGetCaByFournisseur, pgGetStockNegatif, pgGetSansVente6Mois : sous-requête artfou1 DISTINCT ON au lieu du LATERAL par ligne. --- src/lib/pg-ff-client.ts | 108 +++++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 51 deletions(-) diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 1ca1af1..080767d 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -676,8 +676,8 @@ export interface CaByNomenclatureRow { * 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 LATERAL ... LIMIT 1 pour éviter la duplication des mouvements si un - * article a plusieurs lignes artfou1 avec preference = 1. + * 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, @@ -692,13 +692,12 @@ export async function pgGetCaByFournisseur( SUM(-m.mntmvtttc)::float AS ca_ttc FROM mvtart m JOIN articles a ON a.no_id = m.artnoid - 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 ( + 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') @@ -761,31 +760,41 @@ 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. * - * Ventes NETTES, alignées sur pgGetMensuelByFournisseur (genremvt = 3) : - * -SUM(qtemvt) / -SUM(mntmvtttc) — les retours clients (mouvements positifs) - * sont déduits au lieu d'être additionnés comme des ventes (l'ancien - * SUM(ABS(qtemvt)) gonflait les chiffres du magasin ayant des retours). - * Agrégation par (codein, site) — garantit 1 ligne par produit et par magasin - * même si un codein correspond à plusieurs no_id. Le fournisseur préféré est - * résolu via LATERAL ... LIMIT 1 pour éviter toute duplication de jointure. + * 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). */ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promise { const result = await pgNoParallel(sql` WITH ventes AS ( SELECT - a.codein::text AS codein, - MIN(a.libelle1::text) AS libelle, - MIN(COALESCE(fi.nom, af.code, 'Sans fournisseur')::text) AS fournisseur, - MIN(COALESCE(n.code, '')::text) AS nomenclature_code, - MIN(COALESCE(n.libelle, 'Sans nomenclature')::text) AS nomenclature, + TRIM(a.codein::text) AS codein, m.site, 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 + 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 + 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 @@ -793,30 +802,28 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis 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 m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date - AND m.site IN ('292', '579') - AND m.genremvt = 3 - GROUP BY a.codein, m.site + 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 - a2.codein::text AS codein, + 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 JOIN articles a2 ON a2.no_id = cs.artnoid - WHERE a2.codein::text IN (SELECT codein FROM ventes) - GROUP BY a2.codein + 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, @@ -825,7 +832,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.codein = v.codein + 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 `); @@ -954,13 +962,12 @@ export async function pgGetStockNegatif(site?: string): Promise 0 From ea19c5771eba344b52c633a8061de404171991c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 21:15:44 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(hit-parade):=20exclut=20les=20lignes=20?= =?UTF-8?q?de=20quantit=C3=A9=20sans=20montant=20(corrections)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cas réel : codein 487673 site 579 affichait 38 vendus au lieu de 24, avec un CA pourtant exact (215,76 €) — mvtart contient des lignes genremvt=3 portant une quantité mais aucun montant TTC (corrections / régularisations), que les endpoints officiels de l'API excluent. Ajout du filtre mntmvtttc IS NOT NULL AND <> 0 dans l'agrégation : une vente réelle a toujours un montant TTC non nul. Validé sur PostgreSQL local en reproduisant le cas : sans filtre 38/215,76 € (= symptôme), avec filtre 24/215,76 € (= réalité). Le CA et les requêtes Analytics ne changent pas (ces lignes valent 0 €). --- src/lib/pg-ff-client.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 080767d..530530d 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -769,6 +769,11 @@ export interface HitParadeRow { * 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` @@ -784,6 +789,8 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date AND m.site IN ('292', '579') AND m.genremvt = 3 + AND m.mntmvtttc IS NOT NULL + AND m.mntmvtttc <> 0 AND a.codein IS NOT NULL GROUP BY TRIM(a.codein::text), m.site ),