From 159508132cfe9266b72205f97d96274eefcd03e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 20:49:12 +0000 Subject: [PATCH] =?UTF-8?q?fix(hit-parade):=20agr=C3=A9gation=20isol=C3=A9?= =?UTF-8?q?e=20sur=20mvtart=C3=97articles=20=E2=80=94=20corrige=20les=20ch?= =?UTF-8?q?iffres=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