From 9729b00dc7eca60885ff5f0ae9efbf95197b71cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:32:45 +0000 Subject: [PATCH 1/4] =?UTF-8?q?Diagnostic=20:=20d'o=C3=B9=20vient=20(ou=20?= =?UTF-8?q?ne=20vient=20pas)=20le=20prix=20de=20vente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La colonne « PV central » s'affiche vide sur toutes les lignes. Le chemin du code est pourtant intact : `getProductRows` renseigne `prixVente` depuis `article_infosup.prix_vente_mini` (phase 3), aucune phase suivante ne l'efface, et la route de streaming diffuse la ligne entière sans filtrer ses champs. La valeur est donc absente à la source. Deux causes possibles, que seule la base peut départager : la colonne existe mais n'est pas renseignée, ou le prix de vente vit ailleurs sous un autre nom. `article_infosup.prix_vente_mini` est la seule source de prix de vente câblée dans l'application — la Grille et la fiche produit la partagent, si bien qu'une fiche produit affichant « — » sur PV central confirmerait la première hypothèse. Cette route de diagnostic répond aux deux questions : remplissage réel de la colonne actuelle (absente / à zéro / renseignée), globalement puis pour un fournisseur donné, et énumération des colonnes candidates du schéma AVEC leur remplissage — une colonne bien nommée mais vide ne servirait à rien. À supprimer une fois la source établie. Elle est derrière l'authentification et ne renvoie que des comptages et quelques exemples. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR --- src/app/api/diag/prix-vente/route.ts | 118 +++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 src/app/api/diag/prix-vente/route.ts diff --git a/src/app/api/diag/prix-vente/route.ts b/src/app/api/diag/prix-vente/route.ts new file mode 100644 index 0000000..eeef0b7 --- /dev/null +++ b/src/app/api/diag/prix-vente/route.ts @@ -0,0 +1,118 @@ +import { NextRequest } from "next/server"; +import { db } from "@/db"; +import { sql } from "drizzle-orm"; + +export const dynamic = "force-dynamic"; + +/** + * CollectFlow — Diagnostic « d'où vient le prix de vente ? » + * + * La colonne « PV central » de la Grille lit `article_infosup.prix_vente_mini`, + * seule source de prix de vente câblée dans l'application (Grille et fiche + * produit la partagent). Quand elle s'affiche vide de bout en bout, deux causes + * possibles, que seule la base peut départager : + * + * 1. la colonne existe mais n'est pas renseignée (ou vaut 0) ; + * 2. le prix de vente vit ailleurs, sous un autre nom. + * + * Cette route répond aux deux : elle compte le remplissage de la colonne + * actuelle, et énumère les colonnes candidates du schéma avec leur remplissage + * réel — de quoi rebrancher la Grille sur la bonne source sans deviner. + * + * Route de diagnostic, à supprimer une fois la source établie. Elle est derrière + * l'authentification (cf. `middleware.ts`) et ne renvoie que des comptages et + * quelques exemples. + */ + +/** Identifiant SQL sûr : les noms viennent d'`information_schema`, on le vérifie quand même. */ +const identifiantValide = (nom: string) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(nom); + +export async function GET(req: NextRequest) { + const codefou = req.nextUrl.searchParams.get("fournisseur")?.trim() || null; + const diagnostic: Record = { + lecture: "La Grille (« PV central ») et la fiche produit lisent article_infosup.prix_vente_mini", + fournisseur: codefou ?? "(non précisé — passez ?fournisseur=CODE pour le détail par fournisseur)", + }; + + // ── 1. Remplissage de la colonne actuellement câblée ──────────────────── + try { + const r = await db.execute(sql` + SELECT + COUNT(*)::int AS lignes, + COUNT(*) FILTER (WHERE prix_vente_mini IS NULL)::int AS sans_valeur, + COUNT(*) FILTER (WHERE prix_vente_mini = 0)::int AS a_zero, + COUNT(*) FILTER (WHERE prix_vente_mini > 0)::int AS renseigne, + MAX(prix_vente_mini)::float AS maximum + FROM article_infosup + `); + diagnostic.prix_vente_mini_global = r.rows[0]; + } catch (e) { + diagnostic.prix_vente_mini_global = { erreur: (e as Error).message?.slice(0, 200) }; + } + + // ── 2. Le même comptage sur les articles d'un fournisseur ─────────────── + if (codefou) { + try { + const r = await db.execute(sql` + SELECT + COUNT(*)::int AS articles, + COUNT(ai.artnoid)::int AS avec_fiche_infosup, + COUNT(*) FILTER (WHERE ai.prix_vente_mini > 0)::int AS avec_prix, + COUNT(*) FILTER (WHERE ai.prix_vente_mini = 0)::int AS prix_a_zero, + COUNT(*) FILTER (WHERE ai.prix_vente_mini IS NULL)::int AS prix_absent + FROM artfou1 af + JOIN articles a ON a.no_id = af.art_no_id + LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id + WHERE af.code = ${codefou} AND a.codein IS NOT NULL + `); + diagnostic.pour_ce_fournisseur = r.rows[0]; + + const ex = await db.execute(sql` + SELECT a.codein, a.libelle1, ai.prix_vente_mini::float AS prix_vente_mini + FROM artfou1 af + JOIN articles a ON a.no_id = af.art_no_id + LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id + WHERE af.code = ${codefou} AND a.codein IS NOT NULL + LIMIT 8 + `); + diagnostic.exemples = ex.rows; + } catch (e) { + diagnostic.pour_ce_fournisseur = { erreur: (e as Error).message?.slice(0, 200) }; + } + } + + // ── 3. Colonnes candidates du schéma, et leur remplissage réel ────────── + // C'est la réponse à « le prix est peut-être ailleurs » : une colonne bien + // nommée mais vide ne sert à rien, seul le comptage tranche. + try { + const candidates = await db.execute(sql` + SELECT table_name, column_name, data_type + FROM information_schema.columns + WHERE table_schema NOT IN ('pg_catalog', 'information_schema') + AND data_type IN ('numeric', 'double precision', 'real', 'integer', 'bigint') + AND (column_name ILIKE '%prix%' OR column_name ILIKE 'pv%' OR column_name ILIKE '%vente%' OR column_name ILIKE '%tarif%') + ORDER BY table_name, column_name + LIMIT 60 + `); + const lignes = candidates.rows as { table_name: string; column_name: string; data_type: string }[]; + diagnostic.colonnes_candidates = lignes.map((l) => `${l.table_name}.${l.column_name} (${l.data_type})`); + + // Remplissage des colonnes candidates portées par article_infosup : + // c'est la table déjà jointe, donc la moins coûteuse à rebrancher. + const surInfosup = lignes.filter((l) => l.table_name === "article_infosup" && identifiantValide(l.column_name)); + if (surInfosup.length > 0) { + const morceaux = surInfosup.map((l) => + sql`COUNT(*) FILTER (WHERE ${sql.raw(`"${l.column_name}"`)} > 0)::int AS ${sql.raw(`"${l.column_name}"`)}` + ); + const r = await db.execute(sql`SELECT ${sql.join(morceaux, sql`, `)} FROM article_infosup`); + diagnostic.remplissage_article_infosup = { + commentaire: "nombre de lignes où la colonne est > 0", + ...(r.rows[0] as Record), + }; + } + } catch (e) { + diagnostic.colonnes_candidates = { erreur: (e as Error).message?.slice(0, 200) }; + } + + return Response.json(diagnostic, { headers: { "Cache-Control": "no-store" } }); +} From c9e1934aae9613f324c152d96b8776b33ae07c16 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:44:48 +0000 Subject: [PATCH 2/4] Grille : le prix de vente vient de cube_pv, pas d'article_infosup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La colonne PV s'affichait vide sur toutes les lignes. Le schéma du SaaS Postgres (dépôt Apiflow, postgres/init.sql) explique pourquoi : la seule source lue jusqu'ici, `article_infosup.prix_vente_mini`, est une borne de PARAMÉTRAGE — un prix plancher, à côté de `prix_vente_maxi` et `pv_conseille` — et non le prix pratiqué. Le prix de vente vit dans `cube_pv (artnoid, site, pv)`, pendant exact du `cube_pa` déjà utilisé pour l'achat, rafraîchi chaque nuit depuis le cube MSSQL `Cube_PV`. `cube_stock` porte le même PV et sert de filet : il ne couvre que les articles ayant une ligne de stock, mais il est déjà interrogé par la Grille, une colonne de plus dans le SELECT suffit. Le prix est propre à chaque MAGASIN. La colonne suit donc le magasin consulté ; en « tous magasins » elle montre le prix commun, et s'ils divergent le plus élevé assorti d'un « ≠ » et de l'infobulle qui donne les deux. Une moyenne afficherait un prix qu'aucune caisse ne pratique, et retenir silencieusement l'un des deux ferait passer le prix d'un magasin pour celui des deux. L'en-tête devient « PV / Magasin » : « PV central » décrivait la fiche article, ce n'est plus la source. Vérifié sur build de production, prix identiques, divergents et absents : en « tous magasins » 14,90 € ≠ avec l'infobulle « Frouard (Nancy) : 13,90 € · Houdemont : 14,90 € » ; sur Frouard 13,90 €, sur Houdemont 14,90 €, sans marqueur ; « - » quand aucune source n'a de prix. La route de diagnostic compte désormais les trois sources côte à côte, de quoi confirmer sur la base réelle laquelle est renseignée. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR --- src/app/api/diag/prix-vente/route.ts | 30 +++++++++++ src/features/grid/api/get-product-rows.ts | 33 +++++++++++- src/features/grid/components/heatmap-grid.tsx | 53 ++++++++++++++++--- src/lib/pg-ff-client.ts | 38 +++++++++++++ src/types/grid.ts | 16 +++++- 5 files changed, 160 insertions(+), 10 deletions(-) diff --git a/src/app/api/diag/prix-vente/route.ts b/src/app/api/diag/prix-vente/route.ts index eeef0b7..b8dc6cf 100644 --- a/src/app/api/diag/prix-vente/route.ts +++ b/src/app/api/diag/prix-vente/route.ts @@ -81,6 +81,36 @@ export async function GET(req: NextRequest) { } } + // ── 2 bis. Les trois sources de prix, côte à côte ─────────────────────── + // `cube_pv` est la source retenue (pendant de `cube_pa` pour l'achat) ; + // `cube_stock.pv` sert de filet ; `article_infosup` ferme la marche. + if (codefou) { + try { + const r = await db.execute(sql` + WITH art AS ( + SELECT DISTINCT a.no_id, a.codein + FROM artfou1 af + JOIN articles a ON a.no_id = af.art_no_id + WHERE af.code = ${codefou} AND a.codein IS NOT NULL + ) + SELECT + (SELECT COUNT(*)::int FROM art) AS articles, + (SELECT COUNT(DISTINCT cpv.artnoid)::int FROM cube_pv cpv + JOIN art ON art.no_id = cpv.artnoid WHERE cpv.pv > 0) AS avec_cube_pv, + (SELECT COUNT(DISTINCT cs.artnoid)::int FROM cube_stock cs + JOIN art ON art.no_id = cs.artnoid WHERE cs.pv > 0) AS avec_cube_stock_pv, + (SELECT COUNT(*)::int FROM article_infosup ai + JOIN art ON art.no_id = ai.artnoid WHERE ai.prix_vente_mini > 0) AS avec_prix_vente_mini + `); + diagnostic.sources_de_prix = { + commentaire: "nombre d'articles du fournisseur ayant un prix > 0 dans chaque source", + ...(r.rows[0] as Record), + }; + } catch (e) { + diagnostic.sources_de_prix = { erreur: (e as Error).message?.slice(0, 200) }; + } + } + // ── 3. Colonnes candidates du schéma, et leur remplissage réel ────────── // C'est la réponse à « le prix est peut-être ailleurs » : une colonne bien // nommée mais vide ne sert à rien, seul le comptage tranche. diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 477f741..a87d0cb 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -9,6 +9,7 @@ import { pgGetNomenclatureByFournisseur, pgGetStockByFournisseur, pgGetCommandesByFournisseur, + pgGetPrixVenteByFournisseur, type PgStockRow, } from "@/lib/pg-ff-client"; import { db } from "@/db"; @@ -124,7 +125,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }), pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur ERROR:", e); return []; }), @@ -140,6 +142,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }), pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map(); }), pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map(); }), + pgGetPrixVenteByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetPrixVenteByFournisseur ERROR:", e); return new Map>(); }), ]); console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`); @@ -374,6 +377,34 @@ async function buildProductRows(input: GetProductRowsInput): Promise 0) { + product.prixVenteByStore = parSite; + } else { + // Filet de secours : le cube de stock porte le même PV, mais + // seulement pour les articles qui ont une ligne de stock. + const depuisStock: Record = {}; + for (const s of stockMap.get(product.codein) ?? []) { + const pv = Number(s.pv); + if (s.site && Number.isFinite(pv) && pv > 0) depuisStock[s.site] = pv; + } + if (Object.keys(depuisStock).length > 0) product.prixVenteByStore = depuisStock; + } + + const prix = Object.values(product.prixVenteByStore ?? {}); + // Le plus élevé quand les magasins divergent : une moyenne inventerait + // un prix qu'aucune caisse ne pratique. L'écart est signalé à l'écran. + if (prix.length > 0) product.prixVente = Math.max(...prix); + } + // ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ── await enrichWithNetworkMetrics(productMap); diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index bb73b16..6444024 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -93,6 +93,40 @@ function prixMoyenReseau(row: ProductRow): number | null { const fmtEuro2 = (v: number) => v.toLocaleString("fr-FR", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 }); +/** + * Prix de vente à montrer pour le magasin consulté. + * + * Sur un magasin donné, c'est SON prix. En « tous magasins », c'est le prix + * commun — et s'ils divergent, le plus élevé assorti d'un signal : une cellule + * ne peut afficher qu'un nombre, mais taire l'écart ferait passer le prix d'un + * magasin pour celui des deux. + */ +function prixVenteAffiche(row: ProductRow, activeMagasin: string): { valeur: number | null; divergent: boolean; detail: string | null } { + const parSite = row.prixVenteByStore; + + if (activeMagasin !== "TOTAL") { + const pv = parSite?.[activeMagasin]; + return { valeur: pv ?? null, divergent: false, detail: null }; + } + + const entrees = Object.entries(parSite ?? {}); + if (entrees.length === 0) return { valeur: row.prixVente ?? null, divergent: false, detail: null }; + + // Comparaison au centime : deux flottants issus du même prix peuvent différer + // dans les décimales lointaines sans que le prix affiché change. + const distincts = new Set(entrees.map(([, pv]) => Math.round(pv * 100))); + const detail = entrees + .sort(([a], [b]) => a.localeCompare(b)) + .map(([site, pv]) => `${SITE_LABELS[site]?.nom ?? site} : ${fmtEuro2(pv)}`) + .join(" · "); + + return { + valeur: Math.max(...entrees.map(([, pv]) => pv)), + divergent: distincts.size > 1, + detail, + }; +} + // ========================================================================= // OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires) // ========================================================================= @@ -792,21 +826,24 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { }, { id: "prixVente", - accessorFn: (row: ProductRow) => valeurTriable(row.prixVente), + accessorFn: (row: ProductRow) => valeurTriable(prixVenteAffiche(row, activeMagasin).valeur), sortUndefined: "last" as const, - header: () =>
PV central
Article
, + header: () =>
PV
Magasin
, size: 88, cell: ({ row }: { row: { original: ProductRow } }) => { - const val = row.original.prixVente; + const { valeur, divergent, detail } = prixVenteAffiche(row.original, activeMagasin); return (
- {val != null ? fmtEuro2(val) : "-"} + {valeur != null ? fmtEuro2(valeur) : "-"} + {/* Prix différents d'un magasin à l'autre : le signaler, la + cellule ne peut en afficher qu'un. */} + {divergent && ≠}
); }, diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 052bfeb..fbdeefd 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -72,6 +72,8 @@ export interface PgStockRow { qte: number; valstock: number; prmp: number; + /** Prix de vente du site — cube_stock.pv, filet de secours si cube_pv est vide. */ + pv?: number; dernierevente?: string; dernierereception?: string; } @@ -456,6 +458,7 @@ export async function pgGetStockByFournisseur(codefou: string): Promise>> { + const result = await pgNoParallel(sql` + SELECT + a.codein, + cpv.site, + cpv.pv::float AS pv + FROM cube_pv cpv + JOIN articles a ON a.no_id = cpv.artnoid + JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af + ON af.art_no_id = a.no_id + WHERE cpv.pv IS NOT NULL AND cpv.pv > 0 + `); + + const map = new Map>(); + for (const row of result.rows as { codein: string; site: string; pv: number }[]) { + if (!row.codein || !row.site) continue; + const parSite = map.get(row.codein) ?? {}; + parSite[String(row.site)] = Number(row.pv); + map.set(row.codein, parSite); + } + console.log(`[pg-ff] Prix de vente : ${map.size} articles tarifés pour ${codefou}`); + return map; +} + // --------------------------------------------------------------------------- // 6. Commandes en cours // --------------------------------------------------------------------------- diff --git a/src/types/grid.ts b/src/types/grid.ts index fea77bd..07107d2 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -89,7 +89,21 @@ export interface ProductRow { /** Données stock & approvisionnement (API FF Nancy) */ pcb?: number; // Conditionnement (pack size) prixAchat?: number; // PA / PRMP - prixVente?: number; // PV central + /** + * Prix de vente pratiqué, en euros. + * + * Source par ordre de préférence : `cube_pv` (prix de vente par article et + * par site, pendant de `cube_pa` pour l'achat), puis le PV porté par + * `cube_stock`, puis `article_infosup.prix_vente_mini` — cette dernière + * n'est qu'une borne de paramétrage, vide en pratique. + * + * Quand les magasins ne pratiquent pas le même prix, cette valeur est la + * plus élevée : `prixVenteByStore` porte le détail, et l'affichage signale + * la divergence plutôt que d'inventer une moyenne que personne n'encaisse. + */ + prixVente?: number; + /** Prix de vente par magasin (site → PV) — `cube_pv`. */ + prixVenteByStore?: Record; stockActuel?: number; // Stock dispo agrégé tous sites stockTotal?: number; // Qte en stock agrégé stockValeur?: number; // Valeur stock agrégée From 8158eda11b43e2add0229b50e9d497f9a696ed2d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:53:31 +0000 Subject: [PATCH 3/4] =?UTF-8?q?Prix=20de=20vente=20:=20=C3=A9laguer=20le?= =?UTF-8?q?=20code=20magasin,=20qui=20sert=20de=20cl=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le code magasin de `cube_pv` sert de clé de recherche côté Grille (`prixVenteByStore["292"]`). Or la synchronisation Apiflow l'écrit BRUT (`site: r.Site`), là où elle fait passer celui de `cube_stock` par `safeStr` — et aucune des deux ne l'élague. Une colonne MSSQL de largeur fixe remonte complétée d'espaces : la recherche échouerait alors sans rien signaler, et de la pire façon qui soit, le prix restant visible en « tous magasins » et vide magasin par magasin. `TRIM` des deux côtés, plus l'élagage du repli lu depuis le cube de stock. L'opération ne coûte rien quand il n'y a pas de remplissage, et le doute n'avait pas de raison de subsister. La route de diagnostic affiche désormais les valeurs de `site` telles qu'elles sont stockées, avec leur longueur : de quoi vérifier d'un coup d'œil que les clés sont bien « 292 » et « 579 », sur trois caractères. Commentaires SQL déplacés hors du littéral gabarit, comme partout ailleurs dans le fichier : un accent grave dans un commentaire refermait la chaîne. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR --- src/app/api/diag/prix-vente/route.ts | 20 ++++++++++++++++++++ src/features/grid/api/get-product-rows.ts | 3 ++- src/lib/pg-ff-client.ts | 11 +++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/app/api/diag/prix-vente/route.ts b/src/app/api/diag/prix-vente/route.ts index b8dc6cf..fee28c5 100644 --- a/src/app/api/diag/prix-vente/route.ts +++ b/src/app/api/diag/prix-vente/route.ts @@ -111,6 +111,26 @@ export async function GET(req: NextRequest) { } } + // ── 2 ter. Les valeurs de `site` telles qu'elles sont stockées ────────── + // Ces chaînes servent de CLÉ de recherche par magasin côté Grille : un + // espace de remplissage ou un code inattendu et le prix par magasin serait + // introuvable, sans que rien ne le signale à l'écran. + try { + const r = await db.execute(sql` + SELECT site, LENGTH(site)::int AS longueur, COUNT(*)::int AS lignes + FROM cube_pv + GROUP BY site, LENGTH(site) + ORDER BY lignes DESC + LIMIT 12 + `); + diagnostic.sites_cube_pv = { + commentaire: "la Grille recherche les codes magasin tels quels — longueur 3 attendue pour « 292 » / « 579 »", + valeurs: r.rows, + }; + } catch (e) { + diagnostic.sites_cube_pv = { erreur: (e as Error).message?.slice(0, 200) }; + } + // ── 3. Colonnes candidates du schéma, et leur remplissage réel ────────── // C'est la réponse à « le prix est peut-être ailleurs » : une colonne bien // nommée mais vide ne sert à rien, seul le comptage tranche. diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index a87d0cb..b59f4fc 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -394,7 +394,8 @@ async function buildProductRows(input: GetProductRowsInput): Promise = {}; for (const s of stockMap.get(product.codein) ?? []) { const pv = Number(s.pv); - if (s.site && Number.isFinite(pv) && pv > 0) depuisStock[s.site] = pv; + const site = String(s.site ?? "").trim(); + if (site && Number.isFinite(pv) && pv > 0) depuisStock[site] = pv; } if (Object.keys(depuisStock).length > 0) product.prixVenteByStore = depuisStock; } diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index fbdeefd..8b1580a 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -450,10 +450,12 @@ function buildNomMap(rows: PgNomRow[]): Map { * 1 requête SQL — remplace la partie stock du référentiel. */ export async function pgGetStockByFournisseur(codefou: string): Promise> { + // `site` est élagué pour la même raison que dans pgGetPrixVenteByFournisseur : + // il sert de clé de recherche (derniereLivraisonByStore, prix de repli). const result = await pgNoParallel(sql` SELECT a.codein, - cs.site, + TRIM(cs.site::text) AS site, cs.stockdispo::float, cs.qte::float, cs.valstock::float, @@ -487,10 +489,15 @@ export async function pgGetStockByFournisseur(codefou: string): Promise>> { + // Le site sert de CLÉ de recherche côté Grille (prixVenteByStore["292"]), d'où + // l'élagage : la synchronisation Apiflow écrit `cube_pv.site` brut, sans passer + // par le `safeStr` qu'elle applique à `cube_stock`. Une colonne MSSQL de largeur + // fixe remonterait complétée d'espaces, et la recherche échouerait en silence — + // le prix resterait visible en « tous magasins » et vide magasin par magasin. const result = await pgNoParallel(sql` SELECT a.codein, - cpv.site, + TRIM(cpv.site::text) AS site, cpv.pv::float AS pv FROM cube_pv cpv JOIN articles a ON a.no_id = cpv.artnoid From 06f0bd0513baf9b6b811438231e3fdd7cc7ecdc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 21:01:13 +0000 Subject: [PATCH 4/4] =?UTF-8?q?Prix=20de=20vente=20:=20tenir=20compte=20d'?= =?UTF-8?q?un=20prix=20non=20ventil=C3=A9=20par=20magasin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cube_pv` a une clé `(artnoid, site)`, mais la base n'en porte qu'une ligne par article — 428 530 lignes pour 427 857 articles, là où deux magasins tarifés en donneraient le double. Le prix n'est donc pas ventilé : la colonne serait restée vide dès qu'on consulte le magasin qui n'a pas la ligne, alors même que l'article a un prix. La cellule couvre maintenant les quatre cas : le prix du magasin consulté quand il existe ; le prix unique quand il n'y a qu'un tarif, avec la mention « prix unique, non ventilé par magasin » ; rien quand plusieurs prix coexistent sans concerner ce magasin — celui du voisin n'est pas le sien ; et, en « tous magasins », le prix commun ou le plus élevé assorti du « ≠ ». Vérifié sur build de production, quatre articles couvrant les quatre cas, dans les trois modes de magasin. Le cas décisif : prix porté par le seul site 292, consulté depuis Houdemont — 12,90 € et la mention, au lieu du « - » d'avant. La route de diagnostic est retirée : `cube_pv` est confirmé alimenté et synchronisé, elle a fait son office. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR --- src/app/api/diag/prix-vente/route.ts | 168 ------------------ src/features/grid/components/heatmap-grid.tsx | 44 +++-- 2 files changed, 29 insertions(+), 183 deletions(-) delete mode 100644 src/app/api/diag/prix-vente/route.ts diff --git a/src/app/api/diag/prix-vente/route.ts b/src/app/api/diag/prix-vente/route.ts deleted file mode 100644 index fee28c5..0000000 --- a/src/app/api/diag/prix-vente/route.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { NextRequest } from "next/server"; -import { db } from "@/db"; -import { sql } from "drizzle-orm"; - -export const dynamic = "force-dynamic"; - -/** - * CollectFlow — Diagnostic « d'où vient le prix de vente ? » - * - * La colonne « PV central » de la Grille lit `article_infosup.prix_vente_mini`, - * seule source de prix de vente câblée dans l'application (Grille et fiche - * produit la partagent). Quand elle s'affiche vide de bout en bout, deux causes - * possibles, que seule la base peut départager : - * - * 1. la colonne existe mais n'est pas renseignée (ou vaut 0) ; - * 2. le prix de vente vit ailleurs, sous un autre nom. - * - * Cette route répond aux deux : elle compte le remplissage de la colonne - * actuelle, et énumère les colonnes candidates du schéma avec leur remplissage - * réel — de quoi rebrancher la Grille sur la bonne source sans deviner. - * - * Route de diagnostic, à supprimer une fois la source établie. Elle est derrière - * l'authentification (cf. `middleware.ts`) et ne renvoie que des comptages et - * quelques exemples. - */ - -/** Identifiant SQL sûr : les noms viennent d'`information_schema`, on le vérifie quand même. */ -const identifiantValide = (nom: string) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(nom); - -export async function GET(req: NextRequest) { - const codefou = req.nextUrl.searchParams.get("fournisseur")?.trim() || null; - const diagnostic: Record = { - lecture: "La Grille (« PV central ») et la fiche produit lisent article_infosup.prix_vente_mini", - fournisseur: codefou ?? "(non précisé — passez ?fournisseur=CODE pour le détail par fournisseur)", - }; - - // ── 1. Remplissage de la colonne actuellement câblée ──────────────────── - try { - const r = await db.execute(sql` - SELECT - COUNT(*)::int AS lignes, - COUNT(*) FILTER (WHERE prix_vente_mini IS NULL)::int AS sans_valeur, - COUNT(*) FILTER (WHERE prix_vente_mini = 0)::int AS a_zero, - COUNT(*) FILTER (WHERE prix_vente_mini > 0)::int AS renseigne, - MAX(prix_vente_mini)::float AS maximum - FROM article_infosup - `); - diagnostic.prix_vente_mini_global = r.rows[0]; - } catch (e) { - diagnostic.prix_vente_mini_global = { erreur: (e as Error).message?.slice(0, 200) }; - } - - // ── 2. Le même comptage sur les articles d'un fournisseur ─────────────── - if (codefou) { - try { - const r = await db.execute(sql` - SELECT - COUNT(*)::int AS articles, - COUNT(ai.artnoid)::int AS avec_fiche_infosup, - COUNT(*) FILTER (WHERE ai.prix_vente_mini > 0)::int AS avec_prix, - COUNT(*) FILTER (WHERE ai.prix_vente_mini = 0)::int AS prix_a_zero, - COUNT(*) FILTER (WHERE ai.prix_vente_mini IS NULL)::int AS prix_absent - FROM artfou1 af - JOIN articles a ON a.no_id = af.art_no_id - LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id - WHERE af.code = ${codefou} AND a.codein IS NOT NULL - `); - diagnostic.pour_ce_fournisseur = r.rows[0]; - - const ex = await db.execute(sql` - SELECT a.codein, a.libelle1, ai.prix_vente_mini::float AS prix_vente_mini - FROM artfou1 af - JOIN articles a ON a.no_id = af.art_no_id - LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id - WHERE af.code = ${codefou} AND a.codein IS NOT NULL - LIMIT 8 - `); - diagnostic.exemples = ex.rows; - } catch (e) { - diagnostic.pour_ce_fournisseur = { erreur: (e as Error).message?.slice(0, 200) }; - } - } - - // ── 2 bis. Les trois sources de prix, côte à côte ─────────────────────── - // `cube_pv` est la source retenue (pendant de `cube_pa` pour l'achat) ; - // `cube_stock.pv` sert de filet ; `article_infosup` ferme la marche. - if (codefou) { - try { - const r = await db.execute(sql` - WITH art AS ( - SELECT DISTINCT a.no_id, a.codein - FROM artfou1 af - JOIN articles a ON a.no_id = af.art_no_id - WHERE af.code = ${codefou} AND a.codein IS NOT NULL - ) - SELECT - (SELECT COUNT(*)::int FROM art) AS articles, - (SELECT COUNT(DISTINCT cpv.artnoid)::int FROM cube_pv cpv - JOIN art ON art.no_id = cpv.artnoid WHERE cpv.pv > 0) AS avec_cube_pv, - (SELECT COUNT(DISTINCT cs.artnoid)::int FROM cube_stock cs - JOIN art ON art.no_id = cs.artnoid WHERE cs.pv > 0) AS avec_cube_stock_pv, - (SELECT COUNT(*)::int FROM article_infosup ai - JOIN art ON art.no_id = ai.artnoid WHERE ai.prix_vente_mini > 0) AS avec_prix_vente_mini - `); - diagnostic.sources_de_prix = { - commentaire: "nombre d'articles du fournisseur ayant un prix > 0 dans chaque source", - ...(r.rows[0] as Record), - }; - } catch (e) { - diagnostic.sources_de_prix = { erreur: (e as Error).message?.slice(0, 200) }; - } - } - - // ── 2 ter. Les valeurs de `site` telles qu'elles sont stockées ────────── - // Ces chaînes servent de CLÉ de recherche par magasin côté Grille : un - // espace de remplissage ou un code inattendu et le prix par magasin serait - // introuvable, sans que rien ne le signale à l'écran. - try { - const r = await db.execute(sql` - SELECT site, LENGTH(site)::int AS longueur, COUNT(*)::int AS lignes - FROM cube_pv - GROUP BY site, LENGTH(site) - ORDER BY lignes DESC - LIMIT 12 - `); - diagnostic.sites_cube_pv = { - commentaire: "la Grille recherche les codes magasin tels quels — longueur 3 attendue pour « 292 » / « 579 »", - valeurs: r.rows, - }; - } catch (e) { - diagnostic.sites_cube_pv = { erreur: (e as Error).message?.slice(0, 200) }; - } - - // ── 3. Colonnes candidates du schéma, et leur remplissage réel ────────── - // C'est la réponse à « le prix est peut-être ailleurs » : une colonne bien - // nommée mais vide ne sert à rien, seul le comptage tranche. - try { - const candidates = await db.execute(sql` - SELECT table_name, column_name, data_type - FROM information_schema.columns - WHERE table_schema NOT IN ('pg_catalog', 'information_schema') - AND data_type IN ('numeric', 'double precision', 'real', 'integer', 'bigint') - AND (column_name ILIKE '%prix%' OR column_name ILIKE 'pv%' OR column_name ILIKE '%vente%' OR column_name ILIKE '%tarif%') - ORDER BY table_name, column_name - LIMIT 60 - `); - const lignes = candidates.rows as { table_name: string; column_name: string; data_type: string }[]; - diagnostic.colonnes_candidates = lignes.map((l) => `${l.table_name}.${l.column_name} (${l.data_type})`); - - // Remplissage des colonnes candidates portées par article_infosup : - // c'est la table déjà jointe, donc la moins coûteuse à rebrancher. - const surInfosup = lignes.filter((l) => l.table_name === "article_infosup" && identifiantValide(l.column_name)); - if (surInfosup.length > 0) { - const morceaux = surInfosup.map((l) => - sql`COUNT(*) FILTER (WHERE ${sql.raw(`"${l.column_name}"`)} > 0)::int AS ${sql.raw(`"${l.column_name}"`)}` - ); - const r = await db.execute(sql`SELECT ${sql.join(morceaux, sql`, `)} FROM article_infosup`); - diagnostic.remplissage_article_infosup = { - commentaire: "nombre de lignes où la colonne est > 0", - ...(r.rows[0] as Record), - }; - } - } catch (e) { - diagnostic.colonnes_candidates = { erreur: (e as Error).message?.slice(0, 200) }; - } - - return Response.json(diagnostic, { headers: { "Cache-Control": "no-store" } }); -} diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 6444024..3582e4c 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -96,29 +96,43 @@ const fmtEuro2 = (v: number) => /** * Prix de vente à montrer pour le magasin consulté. * - * Sur un magasin donné, c'est SON prix. En « tous magasins », c'est le prix - * commun — et s'ils divergent, le plus élevé assorti d'un signal : une cellule - * ne peut afficher qu'un nombre, mais taire l'écart ferait passer le prix d'un - * magasin pour celui des deux. + * `cube_pv` a une clé `(artnoid, site)`, mais notre base n'y met en pratique + * qu'une ligne par article : le prix n'est pas ventilé par magasin. La colonne + * doit donc rester lisible dans les deux cas de figure — + * + * · magasin consulté tarifé → SON prix ; + * · prix unique, non ventilé → ce prix vaut pour tous les magasins ; + * · plusieurs prix mais aucun pour ce magasin → rien, plutôt que celui du + * voisin ; + * · en « tous magasins » : le prix commun, et s'ils divergent le plus élevé + * assorti d'un signal. Une cellule ne peut afficher qu'un nombre, mais + * taire l'écart ferait passer le prix d'un magasin pour celui des deux. */ function prixVenteAffiche(row: ProductRow, activeMagasin: string): { valeur: number | null; divergent: boolean; detail: string | null } { - const parSite = row.prixVenteByStore; + const entrees = Object.entries(row.prixVenteByStore ?? {}); - if (activeMagasin !== "TOTAL") { - const pv = parSite?.[activeMagasin]; - return { valeur: pv ?? null, divergent: false, detail: null }; - } - - const entrees = Object.entries(parSite ?? {}); + // Aucun prix ventilé : reste le PV de la fiche article, s'il existe. if (entrees.length === 0) return { valeur: row.prixVente ?? null, divergent: false, detail: null }; // Comparaison au centime : deux flottants issus du même prix peuvent différer // dans les décimales lointaines sans que le prix affiché change. const distincts = new Set(entrees.map(([, pv]) => Math.round(pv * 100))); - const detail = entrees - .sort(([a], [b]) => a.localeCompare(b)) - .map(([site, pv]) => `${SITE_LABELS[site]?.nom ?? site} : ${fmtEuro2(pv)}`) - .join(" · "); + + if (activeMagasin !== "TOTAL") { + const pv = row.prixVenteByStore?.[activeMagasin]; + if (pv != null) return { valeur: pv, divergent: false, detail: null }; + if (distincts.size === 1) { + return { valeur: entrees[0][1], divergent: false, detail: "prix unique, non ventilé par magasin" }; + } + return { valeur: null, divergent: false, detail: null }; + } + + const detail = entrees.length > 1 + ? entrees + .sort(([a], [b]) => a.localeCompare(b)) + .map(([site, pv]) => `${SITE_LABELS[site]?.nom ?? site} : ${fmtEuro2(pv)}`) + .join(" · ") + : null; return { valeur: Math.max(...entrees.map(([, pv]) => pv)),