From 18edda71676de90a86209139de4e6df83619a524 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 10:36:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(api):=20expose=20la=20tendance=20r=C3=A9se?= =?UTF-8?q?au=20calcul=C3=A9e=20dans=20/api/v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La colonne « Tendance » de la Grille était la seule information visible que l'API ne fournissait pas : elle est calculée dans le navigateur par computeNetworkTrend(), qui vivait dans un composant "use client" donc inaccessible côté serveur. L'API livrait la série mensuelle brute et laissait chaque appelant refaire la régression — au risque de diverger de l'affichage. - Le calcul part dans src/lib/network-trend.ts (module pur, sans React) ; le composant le réexporte, aucun import existant ne change. - enrichRows ajoute `trend` : { direction, pct, label, monthsUsed }, calculé sur la même série que la colonne de l'application. - openapi.json : schéma Trend documenté, et il est dit explicitement que la liste des propriétés de Product n'est pas exhaustive — la réponse porte la ligne de grille entière. Vérifié au passage que rien n'est perdu en chemin : upsertGridRows stocke le ProductRow complet dans la colonne jsonb `payload` (les colonnes scalaires ne servent qu'aux index), ne filtre que les lignes sans codein, et pickFields renvoie tout tant que `fields` n'est pas précisé. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675 --- src/app/api/v1/openapi.json/route.ts | 22 +++++- .../network/components/network-trend.tsx | 59 ++-------------- src/lib/api-enrich.ts | 34 ++++++++++ src/lib/network-trend.ts | 67 +++++++++++++++++++ 4 files changed, 128 insertions(+), 54 deletions(-) create mode 100644 src/lib/network-trend.ts diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts index 0b341db..e37bad3 100644 --- a/src/app/api/v1/openapi.json/route.ts +++ b/src/app/api/v1/openapi.json/route.ts @@ -120,7 +120,10 @@ export async function GET(req: NextRequest) { }, Product: { type: "object", - description: "Ligne de grille : un article chez un fournisseur.", + description: + "Ligne de grille : un article chez un fournisseur. La liste de propriétés ci-dessous " + + "n'est pas exhaustive — la réponse contient l'intégralité de la ligne (une soixantaine " + + "de champs). Utilisez `fields` pour n'en garder qu'une partie.", properties: { codein: { type: "string", description: "Identifiant interne de l'article." }, codeFournisseur: { type: "string" }, @@ -149,8 +152,25 @@ export async function GET(req: NextRequest) { nbJoursDerniereVente: { type: "integer" }, sales12m: { type: "object", description: "Quantités vendues par mois, clés « YYYYMM »." }, stock12m: { type: "object", description: "Stock de fin de mois, clés « YYYYMM »." }, + receptions12m: { type: "object", description: "Quantités reçues par mois, clés « YYYYMM »." }, sales12mByStore: { type: "object", description: "Ventes par magasin (292, 579) puis par mois." }, + stock12mByStore: { type: "object", description: "Stock de fin de mois par magasin puis par mois." }, + receptions12mByStore: { type: "object", description: "Réceptions par magasin puis par mois." }, + qteReseauByMonth: { type: ["object", "null"], description: "Quantités vendues par le réseau, clés « YYYY-MM » — série derrière la tendance." }, network: { $ref: "#/components/schemas/NetworkMetrics" }, + trend: { $ref: "#/components/schemas/Trend" }, + }, + }, + Trend: { + type: ["object", "null"], + description: + "Tendance réseau sur 12 mois, par régression linéaire — la même que la colonne " + + "« Tendance » de l'application. `null` si aucune série mensuelle n'est disponible.", + properties: { + direction: { type: "string", enum: ["up", "down", "flat"], description: "Seuil à ±8 % de variation modélisée." }, + pct: { type: ["number", "null"], description: "Variation modélisée sur la période (0.23 = +23 %)." }, + label: { type: "string", description: "Libellé prêt à afficher : « Forte hausse », « Stable »…" }, + monthsUsed: { type: "integer", description: "Nombre de mois réellement utilisés (12 au maximum)." }, }, }, }, diff --git a/src/features/network/components/network-trend.tsx b/src/features/network/components/network-trend.tsx index f3b73cf..701bbb5 100644 --- a/src/features/network/components/network-trend.tsx +++ b/src/features/network/components/network-trend.tsx @@ -14,51 +14,13 @@ import { TrendingUp, TrendingDown, Minus } from "lucide-react"; -export type NetworkTrend = { - values: number[]; // quantités mensuelles, ordre chronologique - labels: string[]; // "YYYY-MM" - direction: "up" | "down" | "flat"; - pct: number | null; // variation modélisée (régression linéaire) sur 12 mois - hasData: boolean; -}; +// Le calcul vit dans `@/lib/network-trend` (module pur, sans React) pour que +// l'API `/api/v1` expose exactement la même tendance que la Grille. On le +// réexporte ici pour ne pas changer les imports existants. +import { computeNetworkTrend, trendLabel, TREND_STRONG, type NetworkTrend } from "@/lib/network-trend"; -/** - * Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois. - * Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la - * variation modélisée sur la période (pente × durée) rapportée à la moyenne. - * Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%. - */ -export function computeNetworkTrend(qteByMonth?: Record | null): NetworkTrend { - const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false }; - if (!qteByMonth) return empty; - let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement - if (labels.length === 0) return empty; - labels = labels.slice(-12); // 12 derniers mois - const values = labels.map((l) => Number(qteByMonth[l]) || 0); - const n = values.length; - let pct: number | null = null; - let direction: NetworkTrend["direction"] = "flat"; - if (n >= 3) { - const mx = (n - 1) / 2; - const my = values.reduce((s, v) => s + v, 0) / n; - let num = 0, den = 0; - for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); } - const slope = den ? num / den : 0; - if (my > 0) { - pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne - direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat"; - } else if (slope > 0) { - direction = "up"; - } - } else if (n === 2 && values[0] > 0) { - pct = (values[1] - values[0]) / values[0]; - direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat"; - } - return { values, labels, direction, pct, hasData: true }; -} - -/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */ -export const TREND_STRONG = 0.25; +export { computeNetworkTrend, trendLabel, TREND_STRONG }; +export type { NetworkTrend }; export const TREND_COLOR: Record = { up: "#22c55e", @@ -66,15 +28,6 @@ export const TREND_COLOR: Record = { flat: "#94a3b8", }; -/** Libellé français de la tendance (« Forte hausse », « Stable »…). */ -export function trendLabel(pct: number): string { - if (pct > TREND_STRONG) return "Forte hausse"; - if (pct > 0.08) return "Hausse"; - if (pct < -TREND_STRONG) return "Forte baisse"; - if (pct < -0.08) return "Baisse"; - return "Stable"; -} - /** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { if (!trend.hasData) return
-
; diff --git a/src/lib/api-enrich.ts b/src/lib/api-enrich.ts index b928309..efc66c7 100644 --- a/src/lib/api-enrich.ts +++ b/src/lib/api-enrich.ts @@ -22,6 +22,19 @@ import "server-only"; import type { ProductRow } from "@/types/grid"; import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache"; import { pgGetGammesByCodeins } from "@/lib/pg-ff-client"; +import { computeNetworkTrend, trendLabel, type TrendDirection } from "@/lib/network-trend"; + +/** Tendance réseau 12 mois, telle qu'affichée dans la colonne « Tendance » de la Grille. */ +export interface ProductTrend { + /** "up" | "down" | "flat" — seuil à ±8 % de variation modélisée. */ + direction: TrendDirection; + /** Variation modélisée sur la période (0.23 = +23 %). `null` si non calculable. */ + pct: number | null; + /** Libellé prêt à afficher : « Forte hausse », « Stable »… */ + label: string; + /** Nombre de mois effectivement utilisés (12 au maximum). */ + monthsUsed: number; +} export interface EnrichedProductRow extends ProductRow { /** @@ -31,6 +44,13 @@ export interface EnrichedProductRow extends ProductRow { codeGammeServeur: string | null; /** Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas. */ network: NetworkMetricCached | null; + /** + * Tendance réseau calculée par régression linéaire sur les 12 derniers mois. + * `null` quand il n'y a aucune série mensuelle. Fournie pour que l'appelant — + * en particulier un agent — n'ait pas à réimplémenter le calcul et à diverger + * de ce que montre l'application. + */ + trend: ProductTrend | null; } /** @@ -69,6 +89,7 @@ export async function enrichRows(rows: ProductRow[]): Promise+8%, stable, baisse <−8%. + */ +export function computeNetworkTrend(qteByMonth?: Record | null): NetworkTrend { + const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false }; + if (!qteByMonth) return empty; + let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement + if (labels.length === 0) return empty; + labels = labels.slice(-12); // 12 derniers mois + const values = labels.map((l) => Number(qteByMonth[l]) || 0); + const n = values.length; + let pct: number | null = null; + let direction: TrendDirection = "flat"; + if (n >= 3) { + const mx = (n - 1) / 2; + const my = values.reduce((s, v) => s + v, 0) / n; + let num = 0, den = 0; + for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); } + const slope = den ? num / den : 0; + if (my > 0) { + pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne + direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat"; + } else if (slope > 0) { + direction = "up"; + } + } else if (n === 2 && values[0] > 0) { + pct = (values[1] - values[0]) / values[0]; + direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat"; + } + return { values, labels, direction, pct, hasData: true }; +} + +/** Libellé français de la tendance (« Forte hausse », « Stable »…). */ +export function trendLabel(pct: number): string { + if (pct > TREND_STRONG) return "Forte hausse"; + if (pct > 0.08) return "Hausse"; + if (pct < -TREND_STRONG) return "Forte baisse"; + if (pct < -0.08) return "Baisse"; + return "Stable"; +}