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"; +}