Compare commits

..
Author SHA1 Message Date
Claude ad5da8f95d Qlik : données contrôlées avant écriture et récupération plus fiable
Fiabilité des données
- Contrôle de plausibilité avant d'écrire le cache (lib/qlik-validation.ts) :
  extraction refusée, cache inchangé, si le dernier mois pèse moins de 40 %
  des 3 précédents dans les 10 premiers jours du mois (mois pas encore
  chargé dans Qlik), ou si plus de la moitié des séries vendues ont la même
  valeur 12 mois (mesure insensible au mois).
- Valeurs impossibles corrigées (NaN, infini, nombre de magasins négatif).

Récupération
- Chromium relancé s'il est tombé : un navigateur déconnecté faisait échouer
  toutes les extractions jusqu'au redémarrage du serveur.
- Une seule session Qlik à la fois (extraction de nuit, synchro manuelle,
  recherche produit) : en parallèle, le serveur Qlik abandonnait les deux.
- Planificateur : un échec est retenté la nuit suivante (au lieu de 7 jours)
  et les données d'avant le mois en cours sont rafraîchies sans attendre le
  délai de fraîcheur. Page Synchronisation mise à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MouZgPPAuEZjHXXifW7bm
2026-10-04 08:05:08 +00:00
Claude 8e37940040 Carte tendance réseau : nos magasins et une lecture en clair
- Phrase de lecture en tête : la tendance du réseau avec ses deux chiffres
  (qté par magasin, 4 derniers mois contre 4 premiers), puis notre
  situation (ventes 12 mois, stock, couverture, commandes en cours).
- Nouvelle section « Nos magasins » : stock fin de mois, ventes 12 mois,
  rythme mensuel, couverture, dernière entrée et écart au magasin moyen
  du réseau, par magasin et pour l'ensemble.
- Réseau : période affichée en clair, tuile CA réseau 12 mois, part du
  réseau dans la tuile des magasins vendeurs.
- Modal sorti de heatmap-grid.tsx (network-monthly-modal.tsx).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MouZgPPAuEZjHXXifW7bm
2026-10-04 08:05:08 +00:00
Claude 48860271a5 Tendance réseau : de retour dans la Grille après le changement de mois
- Grille servie depuis l'instantané (lot 4) : les colonnes réseau sont
  relues dans le cache Qlik, comme le calcul en direct. Elles restaient
  figées à la date du calcul de l'instantané.
- Tendance : si la fenêtre du jour est incomplète mais que les 12 mois
  précédents le sont (cache Qlik pas encore resynchronisé depuis le
  changement de mois), la tendance porte sur ces 12 mois au lieu de
  disparaître. Le modal le signale.
- Courbe des magasins vendeurs alignée sur les mois de la tendance
  (modal Grille et fiche produit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MouZgPPAuEZjHXXifW7bm
2026-10-04 07:29:29 +00:00
11 changed files with 663 additions and 139 deletions

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&apos;avant le mois en cours
(le dernier mois leur manque) et une extraction en échec, retentée la nuit suivante.
</p>
</div>
</div>
+1 -1
View File
@@ -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);
+22 -5
View File
@@ -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);
+10 -4
View File
@@ -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;
+11 -78
View File
@@ -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&apos;est pas encore synchronisé depuis Qlik : la période s&apos;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&apos;hui, tous magasins : <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{fmt(row.stockActuel)}</span>
</p>
)}
</section>
</DialogContent>
);
}
+48 -19
View File
@@ -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 };
}
+59
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
+156
View File
@@ -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 };
}