feat(grid): replace ranking/AI/score with Qlik network data

Remove ranking, AI analysis (routes + ai-copilot module), and score-engine
from the grid. Add Qlik Sense network metrics (CA, Qte, nb magasins per
product) joined by code centrale (articles.artcentrale).

- qlik-client.ts: NTLM + ticket SSO auth, QIX hypercube extraction (master items)
- qlik-network-cache.ts + qlik_network_metrics table (db-init): cache layer
- POST /api/qlik/sync?fournisseur=: per-supplier sync into cache
- SyncQlikButton: per-supplier sync, grid reload, last-update date display
- get-product-rows Phase 8: enrich rows with cached network metrics
- heatmap-grid: CA reseau / Qte reseau / Magasins (/270) / % presence columns
- discovery: app 9872ee6e, master items CA N / Quantite N / Magasin Ventes Nb N
- deps: httpntlm, ws

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-06-20 22:52:39 +02:00
1 parent 02a10caa4b
commit aee968d30f
34 files changed
+1043 -2925

No files matched your search

-96
View File
@@ -496,102 +496,6 @@ export async function getCommandesByFournisseur(
return result;
}
// ---------------------------------------------------------------------------
// Ranking — classement réseau et magasin
// GET /api/ranking?codein=<codein>
// Réponse : { count: number, ranking: RankingEntry[] }
// Les valeurs ranking sont des string|null, à parser en number.
// ---------------------------------------------------------------------------
export interface FfRanking {
ranking_ca?: number;
ranking_qte?: number;
ranking_mag_ca?: number;
ranking_mag_qte?: number;
ranking_mag_marge?: number;
pv_calcule?: number;
pv_mag?: number;
pv_cen?: number;
}
/** Parse une valeur string|null en number|undefined */
function parseRankNum(val: string | null | undefined): number | undefined {
if (val == null || val === "") return undefined;
const n = Number(val);
return isNaN(n) ? undefined : n;
}
export interface RankingResult {
rankings: Map<string, FfRanking>;
/** Nombre total de produits dans le classement (produits avec ventes sur la période) */
totalRankedProducts: number;
}
/**
* Récupère le ranking réseau pour les articles en 1 seul appel :
* GET /api/ranking?limit=<N> — récupère tout le classement réseau, puis filtre par codein.
*
* Fallback par article individuel si le bulk échoue.
*/
/**
* Récupère le ranking par article individuel (GET /api/ranking?codein=<codein>).
* Appelé seulement pour les articles AVEC ventes — taille raisonnable même pour grands fournisseurs.
* Le bulk endpoint est capé à 500 résultats par l'API → inutilisable pour matching complet.
*/
export async function getRankingByArticles(
articles: { codein: string; gtin?: string }[],
_codeFournisseur?: string,
): Promise<RankingResult> {
const rankings = new Map<string, FfRanking>();
if (articles.length === 0) return { rankings, totalRankedProducts: 0 };
const batchSize = 50;
for (let i = 0; i < articles.length; i += batchSize) {
const batch = articles.slice(i, i + batchSize);
await Promise.all(
batch.map(async (art) => {
try {
const url = `${FF_API_BASE}/api/ranking?codein=${encodeURIComponent(art.codein)}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
const rankingList = data?.ranking;
if (!Array.isArray(rankingList) || rankingList.length === 0) return;
const entry = rankingList[0] as Record<string, string | null>;
rankings.set(art.codein, {
ranking_ca: parseRankNum(entry.ranking_ca),
ranking_qte: parseRankNum(entry.ranking_qte),
ranking_mag_ca: parseRankNum(entry.ranking_mag_ca),
ranking_mag_qte: parseRankNum(entry.ranking_mag_qte),
ranking_mag_marge: parseRankNum(entry.ranking_mag_marge),
pv_calcule: parseRankNum(entry.pv_calcule),
pv_mag: parseRankNum(entry.pv_mag),
pv_cen: parseRankNum(entry.pv_cen),
});
} catch (err) {
console.error(`[api-ff] getRanking error for ${art.codein}:`, err);
}
})
);
}
// totalRankedProducts = nombre d'articles ayant un classement réseau
const totalRankedProducts = rankings.size > 0
? await fetchTotalRankedProducts()
: 0;
console.log(`[api-ff] Ranking: ${rankings.size}/${articles.length} articles classés (réseau: ${totalRankedProducts})`);
return { rankings, totalRankedProducts };
}
async function fetchTotalRankedProducts(): Promise<number> {
try {
const res = await fetch(`${FF_API_BASE}/api/ranking?limit=500`, { cache: "no-store" });
if (!res.ok) return 0;
const data = await res.json();
return (data?.ranking as unknown[])?.length ?? 0;
} catch { return 0; }
}
// ---------------------------------------------------------------------------
// Statut de synchronisation
// ---------------------------------------------------------------------------
+3 -82
View File
@@ -39,6 +39,8 @@ async function pgNoParallel(query: SQL): Promise<{ rows: unknown[] }> {
export interface PgArticle {
no_id: number;
codein: string;
/** Code centrale = articles.artcentrale (format 10000XXXXXX) — clé jointure Qlik "Article Code". Vide pour les articles non référencés centralement. */
codeCentrale?: string;
codefou: string;
nomfou?: string;
libelle1?: string;
@@ -74,15 +76,6 @@ export interface PgStockRow {
dernierereception?: string;
}
export interface PgRankingRow {
codein: string;
site: string;
ranking_ca?: number;
ranking_qte?: number;
ranking_mag_ca?: number;
ranking_mag_qte?: number;
}
// ---------------------------------------------------------------------------
// 0. Liste des fournisseurs
// ---------------------------------------------------------------------------
@@ -127,6 +120,7 @@ export async function pgGetArticlesByFournisseur(codefou: string): Promise<PgArt
SELECT DISTINCT ON (a.no_id)
a.no_id,
a.codein,
a.artcentrale AS "codeCentrale",
af.code AS codefou,
fi.nom AS nomfou,
a.libelle1,
@@ -378,79 +372,6 @@ export async function pgGetStockByFournisseur(codefou: string): Promise<Map<stri
return map;
}
// ---------------------------------------------------------------------------
// 5. Ranking réseau
// ---------------------------------------------------------------------------
/**
* Retourne le classement réseau pour chaque article du fournisseur.
* 1 requête SQL — remplace getRankingByArticles() (N appels HTTP per-article,
* capés à 500 par l'API).
*/
export async function pgGetRankingByFournisseur(codefou: string): Promise<{
rankings: Map<string, PgRankingRow>;
totalRankedProducts: number;
}> {
// D'abord, découvrir quels sites existent dans la table ranking (réseau vs magasin)
const [rankResult, totalResult, sitesSample] = await Promise.all([
pgNoParallel(sql`
SELECT DISTINCT ON (a.codein)
a.codein,
r.site,
r.ranking_ca,
r.ranking_qte,
r.ranking_mag_ca,
r.ranking_mag_qte
FROM ranking r
JOIN art_gtin ag
ON ag.gtin = r.gencod
JOIN articles a
ON a.no_id = ag.idarticle
JOIN artfou1 af
ON af.art_no_id = a.no_id AND af.code = ${codefou}
ORDER BY a.codein,
CASE
WHEN r.site IN ('000', 'ALL', 'TOTAL', 'NET', 'RES') THEN 0
ELSE 1
END,
r.site
`),
pgNoParallel(sql`
SELECT COUNT(DISTINCT gencod)::int AS total FROM ranking
`),
pgNoParallel(sql`
SELECT DISTINCT site FROM ranking ORDER BY site LIMIT 10
`),
]);
// Log sites disponibles pour diagnostic ranking
const sitesDispos = (sitesSample.rows as { site: string }[]).map(r => r.site);
console.log(`[pg-ff] Ranking sites disponibles:`, sitesDispos.join(", "));
const rankings = new Map<string, PgRankingRow>();
for (const row of rankResult.rows as unknown as PgRankingRow[]) {
if (row.codein) rankings.set(row.codein, row);
}
// Log doublons de rank pour diagnostic
const rankCount = new Map<number, number>();
for (const r of rankings.values()) {
if (r.ranking_ca) {
const v = Number(r.ranking_ca);
rankCount.set(v, (rankCount.get(v) ?? 0) + 1);
}
}
const dupes = [...rankCount.entries()].filter(([, c]) => c > 1).slice(0, 5);
if (dupes.length > 0) {
console.log(`[pg-ff] Ranking doublons détectés (rank → nb articles):`, dupes.map(([r, c]) => `rank${r}×${c}`).join(", "));
}
const totalRankedProducts = Number((totalResult.rows[0] as unknown as { total: number })?.total ?? 0);
console.log(`[pg-ff] Ranking: ${rankings.size} articles classés, réseau total: ${totalRankedProducts}`);
return { rankings, totalRankedProducts };
}
// ---------------------------------------------------------------------------
// 6. Commandes en cours
// ---------------------------------------------------------------------------
+262
View File
@@ -0,0 +1,262 @@
/**
* CollectFlow — Client Qlik Sense (donnees reseau ~270 magasins)
*
* Recupere par produit (cle = code centrale via la dimension "Article Code") :
* - CA reseau, Qte vendue reseau, Nb magasins travaillant le produit.
*
* Auth = NTLM (Qlik Sense Enterprise on Windows) via flux ticket SSO :
* 1. GET /hub/ (non authentifie) -> 302 vers le proxy forms, on extrait le targetId
* 2. NTLM sur /internal_windows_authentication/?targetId=... -> 302 /hub/?qlikTicket=XXX
* 3. GET /hub/?qlikTicket=XXX -> Qlik pose le cookie X-Qlik-Session
* Le cookie + Xrfkey servent ensuite pour QRS (REST) et l'Engine API (websocket).
*
* Extraction = Engine API (QIX) sur websocket, hypercube generique reference par
* master items (qLibraryId) -> pas besoin des expressions brutes.
*
* Tout est parametrable par variables d'environnement (defauts = discovery 2026-06-20).
*/
import httpntlm from "httpntlm";
import { WebSocket } from "ws";
import { randomBytes } from "node:crypto";
// ---------------------------------------------------------------------------
// Config (env) — defauts issus de la discovery
// ---------------------------------------------------------------------------
export interface QlikConfig {
host: string;
user: string;
password: string;
domain: string; // vide par defaut (NTLM sans domaine, comme requests_ntlm)
workstation: string;
appNetwork: string; // GUID app "Magasins Vision Consolidee" (article x reseau)
dimCodeArticleId: string; // master dimension "Article Code"
measCaId: string; // master measure "CA N"
measQteId: string; // master measure "Quantite N"
measNbMagId: string; // master measure "Magasin Ventes Nb N"
tlsInsecure: boolean;
timeoutMs: number;
}
export function getQlikConfig(): QlikConfig {
return {
host: process.env.QLIK_HOST ?? "reporting-magasins.lafoirfouille.fr",
user: process.env.QLIK_USER ?? "FFSCH",
password: process.env.QLIK_PWD ?? "",
domain: process.env.QLIK_DOMAIN ?? "",
workstation: process.env.QLIK_WORKSTATION ?? "",
appNetwork: process.env.QLIK_APP_NETWORK ?? "9872ee6e-d64a-4b43-984a-076bf1f7f647",
dimCodeArticleId: process.env.QLIK_DIM_CODE_ARTICLE_ID ?? "fcd239e5-288b-4830-a047-0e3d7665d971",
measCaId: process.env.QLIK_MEAS_CA_ID ?? "43a76088-86fa-402e-a80e-0efd7701b3e1",
measQteId: process.env.QLIK_MEAS_QTE_ID ?? "7b40caf1-be4b-4811-8d45-50acde33e715",
measNbMagId: process.env.QLIK_MEAS_NBMAG_ID ?? "8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b",
tlsInsecure: (process.env.QLIK_TLS_INSECURE ?? "true") === "true",
timeoutMs: Number(process.env.QLIK_TIMEOUT_MS ?? "60000"),
};
}
export interface NetworkMetric {
codeCentrale: string;
caReseau: number;
qteReseau: number;
nbMagasinsReseau: number;
periode?: string;
}
function makeXrfkey(): string {
return randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
}
// ---------------------------------------------------------------------------
// 1. Auth NTLM -> ticket SSO -> cookie de session
// ---------------------------------------------------------------------------
interface QlikSession {
cookie: string;
xrfkey: string;
}
function ntlmGet(cfg: QlikConfig, path: string): Promise<{ statusCode: number; headers: Record<string, unknown>; body: string }> {
return new Promise((resolve, reject) => {
httpntlm.get(
{
url: `https://${cfg.host}${path}`,
username: cfg.user,
password: cfg.password,
domain: cfg.domain,
workstation: cfg.workstation,
rejectUnauthorized: !cfg.tlsInsecure,
headers: { "User-Agent": "Mozilla/5.0 CollectFlow", "X-Qlik-Xrfkey": "" },
} as unknown as Parameters<typeof httpntlm.get>[0],
(err: Error | null, res: { statusCode: number; headers: Record<string, unknown>; body: string }) =>
err ? reject(err) : resolve(res),
);
});
}
export async function qlikNtlmSession(cfg = getQlikConfig()): Promise<QlikSession> {
if (!cfg.user || !cfg.password) {
throw new Error("[qlik] QLIK_USER / QLIK_PWD manquants dans l'environnement");
}
const xrfkey = makeXrfkey();
const tlsRestore = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
if (cfg.tlsInsecure) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
try {
// 1. targetId
const hub = await fetch(`https://${cfg.host}/hub/`, { redirect: "manual" });
const loc1 = hub.headers.get("location") ?? "";
const targetId = (loc1.match(/targetId=([0-9a-f-]+)/i) ?? [])[1] ?? "";
// 2. NTLM -> ticket. Interne (reseau corp), /hub/ peut deja renvoyer 401 NTLM
// et poser le cookie directement ; on gere les 2 cas.
const winPath = targetId
? `/internal_windows_authentication/?targetId=${targetId}&xrfkey=${xrfkey}`
: `/hub/?xrfkey=${xrfkey}`;
const ntlm = await ntlmGet(cfg, winPath);
// Cookie posé directement (cas interne) ?
const directCookie = extractSessionCookie(ntlm.headers["set-cookie"]);
if (directCookie) return { cookie: directCookie, xrfkey };
// 3. Échange ticket -> cookie
const ticketLoc = String(ntlm.headers["location"] ?? "");
if (!/qlikTicket=/.test(ticketLoc)) {
throw new Error(`[qlik] Auth NTLM sans ticket (HTTP ${ntlm.statusCode}) — verifier mot de passe`);
}
const exch = await fetch(ticketLoc, { redirect: "manual" });
const sc = typeof exch.headers.getSetCookie === "function" ? exch.headers.getSetCookie() : [];
const cookie = sc.map((c) => c.split(";")[0]).filter((c) => /X-Qlik-Session/i.test(c)).join("; ");
if (!cookie) throw new Error("[qlik] ticket non echange contre un cookie de session");
return { cookie, xrfkey };
} finally {
if (cfg.tlsInsecure) process.env.NODE_TLS_REJECT_UNAUTHORIZED = tlsRestore;
}
}
function extractSessionCookie(setCookie: unknown): string | null {
const arr = Array.isArray(setCookie) ? setCookie : setCookie ? [String(setCookie)] : [];
const c = arr.map((x) => String(x).split(";")[0]).find((x) => /X-Qlik-Session/i.test(x));
return c ?? null;
}
// ---------------------------------------------------------------------------
// 2. Websocket Engine API (QIX) + JSON-RPC minimal
// ---------------------------------------------------------------------------
interface RpcConn {
rpc: (method: string, params: unknown, handle?: number) => Promise<Record<string, unknown>>;
close: () => void;
}
function openEngine(appGuid: string, sess: QlikSession, cfg: QlikConfig): Promise<RpcConn> {
const url = `wss://${cfg.host}/app/${encodeURIComponent(appGuid)}?Xrfkey=${sess.xrfkey}`;
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, {
headers: { Cookie: sess.cookie, "X-Qlik-Xrfkey": sess.xrfkey },
rejectUnauthorized: !cfg.tlsInsecure,
handshakeTimeout: cfg.timeoutMs,
});
let nextId = 1;
const pending = new Map<number, { resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }>();
const timer = setTimeout(() => { ws.terminate(); reject(new Error("[qlik] timeout engine")); }, cfg.timeoutMs);
ws.on("message", (raw: Buffer) => {
let msg: Record<string, unknown>;
try { msg = JSON.parse(raw.toString()); } catch { return; }
if (msg.method === "OnConnected") { clearTimeout(timer); resolve(conn); return; }
const id = msg.id as number | undefined;
if (id != null && pending.has(id)) {
const p = pending.get(id)!;
pending.delete(id);
if (msg.error) p.reject(new Error(`[qlik] RPC ${JSON.stringify(msg.error)}`));
else p.resolve(msg.result as Record<string, unknown>);
}
});
ws.on("unexpected-response", (_req, res) => { clearTimeout(timer); reject(new Error(`[qlik] ws HTTP ${res.statusCode}`)); });
ws.on("error", (e: Error) => { clearTimeout(timer); reject(e); });
ws.on("close", () => { for (const p of pending.values()) p.reject(new Error("[qlik] ws ferme")); pending.clear(); });
const rpc = (method: string, params: unknown, handle = -1) =>
new Promise<Record<string, unknown>>((res, rej) => {
const id = nextId++;
pending.set(id, { resolve: res, reject: rej });
ws.send(JSON.stringify({ jsonrpc: "2.0", id, handle, method, params }));
});
const conn: RpcConn = { rpc, close: () => ws.close() };
});
}
// ---------------------------------------------------------------------------
// 3. Extraction hypercube (master items) -> NetworkMetric[]
// ---------------------------------------------------------------------------
function hyperCubeDef(cfg: QlikConfig, height: number) {
return {
qInfo: { qType: "collectflow-network" },
qHyperCubeDef: {
qDimensions: [{ qLibraryId: cfg.dimCodeArticleId, qNullSuppression: true }],
qMeasures: [
{ qLibraryId: cfg.measCaId },
{ qLibraryId: cfg.measQteId },
{ qLibraryId: cfg.measNbMagId },
],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 4, qHeight: height }],
qSuppressZero: false,
qSuppressMissing: true,
},
};
}
type Matrix = Array<Array<{ qText?: string; qNum?: number }>>;
export async function fetchNetworkMetrics(
codeCentraux?: string[],
cfg = getQlikConfig(),
): Promise<Map<string, NetworkMetric>> {
if (!cfg.appNetwork) throw new Error("[qlik] QLIK_APP_NETWORK manquant");
const wanted = codeCentraux ? new Set(codeCentraux) : null;
const out = new Map<string, NetworkMetric>();
const sess = await qlikNtlmSession(cfg);
const conn = await openEngine(cfg.appNetwork, sess, cfg);
try {
const openRes = await conn.rpc("OpenDoc", { qDocName: cfg.appNetwork });
const docHandle = (((openRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number) ?? 1;
const pageHeight = 2500; // 4 colonnes * 2500 = 10000 cellules max
const createRes = await conn.rpc("CreateSessionObject", { qProp: hyperCubeDef(cfg, pageHeight) }, docHandle);
const objHandle = (((createRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number);
let top = 0;
for (;;) {
const dataRes = await conn.rpc(
"GetHyperCubeData",
{ qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 4, qHeight: pageHeight }] },
objHandle,
);
const pages = (dataRes.qDataPages as Array<{ qMatrix?: Matrix }>) ?? [];
const matrix: Matrix = pages[0]?.qMatrix ?? [];
if (matrix.length === 0) break;
for (const row of matrix) {
const codeCentrale = (row[0]?.qText ?? "").trim();
if (!codeCentrale) continue;
if (wanted && !wanted.has(codeCentrale)) continue;
out.set(codeCentrale, {
codeCentrale,
caReseau: Number(row[1]?.qNum ?? 0) || 0,
qteReseau: Number(row[2]?.qNum ?? 0) || 0,
nbMagasinsReseau: Number(row[3]?.qNum ?? 0) || 0,
});
}
if (matrix.length < pageHeight) break;
top += matrix.length;
}
} finally {
conn.close();
}
console.log(`[qlik] fetchNetworkMetrics: ${out.size} produits reseau`);
return out;
}
+79
View File
@@ -0,0 +1,79 @@
/**
* CollectFlow — Cache des metriques reseau Qlik (table qlik_network_metrics).
*
* Lecture par lot (jointure grille) + upsert masse (route de sync).
*/
import "server-only";
import { db } from "@/db";
import { qlikNetworkMetrics } from "@/db/schema";
import { inArray, sql } from "drizzle-orm";
import type { NetworkMetric } from "@/lib/qlik-client";
export interface NetworkMetricCached {
caReseau: number;
qteReseau: number;
nbMagasinsReseau: number;
fetchedAt: string | null;
}
/** Lit les metriques reseau pour une liste de codes centraux. */
export async function getNetworkMetricsByCodeCentrale(
codes: string[],
): Promise<Map<string, NetworkMetricCached>> {
const out = new Map<string, NetworkMetricCached>();
const unique = [...new Set(codes.filter(Boolean))];
if (unique.length === 0) return out;
const rows = await db
.select()
.from(qlikNetworkMetrics)
.where(inArray(qlikNetworkMetrics.codeCentrale, unique));
for (const r of rows) {
out.set(r.codeCentrale, {
caReseau: Number(r.caReseau ?? 0) || 0,
qteReseau: Number(r.qteReseau ?? 0) || 0,
nbMagasinsReseau: Number(r.nbMagasinsReseau ?? 0) || 0,
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
});
}
return out;
}
/** Upsert en masse des metriques reseau (depuis Qlik). */
export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<number> {
if (metrics.length === 0) return 0;
const now = new Date();
const values = metrics.map((m) => ({
codeCentrale: m.codeCentrale,
caReseau: String(m.caReseau),
qteReseau: String(m.qteReseau),
nbMagasinsReseau: m.nbMagasinsReseau,
periode: m.periode ?? null,
fetchedAt: now,
}));
// Upsert par paquets pour eviter les requetes trop volumineuses.
const chunk = 500;
let count = 0;
for (let i = 0; i < values.length; i += chunk) {
const batch = values.slice(i, i + chunk);
await db
.insert(qlikNetworkMetrics)
.values(batch)
.onConflictDoUpdate({
target: qlikNetworkMetrics.codeCentrale,
set: {
caReseau: sql`excluded.ca_reseau`,
qteReseau: sql`excluded.qte_reseau`,
nbMagasinsReseau: sql`excluded.nb_magasins_reseau`,
periode: sql`excluded.periode`,
fetchedAt: sql`excluded.fetched_at`,
},
});
count += batch.length;
}
return count;
}
-234
View File
@@ -1,234 +0,0 @@
/**
* CollectFlow — Score Engine v4 (Hybride Absolu + Relatif)
*
* Calcule le score de performance d'un produit en combinant :
* - Performance ABSOLUE : Volume (50pts), CA (30pts), Marge (20pts)
* - Ajustement RELATIF : Position dans le lot fournisseur (±10pts)
* - Pénalité de régularité : multiplicateur si inactivité prolongée
*
* Score final : 0-100, sensible au prix unitaire.
*
* Exemples :
* - 0.80€ × 100 unités (80€ CA) → ~53 (bon trafic malgré petit CA)
* - 40€ × 2 unités (80€ CA) → ~19 (faible rotation)
* - 500€ CA, 200u, 45% marge → ~90 (star)
* - 8€ CA, 2 unités → ~13 (stock mort)
*/
import type { ProductRow } from "@/types/grid";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Interpolation linéaire entre deux paliers */
function lerp(value: number, minVal: number, maxVal: number, minPts: number, maxPts: number): number {
if (value >= maxVal) return maxPts;
if (value <= minVal) return minPts;
return minPts + ((value - minVal) / (maxVal - minVal)) * (maxPts - minPts);
}
/** Score Volume (0-50) basé sur unités / magasin / mois */
function computeVolumeScore(unitsPerStorePerMonth: number): number {
if (unitsPerStorePerMonth >= 10) return 50;
if (unitsPerStorePerMonth >= 5) return lerp(unitsPerStorePerMonth, 5, 10, 40, 50);
if (unitsPerStorePerMonth >= 2) return lerp(unitsPerStorePerMonth, 2, 5, 30, 40);
if (unitsPerStorePerMonth >= 1) return lerp(unitsPerStorePerMonth, 1, 2, 20, 30);
if (unitsPerStorePerMonth >= 0.5) return lerp(unitsPerStorePerMonth, 0.5, 1, 10, 20);
return lerp(unitsPerStorePerMonth, 0, 0.5, 0, 10);
}
/** Score CA (0-30) basé sur CA € / magasin / an */
function computeCaScore(caPerStorePerYear: number): number {
if (caPerStorePerYear >= 500) return 30;
if (caPerStorePerYear >= 200) return lerp(caPerStorePerYear, 200, 500, 22, 30);
if (caPerStorePerYear >= 100) return lerp(caPerStorePerYear, 100, 200, 15, 22);
if (caPerStorePerYear >= 50) return lerp(caPerStorePerYear, 50, 100, 8, 15);
return lerp(caPerStorePerYear, 0, 50, 0, 8);
}
/** Score Marge (0-20) basé sur taux de marge % */
function computeMargeScore(tauxMarge: number): number {
if (tauxMarge >= 50) return 20;
if (tauxMarge >= 40) return lerp(tauxMarge, 40, 50, 16, 20);
if (tauxMarge >= 30) return lerp(tauxMarge, 30, 40, 12, 16);
if (tauxMarge >= 20) return lerp(tauxMarge, 20, 30, 8, 12);
return lerp(tauxMarge, 0, 20, 0, 8);
}
/** Rangs percentiles (0-100) pour un tableau de valeurs numériques. */
function computePercentileRanks(values: number[]): number[] {
const n = values.length;
if (n === 0) return [];
if (n === 1) return [50];
const indexed = values.map((v, i) => ({ v, i }));
indexed.sort((a, b) => a.v - b.v);
const ranks = new Array<number>(n);
let i = 0;
while (i < n) {
let j = i;
while (j < n && indexed[j].v === indexed[i].v) j++;
const avgRank = ((i + j - 1) / 2) / (n - 1) * 100;
for (let k = i; k < j; k++) {
ranks[indexed[k].i] = Math.round(avgRank);
}
i = j;
}
return ranks;
}
// ---------------------------------------------------------------------------
// API publique
// ---------------------------------------------------------------------------
/**
* Calcule les scores pour un ensemble de produits d'un même fournisseur.
* Les rows sont modifiées en place et retournées.
*/
export function computeProductScores(rows: ProductRow[]): ProductRow[] {
if (rows.length === 0) return rows;
// 1. Données pondérées pour normalisation multi-magasins
const weightedData = rows.map((r) => {
const storeCount = Math.max(1, r.workingStores?.length || 1);
const weight = storeCount === 1 ? 2 : 1;
// Régularité : nombre de mois avec au moins 1 vente
const regScore = Object.values(r.sales12m || {}).filter((v) => v > 0).length;
// Inactivité : mois sans vente en fin de fenêtre
const allMonths = Object.keys(r.sales12m || {});
const refMonth = allMonths.length > 0 ? Math.max(...allMonths.map(m => parseInt(m))).toString() : "";
const salesMonths = Object.entries(r.sales12m || {})
.filter(([, qty]) => qty > 0)
.map(([m]) => parseInt(m))
.sort((a, b) => b - a);
const lastSaleMonth = salesMonths.length > 0 ? salesMonths[0].toString() : "";
let inactivity = 0;
if (refMonth && lastSaleMonth) {
const refY = parseInt(refMonth.substring(0, 4));
const refM = parseInt(refMonth.substring(4, 6));
const lastY = parseInt(lastSaleMonth.substring(0, 4));
const lastM = parseInt(lastSaleMonth.substring(4, 6));
inactivity = (refY - lastY) * 12 + (refM - lastM);
} else if (refMonth && !lastSaleMonth) {
// Jamais vendu sur la fenêtre → inactivité maximale
inactivity = 24;
}
return {
row: r,
storeCount,
weight,
wQty: (r.totalQuantite ?? 0) * weight,
wCa: (r.totalCa ?? 0) * weight,
tauxMarge: Number.isFinite(r.tauxMarge) ? r.tauxMarge : 0,
regScore,
inactivity,
};
});
// 2. Totaux fournisseur
const totalQty = weightedData.reduce((acc, d) => acc + d.wQty, 0);
const totalCa = weightedData.reduce((acc, d) => acc + d.wCa, 0);
const totalMarge = weightedData.reduce((sum, d) => sum + d.tauxMarge, 0);
const avgQty = rows.length > 0 ? totalQty / rows.length : 0;
// 3. Totaux par rayon (code2)
const rayonTotals = new Map<string, { total: number; count: number }>();
weightedData.forEach((d) => {
const r2 = d.row.code2 || "default";
const current = rayonTotals.get(r2) || { total: 0, count: 0 };
rayonTotals.set(r2, { total: current.total + d.wQty, count: current.count + 1 });
});
// 4. Percentiles dans le lot (pour le bonus relatif)
const caPctRanks = computePercentileRanks(weightedData.map(d => d.wCa));
const qtyPctRanks = computePercentileRanks(weightedData.map(d => d.wQty));
const margePctRanks = computePercentileRanks(weightedData.map(d => d.tauxMarge));
// 5. Seuil top 30% CA pour le flag isTop30Supplier
const sortedCa = [...weightedData.map(d => d.wCa)].sort((a, b) => b - a);
const top30Idx = Math.max(0, Math.ceil(sortedCa.length * 0.3) - 1);
const top30CaThreshold = sortedCa[top30Idx] ?? 0;
// 6. Score de chaque produit
for (let idx = 0; idx < weightedData.length; idx++) {
const d = weightedData[idx];
const r = d.row;
// --- Guard absolu : aucune vente → score 0, quelle que soit la marge ou le ranking ---
if (d.wQty === 0 && d.wCa === 0) {
r.avgQtyFournisseur = avgQty;
r.totalFournisseurCa = totalCa;
r.shareQty = 0;
r.shareCa = 0;
r.shareMarge = 0;
const r2z = r.code2 || "default";
const rStat = rayonTotals.get(r2z);
r.avgQtyRayon = rStat ? rStat.total / rStat.count : 0;
r.unitsPerStorePerMonth = 0;
r.caPerStorePerYear = 0;
r.score = 0;
r.isRecent = true;
r.isLastProduct = rows.length === 1;
r.isTop30Supplier = false;
continue;
}
// --- Métriques de contexte (inchangées, pour l'IA et l'affichage) ---
r.avgQtyFournisseur = avgQty;
r.totalFournisseurCa = totalCa;
r.shareQty = totalQty > 0 ? (d.wQty / totalQty) * 100 : 0;
r.shareCa = totalCa > 0 ? (d.wCa / totalCa) * 100 : 0;
r.shareMarge = totalMarge > 0 ? (d.tauxMarge / totalMarge) * 100 : 0;
const r2 = r.code2 || "default";
const rayonStat = rayonTotals.get(r2);
r.avgQtyRayon = rayonStat ? rayonStat.total / rayonStat.count : 0;
// --- KPIs normalisés (sensibles au prix) ---
const activeMonths = Math.max(d.regScore, 3); // plancher 3 mois
const unitsPerStorePerMonth = d.wQty / (d.storeCount * activeMonths);
const caPerStorePerYear = d.wCa / d.storeCount;
r.unitsPerStorePerMonth = Math.round(unitsPerStorePerMonth * 100) / 100;
r.caPerStorePerYear = Math.round(caPerStorePerYear * 100) / 100;
// --- Score absolu (Volume + CA + Marge = 0-100 pts) ---
const volumeScore = computeVolumeScore(unitsPerStorePerMonth);
const caScore = computeCaScore(caPerStorePerYear);
const margeScore = computeMargeScore(d.tauxMarge);
// --- Bonus relatif (±10 pts) ---
const pctComposite = (caPctRanks[idx] * 0.40 + qtyPctRanks[idx] * 0.35 + margePctRanks[idx] * 0.25);
const relativeBonus = ((pctComposite - 50) / 50) * 10;
// --- Pénalité de régularité ---
let regularityMultiplier = 1;
if (d.regScore <= 2 && d.inactivity >= 6) {
regularityMultiplier = 0.3;
} else if (d.regScore <= 2 && d.inactivity >= 3) {
regularityMultiplier = 0.5;
} else if (d.inactivity >= 6) {
regularityMultiplier = 0.5;
} else if (d.inactivity >= 3) {
regularityMultiplier = 0.7;
}
// --- Score final ---
const rawScore = (volumeScore + caScore + margeScore + relativeBonus) * regularityMultiplier;
r.score = Math.round(Math.max(0, Math.min(100, rawScore)) * 10) / 10;
// --- Flags de protection ---
r.isRecent = d.regScore <= 3;
r.isLastProduct = rows.length === 1;
r.isTop30Supplier = d.wCa >= top30CaThreshold && top30CaThreshold > 0;
}
return rows;
}