mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-21 14:08:08 +02:00
feat(grille): tendance réseau (sparkline) + modal ventes réseau par mois
Le sync Qlik calculait déjà les ventes réseau mois par mois mais les
sommait en un total. On conserve désormais le détail mensuel.
- Sync : accumulation additive qteByMonth { code: { 'YYYY-MM': qté } }
in-page (chemins rapide + par lots), rattachée au métrique réseau.
Reset propre en cas de repli code 15. Total inchangé.
- Stockage : colonne jsonb qte_by_month sur qlik_network_metrics
(schema + db-init ALTER), upsert + lecture.
- ProductRow.qteReseauByMonth propagé jusqu'à la grille.
- UI grille :
· colonne '% Présence réseau' remplacée par 'Tendance réseau' :
sparkline 12 mois teintée + flèche + variation % (moyenne 4 derniers
mois vs 4 précédents), triable ;
· cellule 'Nb magasins' rendue cliquable → modal des ventes réseau
mois par mois (barres, 12 derniers mois).
Nécessite un nouveau Sync Qlik pour peupler le détail mensuel (les
données déjà en cache n'ont que le total → modal 'pas encore de détail').
This commit is contained in:
8 files changed
+185
-21
No files matched your search
+2
-1
@@ -112,7 +112,8 @@ async function main() {
|
|||||||
await tempPool.query(`
|
await tempPool.query(`
|
||||||
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 "marge_pct_reseau" numeric(8, 4);
|
ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4),
|
||||||
|
ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb;
|
||||||
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau";
|
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau";
|
||||||
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
|
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
|
|||||||
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
|
margePctReseau: numeric("marge_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 }),
|
||||||
|
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */
|
||||||
|
qteByMonth: jsonb("qte_by_month"),
|
||||||
/** Dernière synchro depuis Qlik */
|
/** Dernière synchro depuis Qlik */
|
||||||
fetchedAt: timestamp("fetched_at").defaultNow(),
|
fetchedAt: timestamp("fetched_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -514,6 +514,7 @@ async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Pr
|
|||||||
product.caParMagasinReseau = m.caParMagasinReseau;
|
product.caParMagasinReseau = m.caParMagasinReseau;
|
||||||
product.margePctReseau = m.margePctReseau;
|
product.margePctReseau = m.margePctReseau;
|
||||||
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
||||||
|
product.qteReseauByMonth = m.qteByMonth ?? null;
|
||||||
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle } from "lucide-react";
|
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
@@ -96,7 +96,7 @@ const QLIK_NETWORK_COLUMN_IDS = new Set<string>([
|
|||||||
"caReseau",
|
"caReseau",
|
||||||
"qteReseau",
|
"qteReseau",
|
||||||
"nbMagasinsReseau",
|
"nbMagasinsReseau",
|
||||||
"tauxPresenceReseau",
|
"tendanceReseau",
|
||||||
"caParMagasinReseau",
|
"caParMagasinReseau",
|
||||||
"margePctReseau",
|
"margePctReseau",
|
||||||
]);
|
]);
|
||||||
@@ -238,6 +238,129 @@ interface CellDetailData {
|
|||||||
receptions: number | null;
|
receptions: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tendance réseau (à partir de qteReseauByMonth) ───────────────────────────
|
||||||
|
type NetworkTrend = {
|
||||||
|
values: number[]; // quantités mensuelles, ordre chronologique
|
||||||
|
labels: string[]; // "YYYY-MM"
|
||||||
|
direction: "up" | "down" | "flat";
|
||||||
|
pct: number | null; // variation moyenne 4 derniers mois vs 4 précédents
|
||||||
|
hasData: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
|
||||||
|
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
|
||||||
|
if (!qteByMonth) return empty;
|
||||||
|
const labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
|
||||||
|
if (labels.length === 0) return empty;
|
||||||
|
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
|
||||||
|
const n = values.length;
|
||||||
|
let pct: number | null = null;
|
||||||
|
let direction: NetworkTrend["direction"] = "flat";
|
||||||
|
if (n >= 2) {
|
||||||
|
const w = Math.max(1, Math.min(4, Math.floor(n / 2)));
|
||||||
|
const mean = (a: number[]) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0);
|
||||||
|
const lastM = mean(values.slice(n - w));
|
||||||
|
const prevM = mean(values.slice(n - 2 * w, n - w));
|
||||||
|
if (prevM > 0) {
|
||||||
|
pct = (lastM - prevM) / prevM;
|
||||||
|
direction = pct > 0.1 ? "up" : pct < -0.1 ? "down" : "flat";
|
||||||
|
} else if (lastM > 0) {
|
||||||
|
direction = "up"; // apparition (rien avant, ventes maintenant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { values, labels, direction, pct, hasData: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||||
|
up: "#22c55e",
|
||||||
|
down: "#ef4444",
|
||||||
|
flat: "#94a3b8",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtMonthYYYYMM(lab: string): string {
|
||||||
|
const [y, m] = lab.split("-");
|
||||||
|
const names = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."];
|
||||||
|
const idx = Number(m) - 1;
|
||||||
|
return `${names[idx] ?? m} ${(y ?? "").slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
|
||||||
|
function TrendSparkline({ trend }: { trend: NetworkTrend }) {
|
||||||
|
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||||
|
const { values, direction, pct } = trend;
|
||||||
|
const color = TREND_COLOR[direction];
|
||||||
|
const W = 46, H = 18, n = values.length;
|
||||||
|
const max = Math.max(...values, 1), min = Math.min(...values, 0);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const pts = values.map((v, i) => {
|
||||||
|
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
|
||||||
|
const y = H - 1 - ((v - min) / range) * (H - 2);
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
}).join(" ");
|
||||||
|
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null ? `${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% (4 derniers mois vs 4 précédents)` : "tendance réseau"}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
|
||||||
|
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
|
||||||
|
{n === 1 && <circle cx={W / 2} cy={H / 2} r={2} fill={color} />}
|
||||||
|
</svg>
|
||||||
|
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
{pct != null && (
|
||||||
|
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
|
||||||
|
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modal : quantités vendues réseau sur les 12 derniers mois, mois par mois. */
|
||||||
|
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
|
||||||
|
const trend = computeNetworkTrend(row.qteReseauByMonth);
|
||||||
|
const { values, labels, direction, pct } = trend;
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
const color = TREND_COLOR[direction];
|
||||||
|
return (
|
||||||
|
<DialogContent className="max-w-md" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{row.libelle1}
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Ventes réseau · 12 derniers mois
|
||||||
|
{row.nbMagasinsReseau != null && <> · <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{row.nbMagasinsReseau} magasins</span></>}
|
||||||
|
{pct != null && <> · <span className="font-bold" style={{ color }}>{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%</span></>}
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
{!trend.hasData ? (
|
||||||
|
<div className="mt-3 rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
|
||||||
|
Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 mt-2 max-h-[60vh] overflow-y-auto pr-1">
|
||||||
|
{labels.map((lab, i) => {
|
||||||
|
const v = values[i];
|
||||||
|
const w = Math.max(2, (v / max) * 100);
|
||||||
|
return (
|
||||||
|
<div key={lab} className="flex items-center gap-2">
|
||||||
|
<span className="w-[64px] text-[11px] tabular-nums shrink-0" style={{ color: "var(--text-muted)" }}>{fmtMonthYYYYMM(lab)}</span>
|
||||||
|
<div className="flex-1 h-4 rounded overflow-hidden" style={{ background: "var(--bg-elevated)" }}>
|
||||||
|
<div className="h-full rounded" style={{ width: `${w}%`, background: "var(--accent)" }} />
|
||||||
|
</div>
|
||||||
|
<span className="w-[52px] text-right text-[12px] font-bold tabular-nums shrink-0" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{Math.round(v).toLocaleString("fr-FR")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; activeMagasin: string; onClose: () => void }) {
|
function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; activeMagasin: string; onClose: () => void }) {
|
||||||
const hasReceptions = (d.receptions ?? 0) > 0;
|
const hasReceptions = (d.receptions ?? 0) > 0;
|
||||||
const storeLabel = activeMagasin === "TOTAL" ? "Total réseau" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin);
|
const storeLabel = activeMagasin === "TOTAL" ? "Total réseau" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin);
|
||||||
@@ -328,6 +451,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
|
|
||||||
// État pour le détail d'une cellule mensuelle (modal)
|
// État pour le détail d'une cellule mensuelle (modal)
|
||||||
const [cellDetail, setCellDetail] = useState<CellDetailData | null>(null);
|
const [cellDetail, setCellDetail] = useState<CellDetailData | null>(null);
|
||||||
|
const [networkModal, setNetworkModal] = useState<ProductRow | null>(null);
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client
|
// Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client
|
||||||
@@ -561,29 +685,30 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
size: 80,
|
size: 80,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const val = row.original.nbMagasinsReseau;
|
const val = row.original.nbMagasinsReseau;
|
||||||
|
if (val == null) return <div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||||
|
// Cliquable → modal des ventes réseau par mois (12 derniers mois).
|
||||||
return (
|
return (
|
||||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
<button
|
||||||
{val != null ? val : "-"}
|
type="button"
|
||||||
</div>
|
onClick={() => setNetworkModal(row.original)}
|
||||||
|
title="Voir les ventes réseau mois par mois"
|
||||||
|
className="w-full text-center tabular-nums text-[12px] font-bold underline decoration-dotted decoration-1 underline-offset-2 hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
{val}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tauxPresenceReseau",
|
id: "tendanceReseau",
|
||||||
accessorFn: (row) => getQlikNetworkSortValue(row.tauxPresenceReseau, "tauxPresenceReseau", sorting),
|
accessorFn: (row) => {
|
||||||
header: () => <div className="text-center w-full">% Prés.<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
const t = computeNetworkTrend(row.qteReseauByMonth);
|
||||||
size: 70,
|
return t.hasData && t.pct != null ? t.pct : Number.NEGATIVE_INFINITY;
|
||||||
cell: ({ row }) => {
|
|
||||||
const val = row.original.tauxPresenceReseau;
|
|
||||||
if (val == null) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
|
||||||
const pct = Math.round(val * 100);
|
|
||||||
const color = pct >= 66 ? "text-emerald-500" : pct >= 33 ? "text-amber-500" : "text-rose-500";
|
|
||||||
return (
|
|
||||||
<div className={cn("text-center font-black text-[13px] tabular-nums", color)}>
|
|
||||||
{pct}%
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
header: () => <div className="text-center w-full">Tendance<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||||
|
size: 78,
|
||||||
|
cell: ({ row }) => <TrendSparkline trend={computeNetworkTrend(row.original.qteReseauByMonth)} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "caParMagasinReseau",
|
id: "caParMagasinReseau",
|
||||||
@@ -903,6 +1028,9 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
<Dialog open={cellDetail !== null} onOpenChange={(open) => { if (!open) setCellDetail(null); }}>
|
<Dialog open={cellDetail !== null} onOpenChange={(open) => { if (!open) setCellDetail(null); }}>
|
||||||
{cellDetail !== null && <CellDetailModal d={cellDetail!} activeMagasin={activeMagasin} onClose={() => setCellDetail(null)} />}
|
{cellDetail !== null && <CellDetailModal d={cellDetail!} activeMagasin={activeMagasin} onClose={() => setCellDetail(null)} />}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<Dialog open={networkModal !== null} onOpenChange={(open) => { if (!open) setNetworkModal(null); }}>
|
||||||
|
{networkModal !== null && <NetworkMonthlyModal row={networkModal} onClose={() => setNetworkModal(null)} />}
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -89,6 +89,8 @@ export interface NetworkMetric {
|
|||||||
/** 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;
|
||||||
periode?: string;
|
periode?: string;
|
||||||
|
/** Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. */
|
||||||
|
qteByMonth?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeXrfkey(): string {
|
function makeXrfkey(): string {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface NetworkMetricCached {
|
|||||||
nbMagasinsReseau: number;
|
nbMagasinsReseau: number;
|
||||||
caParMagasinReseau: number;
|
caParMagasinReseau: number;
|
||||||
margePctReseau: number;
|
margePctReseau: number;
|
||||||
|
/** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */
|
||||||
|
qteByMonth: Record<string, number> | null;
|
||||||
fetchedAt: string | null;
|
fetchedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ export async function getNetworkMetricsByCodeCentrale(
|
|||||||
nbMagasinsReseau: Number(r.nbMagasinsReseau ?? 0) || 0,
|
nbMagasinsReseau: Number(r.nbMagasinsReseau ?? 0) || 0,
|
||||||
caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0,
|
caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0,
|
||||||
margePctReseau: Number(r.margePctReseau ?? 0) || 0,
|
margePctReseau: Number(r.margePctReseau ?? 0) || 0,
|
||||||
|
qteByMonth: (r.qteByMonth as Record<string, number> | null) ?? null,
|
||||||
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
|
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -58,6 +61,7 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
|
|||||||
caParMagasinReseau: String(m.caParMagasinReseau),
|
caParMagasinReseau: String(m.caParMagasinReseau),
|
||||||
margePctReseau: String(m.margePctReseau),
|
margePctReseau: String(m.margePctReseau),
|
||||||
periode: m.periode ?? null,
|
periode: m.periode ?? null,
|
||||||
|
qteByMonth: m.qteByMonth ?? null,
|
||||||
fetchedAt: now,
|
fetchedAt: now,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -78,6 +82,7 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
|
|||||||
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
|
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
|
||||||
margePctReseau: sql`excluded.marge_pct_reseau`,
|
margePctReseau: sql`excluded.marge_pct_reseau`,
|
||||||
periode: sql`excluded.periode`,
|
periode: sql`excluded.periode`,
|
||||||
|
qteByMonth: sql`excluded.qte_by_month`,
|
||||||
fetchedAt: sql`excluded.fetched_at`,
|
fetchedAt: sql`excluded.fetched_at`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ async function getBrowser(): Promise<Browser> {
|
|||||||
interface InPageResult {
|
interface InPageResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
rows?: Array<Array<string | number>>;
|
rows?: Array<Array<string | number>>;
|
||||||
|
/** Quantité réseau par code central et par mois : { code: { "YYYY-MM": qté } }. */
|
||||||
|
monthly?: Record<string, Record<string, number>>;
|
||||||
size?: number;
|
size?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
diag?: Record<string, unknown>;
|
diag?: Record<string, unknown>;
|
||||||
@@ -253,6 +255,18 @@ export async function fetchNetworkMetricsPlaywright(
|
|||||||
// causé par la réutilisation du même objet sur des centaines de recalculs.
|
// causé par la réutilisation du même objet sur des centaines de recalculs.
|
||||||
|
|
||||||
const out: Array<Array<string | number>> = [];
|
const out: Array<Array<string | number>> = [];
|
||||||
|
// Quantité réseau par code central et par mois (label "YYYY-MM").
|
||||||
|
// Accumulée en parallèle du total, sans toucher l'agrégation existante.
|
||||||
|
const monthlyByCode: Record<string, Record<string, number>> = {};
|
||||||
|
const accMonthly = (batchRows: Array<Array<string | number>>, monthLabel: string) => {
|
||||||
|
for (const r of batchRows) {
|
||||||
|
const c = String(r[0] ?? "").trim();
|
||||||
|
if (!c || c === "-") continue;
|
||||||
|
const q = Number(r[2]) || 0;
|
||||||
|
(monthlyByCode[c] ??= {});
|
||||||
|
monthlyByCode[c][monthLabel] = (monthlyByCode[c][monthLabel] ?? 0) + q;
|
||||||
|
}
|
||||||
|
};
|
||||||
// Compteurs agrégés pour le résumé timing final (loggué côté Node après la sync).
|
// Compteurs agrégés pour le résumé timing final (loggué côté Node après la sync).
|
||||||
let aggMonths = 0;
|
let aggMonths = 0;
|
||||||
let aggBatches = 0;
|
let aggBatches = 0;
|
||||||
@@ -366,6 +380,7 @@ export async function fetchNetworkMetricsPlaywright(
|
|||||||
const batchRows: Array<Array<string | number>> = [];
|
const batchRows: Array<Array<string | number>> = [];
|
||||||
const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, monthTimings, () => { code15Retries++; }), monthTimings);
|
const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, monthTimings, () => { code15Retries++; }), monthTimings);
|
||||||
out.push(...batchRows);
|
out.push(...batchRows);
|
||||||
|
accMonthly(batchRows, m.label);
|
||||||
aggBatches++;
|
aggBatches++;
|
||||||
aggCode15Retries += code15Retries;
|
aggCode15Retries += code15Retries;
|
||||||
console.log("[qlik-pw][timing] " + prefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(monthTimings));
|
console.log("[qlik-pw][timing] " + prefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(monthTimings));
|
||||||
@@ -381,6 +396,7 @@ export async function fetchNetworkMetricsPlaywright(
|
|||||||
if (isEngineCode15(e)) {
|
if (isEngineCode15(e)) {
|
||||||
console.log("[qlik-pw] chemin rapide échoué (code 15) → repli sur le chemin par lots");
|
console.log("[qlik-pw] chemin rapide échoué (code 15) → repli sur le chemin par lots");
|
||||||
out.length = savedLen;
|
out.length = savedLen;
|
||||||
|
for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k];
|
||||||
aggMonths = 0; aggBatches = 0; aggCode15Retries = 0;
|
aggMonths = 0; aggBatches = 0; aggCode15Retries = 0;
|
||||||
try { await rpc("Clear", {}, dfh); if (fh !== -1) await rpc("Clear", {}, fh); } catch { /* noop */ }
|
try { await rpc("Clear", {}, dfh); if (fh !== -1) await rpc("Clear", {}, fh); } catch { /* noop */ }
|
||||||
await sleep(qlikSettleMs);
|
await sleep(qlikSettleMs);
|
||||||
@@ -469,6 +485,7 @@ export async function fetchNetworkMetricsPlaywright(
|
|||||||
const batchRows: Array<Array<string | number>> = [];
|
const batchRows: Array<Array<string | number>> = [];
|
||||||
const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings);
|
const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings);
|
||||||
out.push(...batchRows);
|
out.push(...batchRows);
|
||||||
|
accMonthly(batchRows, m.label);
|
||||||
aggBatches++;
|
aggBatches++;
|
||||||
aggCode15Retries += code15Retries;
|
aggCode15Retries += code15Retries;
|
||||||
console.log("[qlik-pw][timing] " + batchPrefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
|
console.log("[qlik-pw][timing] " + batchPrefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
|
||||||
@@ -508,7 +525,7 @@ export async function fetchNetworkMetricsPlaywright(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
resolve({ ok: true, rows: out, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount });
|
resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount });
|
||||||
} 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)); }
|
||||||
})();
|
})();
|
||||||
}),
|
}),
|
||||||
@@ -577,6 +594,12 @@ export async function fetchNetworkMetricsPlaywright(
|
|||||||
prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0;
|
prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0;
|
||||||
margeWeight.set(code, nextWeight);
|
margeWeight.set(code, nextWeight);
|
||||||
}
|
}
|
||||||
|
// Rattache la quantité mensuelle (par code central) au métrique agrégé.
|
||||||
|
const monthly = result.monthly ?? {};
|
||||||
|
for (const [code, metric] of out) {
|
||||||
|
const mm = monthly[code];
|
||||||
|
if (mm && Object.keys(mm).length) metric.qteByMonth = mm;
|
||||||
|
}
|
||||||
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) => ({
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ export interface ProductRow {
|
|||||||
margePctReseau?: number;
|
margePctReseau?: number;
|
||||||
/** Taux de présence réseau = nbMagasinsReseau / 270 */
|
/** Taux de présence réseau = nbMagasinsReseau / 270 */
|
||||||
tauxPresenceReseau?: number;
|
tauxPresenceReseau?: number;
|
||||||
|
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal). */
|
||||||
|
qteReseauByMonth?: Record<string, number> | null;
|
||||||
/** Fraîcheur des données réseau (ISO) */
|
/** Fraîcheur des données réseau (ISO) */
|
||||||
networkFetchedAt?: string;
|
networkFetchedAt?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user