diff --git a/scripts/db-init.js b/scripts/db-init.js index 8b97fba..1d845fb 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -103,7 +103,6 @@ async function main() { "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), "periode" varchar(20), "fetched_at" timestamp DEFAULT now() @@ -113,9 +112,9 @@ async function main() { 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); ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau"; + ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau"; `); console.log("[DB Init] Table qlik_network_metrics is verified/created."); diff --git a/src/db/schema.ts b/src/db/schema.ts index b16f1b0..d65951e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -119,8 +119,6 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", { 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 }), /** Période couverte (libre, ex "12m" ou "2025") */ diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 0d475df..102bb76 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -484,7 +484,6 @@ async function enrichWithNetworkMetrics(productMap: Map): Pr product.qteReseau = m.qteReseau; product.nbMagasinsReseau = m.nbMagasinsReseau; product.caParMagasinReseau = m.caParMagasinReseau; - product.couvertureStockReseau = m.couvertureStockReseau; product.margePctReseau = m.margePctReseau; 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 9e6a364..32b4ef1 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 === "caParMagasinReseau" || cell.column.id === "couvertureStockReseau" || cell.column.id === "margePctReseau" || 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 === "margePctReseau" || cell.column.id === "tauxPresenceReseau" || cell.column.id === "gamme"; const size = cell.column.getSize(); return (
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
, @@ -823,7 +807,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 === "caParMagasinReseau" || header.column.id === "couvertureStockReseau" || header.column.id === "margePctReseau" || 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 === "margePctReseau" || header.column.id === "tauxPresenceReseau" || header.column.id === "gamme"; const size = header.getSize(); return ( ) ?? {}).qHandle as number) ?? 1; - const pageHeight = 1400; // 7 colonnes * 1400 = 9800 cellules max + const pageHeight = 1400; // 6 colonnes * 1400 = 8400 cellules max const createRes = await conn.rpc("CreateSessionObject", { qProp: hyperCubeDef(cfg, pageHeight) }, docHandle); const objHandle = (((createRes.qReturn as Record) ?? {}).qHandle as number); @@ -278,7 +273,7 @@ export async function fetchNetworkMetrics( for (;;) { const dataRes = await conn.rpc( "GetHyperCubeData", - { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 7, qHeight: pageHeight }] }, + { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: pageHeight }] }, objHandle, ); const pages = (dataRes.qDataPages as Array<{ qMatrix?: Matrix }>) ?? []; @@ -295,8 +290,7 @@ export async function fetchNetworkMetrics( 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, + margePctReseau: Number(row[5]?.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 a25c662..e9b10a1 100644 --- a/src/lib/qlik-network-cache.ts +++ b/src/lib/qlik-network-cache.ts @@ -16,7 +16,6 @@ export interface NetworkMetricCached { qteReseau: number; nbMagasinsReseau: number; caParMagasinReseau: number; - couvertureStockReseau: number; margePctReseau: number; fetchedAt: string | null; } @@ -40,7 +39,6 @@ export async function getNetworkMetricsByCodeCentrale( 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, fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null, }); @@ -58,7 +56,6 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise + ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token }: { app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mmarge: 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}`; @@ -103,12 +103,11 @@ export async function fetchNetworkMetricsPlaywright( const open = await rpc("OpenDoc", { qDocName: app }); const doc = (open.qReturn as { qHandle: number }).qHandle; console.log("OpenDoc OK handle=" + doc); - // Sélection des codes du fournisseur (sinon ~1,2M lignes) + // Handle du champ de sélection (réutilisé pour chaque lot). + let fh = -1; if (codes.length) { const gf = await rpc("GetField", { qFieldName: "Article Code" }, doc); - const fh = (gf.qReturn as { qHandle: number }).qHandle; - const sel = await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh); - console.log("SelectValues " + codes.length + " codes → " + JSON.stringify(sel.qReturn)); + fh = (gf.qReturn as { qHandle: number }).qHandle; } // Résoudre les master measures par TITRE (le qLibraryId fiable = qInfo.qId, ≠ GUID QRS). const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase(); @@ -119,33 +118,45 @@ export async function fetchNetworkMetricsPlaywright( for (const it of items) { const t = it.qMeta?.title ?? it.qData?.title ?? ""; if (t) byTitle.set(norm(t), it.qInfo.qId); } const pickMeasure = (title: string, fallback: string) => byTitle.get(norm(title)) ?? fallback; const rCamag = pickMeasure("CA par Magasin N", mcamag); - const rCouv = pickMeasure("Couverture de stock Quantité N", mcouv); const rMarge = pickMeasure("Marge % N", mmarge); - console.log("measures résolues par titre: " + JSON.stringify({ rCamag, rCouv, rMarge }) + " (sur " + items.length + " mesures)"); + console.log("measures résolues par titre: " + JSON.stringify({ rCamag, rMarge }) + " (sur " + items.length + " mesures)"); const obj = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "cf-net" }, qHyperCubeDef: { qDimensions: [{ qLibraryId: dim }], - qMeasures: [{ qLibraryId: mca }, { qLibraryId: mqte }, { qLibraryId: mnb }, { qLibraryId: rCamag }, { qLibraryId: rCouv }, { qLibraryId: rMarge }], + qMeasures: [{ qLibraryId: mca }, { qLibraryId: mqte }, { qLibraryId: mnb }, { qLibraryId: rCamag }, { qLibraryId: rMarge }], qInitialDataFetch: [], } } }, doc); const oh = (obj.qReturn as { qHandle: number }).qHandle; - const layout = await rpc("GetLayout", {}, oh); - const size = ((layout.qLayout as { qHyperCube: { qSize: { qcy: number } } }).qHyperCube).qSize.qcy; - console.log("cube qSize.qcy=" + size + " lignes"); const out: Array> = []; const PAGE = 1400; - for (let top = 0; top < size; top += PAGE) { - const d = await rpc("GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 7, 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, Number(r[4]?.qNum) || 0, Number(r[5]?.qNum) || 0, Number(r[6]?.qNum) || 0]); - if (matrix.length < PAGE) break; + // Lots de sélection : une grande sélection (>~1500 codes) fait calculer le cube + // sur trop de lignes d'un coup → l'Engine annule (code 15 "Request aborted"). + // On sélectionne par paquets et on accumule. + const BATCH = 1200; + const batches: Array = codes.length + ? Array.from({ length: Math.ceil(codes.length / BATCH) }, (_, i) => codes.slice(i * BATCH, i * BATCH + BATCH)) + : [null]; + for (let b = 0; b < batches.length; b++) { + const batch = batches[b]; + if (batch) { + const sel = await rpc("SelectValues", { qFieldValues: batch.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh); + console.log("lot " + (b + 1) + "/" + batches.length + " — SelectValues " + batch.length + " codes → " + JSON.stringify(sel.qReturn)); + } + const layout = await rpc("GetLayout", {}, oh); + const size = ((layout.qLayout as { qHyperCube: { qSize: { qcy: number } } }).qHyperCube).qSize.qcy; + for (let top = 0; top < size; top += PAGE) { + const d = await rpc("GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, 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, Number(r[4]?.qNum) || 0, Number(r[5]?.qNum) || 0]); + if (matrix.length < PAGE) break; + } } ws.close(); - resolve({ ok: true, rows: out, size }); + resolve({ ok: true, rows: out, size: out.length }); } 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, mcamag: cfg.measCaMagId, mcouv: cfg.measCouvQteId, mmarge: cfg.measMargePctId, codes: codeCentraux ?? [], token: csrfToken }, + { app: cfg.appNetwork, dim: cfg.dimCodeArticleId, mca: cfg.measCaId, mqte: cfg.measQteId, mnb: cfg.measNbMagId, mcamag: cfg.measCaMagId, mmarge: cfg.measMargePctId, codes: codeCentraux ?? [], token: csrfToken }, )) as InPageResult; if (!result.ok) throw new Error(`[qlik-pw] ${result.error}`); @@ -156,12 +167,12 @@ 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, caParMagasinReseau: Number(r[4]) || 0, couvertureStockReseau: Number(r[5]) || 0, margePctReseau: Number(r[6]) || 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, margePctReseau: Number(r[5]) || 0 }); } console.log(`[qlik-pw] ${out.size} produits réseau (cube ${result.size})`); // Échantillon brut pour vérifier que les 4 nouvelles mesures (cols 4-7) renvoient des valeurs const sample = (result.rows ?? []).slice(0, 3).map((r) => ({ - code: r[0], ca: r[1], qte: r[2], nbMag: r[3], caMag: r[4], couv: r[5], margePct: r[6], + code: r[0], ca: r[1], qte: r[2], nbMag: r[3], caMag: r[4], margePct: r[5], })); console.log(`[qlik-pw] échantillon lignes:`, JSON.stringify(sample)); return out; diff --git a/src/types/grid.ts b/src/types/grid.ts index 1bc32a9..fe48b4f 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -115,8 +115,6 @@ export interface ProductRow { 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 présence réseau = nbMagasinsReseau / 270 */