mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-20 21:48:33 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad5da8f95d | ||
|
|
8e37940040 | ||
|
|
48860271a5 |
No files matched your search
@@ -480,6 +480,8 @@ export function SynchronisationClient() {
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Ce sont des chiffres mensuels : les refaire chaque nuit coûterait du temps pour rien.
|
||||
Sont aussi refaits, sans attendre ce délai : les données d'avant le mois en cours
|
||||
(le dernier mois leur manque) et une extraction en échec, retentée la nuit suivante.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -227,7 +227,7 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
|
||||
() => computeNetworkTrend(reseau?.qteByMonth ?? null, nbMagByMonth),
|
||||
[reseau, nbMagByMonth],
|
||||
);
|
||||
const magasinsParMois = useMemo(() => computeStoresSeries(nbMagByMonth), [nbMagByMonth]);
|
||||
const magasinsParMois = useMemo(() => computeStoresSeries(nbMagByMonth, trend.labels), [nbMagByMonth, trend.labels]);
|
||||
const comparatif = useMemo(() => computeComparatif(fiche), [fiche]);
|
||||
const verdict = indiceVerdict(comparatif.indiceQte);
|
||||
|
||||
|
||||
@@ -140,19 +140,36 @@ async function prochainSql(depuis: Date) {
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Délai avant de retenter un fournisseur Qlik en échec : la nuit suivante. */
|
||||
const QLIK_REPRISE_ECHEC_MS = 18 * 3600 * 1000;
|
||||
|
||||
/**
|
||||
* Le plus ancien fournisseur Qlik dont les données dépassent `qlikMinJours`.
|
||||
* Le seuil en jours, plutôt que la fenêtre courante, évite de relancer chaque
|
||||
* nuit une extraction de plusieurs minutes pour des agrégats mensuels.
|
||||
* Le plus ancien fournisseur Qlik à rafraîchir. Il l'est si :
|
||||
* - ses données dépassent `qlikMinJours` (agrégats mensuels : inutile de
|
||||
* relancer chaque nuit une extraction de plusieurs minutes) ;
|
||||
* - ou elles datent d'avant le mois en cours : la fenêtre de 12 mois a glissé,
|
||||
* son dernier mois manque, sa tendance n'est plus à jour ;
|
||||
* - ou sa dernière extraction a échoué il y a plus de 18 h. Un échec attendait
|
||||
* jusqu'ici `qlikMinJours` (7 jours) avant un nouvel essai, alors que la
|
||||
* plupart sont passagers (serveur Qlik chargé, dernier mois pas encore
|
||||
* chargé). Le plus ancien passant en premier, un fournisseur qui échoue à
|
||||
* chaque fois repart en fin de file et ne bloque personne.
|
||||
*/
|
||||
async function prochainQlik(qlikMinJours: number, maintenant: Date) {
|
||||
const seuil = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000);
|
||||
const seuilAge = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000);
|
||||
const debutMois = new Date(maintenant.getFullYear(), maintenant.getMonth(), 1);
|
||||
const seuil = seuilAge > debutMois ? seuilAge : debutMois;
|
||||
const seuilEchec = new Date(maintenant.getTime() - QLIK_REPRISE_ECHEC_MS);
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(syncFournisseurs)
|
||||
.where(and(
|
||||
eq(syncFournisseurs.actifQlik, true),
|
||||
or(isNull(syncFournisseurs.dernierQlikAt), lt(syncFournisseurs.dernierQlikAt, seuil)) as SQL,
|
||||
or(
|
||||
isNull(syncFournisseurs.dernierQlikAt),
|
||||
lt(syncFournisseurs.dernierQlikAt, seuil),
|
||||
and(eq(syncFournisseurs.dernierQlikStatut, "echec"), lt(syncFournisseurs.dernierQlikAt, seuilEchec)),
|
||||
) as SQL,
|
||||
))
|
||||
.orderBy(asc(sql`${syncFournisseurs.dernierQlikAt} nulls first`))
|
||||
.limit(1);
|
||||
|
||||
@@ -217,11 +217,17 @@ async function serveFromSnapshot(input: GetProductRowsInput, cacheKey: string):
|
||||
if (!snapshot || snapshot.rows.length === 0) return null;
|
||||
|
||||
const { rows } = snapshot;
|
||||
const [changes] = await Promise.all([
|
||||
// Les colonnes réseau de l'instantané datent de son calcul : on les relit dans
|
||||
// le cache Qlik, comme le calcul en direct. Sans cela, une synchro Qlik faite
|
||||
// depuis (au changement de mois notamment) restait invisible jusqu'au
|
||||
// prochain recalcul, et la tendance réseau disparaissait.
|
||||
const [changes, network] = await Promise.all([
|
||||
loadLatestSnapshotChanges(codeFournisseur),
|
||||
fetchNetworkMetrics(rows.map((r) => r.codeCentrale ?? "")),
|
||||
refreshGammeInit(rows, codeFournisseur),
|
||||
]);
|
||||
applySnapshotChanges(rows, changes);
|
||||
enrichWithNetworkMetrics(rows, network);
|
||||
|
||||
if (magasin !== "TOTAL") {
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
@@ -545,7 +551,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
}
|
||||
|
||||
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
|
||||
enrichWithNetworkMetrics(productMap, await networkPromise);
|
||||
enrichWithNetworkMetrics(productMap.values(), await networkPromise);
|
||||
|
||||
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
|
||||
// La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle.
|
||||
@@ -753,11 +759,11 @@ async function fetchNetworkMetrics(
|
||||
* n'est pas encore disponible.
|
||||
*/
|
||||
function enrichWithNetworkMetrics(
|
||||
productMap: Map<string, ProductRow>,
|
||||
products: Iterable<ProductRow>,
|
||||
metrics: Map<string, NetworkMetricCached> | null,
|
||||
): void {
|
||||
if (!metrics || metrics.size === 0) return;
|
||||
for (const product of productMap.values()) {
|
||||
for (const product of products) {
|
||||
const m = product.codeCentrale ? metrics.get(product.codeCentrale) : undefined;
|
||||
if (!m) continue;
|
||||
product.caReseau = m.caReseau;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type FilterFn,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus, CalendarRange, Tag, Eye, EyeOff } from "lucide-react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, Warehouse, AlertTriangle, CalendarRange, Tag, Eye, EyeOff } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -26,8 +26,8 @@ import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { GammeSelect } from "@/features/grid/components/gamme-select";
|
||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||
import { TuileStat } from "@/features/grid/components/stat-tile";
|
||||
import { ProductMonthlyModal } from "@/features/grid/components/product-monthly-modal";
|
||||
import { NetworkMonthlyModal } from "@/features/grid/components/network-monthly-modal";
|
||||
import { AssortmentGapsModal, PROFONDEUR_MAX, type LigneClassee } from "@/features/grid/components/assortment-gaps-modal";
|
||||
import type { ProductRow, GammeCode } from "@/types/grid";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -41,12 +41,9 @@ import {
|
||||
} from "@/features/grid/lib/months";
|
||||
import {
|
||||
computeNetworkTrend,
|
||||
computeStoresSeries,
|
||||
trendLabel,
|
||||
TREND_COLOR,
|
||||
NB_MAGASINS_RESEAU,
|
||||
} from "@/features/grid/lib/network-trend";
|
||||
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||
import { TrendSparkline } from "@/features/grid/components/network-charts";
|
||||
import type { NetworkTrend } from "@/features/grid/lib/network-trend";
|
||||
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
|
||||
import { CLASSES_SANS_GAMME, trouverGamme } from "@/lib/gammes";
|
||||
@@ -553,77 +550,6 @@ interface CellDetailData {
|
||||
receptions: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte « tendance réseau » : le verdict en tuiles, puis les trois séries
|
||||
* mensuelles en petits multiples.
|
||||
*
|
||||
* La couleur de tendance (vert / rouge / gris) ne vit QUE dans la tuile de
|
||||
* verdict, accompagnée d'une flèche et d'un libellé : un état ne se signale
|
||||
* jamais par la couleur seule. Les courbes, elles, portent des teintes
|
||||
* d'identité — sans quoi la même couleur voudrait dire deux choses.
|
||||
*/
|
||||
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
|
||||
const trend = tendanceDe(row);
|
||||
const { values, labels, direction, pct } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
const magasins = computeStoresSeries(row.nbMagReseauByMonth);
|
||||
const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
|
||||
const dernier = values.length - 1;
|
||||
const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const verdict = trend.nouveau
|
||||
? "Nouveau"
|
||||
: pct != null
|
||||
? `${pct >= 0 ? "+" : ""}${Math.round(pct * 100)} %`
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-2xl" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle1}
|
||||
</DialogTitle>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Réseau · 12 mois glissants, mois en cours exclu
|
||||
</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="flex flex-wrap gap-2 mt-1">
|
||||
<TuileStat
|
||||
label="Tendance"
|
||||
valeur={verdict}
|
||||
indice={trend.surQteParMagasin
|
||||
? `${trendLabel(pct, trend.nouveau)} · sur la qté/magasin`
|
||||
: `${trendLabel(pct, trend.nouveau)} · sur les volumes (magasins inconnus)`}
|
||||
couleur={color}
|
||||
icone={Fleche}
|
||||
/>
|
||||
{trend.perStore && (
|
||||
<TuileStat
|
||||
label="Qté / magasin"
|
||||
valeur={fmt(trend.perStore[dernier], trend.perStore[dernier] < 10 ? 1 : 0)}
|
||||
indice={`dernier mois · ${fmt(trend.perStore.reduce((t, v) => t + v, 0) / trend.perStore.length, 1)} en moyenne`}
|
||||
/>
|
||||
)}
|
||||
<TuileStat
|
||||
label="Magasins vendeurs"
|
||||
valeur={magasins ? fmt(magasins.values[dernier]) : fmt(row.nbMagasinsReseau ?? 0)}
|
||||
indice={`sur ${NB_MAGASINS_RESEAU} du réseau`}
|
||||
/>
|
||||
</div>
|
||||
<NetworkLineChart labels={labels} values={values} stores={magasins?.values} perStore={trend.perStore} />
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; activeMagasin: string; onClose: () => void }) {
|
||||
const hasReceptions = (d.receptions ?? 0) > 0;
|
||||
const storeLabel = activeMagasin === "TOTAL" ? "Total réseau" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin);
|
||||
@@ -1505,7 +1431,14 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
|
||||
{cellDetail !== null && <CellDetailModal d={cellDetail!} activeMagasin={activeMagasin} onClose={() => setCellDetail(null)} />}
|
||||
</Dialog>
|
||||
<Dialog open={networkModal !== null} onOpenChange={(open) => { if (!open) setNetworkModal(null); }}>
|
||||
{networkModal !== null && <NetworkMonthlyModal row={networkModal} onClose={() => setNetworkModal(null)} />}
|
||||
{networkModal !== null && (
|
||||
<NetworkMonthlyModal
|
||||
row={networkModal}
|
||||
trend={tendanceDe(networkModal)}
|
||||
mois={MONTHS_12}
|
||||
onClose={() => setNetworkModal(null)}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
{/* Trous d'assortiment sur le haut du classement affiché */}
|
||||
<Dialog open={gapsOpen} onOpenChange={setGapsOpen}>
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Carte « tendance réseau », ouverte depuis la colonne Tendance.
|
||||
*
|
||||
* Trois étages, dans l'ordre où on se pose les questions :
|
||||
* 1. la lecture en une phrase (le produit progresse-t-il dans le réseau ?) ;
|
||||
* 2. le réseau : verdict, rythme par magasin, diffusion, puis les courbes ;
|
||||
* 3. nos magasins : stock, ventes, couverture, comparés au rythme du réseau.
|
||||
*
|
||||
* Sans le troisième étage, la carte disait comment le produit marche AILLEURS
|
||||
* sans dire où on en est CHEZ NOUS — il fallait rouvrir un autre détail pour
|
||||
* savoir s'il restait du stock.
|
||||
*
|
||||
* La couleur de tendance (vert / rouge / gris) ne vit que dans la tuile de
|
||||
* verdict et la colonne « vs réseau », toujours accompagnée d'un signe ou d'un
|
||||
* libellé : un état ne se signale jamais par la couleur seule.
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
import { TrendingUp, TrendingDown, Minus, Store, Warehouse } from "lucide-react";
|
||||
import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { TuileStat } from "@/features/grid/components/stat-tile";
|
||||
import { NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||
import {
|
||||
computeStoresSeries,
|
||||
trendLabel,
|
||||
TREND_COLOR,
|
||||
TREND_FLAT,
|
||||
TREND_WINDOW,
|
||||
NB_MAGASINS_RESEAU,
|
||||
type NetworkTrend,
|
||||
} from "@/features/grid/lib/network-trend";
|
||||
import { formatDate, formatMonthLabel, qlikMonthToFf } from "@/features/grid/lib/months";
|
||||
import { MAGASINS, LIBELLE_TOUS_MAGASINS } from "@/lib/magasins";
|
||||
import { fmtEur0 } from "@/lib/format";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
/** Une décimale sous 10, aucune au-delà : « 2,4 » mais « 37 ». */
|
||||
const fmtRythme = (v: number) => fmt(v, Math.abs(v) < 10 ? 1 : 0);
|
||||
const moyenne = (xs: number[]) => (xs.length ? xs.reduce((t, v) => t + v, 0) / xs.length : 0);
|
||||
const pctTexte = (p: number) => `${p >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(p * 100)))} %`;
|
||||
|
||||
/** Mois de stock au rythme des ventes ; sans vente, le ratio n'existe pas et on le dit. */
|
||||
function couvertureTexte(stock: number, parMois: number): string {
|
||||
if (parMois <= 0) return stock > 0 ? "sans vente" : "—";
|
||||
const c = stock / parMois;
|
||||
if (c < 1) return "< 1 mois";
|
||||
if (c > 24) return "> 24 mois";
|
||||
return `${fmt(c, c < 10 ? 1 : 0)} mois`;
|
||||
}
|
||||
|
||||
interface LigneMagasin {
|
||||
cle: string;
|
||||
nom: string;
|
||||
stock: number;
|
||||
ventes: number;
|
||||
parMois: number;
|
||||
derniereEntree?: string;
|
||||
/** Écart à la moyenne réseau par magasin, `null` si non comparable. */
|
||||
vsReseau: number | null;
|
||||
total?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nos magasins, un par ligne, puis l'ensemble.
|
||||
*
|
||||
* « vs réseau » compare nos ventes mensuelles moyennes à celles d'un magasin
|
||||
* vendeur moyen du réseau sur la même période : c'est la seule assiette
|
||||
* commune (le réseau n'a pas de stock par magasin à nous offrir).
|
||||
*/
|
||||
function lignesMagasins(row: ProductRow, mois: string[], moyenneReseau: number | null): LigneMagasin[] {
|
||||
const dernier = mois[mois.length - 1];
|
||||
const ecart = (parMois: number) =>
|
||||
moyenneReseau != null && moyenneReseau > 0 ? parMois / moyenneReseau - 1 : null;
|
||||
|
||||
const lignes: LigneMagasin[] = MAGASINS.map((m) => {
|
||||
const ventes = mois.reduce((t, k) => t + (row.sales12mByStore?.[m.code]?.[k] ?? 0), 0);
|
||||
const parMois = ventes / mois.length;
|
||||
return {
|
||||
cle: m.code,
|
||||
nom: m.nom,
|
||||
stock: row.stock12mByStore?.[m.code]?.[dernier] ?? 0,
|
||||
ventes,
|
||||
parMois,
|
||||
derniereEntree: row.derniereLivraisonByStore?.[m.code],
|
||||
vsReseau: ventes > 0 ? ecart(parMois) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const ventes = mois.reduce((t, k) => t + (row.sales12m?.[k] ?? 0), 0);
|
||||
lignes.push({
|
||||
cle: "TOTAL",
|
||||
nom: LIBELLE_TOUS_MAGASINS,
|
||||
stock: row.stock12m?.[dernier] ?? lignes.reduce((t, l) => t + l.stock, 0),
|
||||
ventes,
|
||||
parMois: ventes / mois.length,
|
||||
derniereEntree: row.derniereLivraison,
|
||||
// Comparer deux magasins cumulés à UN magasin réseau n'aurait pas de sens.
|
||||
vsReseau: null,
|
||||
total: true,
|
||||
});
|
||||
return lignes;
|
||||
}
|
||||
|
||||
function TableauNosMagasins({ lignes, finPeriode }: { lignes: LigneMagasin[]; finPeriode: string }) {
|
||||
const entete = "px-2.5 py-2 font-semibold whitespace-nowrap";
|
||||
return (
|
||||
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[13px] tabular-nums" style={{ minWidth: 560 }} aria-label="Stock et ventes de nos magasins">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)", color: "var(--text-secondary)" }}>
|
||||
<th className={`${entete} text-left`}>Magasin</th>
|
||||
<th className={`${entete} text-right`} title="Stock de fin de mois du dernier mois complet">Stock fin {finPeriode}</th>
|
||||
<th className={`${entete} text-right`}>Ventes 12 m</th>
|
||||
<th className={`${entete} text-right`}>Par mois</th>
|
||||
<th className={`${entete} text-right`} title="Combien de mois le stock tient au rythme des 12 derniers mois">Couverture</th>
|
||||
<th className={`${entete} text-right`} title="Nos ventes par mois comparées à celles d'un magasin vendeur moyen du réseau">vs réseau</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lignes.map((l) => {
|
||||
const couleurEcart = l.vsReseau == null || Math.abs(l.vsReseau) <= TREND_FLAT
|
||||
? "var(--text-secondary)"
|
||||
: TREND_COLOR[l.vsReseau > 0 ? "up" : "down"];
|
||||
return (
|
||||
<tr
|
||||
key={l.cle}
|
||||
style={{
|
||||
borderTop: `1px solid ${l.total ? "var(--border-strong)" : "var(--border)"}`,
|
||||
background: l.total ? "var(--bg-elevated)" : undefined,
|
||||
}}
|
||||
>
|
||||
<td className="px-2.5 py-2 whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
|
||||
<div className={l.total ? "font-bold" : "font-semibold"}>{l.nom}</div>
|
||||
<div className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
{l.derniereEntree ? `dernière entrée ${formatDate(l.derniereEntree)}` : "aucune entrée enregistrée"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right font-bold" style={{ color: l.stock > 0 ? "var(--text-primary)" : "var(--text-muted)" }}>
|
||||
{fmt(l.stock)}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right" style={{ color: l.ventes !== 0 ? "var(--text-primary)" : "var(--text-muted)" }}>
|
||||
{fmt(l.ventes)}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right" style={{ color: "var(--text-secondary)" }}>
|
||||
{l.ventes !== 0 ? fmtRythme(l.parMois) : "—"}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right" style={{ color: "var(--text-secondary)" }}>
|
||||
{couvertureTexte(l.stock, l.parMois)}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right font-semibold" style={{ color: couleurEcart }}>
|
||||
{l.vsReseau != null ? pctTexte(l.vsReseau) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Titre de section : icône + intitulé + précision en clair. */
|
||||
function TitreSection({ icone: Icone, titre, detail }: {
|
||||
icone: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
titre: string;
|
||||
detail?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<Icone className="w-4 h-4 shrink-0 self-center" style={{ color: "var(--text-muted)" }} />
|
||||
<h3 className="text-[14px] font-bold" style={{ color: "var(--text-primary)" }}>{titre}</h3>
|
||||
{detail && <span className="text-[12px]" style={{ color: "var(--text-muted)" }}>{detail}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* La tendance dite en une phrase, avec ses deux chiffres : on la lit avant
|
||||
* d'avoir à interpréter les tuiles et les courbes.
|
||||
*/
|
||||
function phraseReseau(trend: NetworkTrend): string {
|
||||
if (trend.nouveau) {
|
||||
return `Dans le réseau, le produit est nouveau : aucune vente sur les ${TREND_WINDOW} premiers mois, des ventes sur les ${TREND_WINDOW} derniers.`;
|
||||
}
|
||||
const serie = trend.perStore ?? trend.values;
|
||||
const n = serie.length;
|
||||
const debut = moyenne(serie.slice(0, TREND_WINDOW));
|
||||
const fin = moyenne(serie.slice(n - TREND_WINDOW));
|
||||
const unite = trend.perStore ? "par magasin vendeur et par mois" : "par mois dans tout le réseau";
|
||||
const sens = trend.direction === "up" ? "progresse" : trend.direction === "down" ? "recule" : "est stable";
|
||||
return `Dans le réseau, le produit ${sens} : ${fmtRythme(fin)} unités ${unite} sur les ${TREND_WINDOW} derniers mois, contre ${fmtRythme(debut)} sur les ${TREND_WINDOW} premiers.`;
|
||||
}
|
||||
|
||||
export function NetworkMonthlyModal({ row, trend, mois, onClose }: {
|
||||
row: ProductRow;
|
||||
trend: NetworkTrend;
|
||||
/** Fenêtre des ventes FF de la Grille (clés « YYYYMM »). */
|
||||
mois: string[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { values, labels, direction, pct } = trend;
|
||||
const couleur = TREND_COLOR[direction];
|
||||
const magasins = computeStoresSeries(row.nbMagReseauByMonth, labels);
|
||||
const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
const dernier = values.length - 1;
|
||||
|
||||
const verdict = trend.nouveau ? "Nouveau" : pct != null ? pctTexte(pct) : "—";
|
||||
const moyenneReseau = trend.perStore ? moyenne(trend.perStore) : null;
|
||||
const magasinsDernierMois = magasins ? magasins.values[dernier] : (row.nbMagasinsReseau ?? 0);
|
||||
|
||||
const lignes = lignesMagasins(row, mois, moyenneReseau);
|
||||
const finPeriode = mois.length ? formatMonthLabel(mois[mois.length - 1]) : "";
|
||||
const nous = lignes[lignes.length - 1];
|
||||
|
||||
const periodeReseau = labels.length
|
||||
? `${formatMonthLabel(qlikMonthToFf(labels[0]))} → ${formatMonthLabel(qlikMonthToFf(labels[labels.length - 1]))}`
|
||||
: "12 mois glissants";
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
// Même gabarit que le détail 12 mois : grille à une colonne, sinon le
|
||||
// tableau le plus large dimensionne la fenêtre et déborde.
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-3xl grid-cols-1 max-h-[88vh] overflow-y-auto"
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
||||
onInteractOutside={onClose}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle1}
|
||||
</DialogTitle>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
{row.codein}
|
||||
{row.reference ? ` · réf. ${row.reference}` : ""}
|
||||
{row.codeCentrale ? ` · centrale ${row.codeCentrale}` : ""}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Lecture en clair : réseau, puis nous. */}
|
||||
<div className="rounded-xl px-4 py-3 space-y-1 text-[14px] leading-relaxed" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}>
|
||||
{trend.hasData
|
||||
? <p>{phraseReseau(trend)}</p>
|
||||
: <p style={{ color: "var(--text-muted)" }}>Pas encore de détail mensuel réseau pour ce produit : relancez une synchronisation Qlik.</p>}
|
||||
<p style={{ color: "var(--text-secondary)" }}>
|
||||
Chez nous : {fmt(nous.ventes)} unités vendues en 12 mois, {fmt(nous.stock)} en stock fin {finPeriode}
|
||||
{nous.ventes > 0 ? ` (${couvertureTexte(nous.stock, nous.parMois)} de couverture)` : ""}
|
||||
{row.commandesEnCours ? `, ${fmt(row.commandesEnCours)} en commande` : ""}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{trend.hasData && (
|
||||
<section className="space-y-2">
|
||||
<TitreSection
|
||||
icone={TrendingUp}
|
||||
titre="Réseau"
|
||||
detail={`${NB_MAGASINS_RESEAU} magasins · ${periodeReseau}, mois en cours exclu`}
|
||||
/>
|
||||
{trend.enRetard && (
|
||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
Le dernier mois n'est pas encore synchronisé depuis Qlik : la période s'arrête un mois plus tôt.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TuileStat
|
||||
label="Tendance"
|
||||
valeur={verdict}
|
||||
indice={`${trendLabel(pct, trend.nouveau)} · ${trend.surQteParMagasin ? "sur la qté par magasin" : "sur les volumes"}`}
|
||||
couleur={couleur}
|
||||
icone={Fleche}
|
||||
/>
|
||||
{trend.perStore && moyenneReseau != null && (
|
||||
<TuileStat
|
||||
label="Qté / magasin / mois"
|
||||
valeur={fmtRythme(trend.perStore[dernier])}
|
||||
indice={`dernier mois · ${fmtRythme(moyenneReseau)} en moyenne`}
|
||||
/>
|
||||
)}
|
||||
<TuileStat
|
||||
label="Magasins vendeurs"
|
||||
valeur={fmt(magasinsDernierMois)}
|
||||
indice={`dernier mois · ${fmt((magasinsDernierMois / NB_MAGASINS_RESEAU) * 100)} % du réseau`}
|
||||
/>
|
||||
{row.caReseau != null && row.caReseau > 0 && (
|
||||
<TuileStat
|
||||
label="CA réseau 12 m"
|
||||
valeur={fmtEur0(row.caReseau)}
|
||||
indice={row.margePctReseau ? `marge ${fmt(row.margePctReseau * 100, 1)} %` : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<NetworkLineChart labels={labels} values={values} stores={magasins?.values} perStore={trend.perStore} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="space-y-2">
|
||||
<TitreSection
|
||||
icone={Store}
|
||||
titre="Nos magasins"
|
||||
detail={`stock et ventes sur nos 12 derniers mois${moyenneReseau != null ? ` · réseau : ${fmtRythme(moyenneReseau)} / magasin / mois` : ""}`}
|
||||
/>
|
||||
<TableauNosMagasins lignes={lignes} finPeriode={finPeriode} />
|
||||
{row.stockActuel != null && (
|
||||
<p className="text-[12px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
<Warehouse className="w-3.5 h-3.5 shrink-0" />
|
||||
Stock disponible aujourd'hui, tous magasins : <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{fmt(row.stockActuel)}</span>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,11 @@ export type NetworkTrend = {
|
||||
pct: number | null;
|
||||
/** Rien sur les 4 premiers mois, des ventes sur les 4 derniers. */
|
||||
nouveau: boolean;
|
||||
/**
|
||||
* `true` si la fenêtre s'arrête un mois plus tôt que prévu : le cache Qlik
|
||||
* n'a pas encore été resynchronisé depuis le changement de mois.
|
||||
*/
|
||||
enRetard: boolean;
|
||||
hasData: boolean;
|
||||
};
|
||||
|
||||
@@ -71,6 +76,37 @@ export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||
flat: "#94a3b8",
|
||||
};
|
||||
|
||||
/** Les 12 clés de la fenêtre sont-elles explicitement présentes et chiffrées ? */
|
||||
function fenetreComplete(serie: Record<string, number>, labels: string[]): boolean {
|
||||
return labels.every((label) =>
|
||||
Object.prototype.hasOwnProperty.call(serie, label) &&
|
||||
Number.isFinite(Number(serie[label])),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les 12 mois réellement disponibles dans une série Qlik : la fenêtre du jour,
|
||||
* sinon celle qui s'arrête un mois plus tôt.
|
||||
*
|
||||
* Le repli couvre les jours qui suivent un changement de mois : la fenêtre a
|
||||
* glissé, mais le cache Qlik n'est resynchronisé que fournisseur par
|
||||
* fournisseur, la nuit. Sans lui, toutes les tendances disparaissaient d'un
|
||||
* coup le 1er du mois. Ce sont toujours 12 mois complets et extraits — aucun
|
||||
* mois n'est inventé — simplement décalés d'un cran. `null` si aucune des deux
|
||||
* fenêtres n'est complète.
|
||||
*/
|
||||
export function fenetreDisponible(
|
||||
serie: Record<string, number> | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): { labels: string[]; enRetard: boolean } | null {
|
||||
if (!serie) return null;
|
||||
const labels = buildRolling12QlikMonths(now);
|
||||
if (fenetreComplete(serie, labels)) return { labels, enRetard: false };
|
||||
const precedente = buildRolling12QlikMonths(new Date(now.getFullYear(), now.getMonth() - 1, 1));
|
||||
if (fenetreComplete(serie, precedente)) return { labels: precedente, enRetard: true };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tendance réseau : variation entre les **4 premiers** et les **4 derniers**
|
||||
* mois de la fenêtre de 12 mois glissants, mois en cours exclu.
|
||||
@@ -79,7 +115,8 @@ export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||
* présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance
|
||||
* réellement glissante. Un mois en cours (partiel), ou plus vieux que 12 mois,
|
||||
* présent dans le cache est donc **ignoré** — sinon la pente est faussée par un
|
||||
* mois tronqué.
|
||||
* mois tronqué. Seule tolérance : un cache pas encore resynchronisé depuis le
|
||||
* changement de mois, lu sur ses 12 mois complets (cf. `fenetreDisponible`).
|
||||
*
|
||||
* Une série n'est affichée que si les **12 clés sont explicitement présentes**.
|
||||
* L'extracteur Qlik écrit lui-même les mois sans faits à 0 après avoir validé
|
||||
@@ -129,15 +166,11 @@ export function computeNetworkTrend(
|
||||
): NetworkTrend {
|
||||
const empty: NetworkTrend = {
|
||||
values: [], labels: [], perStore: null, surQteParMagasin: false,
|
||||
direction: "flat", pct: null, nouveau: false, hasData: false,
|
||||
direction: "flat", pct: null, nouveau: false, enRetard: false, hasData: false,
|
||||
};
|
||||
if (!qteByMonth) return empty;
|
||||
const labels = buildRolling12QlikMonths(now);
|
||||
const complet = labels.every((label) =>
|
||||
Object.prototype.hasOwnProperty.call(qteByMonth, label) &&
|
||||
Number.isFinite(Number(qteByMonth[label])),
|
||||
);
|
||||
if (!complet) return empty;
|
||||
const fenetre = fenetreDisponible(qteByMonth, now);
|
||||
if (!qteByMonth || !fenetre) return empty;
|
||||
const { labels, enRetard } = fenetre;
|
||||
|
||||
const values = labels.map((l) => Number(qteByMonth[l]));
|
||||
const n = values.length;
|
||||
@@ -145,7 +178,7 @@ export function computeNetworkTrend(
|
||||
// Quantité par magasin vendeur : l'indicateur qui isole la performance du
|
||||
// produit de sa diffusion. Un mois sans magasin vendeur vaut 0 — c'est le
|
||||
// seul choix cohérent avec une quantité elle-même nulle.
|
||||
const magasins = computeStoresSeries(nbMagByMonth, now);
|
||||
const magasins = computeStoresSeries(nbMagByMonth, labels);
|
||||
const perStore = magasins
|
||||
? values.map((v, i) => (magasins.values[i] > 0 ? v / magasins.values[i] : 0))
|
||||
: null;
|
||||
@@ -172,7 +205,7 @@ export function computeNetworkTrend(
|
||||
direction = "up";
|
||||
}
|
||||
|
||||
return { values, labels, perStore, surQteParMagasin: perStore != null, direction, pct, nouveau, hasData: true };
|
||||
return { values, labels, perStore, surQteParMagasin: perStore != null, direction, pct, nouveau, enRetard, hasData: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,15 +223,11 @@ export function computeNetworkTrend(
|
||||
*/
|
||||
export function computeStoresSeries(
|
||||
nbMagByMonth?: Record<string, number> | null,
|
||||
now: Date = new Date(),
|
||||
/** Mois de la courbe des quantités (`trend.labels`), pour rester alignés. */
|
||||
labels?: string[],
|
||||
): { values: number[]; labels: string[] } | null {
|
||||
if (!nbMagByMonth) return null;
|
||||
const labels = buildRolling12QlikMonths(now);
|
||||
const complet = labels.every((label) =>
|
||||
Object.prototype.hasOwnProperty.call(nbMagByMonth, label) &&
|
||||
Number.isFinite(Number(nbMagByMonth[label])),
|
||||
);
|
||||
if (!complet) return null;
|
||||
if (!nbMagByMonth || !labels || labels.length === 0) return null;
|
||||
if (!fenetreComplete(nbMagByMonth, labels)) return null;
|
||||
return { values: labels.map((l) => Number(nbMagByMonth[l])), labels };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* CollectFlow — Chromium et file d'attente partagés par tous les accès Qlik
|
||||
* (extraction réseau `qlik-playwright.ts`, recherche produit `qlik-search.ts`).
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { chromium, type Browser } from "playwright-core";
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
/**
|
||||
* Chromium partagé entre les extractions.
|
||||
*
|
||||
* Un navigateur tombé (plantage, mémoire, arrêt du processus enfant) restait
|
||||
* mémorisé : toutes les extractions suivantes échouaient sur « Target closed »
|
||||
* jusqu'au redémarrage du serveur. On le relance donc dès qu'il est déconnecté.
|
||||
*/
|
||||
export async function getQlikBrowser(): Promise<Browser> {
|
||||
if (browserPromise) {
|
||||
const existant = await browserPromise.catch(() => null);
|
||||
if (existant?.isConnected()) return existant;
|
||||
browserPromise = null;
|
||||
}
|
||||
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
|
||||
const lancement = chromium.launch({
|
||||
headless: true,
|
||||
executablePath: execPath || undefined,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
|
||||
});
|
||||
browserPromise = lancement;
|
||||
lancement
|
||||
.then((b) => b.on("disconnected", () => {
|
||||
console.warn("[qlik] Chromium déconnecté — il sera relancé à la prochaine extraction");
|
||||
if (browserPromise === lancement) browserPromise = null;
|
||||
}))
|
||||
.catch(() => { if (browserPromise === lancement) browserPromise = null; });
|
||||
return lancement;
|
||||
}
|
||||
|
||||
/**
|
||||
* File unique des extractions.
|
||||
*
|
||||
* Le serveur Qlik abandonne les requêtes dès qu'il est trop sollicité. Une
|
||||
* synchro manuelle lancée pendant la synchro de nuit, ou deux recherches
|
||||
* produit simultanées, ouvraient deux sessions Engine en parallèle — et les
|
||||
* deux échouaient. Les extractions passent désormais l'une après l'autre.
|
||||
*/
|
||||
let fileExtractions: Promise<unknown> = Promise.resolve();
|
||||
let extractionsEnAttente = 0;
|
||||
|
||||
export function dansLaFileQlik<T>(libelle: string, tache: () => Promise<T>): Promise<T> {
|
||||
if (extractionsEnAttente > 0) {
|
||||
console.log(`[qlik] ${libelle} : en attente de ${extractionsEnAttente} extraction(s) en cours`);
|
||||
}
|
||||
extractionsEnAttente++;
|
||||
const resultat = fileExtractions.then(tache, tache);
|
||||
fileExtractions = resultat.then(() => undefined, () => undefined).finally(() => { extractionsEnAttente--; });
|
||||
return resultat;
|
||||
}
|
||||
+27
-16
@@ -13,23 +13,10 @@
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { chromium, type Browser } from "playwright-core";
|
||||
import { getQlikBrowser as getBrowser, dansLaFileQlik as dansLaFile } from "@/lib/qlik-browser";
|
||||
import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric, type QlikMonthMetrics } from "@/lib/qlik-client";
|
||||
import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range";
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
if (!browserPromise) {
|
||||
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
|
||||
browserPromise = chromium.launch({
|
||||
headless: true,
|
||||
executablePath: execPath || undefined,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
|
||||
}).catch((e) => { browserPromise = null; throw e; });
|
||||
}
|
||||
return browserPromise;
|
||||
}
|
||||
import { assainirMetrique, controlerExtraction } from "@/lib/qlik-validation";
|
||||
|
||||
interface InPageResult {
|
||||
ok: boolean;
|
||||
@@ -86,7 +73,17 @@ function uniqueOrdered(values: number[]): number[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function fetchNetworkMetricsPlaywright(
|
||||
export function fetchNetworkMetricsPlaywright(
|
||||
codeCentraux?: string[],
|
||||
cfg: QlikConfig = getQlikConfig(),
|
||||
dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(),
|
||||
fournisseur?: string,
|
||||
): Promise<Map<string, NetworkMetric>> {
|
||||
const libelle = fournisseur ? `fournisseur ${fournisseur}` : `${(codeCentraux ?? []).length} code(s)`;
|
||||
return dansLaFile(libelle, () => extraire(codeCentraux, cfg, dateFilter, fournisseur));
|
||||
}
|
||||
|
||||
async function extraire(
|
||||
codeCentraux?: string[],
|
||||
cfg: QlikConfig = getQlikConfig(),
|
||||
dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(),
|
||||
@@ -2477,6 +2474,20 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
if (exVar) console.log(`[qlik-pw] exemple VARIE ${exVar.codeCentrale}:`, JSON.stringify(exVar.qteByMonth));
|
||||
if (exFlat) console.log(`[qlik-pw] exemple IDENTIQUE ${exFlat.codeCentrale}:`, JSON.stringify(exFlat.qteByMonth));
|
||||
console.log(`[qlik-pw] ${out.size} produits réseau (cube ${result.size})`);
|
||||
|
||||
// Contrôle de plausibilité AVANT toute écriture : des données complètes
|
||||
// mais fausses (dernier mois pas encore chargé, mesure insensible au
|
||||
// mois) laisseraient le cache faux jusqu'à la synchro suivante.
|
||||
const corrections = [...out.values()].flatMap(assainirMetrique);
|
||||
if (corrections.length) {
|
||||
console.warn(`[qlik-pw] ${corrections.length} valeur(s) impossible(s) corrigée(s) : ${corrections.slice(0, 5).join(" ; ")}`);
|
||||
}
|
||||
if (dateFilter) {
|
||||
const controle = controlerExtraction(out.values(), monthRanges.map((m) => m.label));
|
||||
for (const a of controle.avertissements) console.warn(`[qlik-pw][contrôle] ${a}`);
|
||||
if (!controle.ok) throw new Error(`[qlik-pw] ${controle.motif} — cache inchangé`);
|
||||
console.log(`[qlik-pw][contrôle] extraction validée (${out.size} codes, ${monthRanges.length} mois)`);
|
||||
}
|
||||
// É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], margePct: r[5],
|
||||
|
||||
+14
-16
@@ -35,7 +35,7 @@
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { chromium, type Browser } from "playwright-core";
|
||||
import { getQlikBrowser as getBrowser, dansLaFileQlik } from "@/lib/qlik-browser";
|
||||
import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client";
|
||||
import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
||||
|
||||
@@ -112,20 +112,6 @@ const CHAMPS_FOURNISSEUR_PREFERES = ["Fournisseur", "code_fournisseur"];
|
||||
*/
|
||||
const CHAMP_CODE_CENTRALE_ALT = "article_no_centrale";
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
if (!browserPromise) {
|
||||
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
|
||||
browserPromise = chromium.launch({
|
||||
headless: true,
|
||||
executablePath: execPath || undefined,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
|
||||
}).catch((e) => { browserPromise = null; throw e; });
|
||||
}
|
||||
return browserPromise;
|
||||
}
|
||||
|
||||
interface InPageSearchResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
@@ -152,10 +138,22 @@ interface InPageSearchResult {
|
||||
* ensuite extraites par `fetchNetworkMetricsPlaywright`, qui sait déjà produire
|
||||
* le détail mensuel sur 12 mois glissants.
|
||||
*/
|
||||
export async function searchQlikArticles(
|
||||
export function searchQlikArticles(
|
||||
term: string,
|
||||
limit: number = QLIK_SEARCH_MAX_RESULTS,
|
||||
cfg: QlikConfig = getQlikConfig(),
|
||||
): Promise<QlikSearchOutcome> {
|
||||
// Terme trop court : réponse vide immédiate, sans attendre la file.
|
||||
if (term.trim().length < 3) return rechercher(term, limit, cfg);
|
||||
// Même file que les extractions : deux sessions Engine simultanées font
|
||||
// abandonner le serveur Qlik, et les deux requêtes échouent.
|
||||
return dansLaFileQlik(`recherche « ${term.trim()} »`, () => rechercher(term, limit, cfg));
|
||||
}
|
||||
|
||||
async function rechercher(
|
||||
term: string,
|
||||
limit: number,
|
||||
cfg: QlikConfig,
|
||||
): Promise<QlikSearchOutcome> {
|
||||
const cleaned = term.trim();
|
||||
if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null, periode: null };
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* CollectFlow — Contrôles de plausibilité d'une extraction réseau Qlik.
|
||||
*
|
||||
* L'extracteur vérifie déjà que les 12 mois de la fenêtre sont servis. Il ne
|
||||
* peut pas voir deux pièges, observés ou attendus en production, qui donnent
|
||||
* des chiffres faux mais complets :
|
||||
*
|
||||
* 1. **Dernier mois pas encore chargé dans Qlik.** Les premiers jours du mois,
|
||||
* le mois qui vient de se terminer peut n'être chargé qu'en partie. Il est
|
||||
* « couvert » (quelques ventes), donc accepté — et toutes les tendances du
|
||||
* fournisseur plongent d'un coup, jusqu'à la synchro suivante, une semaine
|
||||
* ou plus après.
|
||||
* 2. **Mesure insensible au mois.** Quand une mesure ignore la sélection de
|
||||
* dates, chaque mois reçoit la même valeur (le total de l'année). Le
|
||||
* diagnostic existait dans les journaux ; rien n'empêchait l'écriture.
|
||||
*
|
||||
* Dans les deux cas, garder le cache précédent vaut mieux qu'écrire des
|
||||
* données fausses : l'extraction est refusée, et le planificateur la reprend
|
||||
* une nuit suivante.
|
||||
*
|
||||
* Module pur (aucune dépendance d'exécution) : testable hors serveur.
|
||||
*/
|
||||
|
||||
import type { NetworkMetric } from "@/lib/qlik-client";
|
||||
|
||||
export interface ControleExtraction {
|
||||
ok: boolean;
|
||||
/** Raison du refus, lisible par l'administrateur (statut de synchro). */
|
||||
motif?: string;
|
||||
/** Anomalies corrigées ou tolérées, à journaliser. */
|
||||
avertissements: string[];
|
||||
}
|
||||
|
||||
export interface OptionsControle {
|
||||
/**
|
||||
* Jusqu'à quel jour du mois le dernier mois est soupçonné d'être incomplet.
|
||||
* Au-delà, une baisse est prise pour ce qu'elle est : une baisse.
|
||||
*/
|
||||
jourLimiteDernierMois?: number;
|
||||
/** Part minimale du dernier mois par rapport à la moyenne des 3 précédents. */
|
||||
ratioDernierMoisMin?: number;
|
||||
/** Volume mensuel moyen en dessous duquel le contrôle n'a pas de sens. */
|
||||
volumeMinimal?: number;
|
||||
/** Part maximale de séries plates (même valeur 12 mois) parmi les séries vendues. */
|
||||
partSeriesPlatesMax?: number;
|
||||
}
|
||||
|
||||
const DEFAUTS: Required<OptionsControle> = {
|
||||
jourLimiteDernierMois: 10,
|
||||
ratioDernierMoisMin: 0.4,
|
||||
volumeMinimal: 30,
|
||||
partSeriesPlatesMax: 0.5,
|
||||
};
|
||||
|
||||
const fini = (v: unknown): number => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Corrige sur place les valeurs impossibles d'une métrique (NaN, infini,
|
||||
* nombre de magasins négatif) et renvoie la liste des corrections.
|
||||
*/
|
||||
export function assainirMetrique(m: NetworkMetric): string[] {
|
||||
const corrections: string[] = [];
|
||||
const champs = ["caReseau", "qteReseau", "nbMagasinsReseau", "caParMagasinReseau", "margePctReseau"] as const;
|
||||
for (const champ of champs) {
|
||||
if (!Number.isFinite(Number(m[champ]))) {
|
||||
corrections.push(`${m.codeCentrale} : ${champ} non numérique (${String(m[champ])}) remplacé par 0`);
|
||||
m[champ] = 0;
|
||||
}
|
||||
}
|
||||
if (m.nbMagasinsReseau < 0) {
|
||||
corrections.push(`${m.codeCentrale} : nombre de magasins négatif (${m.nbMagasinsReseau}) remplacé par 0`);
|
||||
m.nbMagasinsReseau = 0;
|
||||
}
|
||||
m.nbMagasinsReseau = Math.round(m.nbMagasinsReseau);
|
||||
m.caParMagasinReseau = m.nbMagasinsReseau > 0 ? m.caReseau / m.nbMagasinsReseau : 0;
|
||||
|
||||
if (m.qteByMonth) {
|
||||
for (const [mois, v] of Object.entries(m.qteByMonth)) {
|
||||
if (!Number.isFinite(Number(v))) {
|
||||
corrections.push(`${m.codeCentrale} : quantité ${mois} non numérique remplacée par 0`);
|
||||
m.qteByMonth[mois] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.metricsByMonth) {
|
||||
for (const [mois, v] of Object.entries(m.metricsByMonth)) {
|
||||
m.metricsByMonth[mois] = {
|
||||
qte: fini(v?.qte),
|
||||
ca: fini(v?.ca),
|
||||
nbMag: Math.max(0, Math.round(fini(v?.nbMag))),
|
||||
caMag: fini(v?.caMag),
|
||||
margePct: fini(v?.margePct),
|
||||
};
|
||||
}
|
||||
}
|
||||
return corrections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contrôle d'ensemble d'une extraction 12 mois, AVANT écriture du cache.
|
||||
*
|
||||
* `fenetre` : les mois attendus (« YYYY-MM »), du plus ancien au plus récent.
|
||||
*/
|
||||
export function controlerExtraction(
|
||||
metriques: Iterable<NetworkMetric>,
|
||||
fenetre: string[],
|
||||
now: Date = new Date(),
|
||||
options: OptionsControle = {},
|
||||
): ControleExtraction {
|
||||
const o = { ...DEFAUTS, ...options };
|
||||
const avertissements: string[] = [];
|
||||
const liste = [...metriques];
|
||||
if (liste.length === 0 || fenetre.length < 4) return { ok: true, avertissements };
|
||||
|
||||
// ── 1. Séries plates : la mesure a ignoré la sélection de mois ──────────
|
||||
let vendues = 0;
|
||||
let plates = 0;
|
||||
for (const m of liste) {
|
||||
const valeurs = fenetre.map((mois) => fini(m.qteByMonth?.[mois]));
|
||||
if (!valeurs.some((v) => v !== 0)) continue;
|
||||
vendues++;
|
||||
if (valeurs.every((v) => v === valeurs[0])) plates++;
|
||||
}
|
||||
if (vendues >= 5 && plates / vendues > o.partSeriesPlatesMax) {
|
||||
return {
|
||||
ok: false,
|
||||
motif: `${plates} séries sur ${vendues} ont la même quantité les 12 mois : la mesure Qlik ignore le mois, données refusées`,
|
||||
avertissements,
|
||||
};
|
||||
}
|
||||
if (plates > 0) avertissements.push(`${plates} série(s) identique(s) sur 12 mois parmi ${vendues} vendues`);
|
||||
|
||||
// ── 2. Dernier mois manifestement incomplet ─────────────────────────────
|
||||
const totaux = fenetre.map((mois) => liste.reduce((t, m) => t + fini(m.qteByMonth?.[mois]), 0));
|
||||
const dernier = totaux[totaux.length - 1];
|
||||
const precedents = totaux.slice(-4, -1);
|
||||
const reference = precedents.reduce((t, v) => t + v, 0) / precedents.length;
|
||||
if (
|
||||
now.getDate() <= o.jourLimiteDernierMois &&
|
||||
reference >= o.volumeMinimal &&
|
||||
dernier < reference * o.ratioDernierMoisMin
|
||||
) {
|
||||
const pct = Math.round((dernier / reference) * 100);
|
||||
return {
|
||||
ok: false,
|
||||
motif: `le dernier mois (${fenetre[fenetre.length - 1]}) ne pèse que ${pct} % des 3 mois précédents : `
|
||||
+ `probablement pas encore entièrement chargé dans Qlik, données refusées (nouvel essai une prochaine nuit)`,
|
||||
avertissements,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, avertissements };
|
||||
}
|
||||
Reference in new issue
Block a user