Prix de vente : tenir compte d'un prix non ventilé par magasin

`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
This commit is contained in:
Claude committed 2026-08-14 06:12:25 +00:00
1 parent 8158eda11b
commit 06f0bd0513
2 files changed
+27 -181

No files matched your search

-168
View File
@@ -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<string, unknown> = {
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<string, unknown>),
};
} 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<string, unknown>),
};
}
} catch (e) {
diagnostic.colonnes_candidates = { erreur: (e as Error).message?.slice(0, 200) };
}
return Response.json(diagnostic, { headers: { "Cache-Control": "no-store" } });
}
+27 -13
View File
@@ -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
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(" · ");
.join(" · ")
: null;
return {
valeur: Math.max(...entrees.map(([, pv]) => pv)),