mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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:
2 files changed
+29
-183
No files matched your search
@@ -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" } });
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
Reference in new issue
Block a user