From bd9bc78f4c4201c8d2342285d3e3fde46dc158d0 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 21 Jun 2026 08:34:54 +0200 Subject: [PATCH] feat(qlik): extract 4 decision metrics (CA/mag, couverture, marge%, rupture%) Adds CA par Magasin, Couverture stock Qte, Marge % and Stock Rupture % network measures to the Qlik hypercube extraction (in-page Playwright ws + legacy server ws), persists them in qlik_network_metrics, joins them in getProductRows and renders 4 new grid columns with buy-signal coloring (low coverage / high margin = green; high rupture = rose). - qlik-client: config IDs (env-overridable, master GUIDs of app 9872ee6e), NetworkMetric fields, hypercube width 4->8, page height 2500->1250 - qlik-playwright: same 4 measures in the in-page hypercube + row mapping - schema + db-init: 4 numeric columns (ALTER ADD COLUMN IF NOT EXISTS for existing DBs) - cache + grid types + enrich: plumb the new fields end to end Note: percent measures stored as raw Qlik ratio; UI renders x100 when |v|<=1. Co-Authored-By: Claude Opus 4.8 --- scripts/db-init.js | 12 ++++ src/db/schema.ts | 8 +++ src/features/grid/api/get-product-rows.ts | 4 ++ src/features/grid/components/heatmap-grid.tsx | 66 ++++++++++++++++++- src/lib/qlik-client.ts | 32 ++++++++- src/lib/qlik-network-cache.ts | 16 +++++ src/lib/qlik-playwright.ts | 14 ++-- src/types/grid.ts | 8 +++ 8 files changed, 148 insertions(+), 12 deletions(-) diff --git a/scripts/db-init.js b/scripts/db-init.js index 0b8b28a..1da21c1 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -102,10 +102,22 @@ async function main() { "ca_reseau" numeric(16, 2), "qte_reseau" numeric(14, 2), "nb_magasins_reseau" integer, + "ca_par_magasin_reseau" numeric(16, 2), + "couverture_stock_reseau" numeric(12, 3), + "marge_pct_reseau" numeric(8, 4), + "rupture_pct_reseau" numeric(8, 4), "periode" varchar(20), "fetched_at" timestamp DEFAULT now() ); `); + // Colonnes ajoutées après coup (DB existante) — CREATE IF NOT EXISTS ne les pose pas. + await tempPool.query(` + ALTER TABLE "qlik_network_metrics" + ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2), + ADD COLUMN IF NOT EXISTS "couverture_stock_reseau" numeric(12, 3), + ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4), + ADD COLUMN IF NOT EXISTS "rupture_pct_reseau" numeric(8, 4); + `); console.log("[DB Init] Table qlik_network_metrics is verified/created."); await tempPool.end(); diff --git a/src/db/schema.ts b/src/db/schema.ts index 3afe040..f67366d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -117,6 +117,14 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", { qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }), /** Nombre de magasins travaillant le produit (sur ~270) */ nbMagasinsReseau: integer("nb_magasins_reseau"), + /** CA moyen par magasin (productivité normalisée présence) */ + caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }), + /** Couverture de stock en quantité (rotation) */ + couvertureStockReseau: numeric("couverture_stock_reseau", { precision: 12, scale: 3 }), + /** Taux de marge réseau (ratio brut Qlik) */ + margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }), + /** Taux de rupture réseau (ratio brut Qlik) */ + rupturePctReseau: numeric("rupture_pct_reseau", { precision: 8, scale: 4 }), /** Période couverte (libre, ex "12m" ou "2025") */ periode: varchar("periode", { length: 20 }), /** Dernière synchro depuis Qlik */ diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 370b515..71e23a2 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -481,6 +481,10 @@ async function enrichWithNetworkMetrics(productMap: Map): Pr product.caReseau = m.caReseau; product.qteReseau = m.qteReseau; product.nbMagasinsReseau = m.nbMagasinsReseau; + product.caParMagasinReseau = m.caParMagasinReseau; + product.couvertureStockReseau = m.couvertureStockReseau; + product.margePctReseau = m.margePctReseau; + product.rupturePctReseau = m.rupturePctReseau; product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU; product.networkFetchedAt = m.fetchedAt ?? undefined; } diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index cf18c1a..bb67f69 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -178,7 +178,7 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisi > {row.getVisibleCells().map((cell: Cell) => { const isFlexible = cell.column.id === "libelle1" || cell.column.id === "libelle3"; - const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || cell.column.id === "caReseau" || cell.column.id === "qteReseau" || cell.column.id === "nbMagasinsReseau" || cell.column.id === "tauxPresenceReseau" || cell.column.id === "gamme"; + const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || cell.column.id === "caReseau" || cell.column.id === "qteReseau" || cell.column.id === "nbMagasinsReseau" || cell.column.id === "caParMagasinReseau" || cell.column.id === "couvertureStockReseau" || cell.column.id === "margePctReseau" || cell.column.id === "rupturePctReseau" || cell.column.id === "tauxPresenceReseau" || cell.column.id === "gamme"; const size = cell.column.getSize(); return (
CA / Mag
Réseau
, + size: 85, + cell: ({ getValue }) => { + const val = getValue(); + return ( +
+ {val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"} +
+ ); + }, + }, + { + accessorKey: "couvertureStockReseau", + header: () =>
Couv.
Stock
, + size: 70, + cell: ({ getValue }) => { + const val = getValue(); + if (val == null) return
-
; + // Couverture basse = rotation rapide = bon signal d'achat + const color = val <= 2 ? "text-emerald-500" : val <= 4 ? "text-amber-500" : "text-rose-500"; + return ( +
+ {val.toLocaleString("fr-FR", { maximumFractionDigits: 1 })} +
+ ); + }, + }, + { + accessorKey: "margePctReseau", + header: () =>
Marge %
Réseau
, + size: 75, + cell: ({ getValue }) => { + const val = getValue(); + if (val == null) return
-
; + const pct = Math.abs(val) <= 1 ? val * 100 : val; + const color = pct >= 30 ? "text-emerald-500" : pct >= 15 ? "text-amber-500" : "text-rose-500"; + return ( +
+ {pct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}% +
+ ); + }, + }, + { + accessorKey: "rupturePctReseau", + header: () =>
Rupt. %
Réseau
, + size: 75, + cell: ({ getValue }) => { + const val = getValue(); + if (val == null) return
-
; + const pct = Math.abs(val) <= 1 ? val * 100 : val; + // Rupture haute = demande non servie = signal, mais aussi tension stock + const color = pct >= 15 ? "text-rose-500" : pct >= 5 ? "text-amber-500" : "text-emerald-500"; + return ( +
+ {pct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}% +
+ ); + }, + }, ...MONTHS_12.map((monthKey) => ({ id: `month_${monthKey}`, header: () =>
{formatMonthLabel(monthKey)}
, @@ -778,7 +840,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { {headerGroup.headers.map((header) => { const isFlexible = header.column.id === "libelle1" || header.column.id === "libelle3"; - const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "caReseau" || header.column.id === "qteReseau" || header.column.id === "nbMagasinsReseau" || header.column.id === "tauxPresenceReseau" || header.column.id === "gamme"; + const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "caReseau" || header.column.id === "qteReseau" || header.column.id === "nbMagasinsReseau" || header.column.id === "caParMagasinReseau" || header.column.id === "couvertureStockReseau" || header.column.id === "margePctReseau" || header.column.id === "rupturePctReseau" || header.column.id === "tauxPresenceReseau" || header.column.id === "gamme"; const size = header.getSize(); return ( ) ?? {}).qHandle as number) ?? 1; - const pageHeight = 2500; // 4 colonnes * 2500 = 10000 cellules max + const pageHeight = 1250; // 8 colonnes * 1250 = 10000 cellules max const createRes = await conn.rpc("CreateSessionObject", { qProp: hyperCubeDef(cfg, pageHeight) }, docHandle); const objHandle = (((createRes.qReturn as Record) ?? {}).qHandle as number); @@ -261,7 +283,7 @@ export async function fetchNetworkMetrics( for (;;) { const dataRes = await conn.rpc( "GetHyperCubeData", - { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 4, qHeight: pageHeight }] }, + { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 8, qHeight: pageHeight }] }, objHandle, ); const pages = (dataRes.qDataPages as Array<{ qMatrix?: Matrix }>) ?? []; @@ -277,6 +299,10 @@ export async function fetchNetworkMetrics( caReseau: Number(row[1]?.qNum ?? 0) || 0, qteReseau: Number(row[2]?.qNum ?? 0) || 0, nbMagasinsReseau: Number(row[3]?.qNum ?? 0) || 0, + caParMagasinReseau: Number(row[4]?.qNum ?? 0) || 0, + couvertureStockReseau: Number(row[5]?.qNum ?? 0) || 0, + margePctReseau: Number(row[6]?.qNum ?? 0) || 0, + rupturePctReseau: Number(row[7]?.qNum ?? 0) || 0, }); } if (matrix.length < pageHeight) break; diff --git a/src/lib/qlik-network-cache.ts b/src/lib/qlik-network-cache.ts index 128b95b..ea65f7b 100644 --- a/src/lib/qlik-network-cache.ts +++ b/src/lib/qlik-network-cache.ts @@ -15,6 +15,10 @@ export interface NetworkMetricCached { caReseau: number; qteReseau: number; nbMagasinsReseau: number; + caParMagasinReseau: number; + couvertureStockReseau: number; + margePctReseau: number; + rupturePctReseau: number; fetchedAt: string | null; } @@ -36,6 +40,10 @@ export async function getNetworkMetricsByCodeCentrale( caReseau: Number(r.caReseau ?? 0) || 0, qteReseau: Number(r.qteReseau ?? 0) || 0, nbMagasinsReseau: Number(r.nbMagasinsReseau ?? 0) || 0, + caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0, + couvertureStockReseau: Number(r.couvertureStockReseau ?? 0) || 0, + margePctReseau: Number(r.margePctReseau ?? 0) || 0, + rupturePctReseau: Number(r.rupturePctReseau ?? 0) || 0, fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null, }); } @@ -51,6 +59,10 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise + ({ app, dim, mca, mqte, mnb, mcamag, mcouv, mmarge, mrupt, codes, token }: { app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mcouv: string; mmarge: string; mrupt: string; codes: string[]; token: string }) => new Promise((resolve) => { const loc = (window as unknown as { location: Location }).location; const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`; @@ -112,7 +112,7 @@ export async function fetchNetworkMetricsPlaywright( } const obj = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "cf-net" }, qHyperCubeDef: { qDimensions: [{ qLibraryId: dim }], - qMeasures: [{ qLibraryId: mca }, { qLibraryId: mqte }, { qLibraryId: mnb }], + qMeasures: [{ qLibraryId: mca }, { qLibraryId: mqte }, { qLibraryId: mnb }, { qLibraryId: mcamag }, { qLibraryId: mcouv }, { qLibraryId: mmarge }, { qLibraryId: mrupt }], qInitialDataFetch: [], } } }, doc); const oh = (obj.qReturn as { qHandle: number }).qHandle; @@ -120,12 +120,12 @@ export async function fetchNetworkMetricsPlaywright( const size = ((layout.qLayout as { qHyperCube: { qSize: { qcy: number } } }).qHyperCube).qSize.qcy; console.log("cube qSize.qcy=" + size + " lignes"); const out: Array> = []; - const PAGE = 2500; + const PAGE = 1250; for (let top = 0; top < size; top += PAGE) { - const d = await rpc("GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 4, qHeight: PAGE }] }, oh); + const d = await rpc("GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 8, qHeight: PAGE }] }, oh); const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; if (!matrix.length) break; - for (const r of matrix) out.push([String(r[0]?.qText ?? ""), Number(r[1]?.qNum) || 0, Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0]); + for (const r of matrix) out.push([String(r[0]?.qText ?? ""), Number(r[1]?.qNum) || 0, Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0, Number(r[5]?.qNum) || 0, Number(r[6]?.qNum) || 0, Number(r[7]?.qNum) || 0]); if (matrix.length < PAGE) break; } ws.close(); @@ -133,7 +133,7 @@ export async function fetchNetworkMetricsPlaywright( } catch (e) { try { ws.close(); } catch { /* noop */ } fail(String((e as Error)?.message || e)); } })(); }), - { app: cfg.appNetwork, dim: cfg.dimCodeArticleId, mca: cfg.measCaId, mqte: cfg.measQteId, mnb: cfg.measNbMagId, codes: codeCentraux ?? [], token: csrfToken }, + { app: cfg.appNetwork, dim: cfg.dimCodeArticleId, mca: cfg.measCaId, mqte: cfg.measQteId, mnb: cfg.measNbMagId, mcamag: cfg.measCaMagId, mcouv: cfg.measCouvQteId, mmarge: cfg.measMargePctId, mrupt: cfg.measRuptPctId, codes: codeCentraux ?? [], token: csrfToken }, )) as InPageResult; if (!result.ok) throw new Error(`[qlik-pw] ${result.error}`); @@ -144,7 +144,7 @@ export async function fetchNetworkMetricsPlaywright( const code = String(r[0]).trim(); if (!code || code === "-") continue; if (wanted && !wanted.has(code)) continue; - out.set(code, { codeCentrale: code, caReseau: Number(r[1]) || 0, qteReseau: Number(r[2]) || 0, nbMagasinsReseau: Number(r[3]) || 0 }); + out.set(code, { codeCentrale: code, caReseau: Number(r[1]) || 0, qteReseau: Number(r[2]) || 0, nbMagasinsReseau: Number(r[3]) || 0, caParMagasinReseau: Number(r[4]) || 0, couvertureStockReseau: Number(r[5]) || 0, margePctReseau: Number(r[6]) || 0, rupturePctReseau: Number(r[7]) || 0 }); } console.log(`[qlik-pw] ${out.size} produits réseau (cube ${result.size})`); return out; diff --git a/src/types/grid.ts b/src/types/grid.ts index 8881a6e..7f8e88e 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -113,6 +113,14 @@ export interface ProductRow { caReseau?: number; qteReseau?: number; nbMagasinsReseau?: number; + /** CA moyen par magasin réseau (productivité normalisée présence) */ + caParMagasinReseau?: number; + /** Couverture de stock quantité réseau (rotation : bas = tourne vite) */ + couvertureStockReseau?: number; + /** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */ + margePctReseau?: number; + /** Taux de rupture réseau (ratio brut Qlik, ex 0.05 = 5%) */ + rupturePctReseau?: number; /** Taux de présence réseau = nbMagasinsReseau / 270 */ tauxPresenceReseau?: number; /** Fraîcheur des données réseau (ISO) */