mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat(qlik): drop network rupture% metric and the "Sans vente 6 mois" grid tab
Stock Rupture % N saturated at 100% for high-presence products (degenerate at the article x network grain), so remove it end to end: config id, NetworkMetric, hypercube (width 8->7), cache, schema, enrich and grid column. db-init drops the column on existing DBs. Also remove the "Sans vente 6 mois" tab from the grid page: the grid now renders directly without the tab switcher; delete the NoSalesTab component. (The separate Stock negatif page keeps its own sans-vente tabs.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
af93e362e0
commit
524faf4a7a
10 files changed
+32
-303
No files matched your search
+2
-3
@@ -105,7 +105,6 @@ async function main() {
|
|||||||
"ca_par_magasin_reseau" numeric(16, 2),
|
"ca_par_magasin_reseau" numeric(16, 2),
|
||||||
"couverture_stock_reseau" numeric(12, 3),
|
"couverture_stock_reseau" numeric(12, 3),
|
||||||
"marge_pct_reseau" numeric(8, 4),
|
"marge_pct_reseau" numeric(8, 4),
|
||||||
"rupture_pct_reseau" numeric(8, 4),
|
|
||||||
"periode" varchar(20),
|
"periode" varchar(20),
|
||||||
"fetched_at" timestamp DEFAULT now()
|
"fetched_at" timestamp DEFAULT now()
|
||||||
);
|
);
|
||||||
@@ -115,8 +114,8 @@ async function main() {
|
|||||||
ALTER TABLE "qlik_network_metrics"
|
ALTER TABLE "qlik_network_metrics"
|
||||||
ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2),
|
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 "couverture_stock_reseau" numeric(12, 3),
|
||||||
ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4),
|
ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4);
|
||||||
ADD COLUMN IF NOT EXISTS "rupture_pct_reseau" numeric(8, 4);
|
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau";
|
||||||
`);
|
`);
|
||||||
console.log("[DB Init] Table qlik_network_metrics is verified/created.");
|
console.log("[DB Init] Table qlik_network_metrics is verified/created.");
|
||||||
|
|
||||||
|
|||||||
@@ -123,8 +123,6 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
|
|||||||
couvertureStockReseau: numeric("couverture_stock_reseau", { precision: 12, scale: 3 }),
|
couvertureStockReseau: numeric("couverture_stock_reseau", { precision: 12, scale: 3 }),
|
||||||
/** Taux de marge réseau (ratio brut Qlik) */
|
/** Taux de marge réseau (ratio brut Qlik) */
|
||||||
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
|
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") */
|
/** Période couverte (libre, ex "12m" ou "2025") */
|
||||||
periode: varchar("periode", { length: 20 }),
|
periode: varchar("periode", { length: 20 }),
|
||||||
/** Dernière synchro depuis Qlik */
|
/** Dernière synchro depuis Qlik */
|
||||||
|
|||||||
@@ -486,7 +486,6 @@ async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Pr
|
|||||||
product.caParMagasinReseau = m.caParMagasinReseau;
|
product.caParMagasinReseau = m.caParMagasinReseau;
|
||||||
product.couvertureStockReseau = m.couvertureStockReseau;
|
product.couvertureStockReseau = m.couvertureStockReseau;
|
||||||
product.margePctReseau = m.margePctReseau;
|
product.margePctReseau = m.margePctReseau;
|
||||||
product.rupturePctReseau = m.rupturePctReseau;
|
|
||||||
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
||||||
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-
|
|||||||
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
||||||
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
|
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
|
||||||
import { ExportDropdown } from "@/features/grid/components/export-dropdown";
|
import { ExportDropdown } from "@/features/grid/components/export-dropdown";
|
||||||
import { NoSalesTab, countNoSalesIn6Months } from "@/features/grid/components/no-sales-tab";
|
|
||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
||||||
import type { ProductRow } from "@/types/grid";
|
import type { ProductRow } from "@/types/grid";
|
||||||
@@ -44,15 +43,12 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
|||||||
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
|
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||||
const [activeTab, setActiveTab] = useState<"grid" | "no-sales">("grid");
|
|
||||||
const [isLoadingRows, setIsLoadingRows] = useState(false);
|
const [isLoadingRows, setIsLoadingRows] = useState(false);
|
||||||
const [rowsLoaded, setRowsLoaded] = useState(0);
|
const [rowsLoaded, setRowsLoaded] = useState(0);
|
||||||
const [totalRows, setTotalRows] = useState<number | null>(null);
|
const [totalRows, setTotalRows] = useState<number | null>(null);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [showStartOverlay, setShowStartOverlay] = useState(false);
|
const [showStartOverlay, setShowStartOverlay] = useState(false);
|
||||||
|
|
||||||
const noSalesCount = useMemo(() => countNoSalesIn6Months(rows), [rows]);
|
|
||||||
|
|
||||||
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
|
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
|
||||||
const { save, hasDrafts, count } = useSaveDrafts(magasin, visibleCodeins);
|
const { save, hasDrafts, count } = useSaveDrafts(magasin, visibleCodeins);
|
||||||
|
|
||||||
@@ -234,68 +230,26 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
|||||||
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
|
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab switcher */}
|
{/* Bulk toolbar (contextual) */}
|
||||||
<div className="shrink-0 flex gap-0 print:hidden" style={{ borderBottom: "1px solid var(--border)" }}>
|
<div className="shrink-0 print:hidden">
|
||||||
{(["grid", "no-sales"] as const).map(tab => {
|
<BulkActionToolbar
|
||||||
const isActive = activeTab === tab;
|
selectedCodeins={selectedCodeins}
|
||||||
return (
|
onClearSelection={() => setSelectedCodeins([])}
|
||||||
<button
|
/>
|
||||||
key={tab}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className="flex items-center gap-1.5 px-4 py-2 text-[13px] font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
color: isActive ? "var(--text-primary)" : "var(--text-muted)",
|
|
||||||
borderBottom: isActive ? "2px solid var(--accent)" : "2px solid transparent",
|
|
||||||
marginBottom: "-1px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tab === "grid" ? "Assortiment" : "Sans vente 6 mois"}
|
|
||||||
{tab === "no-sales" && noSalesCount > 0 && (
|
|
||||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-bold"
|
|
||||||
style={{
|
|
||||||
background: isActive ? "var(--accent)" : "var(--bg-elevated)",
|
|
||||||
color: isActive ? "white" : "var(--text-secondary)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{noSalesCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bulk toolbar (contextual) — uniquement sur l'onglet grille */}
|
|
||||||
{activeTab === "grid" && (
|
|
||||||
<div className="shrink-0 print:hidden">
|
|
||||||
<BulkActionToolbar
|
|
||||||
selectedCodeins={selectedCodeins}
|
|
||||||
onClearSelection={() => setSelectedCodeins([])}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
{activeTab === "grid" ? (
|
<div className="flex-1 min-h-0 min-w-0">
|
||||||
<div className="flex-1 min-h-0 min-w-0">
|
<HeatmapGrid
|
||||||
<HeatmapGrid
|
onSelectionChange={setSelectedCodeins}
|
||||||
onSelectionChange={setSelectedCodeins}
|
isAdmin={isAdmin}
|
||||||
isAdmin={isAdmin}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 min-h-0 min-w-0 flex flex-col">
|
|
||||||
<NoSalesTab />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Summary bar — uniquement sur l'onglet grille */}
|
{/* Summary bar */}
|
||||||
{activeTab === "grid" && (
|
<div className="print:hidden">
|
||||||
<div className="print:hidden">
|
<FloatingSummaryBar />
|
||||||
<FloatingSummaryBar />
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Overlay de démarrage premium (Glassmorphism semi-bloquant) */}
|
{/* Overlay de démarrage premium (Glassmorphism semi-bloquant) */}
|
||||||
{showStartOverlay && isLoadingRows && (
|
{showStartOverlay && isLoadingRows && (
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisi
|
|||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell: Cell<ProductRow, unknown>) => {
|
{row.getVisibleCells().map((cell: Cell<ProductRow, unknown>) => {
|
||||||
const isFlexible = cell.column.id === "libelle1" || cell.column.id === "libelle3";
|
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 === "rupturePctReseau" || 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 === "tauxPresenceReseau" || cell.column.id === "gamme";
|
||||||
const size = cell.column.getSize();
|
const size = cell.column.getSize();
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
@@ -605,23 +605,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: "rupturePctReseau",
|
|
||||||
header: () => <div className="text-center w-full">Rupt. %<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
|
||||||
size: 75,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue<number | undefined>();
|
|
||||||
if (val == null) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
|
||||||
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 (
|
|
||||||
<div className={cn("text-center font-bold text-[12px] tabular-nums", color)}>
|
|
||||||
{pct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}%
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...MONTHS_12.map((monthKey) => ({
|
...MONTHS_12.map((monthKey) => ({
|
||||||
id: `month_${monthKey}`,
|
id: `month_${monthKey}`,
|
||||||
header: () => <div className="text-center w-full">{formatMonthLabel(monthKey)}</div>,
|
header: () => <div className="text-center w-full">{formatMonthLabel(monthKey)}</div>,
|
||||||
@@ -840,7 +823,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
<tr key={headerGroup.id} className="flex w-full">
|
<tr key={headerGroup.id} className="flex w-full">
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
const isFlexible = header.column.id === "libelle1" || header.column.id === "libelle3";
|
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 === "rupturePctReseau" || 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 === "tauxPresenceReseau" || header.column.id === "gamme";
|
||||||
const size = header.getSize();
|
const size = header.getSize();
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo, useRef } from "react";
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
|
||||||
import type { ProductRow } from "@/types/grid";
|
|
||||||
import { PackageX } from "lucide-react";
|
|
||||||
|
|
||||||
function getNoSalesIn6Months(rows: ProductRow[]): ProductRow[] {
|
|
||||||
const sixMonthsAgo = new Date();
|
|
||||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
|
||||||
|
|
||||||
return rows.filter(row => {
|
|
||||||
if (row.derniereVente) {
|
|
||||||
return new Date(row.derniereVente) < sixMonthsAgo;
|
|
||||||
}
|
|
||||||
// Fallback : calcul depuis sales12m
|
|
||||||
const salesMonths = Object.entries(row.sales12m || {})
|
|
||||||
.filter(([, qty]) => qty > 0)
|
|
||||||
.map(([m]) => m)
|
|
||||||
.sort();
|
|
||||||
if (salesMonths.length === 0) return true;
|
|
||||||
const last = salesMonths[salesMonths.length - 1];
|
|
||||||
const lastDate = new Date(parseInt(last.slice(0, 4)), parseInt(last.slice(4, 6)) - 1, 1);
|
|
||||||
return lastDate < sixMonthsAgo;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso?: string): string {
|
|
||||||
if (!iso) return "—";
|
|
||||||
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function daysSince(iso?: string): number | null {
|
|
||||||
if (!iso) return null;
|
|
||||||
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCa(ca: number): string {
|
|
||||||
return ca.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const GAMME_COLORS: Record<string, string> = {
|
|
||||||
A: "bg-emerald-500/15 text-emerald-700",
|
|
||||||
B: "bg-blue-500/15 text-blue-700",
|
|
||||||
C: "bg-amber-500/15 text-amber-700",
|
|
||||||
Y: "bg-purple-500/15 text-purple-700",
|
|
||||||
Z: "bg-red-500/15 text-red-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NoSalesTab() {
|
|
||||||
const rows = useGridStore(s => s.rows);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const result = getNoSalesIn6Months(rows);
|
|
||||||
// Tri : produits les plus anciens en premier, puis par stock décroissant
|
|
||||||
return result.sort((a, b) => {
|
|
||||||
const da = a.derniereVente ? new Date(a.derniereVente).getTime() : 0;
|
|
||||||
const db = b.derniereVente ? new Date(b.derniereVente).getTime() : 0;
|
|
||||||
if (da !== db) return da - db;
|
|
||||||
return (b.stockActuel ?? 0) - (a.stockActuel ?? 0);
|
|
||||||
});
|
|
||||||
}, [rows]);
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/incompatible-library
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
|
||||||
count: filtered.length,
|
|
||||||
getScrollElement: () => scrollRef.current,
|
|
||||||
estimateSize: () => 36,
|
|
||||||
overscan: 12,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center justify-center text-sm italic" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Chargement des données en cours…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-3" style={{ color: "var(--text-muted)" }}>
|
|
||||||
<PackageX className="w-10 h-10 opacity-30" />
|
|
||||||
<p className="text-sm italic">Aucun produit sans vente depuis 6 mois.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
|
||||||
{/* Résumé */}
|
|
||||||
<div className="shrink-0 px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2"
|
|
||||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
<PackageX className="w-4 h-4 shrink-0" style={{ color: "var(--accent)" }} />
|
|
||||||
<span>
|
|
||||||
<span className="font-bold" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{filtered.length.toLocaleString("fr-FR")}
|
|
||||||
</span>
|
|
||||||
{" "}produit{filtered.length > 1 ? "s" : ""} sans vente depuis plus de 6 mois
|
|
||||||
{" "}({rows.length.toLocaleString("fr-FR")} références au total)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tableau */}
|
|
||||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
|
||||||
<div className="min-w-[900px] text-[12px]">
|
|
||||||
<div className="sticky top-0 z-10 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px]"
|
|
||||||
style={{ background: "var(--bg-elevated)", borderBottom: "1px solid var(--border)" }}
|
|
||||||
>
|
|
||||||
{["Code", "Libellé", "Réf.", "Gamme", "Dernière vente", "Jours", "Stock", "CA 12m", "Mag."].map(col => (
|
|
||||||
<div key={col}
|
|
||||||
className="px-3 py-2 text-left font-semibold whitespace-nowrap"
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{col}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ height: rowVirtualizer.getTotalSize(), position: "relative" }}>
|
|
||||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
|
||||||
const row = filtered[virtualRow.index];
|
|
||||||
const i = virtualRow.index;
|
|
||||||
const days = daysSince(row.derniereVente);
|
|
||||||
const gamme = row.codeGamme?.trim().toUpperCase() ?? "—";
|
|
||||||
const gammeClass = GAMME_COLORS[gamme] ?? "bg-zinc-500/15 text-zinc-600";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={row.codein}
|
|
||||||
className="absolute left-0 right-0 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px] items-center transition-colors hover:bg-white/5"
|
|
||||||
style={{
|
|
||||||
height: `${virtualRow.size}px`,
|
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
|
||||||
borderBottom: i < filtered.length - 1 ? "1px solid var(--border)" : undefined,
|
|
||||||
background: i % 2 === 0 ? "transparent" : "var(--bg-subtle, rgba(0,0,0,0.02))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="px-3 py-1.5 font-mono" style={{ color: "var(--text-muted)" }}>{row.codein}</div>
|
|
||||||
<div className="px-3 py-1.5 truncate" style={{ color: "var(--text-primary)" }} title={row.libelle1}>{row.libelle1}</div>
|
|
||||||
<div className="px-3 py-1.5 font-mono text-[11px]" style={{ color: "var(--text-muted)" }}>{row.reference || "—"}</div>
|
|
||||||
<div className="px-3 py-1.5">
|
|
||||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-bold ${gammeClass}`}>
|
|
||||||
{gamme}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 whitespace-nowrap" style={{ color: row.derniereVente ? "var(--text-secondary)" : "var(--text-muted)" }}>
|
|
||||||
{formatDate(row.derniereVente)}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-right font-medium tabular-nums"
|
|
||||||
style={{ color: days && days > 365 ? "var(--color-red, #ef4444)" : "var(--text-secondary)" }}>
|
|
||||||
{days != null ? days.toLocaleString("fr-FR") : "—"}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{row.stockActuel != null ? row.stockActuel.toLocaleString("fr-FR") : "—"}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{row.totalCa > 0 ? formatCa(row.totalCa) : "—"}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-right tabular-nums font-semibold"
|
|
||||||
style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{row.nbMagasinsReseau != null ? row.nbMagasinsReseau : "—"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compte les produits sans vente depuis 6 mois — utilisé pour le badge dans le switcher */
|
|
||||||
export function countNoSalesIn6Months(rows: ProductRow[]): number {
|
|
||||||
return getNoSalesIn6Months(rows).length;
|
|
||||||
}
|
|
||||||
@@ -51,7 +51,6 @@ export interface QlikConfig {
|
|||||||
measCaMagId: string; // master measure "CA par Magasin N"
|
measCaMagId: string; // master measure "CA par Magasin N"
|
||||||
measCouvQteId: string; // master measure "Couverture de stock Quantite N"
|
measCouvQteId: string; // master measure "Couverture de stock Quantite N"
|
||||||
measMargePctId: string; // master measure "Marge % N"
|
measMargePctId: string; // master measure "Marge % N"
|
||||||
measRuptPctId: string; // master measure "Stock Rupture % N"
|
|
||||||
tlsInsecure: boolean;
|
tlsInsecure: boolean;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
}
|
}
|
||||||
@@ -76,7 +75,6 @@ export function getQlikConfig(): QlikConfig {
|
|||||||
measCaMagId: process.env.QLIK_MEAS_CAMAG_ID ?? "16778c00-a4e0-41c7-ab1c-179810d78172",
|
measCaMagId: process.env.QLIK_MEAS_CAMAG_ID ?? "16778c00-a4e0-41c7-ab1c-179810d78172",
|
||||||
measCouvQteId: process.env.QLIK_MEAS_COUVQTE_ID ?? "dbdf957d-a380-495f-bdc4-3f7b6688f3b5",
|
measCouvQteId: process.env.QLIK_MEAS_COUVQTE_ID ?? "dbdf957d-a380-495f-bdc4-3f7b6688f3b5",
|
||||||
measMargePctId: process.env.QLIK_MEAS_MARGEPCT_ID ?? "ea1e8feb-49ba-4eb3-8db6-72dcfb4c1222",
|
measMargePctId: process.env.QLIK_MEAS_MARGEPCT_ID ?? "ea1e8feb-49ba-4eb3-8db6-72dcfb4c1222",
|
||||||
measRuptPctId: process.env.QLIK_MEAS_RUPTPCT_ID ?? "a60a5c1a-5966-484c-9771-dbe55bd83a3c",
|
|
||||||
tlsInsecure: (process.env.QLIK_TLS_INSECURE ?? "true") === "true",
|
tlsInsecure: (process.env.QLIK_TLS_INSECURE ?? "true") === "true",
|
||||||
timeoutMs: Number(process.env.QLIK_TIMEOUT_MS ?? "60000"),
|
timeoutMs: Number(process.env.QLIK_TIMEOUT_MS ?? "60000"),
|
||||||
};
|
};
|
||||||
@@ -93,8 +91,6 @@ export interface NetworkMetric {
|
|||||||
couvertureStockReseau: number;
|
couvertureStockReseau: number;
|
||||||
/** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */
|
/** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */
|
||||||
margePctReseau: number;
|
margePctReseau: number;
|
||||||
/** Taux de rupture réseau (ratio brut Qlik, ex 0.05 = 5%) */
|
|
||||||
rupturePctReseau: number;
|
|
||||||
periode?: string;
|
periode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,9 +246,8 @@ function hyperCubeDef(cfg: QlikConfig, height: number) {
|
|||||||
{ qLibraryId: cfg.measCaMagId },
|
{ qLibraryId: cfg.measCaMagId },
|
||||||
{ qLibraryId: cfg.measCouvQteId },
|
{ qLibraryId: cfg.measCouvQteId },
|
||||||
{ qLibraryId: cfg.measMargePctId },
|
{ qLibraryId: cfg.measMargePctId },
|
||||||
{ qLibraryId: cfg.measRuptPctId },
|
|
||||||
],
|
],
|
||||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 8, qHeight: height }],
|
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 7, qHeight: height }],
|
||||||
qSuppressZero: false,
|
qSuppressZero: false,
|
||||||
qSuppressMissing: true,
|
qSuppressMissing: true,
|
||||||
},
|
},
|
||||||
@@ -275,7 +270,7 @@ export async function fetchNetworkMetrics(
|
|||||||
const openRes = await conn.rpc("OpenDoc", { qDocName: cfg.appNetwork });
|
const openRes = await conn.rpc("OpenDoc", { qDocName: cfg.appNetwork });
|
||||||
const docHandle = (((openRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number) ?? 1;
|
const docHandle = (((openRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number) ?? 1;
|
||||||
|
|
||||||
const pageHeight = 1250; // 8 colonnes * 1250 = 10000 cellules max
|
const pageHeight = 1400; // 7 colonnes * 1400 = 9800 cellules max
|
||||||
const createRes = await conn.rpc("CreateSessionObject", { qProp: hyperCubeDef(cfg, pageHeight) }, docHandle);
|
const createRes = await conn.rpc("CreateSessionObject", { qProp: hyperCubeDef(cfg, pageHeight) }, docHandle);
|
||||||
const objHandle = (((createRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number);
|
const objHandle = (((createRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number);
|
||||||
|
|
||||||
@@ -283,7 +278,7 @@ export async function fetchNetworkMetrics(
|
|||||||
for (;;) {
|
for (;;) {
|
||||||
const dataRes = await conn.rpc(
|
const dataRes = await conn.rpc(
|
||||||
"GetHyperCubeData",
|
"GetHyperCubeData",
|
||||||
{ qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 8, qHeight: pageHeight }] },
|
{ qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 7, qHeight: pageHeight }] },
|
||||||
objHandle,
|
objHandle,
|
||||||
);
|
);
|
||||||
const pages = (dataRes.qDataPages as Array<{ qMatrix?: Matrix }>) ?? [];
|
const pages = (dataRes.qDataPages as Array<{ qMatrix?: Matrix }>) ?? [];
|
||||||
@@ -302,7 +297,6 @@ export async function fetchNetworkMetrics(
|
|||||||
caParMagasinReseau: Number(row[4]?.qNum ?? 0) || 0,
|
caParMagasinReseau: Number(row[4]?.qNum ?? 0) || 0,
|
||||||
couvertureStockReseau: Number(row[5]?.qNum ?? 0) || 0,
|
couvertureStockReseau: Number(row[5]?.qNum ?? 0) || 0,
|
||||||
margePctReseau: Number(row[6]?.qNum ?? 0) || 0,
|
margePctReseau: Number(row[6]?.qNum ?? 0) || 0,
|
||||||
rupturePctReseau: Number(row[7]?.qNum ?? 0) || 0,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (matrix.length < pageHeight) break;
|
if (matrix.length < pageHeight) break;
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export interface NetworkMetricCached {
|
|||||||
caParMagasinReseau: number;
|
caParMagasinReseau: number;
|
||||||
couvertureStockReseau: number;
|
couvertureStockReseau: number;
|
||||||
margePctReseau: number;
|
margePctReseau: number;
|
||||||
rupturePctReseau: number;
|
|
||||||
fetchedAt: string | null;
|
fetchedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +42,6 @@ export async function getNetworkMetricsByCodeCentrale(
|
|||||||
caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0,
|
caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0,
|
||||||
couvertureStockReseau: Number(r.couvertureStockReseau ?? 0) || 0,
|
couvertureStockReseau: Number(r.couvertureStockReseau ?? 0) || 0,
|
||||||
margePctReseau: Number(r.margePctReseau ?? 0) || 0,
|
margePctReseau: Number(r.margePctReseau ?? 0) || 0,
|
||||||
rupturePctReseau: Number(r.rupturePctReseau ?? 0) || 0,
|
|
||||||
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
|
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -62,7 +60,6 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
|
|||||||
caParMagasinReseau: String(m.caParMagasinReseau),
|
caParMagasinReseau: String(m.caParMagasinReseau),
|
||||||
couvertureStockReseau: String(m.couvertureStockReseau),
|
couvertureStockReseau: String(m.couvertureStockReseau),
|
||||||
margePctReseau: String(m.margePctReseau),
|
margePctReseau: String(m.margePctReseau),
|
||||||
rupturePctReseau: String(m.rupturePctReseau),
|
|
||||||
periode: m.periode ?? null,
|
periode: m.periode ?? null,
|
||||||
fetchedAt: now,
|
fetchedAt: now,
|
||||||
}));
|
}));
|
||||||
@@ -84,7 +81,6 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
|
|||||||
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
|
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
|
||||||
couvertureStockReseau: sql`excluded.couverture_stock_reseau`,
|
couvertureStockReseau: sql`excluded.couverture_stock_reseau`,
|
||||||
margePctReseau: sql`excluded.marge_pct_reseau`,
|
margePctReseau: sql`excluded.marge_pct_reseau`,
|
||||||
rupturePctReseau: sql`excluded.rupture_pct_reseau`,
|
|
||||||
periode: sql`excluded.periode`,
|
periode: sql`excluded.periode`,
|
||||||
fetchedAt: sql`excluded.fetched_at`,
|
fetchedAt: sql`excluded.fetched_at`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export async function fetchNetworkMetricsPlaywright(
|
|||||||
console.log(`[qlik-pw] csrf-token capturé, ouverture du websocket Engine in-page…`);
|
console.log(`[qlik-pw] csrf-token capturé, ouverture du websocket Engine in-page…`);
|
||||||
|
|
||||||
const result = (await page.evaluate(
|
const result = (await page.evaluate(
|
||||||
({ 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 }) =>
|
({ app, dim, mca, mqte, mnb, mcamag, mcouv, mmarge, codes, token }: { app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mcouv: string; mmarge: string; codes: string[]; token: string }) =>
|
||||||
new Promise<InPageResult>((resolve) => {
|
new Promise<InPageResult>((resolve) => {
|
||||||
const loc = (window as unknown as { location: Location }).location;
|
const loc = (window as unknown as { location: Location }).location;
|
||||||
const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`;
|
const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`;
|
||||||
@@ -121,24 +121,10 @@ export async function fetchNetworkMetricsPlaywright(
|
|||||||
const rCamag = pickMeasure("CA par Magasin N", mcamag);
|
const rCamag = pickMeasure("CA par Magasin N", mcamag);
|
||||||
const rCouv = pickMeasure("Couverture de stock Quantité N", mcouv);
|
const rCouv = pickMeasure("Couverture de stock Quantité N", mcouv);
|
||||||
const rMarge = pickMeasure("Marge % N", mmarge);
|
const rMarge = pickMeasure("Marge % N", mmarge);
|
||||||
const rRupt = pickMeasure("Stock Rupture % N", mrupt);
|
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, rCouv, rMarge, rRupt }) + " (sur " + items.length + " mesures)");
|
|
||||||
// Lit l'expression réelle d'une master measure (GetMeasure → GetProperties).
|
|
||||||
const measExpr = async (qId: string): Promise<string> => {
|
|
||||||
try {
|
|
||||||
const gm = await rpc("GetMeasure", { qId }, doc);
|
|
||||||
const h = (gm.qReturn as { qHandle: number }).qHandle;
|
|
||||||
const p = await rpc("GetProperties", {}, h);
|
|
||||||
const def = (p.qProp as { qMeasure?: { qDef?: { qDef?: string; qLabel?: string } } })?.qMeasure?.qDef;
|
|
||||||
return def?.qDef ?? def?.qLabel ?? JSON.stringify(def ?? "n/a");
|
|
||||||
} catch (e) { return "err:" + String((e as Error)?.message || e); }
|
|
||||||
};
|
|
||||||
console.log("expr rupture (" + rRupt + "): " + await measExpr(rRupt));
|
|
||||||
console.log("expr couverture (" + rCouv + "): " + await measExpr(rCouv));
|
|
||||||
console.log("expr camag (" + rCamag + "): " + await measExpr(rCamag));
|
|
||||||
const obj = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "cf-net" }, qHyperCubeDef: {
|
const obj = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "cf-net" }, qHyperCubeDef: {
|
||||||
qDimensions: [{ qLibraryId: dim }],
|
qDimensions: [{ qLibraryId: dim }],
|
||||||
qMeasures: [{ qLibraryId: mca }, { qLibraryId: mqte }, { qLibraryId: mnb }, { qLibraryId: rCamag }, { qLibraryId: rCouv }, { qLibraryId: rMarge }, { qLibraryId: rRupt }],
|
qMeasures: [{ qLibraryId: mca }, { qLibraryId: mqte }, { qLibraryId: mnb }, { qLibraryId: rCamag }, { qLibraryId: rCouv }, { qLibraryId: rMarge }],
|
||||||
qInitialDataFetch: [],
|
qInitialDataFetch: [],
|
||||||
} } }, doc);
|
} } }, doc);
|
||||||
const oh = (obj.qReturn as { qHandle: number }).qHandle;
|
const oh = (obj.qReturn as { qHandle: number }).qHandle;
|
||||||
@@ -146,12 +132,12 @@ export async function fetchNetworkMetricsPlaywright(
|
|||||||
const size = ((layout.qLayout as { qHyperCube: { qSize: { qcy: number } } }).qHyperCube).qSize.qcy;
|
const size = ((layout.qLayout as { qHyperCube: { qSize: { qcy: number } } }).qHyperCube).qSize.qcy;
|
||||||
console.log("cube qSize.qcy=" + size + " lignes");
|
console.log("cube qSize.qcy=" + size + " lignes");
|
||||||
const out: Array<Array<string | number>> = [];
|
const out: Array<Array<string | number>> = [];
|
||||||
const PAGE = 1250;
|
const PAGE = 1400;
|
||||||
for (let top = 0; top < size; top += PAGE) {
|
for (let top = 0; top < size; top += PAGE) {
|
||||||
const d = await rpc("GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 8, qHeight: PAGE }] }, oh);
|
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<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
|
const matrix = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
|
||||||
if (!matrix.length) break;
|
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, Number(r[7]?.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]);
|
||||||
if (matrix.length < PAGE) break;
|
if (matrix.length < PAGE) break;
|
||||||
}
|
}
|
||||||
ws.close();
|
ws.close();
|
||||||
@@ -159,7 +145,7 @@ export async function fetchNetworkMetricsPlaywright(
|
|||||||
} catch (e) { try { ws.close(); } catch { /* noop */ } fail(String((e as Error)?.message || e)); }
|
} 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, mrupt: cfg.measRuptPctId, 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, codes: codeCentraux ?? [], token: csrfToken },
|
||||||
)) as InPageResult;
|
)) as InPageResult;
|
||||||
|
|
||||||
if (!result.ok) throw new Error(`[qlik-pw] ${result.error}`);
|
if (!result.ok) throw new Error(`[qlik-pw] ${result.error}`);
|
||||||
@@ -170,12 +156,12 @@ export async function fetchNetworkMetricsPlaywright(
|
|||||||
const code = String(r[0]).trim();
|
const code = String(r[0]).trim();
|
||||||
if (!code || code === "-") continue;
|
if (!code || code === "-") continue;
|
||||||
if (wanted && !wanted.has(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, rupturePctReseau: Number(r[7]) || 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 });
|
||||||
}
|
}
|
||||||
console.log(`[qlik-pw] ${out.size} produits réseau (cube ${result.size})`);
|
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
|
// É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) => ({
|
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], ruptPct: r[7],
|
code: r[0], ca: r[1], qte: r[2], nbMag: r[3], caMag: r[4], couv: r[5], margePct: r[6],
|
||||||
}));
|
}));
|
||||||
console.log(`[qlik-pw] échantillon lignes:`, JSON.stringify(sample));
|
console.log(`[qlik-pw] échantillon lignes:`, JSON.stringify(sample));
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -119,8 +119,6 @@ export interface ProductRow {
|
|||||||
couvertureStockReseau?: number;
|
couvertureStockReseau?: number;
|
||||||
/** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */
|
/** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */
|
||||||
margePctReseau?: number;
|
margePctReseau?: number;
|
||||||
/** Taux de rupture réseau (ratio brut Qlik, ex 0.05 = 5%) */
|
|
||||||
rupturePctReseau?: number;
|
|
||||||
/** Taux de présence réseau = nbMagasinsReseau / 270 */
|
/** Taux de présence réseau = nbMagasinsReseau / 270 */
|
||||||
tauxPresenceReseau?: number;
|
tauxPresenceReseau?: number;
|
||||||
/** Fraîcheur des données réseau (ISO) */
|
/** Fraîcheur des données réseau (ISO) */
|
||||||
|
|||||||
Reference in new issue
Block a user