fix(hit-parade): ventes nettes par magasin — corrige les chiffres faussés (579)

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.
This commit is contained in:
Claude committed 2026-07-06 15:55:43 +00:00
1 parent 32f9a27563
commit d733755659
2 files changed
+66 -26

No files matched your search

+11 -8
View File
@@ -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;
}
}
+55 -18
View File
@@ -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<HitParadeRow[]> {
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<PgStockNegatifRo
) AS derniereentree
FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
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
JOIN fouident f ON f.code = af.code
WHERE cs.qte < 0
AND cs.site IN ('292', '579')
@@ -982,7 +1013,13 @@ export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6Mo
) AS derniere_entree
FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
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
JOIN fouident f ON f.code = af.code
WHERE cs.site IN ('292', '579')
AND cs.qte > 0