mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat(api): expose la tendance réseau calculée dans /api/v1
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
4 files changed
+128
-54
No files matched your search
@@ -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)." },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<string, number> | 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<NetworkTrend["direction"], string> = {
|
||||
up: "#22c55e",
|
||||
@@ -66,15 +28,6 @@ export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||
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 <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
|
||||
@@ -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<EnrichedProductRow
|
||||
...row,
|
||||
codeGammeServeur: gammeServeur,
|
||||
network: metrics,
|
||||
trend: null, // renseignée plus bas, une fois la série mensuelle connue
|
||||
};
|
||||
|
||||
// `codeGammeInit` porte déjà la sémantique « état serveur » dans la Grille :
|
||||
@@ -89,6 +110,19 @@ export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow
|
||||
enriched.networkFetchedAt = metrics.fetchedAt ?? undefined;
|
||||
}
|
||||
|
||||
// Tendance calculée sur la série finale (celle du cache si elle existe,
|
||||
// sinon celle de l'instantané) — même entrée que la colonne « Tendance ».
|
||||
const serie = enriched.qteReseauByMonth;
|
||||
const t = computeNetworkTrend(serie);
|
||||
if (t.hasData) {
|
||||
enriched.trend = {
|
||||
direction: t.direction,
|
||||
pct: t.pct,
|
||||
label: t.pct != null ? trendLabel(t.pct) : "Indéterminée",
|
||||
monthsUsed: t.values.length,
|
||||
};
|
||||
}
|
||||
|
||||
return enriched;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* CollectFlow — Tendance réseau (calcul pur, sans React).
|
||||
*
|
||||
* Isolé du composant d'affichage (`features/network/components/network-trend.tsx`)
|
||||
* pour être utilisable **côté serveur** : l'API `/api/v1` expose ainsi la même
|
||||
* tendance que celle affichée dans la Grille, au lieu de laisser chaque appelant
|
||||
* la recalculer — et diverger.
|
||||
*
|
||||
* Source : `qteByMonth` ({ "YYYY-MM": quantité }) produit par la synchronisation Qlik.
|
||||
*/
|
||||
|
||||
export type TrendDirection = "up" | "down" | "flat";
|
||||
|
||||
export type NetworkTrend = {
|
||||
values: number[]; // quantités mensuelles, ordre chronologique
|
||||
labels: string[]; // "YYYY-MM"
|
||||
direction: TrendDirection;
|
||||
pct: number | null; // variation modélisée (régression linéaire) sur 12 mois
|
||||
hasData: boolean;
|
||||
};
|
||||
|
||||
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse. */
|
||||
export const TREND_STRONG = 0.25;
|
||||
|
||||
/**
|
||||
* 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 : hausse >+8%, stable, baisse <−8%.
|
||||
*/
|
||||
export function computeNetworkTrend(qteByMonth?: Record<string, number> | 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";
|
||||
}
|
||||
Reference in new issue
Block a user