Compare commits

...
Author SHA1 Message Date
Claude cf65968431 Exports : reprendre les gammes changées par l'API avant d'exporter
Les exports et la copie dans l'Historique partaient des lignes chargées dans
le navigateur. Une gamme changée par l'API après le chargement n'y figurait
pas, et la copie (qui devient le dernier snapshot, celui que la Grille
réapplique) l'effaçait au rechargement suivant.

- Avant chaque export ou copie, l'écran relit les gammes enregistrées
  (dernier snapshot) et met à jour les lignes concernées.
- saveSnapshot reprend les gammes enregistrées depuis cet alignement au lieu
  de les écraser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011twCi6DCZ9xTpEpTACiBuN
2026-10-05 13:35:10 +00:00
LogiFlow efd6c00cf0 Merge pull request #21 from R0m1k3/claude/loving-ride-epppkv
Révision d'assortiment : CA par nomenclature, par magasin et face au réseau
2026-10-05 13:29:13 +02:00
Claude 3519597fb7 Révision d'assortiment : CA par nomenclature, par magasin et face au réseau
Nouveau bouton « Nomenclatures » dans la barre d'outils de la Grille : il
ouvre une modale qui regroupe les produits affichés par nomenclature (code3)
et donne, sur les 12 mois de la Grille, le CA de chaque magasin, le CA de nos
magasins, le CA réseau, le poids de la nomenclature chez nous et dans le
réseau (écart en points) et l'indice de chaque magasin face au magasin moyen
du réseau. Tri par colonne, filtre texte, ligne de total et export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvNrtCGyjYgPHFoiUkJzhM
2026-10-05 10:12:49 +00:00
LogiFlow 3a0e3bad50 Merge pull request #20 from R0m1k3/claude/eloquent-cannon-2mm04o
API : affecter ou changer la gamme d'un produit
2026-10-05 10:27:23 +02:00
Claude 9448e68509 API : affecter ou changer la gamme d'un produit
Deux endpoints d'écriture dans /api/v1 :
- PUT /products/{codein}/gamme { gamme, fournisseur? } pour un article ;
- POST /gammes { fournisseur, changes[] } pour plusieurs articles d'un
  fournisseur, en tout ou rien.

Ils passent par le même enregistrement que la Grille (extrait de
saveDraftChanges dans enregistrerGammes) : snapshot du fournisseur, cache
de la Grille et instantané grid_rows. Gamme validée contre la liste des
gammes (A, B, C, Y, Z) ; un article déjà dans la gamme demandée n'est pas
réécrit. Spec OpenAPI et page de connexion à l'API mises à jour ; la
légende des gammes de la spec, qui citait une gamme D inexistante, suit
désormais lib/gammes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEXT61tGbsXHUDNBeghCXQ
2026-10-05 08:19:00 +00:00
LogiFlow 540ff7cf29 Merge pull request #19 from R0m1k3/claude/eloquent-cannon-2mm04o
Afficher les montants au centime dans toute l'application
2026-10-05 09:52:04 +02:00
Claude 4a11bdc3b0 Afficher les montants au centime dans toute l'application
Les CA, marges et valeurs de stock étaient arrondis à l'euro (fmtEur0,
fmtEntier suivi de « € », Math.round dans les exports). Tous les montants
sont désormais affichés avec deux décimales : accueil, grille, fiche
produit, hit-parade, publicités, analytics, snapshots, export PDF et
export Excel des manques d'assortiment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEXT61tGbsXHUDNBeghCXQ
2026-10-05 07:50:59 +00:00
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
34 changed files with 1994 additions and 269 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>
+2 -2
View File
@@ -9,7 +9,7 @@ import { Select } from "@/components/ui/form-controls";
import { DeltaBadge } from "@/components/ui/badge";
import { Terme } from "@/components/ui/tooltip";
import { MAGASINS, nomMagasin } from "@/lib/magasins";
import { fmtEur0 } from "@/lib/format";
import { fmtEur2 } from "@/lib/format";
import { telechargerExcel } from "@/lib/export-excel";
import { cn } from "@/lib/utils";
@@ -81,7 +81,7 @@ const evolution = (ca: number, caN1: number) => (caN1 > 0 ? ((ca - caN1) / caN1)
const recherche = (r: AnalyticsRow) => [r.label, r.key];
function Montant({ v }: { v: number }) {
return <span className={cn("whitespace-nowrap", v === 0 && "text-[var(--text-muted)]")}>{fmtEur0(v)}</span>;
return <span className={cn("whitespace-nowrap", v === 0 && "text-[var(--text-muted)]")}>{fmtEur2(v)}</span>;
}
/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */
+8 -8
View File
@@ -10,7 +10,7 @@ import { Badge, DeltaBadge } from "@/components/ui/badge";
import { EmptyState, ErrorState } from "@/components/ui/states";
import { Terme } from "@/components/ui/tooltip";
import { LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
export const metadata: Metadata = { title: "Accueil" };
@@ -76,8 +76,8 @@ function CarteMagasin({ site }: { site: DashboardSiteStats }) {
<div className="grid gap-3 p-4 sm:grid-cols-3">
<Indicateur
label="Chiffre d'affaires"
valeur={fmtEur0(site.ca_hier)}
valeurN1={site.ca_n1 > 0 ? fmtEur0(site.ca_n1) : null}
valeur={fmtEur2(site.ca_hier)}
valeurN1={site.ca_n1 > 0 ? fmtEur2(site.ca_n1) : null}
delta={evolution(site.ca_hier, site.ca_n1)}
/>
<Indicateur
@@ -107,8 +107,8 @@ const CRITERES: Record<Critere, { titre: string; icone: typeof Euro }> = {
function valeurPrincipale(item: DashboardTopItem, critere: Critere): string {
if (critere === "qte") return `${fmtEntier(item.qte)} vendus`;
if (critere === "marge") return fmtEur0(item.marge);
return fmtEur0(item.ca);
if (critere === "marge") return fmtEur2(item.marge);
return fmtEur2(item.ca);
}
function ListeTop10({ items, critere }: { items: DashboardTopItem[]; critere: Critere }) {
@@ -150,7 +150,7 @@ function ListeTop10({ items, critere }: { items: DashboardTopItem[]; critere: Cr
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--text-muted)]">
<span className="truncate">{item.fournisseur}</span>
{critere !== "ca" && <span>CA {fmtEur0(item.ca)}</span>}
{critere !== "ca" && <span>CA {fmtEur2(item.ca)}</span>}
{critere !== "qte" && <span>{fmtEntier(item.qte)} vendus</span>}
<span
style={{ color: item.stock <= 0 ? "var(--accent-error)" : undefined }}
@@ -259,9 +259,9 @@ export default async function DashboardPage() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<StatCard
label="Chiffre d'affaires"
value={fmtEur0(totalCa)}
value={fmtEur2(totalCa)}
delta={evolution(totalCa, totalCaN1)}
hint={totalCaN1 > 0 ? `N-1 : ${fmtEur0(totalCaN1)}` : undefined}
hint={totalCaN1 > 0 ? `N-1 : ${fmtEur2(totalCaN1)}` : undefined}
icon={Euro}
/>
<StatCard
+3 -3
View File
@@ -7,7 +7,7 @@ import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/dat
import { Input, Label } from "@/components/ui/form-controls";
import { Button } from "@/components/ui/button";
import { MAGASINS, nomMagasin } from "@/lib/magasins";
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
import { telechargerExcel } from "@/lib/export-excel";
import { cn } from "@/lib/utils";
@@ -99,8 +99,8 @@ function colonnesGroupe(g: Groupe): DataColumn<Ligne>[] {
hint: `Chiffre d'affaires TTC (${g.nom}) sur la période, retours déduits`,
align: "right",
sortValue: r => r[g.ca],
cell: r => (r[g.ca] !== 0 ? fmtEur0(r[g.ca]) : vide),
footer: rows => fmtEur0(somme(rows, g.ca)),
cell: r => (r[g.ca] !== 0 ? fmtEur2(r[g.ca]) : vide),
footer: rows => fmtEur2(somme(rows, g.ca)),
className: cn("whitespace-nowrap", total && "font-semibold"),
},
{
+11 -11
View File
@@ -26,7 +26,7 @@ import { EmptyState } from "@/components/ui/states";
import { Terme, Tooltip } from "@/components/ui/tooltip";
import { toast } from "@/components/ui/feedback";
import { CODE_TOUS_MAGASINS, LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
import { fmtDecimal1, fmtDecimal2, fmtEntier, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
import { cn } from "@/lib/utils";
import { OpportunitesFamille } from "./opportunites";
@@ -129,7 +129,7 @@ const LIGNES_MENSUELLES_RESEAU: Array<{
{ label: "Quantité vendue", get: (v) => v.qte, fmt: fmtEntier },
{ label: "Magasins vendeurs", get: (v) => v.nbMag, fmt: fmtEntier },
{ label: "Quantité par magasin", get: (v) => (v.nbMag && v.nbMag > 0 ? v.qte / v.nbMag : undefined), fmt: fmtDecimal1 },
{ label: "Chiffre d'affaires (€)", get: (v) => v.ca, fmt: fmtEntier },
{ label: "Chiffre d'affaires (€)", get: (v) => v.ca, fmt: fmtDecimal2 },
{ label: "Prix moyen", get: (v) => (v.ca != null && v.qte > 0 ? v.ca / v.qte : undefined), fmt: fmtEur2 },
{ label: "Taux de marge", get: (v) => normalizeMargePct(v.margePct) ?? undefined, fmt: (n) => `${fmtDecimal1(n)} %` },
];
@@ -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);
@@ -327,8 +327,8 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
value={prixMoyenReseau != null ? fmtEur2(prixMoyenReseau) : "—"}
hint="chiffre d'affaires ÷ quantité"
/>
<StatCard label="Chiffre d'affaires" value={fmtEur0(reseau.caReseau)} hint="de tout le réseau" />
<StatCard label={<Terme id="caParMagasin" />} value={fmtEur0(reseau.caParMagasinReseau)} />
<StatCard label="Chiffre d'affaires" value={fmtEur2(reseau.caReseau)} hint="de tout le réseau" />
<StatCard label={<Terme id="caParMagasin" />} value={fmtEur2(reseau.caParMagasinReseau)} />
<StatCard
label={<Terme id="marge">Taux de marge</Terme>}
value={
@@ -556,7 +556,7 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<Field label="Stock disponible"><span className="tabular-nums">{fmtEntier(s.stockdispo)}</span></Field>
<Field label="Valeur du stock"><span className="tabular-nums">{fmtEur0(s.valstock)}</span></Field>
<Field label="Valeur du stock"><span className="tabular-nums">{fmtEur2(s.valstock)}</span></Field>
<Field label={<Terme id="prmp" />}><span className="tabular-nums">{s.prmp > 0 ? fmtEur2(s.prmp) : "—"}</span></Field>
<Field label="Dernière vente">{formatDate(s.dernierevente)}</Field>
<Field label="Dernière réception">{formatDate(s.dernierereception)}</Field>
@@ -587,8 +587,8 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
>
<div className="mb-5 grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label="Quantité vendue" value={fmtEntier(totauxAffiches.qte)} />
<StatCard label="Chiffre d'affaires TTC" value={fmtEur0(totauxAffiches.ca)} />
<StatCard label={<Terme id="marge" />} value={fmtEur0(totauxAffiches.marge)} />
<StatCard label="Chiffre d'affaires TTC" value={fmtEur2(totauxAffiches.ca)} />
<StatCard label={<Terme id="marge" />} value={fmtEur2(totauxAffiches.marge)} />
<StatCard
label="Taux de marge"
value={
@@ -626,7 +626,7 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
<th scope="row" className={TH_LIGNE}>Chiffre d&apos;affaires TTC (€)</th>
{months.map((m) => (
<td key={m} className={TD}>
{serie[m]?.ca ? fmtEntier(serie[m].ca) : <span className="text-[var(--text-muted)]">—</span>}
{serie[m]?.ca ? fmtDecimal2(serie[m].ca) : <span className="text-[var(--text-muted)]">—</span>}
</td>
))}
</tr>
@@ -690,8 +690,8 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<Comparaison
titre={<Terme id="caParMagasin">Chiffre d&apos;affaires par magasin</Terme>}
nous={fmtEur0(comparatif.caLocalParMagasin)}
reseau={comparatif.caReseauParMagasin != null ? fmtEur0(comparatif.caReseauParMagasin) : "—"}
nous={fmtEur2(comparatif.caLocalParMagasin)}
reseau={comparatif.caReseauParMagasin != null ? fmtEur2(comparatif.caReseauParMagasin) : "—"}
/>
<Comparaison
titre={<Terme id="marge">Taux de marge</Terme>}
+2 -2
View File
@@ -12,7 +12,7 @@ import { DataTable, type DataColumn } from "@/components/ui/data-table";
import { EmptyState, ErrorState } from "@/components/ui/states";
import { Terme } from "@/components/ui/tooltip";
import { GLOSSAIRE } from "@/lib/glossaire";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
/** Intervalle d'interrogation de l'état du job de recherche. */
@@ -401,7 +401,7 @@ function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: stri
hint: GLOSSAIRE.caParMagasin.definition,
align: "right",
sortValue: (r) => (r.caParMagasinReseau > 0 ? r.caParMagasinReseau : null),
cell: (r) => (r.caParMagasinReseau > 0 ? fmtEur0(r.caParMagasinReseau) : "—"),
cell: (r) => (r.caParMagasinReseau > 0 ? fmtEur2(r.caParMagasinReseau) : "—"),
},
{
id: "marge",
+5 -5
View File
@@ -12,7 +12,7 @@ import { Pagination } from "@/components/ui/pagination";
import { ErrorState } from "@/components/ui/states";
import { Tabs, useUrlTab } from "@/components/ui/tabs";
import { Terme, InfoBulle } from "@/components/ui/tooltip";
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { nomMagasin } from "@/lib/magasins";
import { cn } from "@/lib/utils";
@@ -205,8 +205,8 @@ export function PublicitesClient({
header: <InfoBulle explication={EXPLICATION_CA_PUB}>CA pub période</InfoBulle>,
align: "right",
sortValue: (p) => nombre(p.ca_pub_periode_pub),
cell: (p) => <span className="font-semibold">{fmtEur0(nombre(p.ca_pub_periode_pub))}</span>,
footer: (rows) => fmtEur0(somme(rows, (r) => r.ca_pub_periode_pub)),
cell: (p) => <span className="font-semibold">{fmtEur2(nombre(p.ca_pub_periode_pub))}</span>,
footer: (rows) => fmtEur2(somme(rows, (r) => r.ca_pub_periode_pub)),
},
{
id: "partCa",
@@ -297,8 +297,8 @@ export function PublicitesClient({
hint: "Chiffre d'affaires de l'opération, tous magasins confondus.",
align: "right",
sortValue: (h) => nombre(h.ca_total),
cell: (h) => <span className="font-semibold">{fmtEur0(nombre(h.ca_total))}</span>,
footer: (rows) => fmtEur0(somme(rows, (r) => r.ca_total)),
cell: (h) => <span className="font-semibold">{fmtEur2(nombre(h.ca_total))}</span>,
footer: (rows) => fmtEur2(somme(rows, (r) => r.ca_total)),
},
{
id: "quantite",
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { gammesBatchBodySchema } from "@/lib/api-schemas";
import { affecterGammes, lireCorpsJson } from "@/lib/api-gammes";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes.
export const maxDuration = 300;
/**
* POST /api/v1/gammes { "fournisseur": "…", "changes": [{ "codein": "…", "gamme": "A" }, …] }
*
* Affecte ou change la gamme de plusieurs articles d'un fournisseur en un appel —
* un seul snapshot enregistré, au lieu d'un par article avec /products/:codein/gamme.
* Tout ou rien : un article inconnu chez le fournisseur fait échouer l'appel (404).
*/
export async function POST(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const corps = await lireCorpsJson(req);
if (corps instanceof Response) return corps;
const parsed = gammesBatchBodySchema.safeParse(corps);
if (!parsed.success) {
return fail("bad_request", "Corps de requête invalide.", parsed.error.issues);
}
const { fournisseur, changes, compute } = parsed.data;
const res = await affecterGammes({
codeFournisseur: fournisseur,
changes,
compute,
auteur: authCtx.subject,
});
if (res instanceof Response) return res;
return ok(res.resultats, {
meta: {
codeFournisseur: res.codeFournisseur,
nomFournisseur: res.nomFournisseur,
demandes: res.resultats.length,
modifies: res.resultats.filter((r) => r.modifie).length,
enregistreLe: new Date().toISOString(),
},
});
}
+139 -6
View File
@@ -1,5 +1,6 @@
import { NextRequest } from "next/server";
import { GRID_SORT_KEYS } from "@/lib/grid-store";
import { GAMMES } from "@/lib/gammes";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -46,13 +47,20 @@ export async function GET(req: NextRequest) {
},
];
const codesGamme = GAMMES.map((g) => g.code);
const legendeGammes = GAMMES.map((g) => `${g.code} (${g.nom})`).join(", ");
const erreur = (description: string) => ({
description,
content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
});
const spec = {
openapi: "3.1.0",
info: {
title: "CollectFlow API",
version: "1.0.0",
description:
"Lecture des données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, "
"Données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, "
+ "gammes et métriques du réseau Qlik (~270 magasins Foir'Fouille), plus la recherche de produits.\n\n"
+ "**N'importe quel fournisseur peut être interrogé directement.** Les réponses sont servies "
+ "depuis un instantané persisté ; si le fournisseur demandé n'en a pas encore, `/grid` le calcule "
@@ -62,7 +70,11 @@ export async function GET(req: NextRequest) {
+ "alimenté par une synchronisation séparée.\n\n"
+ "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques "
+ "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée "
+ "(`codeGammeServeur`).",
+ "(`codeGammeServeur`).\n\n"
+ "**Gammes** : `PUT /products/{codein}/gamme` (un article) et `POST /gammes` (plusieurs) "
+ "affectent ou changent une gamme exactement comme la Grille : la nouvelle valeur apparaît "
+ "aussitôt dans `codeGamme` et dans l'application, tandis que `codeGammeServeur` reste la gamme "
+ "en base FF jusqu'à l'import des gammes modifiées.",
},
servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }],
security: [{ ApiKeyAuth: [] }],
@@ -137,10 +149,9 @@ export async function GET(req: NextRequest) {
type: ["string", "null"],
description:
"Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour "
+ "raisonner. `null` = aucune gamme. Valeurs : A (pilier), B (bonne rotation), "
+ "C (performance), D (saisonnier/niche), Z (sortie).",
+ "raisonner. `null` = aucune gamme. Valeurs : " + legendeGammes + ".",
},
codeGamme: { type: ["string", "null"], description: "Gamme courante, éventuellement surchargée par une modification locale non enregistrée." },
codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications enregistrées dans CollectFlow (Grille ou API) comprises." },
totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
totalQuantite: { type: "number" },
totalMarge: { type: "number" },
@@ -162,6 +173,16 @@ export async function GET(req: NextRequest) {
trend: { $ref: "#/components/schemas/Trend" },
},
},
ResultatGamme: {
type: "object",
properties: {
codein: { type: "string" },
gamme: { type: "string", enum: codesGamme, description: "Gamme désormais affectée." },
gammePrecedente: { type: ["string", "null"], description: "Gamme courante avant l'appel." },
codeGammeServeur: { type: ["string", "null"], description: "Gamme en base FF, non modifiée par l'API." },
modifie: { type: "boolean", description: "false = l'article avait déjà cette gamme, rien n'a été écrit." },
},
},
Trend: {
type: ["object", "null"],
description:
@@ -266,7 +287,7 @@ export async function GET(req: NextRequest) {
+ "ou n'a aucun article — le signaler plutôt que de conclure à une panne.",
parameters: [
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" }, description: "Code fournisseur (voir listerFournisseurs)." },
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme (A, B, C, D, Z)." },
{ name: "gamme", in: "query", schema: { type: "string" }, description: `Filtre sur la gamme (${codesGamme.join(", ")}).` },
{ name: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." },
{ name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." },
{ name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3 (code exact)." },
@@ -366,6 +387,118 @@ export async function GET(req: NextRequest) {
},
},
},
"/products/{codein}/gamme": {
put: {
operationId: "changerGammeProduit",
summary: "Affecter ou changer la gamme d'un produit",
description:
"Enregistre la gamme d'un article, comme la Grille. Valeurs : " + legendeGammes + ". "
+ "`fournisseur` n'est requis que si l'article est référencé chez plusieurs fournisseurs "
+ "(réponse 400 listant les fournisseurs) ou s'il n'a encore jamais été calculé. "
+ "Pour plusieurs articles d'un même fournisseur, préférer changerGammes.",
parameters: [
{ name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." },
],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["gamme"],
properties: {
gamme: { type: "string", enum: codesGamme },
fournisseur: { type: "string", description: "Code fournisseur de l'article." },
compute: { type: "boolean", default: true, description: "Calcule le fournisseur à la demande s'il n'a jamais été calculé." },
},
},
},
},
},
responses: {
"200": {
description: "Gamme enregistrée",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: {
allOf: [
{ $ref: "#/components/schemas/ResultatGamme" },
{ type: "object", properties: { codeFournisseur: { type: "string" }, nomFournisseur: { type: ["string", "null"] } } },
],
},
meta: { type: "object" },
},
},
},
},
},
"400": erreur("Gamme invalide, ou article chez plusieurs fournisseurs sans « fournisseur »"),
"401": erreur("Clé d'API absente, invalide ou révoquée"),
"404": erreur("Article introuvable chez ce fournisseur"),
},
},
},
"/gammes": {
post: {
operationId: "changerGammes",
summary: "Affecter ou changer la gamme de plusieurs produits",
description:
"Enregistre en un appel les gammes de plusieurs articles d'un même fournisseur. "
+ "Tout ou rien : si un article est inconnu chez le fournisseur, rien n'est enregistré "
+ "et la réponse 404 liste les articles en cause dans `error.details.inconnus`. "
+ "Valeurs : " + legendeGammes + ".",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["fournisseur", "changes"],
properties: {
fournisseur: { type: "string", description: "Code fournisseur." },
changes: {
type: "array",
minItems: 1,
maxItems: 10000,
items: {
type: "object",
required: ["codein", "gamme"],
properties: {
codein: { type: "string" },
gamme: { type: "string", enum: codesGamme },
},
},
},
compute: { type: "boolean", default: true, description: "Calcule le fournisseur à la demande s'il n'a jamais été calculé." },
},
},
},
},
},
responses: {
"200": {
description: "Gammes enregistrées",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: { type: "array", items: { $ref: "#/components/schemas/ResultatGamme" } },
meta: { type: "object" },
},
},
},
},
},
"400": erreur("Corps de requête invalide"),
"401": erreur("Clé d'API absente, invalide ou révoquée"),
"404": erreur("Un ou plusieurs articles introuvables chez ce fournisseur"),
},
},
},
"/network/{codeCentrale}": {
get: {
operationId: "obtenirMetriquesReseau",
@@ -0,0 +1,71 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { productGammeBodySchema } from "@/lib/api-schemas";
import { listGridSuppliersForCodein } from "@/lib/grid-store";
import { affecterGammes, lireCorpsJson } from "@/lib/api-gammes";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes.
export const maxDuration = 300;
/**
* PUT /api/v1/products/:codein/gamme { "gamme": "A", "fournisseur"?: "…" }
*
* Affecte une gamme à un article, ou la change. Même enregistrement que la Grille :
* la nouvelle gamme est visible immédiatement dans l'application et dans `codeGamme`.
*
* `fournisseur` n'est requis que si l'article est référencé chez plusieurs
* fournisseurs, ou s'il n'apparaît pas encore dans l'instantané.
*/
export async function PUT(
req: NextRequest,
ctx: { params: Promise<{ codein: string }> },
) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const { codein } = await ctx.params;
if (!codein) return fail("bad_request", "Paramètre 'codein' requis.");
const corps = await lireCorpsJson(req);
if (corps instanceof Response) return corps;
const parsed = productGammeBodySchema.safeParse(corps);
if (!parsed.success) {
return fail("bad_request", "Corps de requête invalide.", parsed.error.issues);
}
const { gamme, compute } = parsed.data;
let fournisseur = parsed.data.fournisseur;
if (!fournisseur) {
const fournisseurs = await listGridSuppliersForCodein(codein);
if (fournisseurs.length === 0) {
return fail(
"not_found",
`Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur » au corps de la requête pour que l'API calcule le lot à la demande.`,
);
}
if (fournisseurs.length > 1) {
return fail(
"bad_request",
`Le produit « ${codein} » est référencé chez plusieurs fournisseurs : précisez « fournisseur ».`,
{ fournisseurs },
);
}
fournisseur = fournisseurs[0].codeFournisseur;
}
const res = await affecterGammes({
codeFournisseur: fournisseur,
changes: [{ codein, gamme }],
compute,
auteur: authCtx.subject,
});
if (res instanceof Response) return res;
return ok(
{ ...res.resultats[0], codeFournisseur: res.codeFournisseur, nomFournisseur: res.nomFournisseur },
{ meta: { enregistreLe: new Date().toISOString() } },
);
}
+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);
@@ -79,6 +79,8 @@ const ENDPOINTS: Array<{ method: string; path: string; desc: string }> = [
{ method: "GET", path: "/nomenclatures?fournisseur=CODE", desc: "Postes de nomenclature d'un fournisseur, avec nombre d'articles et chiffre d'affaires" },
{ method: "GET", path: "/products/search?q=terme", desc: "Recherche de produits, tous fournisseurs confondus" },
{ method: "GET", path: "/products/{codein}", desc: "Fiche complète d'un produit" },
{ method: "PUT", path: "/products/{codein}/gamme", desc: "Affecter ou changer la gamme d'un produit — corps : { \"gamme\": \"A\" }" },
{ method: "POST", path: "/gammes", desc: "Affecter ou changer la gamme de plusieurs produits d'un fournisseur en un appel" },
{ method: "GET", path: "/network/{codeCentrale}", desc: "Ventes du réseau (Qlik) et courbe sur 12 mois" },
{ method: "GET", path: "/openapi.json", desc: "Description de l'API lisible par un programme (format OpenAPI)" },
];
@@ -203,6 +205,7 @@ export function ApiConnectionInfo() {
</Repli>
<Repli titre="Exemples d'appels">
<CodeLine>{`curl -X PUT -H "X-API-Key: VOTRE_CLE" -H "Content-Type: application/json" \\\n -d '{"gamme":"B"}' "${base}/products/123456/gamme"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
<p className="pt-1 text-[13px] text-[var(--text-secondary)]">
@@ -0,0 +1,89 @@
import "server-only";
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { patchGridRowsCache } from "./get-product-rows";
import { updateGridRowsGamme } from "@/lib/grid-store";
export interface ChangementGamme {
codein: string;
/** Gamme avant la modification (état serveur), conservée pour l'historique. */
codeGammeBefore: string | null;
codeGamme: string;
}
/** Gammes d'un snapshot : codein → gamme avant (état FF) / après. */
export type GammesSnapshot = Record<string, { before: string | null; after: string }>;
/**
* Dernier snapshot du fournisseur — celui que la Grille réapplique à chaque calcul
* (Phase 9). `null` s'il n'y en a aucun ; lève une erreur si la lecture échoue.
*/
export async function lireDernierSnapshot(
codeFournisseur: string,
): Promise<{ id: number; changes: GammesSnapshot } | null> {
const [dernier] = await db
.select({ id: sessionSnapshots.id, changes: sessionSnapshots.changes })
.from(sessionSnapshots)
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
.orderBy(desc(sessionSnapshots.createdAt))
.limit(1);
return dernier ? { id: dernier.id, changes: (dernier.changes ?? {}) as GammesSnapshot } : null;
}
export interface EnregistrementGammes {
codeFournisseur: string;
nomFournisseur?: string | null;
magasin: string;
changes: ChangementGamme[];
userId: number | null;
/** Libellé du snapshot, « Draft — date » par défaut. */
label?: string;
}
/**
* Enregistre des gammes pour un fournisseur — chemin unique de la Grille et de /api/v1.
*
* Les changements sont fusionnés par-dessus le dernier snapshot du fournisseur,
* puis reportés dans le cache mémoire de la Grille et dans l'instantané `grid_rows`.
* Lève une erreur si l'écriture du snapshot échoue.
*/
export async function enregistrerGammes(input: EnregistrementGammes): Promise<number> {
const { codeFournisseur, nomFournisseur, magasin, changes, userId } = input;
if (changes.length === 0) return 0;
// Charger le dernier snapshot existant pour merger les changements
const prevChanges = (await lireDernierSnapshot(codeFournisseur))?.changes ?? {};
// Merger les nouveaux changements par-dessus l'existant
const mergedChanges = { ...prevChanges };
for (const c of changes) {
mergedChanges[c.codein] = {
before: c.codeGammeBefore,
after: c.codeGamme,
};
}
// Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur
await db.insert(sessionSnapshots).values({
userId,
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,
label: input.label ?? `Draft — ${new Date().toLocaleDateString("fr-FR")}`,
changes: mergedChanges,
summaryJson: null,
type: "snapshot",
});
// Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées :
// l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la
// réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer.
patchGridRowsCache(codeFournisseur, changes);
void updateGridRowsGamme(codeFournisseur, changes).catch((e) =>
console.error("[enregistrerGammes] mise à jour grid_rows KO:", (e as Error).message?.slice(0, 200)),
);
return changes.length;
}
+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;
@@ -0,0 +1,37 @@
"use server";
import { z } from "zod";
import { verifierSession } from "@/lib/authz";
import { lireDernierSnapshot } from "./enregistrer-gammes";
/**
* Gammes enregistrées pour un fournisseur (dernier snapshot), quelle qu'en soit
* l'origine : la Grille, ou l'API /api/v1 pendant que la Grille était ouverte.
*
* `snapshotId` identifie l'état lu : `saveSnapshot` s'en sert pour reprendre les
* gammes enregistrées APRÈS cette lecture au lieu de les écraser.
*/
export async function lireGammesEnregistrees(
raw: unknown,
): Promise<
| { success: true; snapshotId: number | null; gammes: Record<string, string> }
| { success: false; error: string }
> {
const acces = await verifierSession();
if (!acces.ok) return { success: false, error: acces.message };
const parsed = z.string().min(1).safeParse(raw);
if (!parsed.success) return { success: false, error: "Code fournisseur manquant." };
try {
const dernier = await lireDernierSnapshot(parsed.data);
const gammes: Record<string, string> = {};
for (const [codein, delta] of Object.entries(dernier?.changes ?? {})) {
if (delta?.after) gammes[codein] = delta.after;
}
return { success: true, snapshotId: dernier?.id ?? null, gammes };
} catch (err) {
console.error("[lireGammesEnregistrees]", err instanceof Error ? err.message : String(err));
return { success: false, error: "lecture des gammes enregistrées impossible" };
}
}
+3 -48
View File
@@ -1,12 +1,8 @@
"use server";
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { z } from "zod";
import { verifierSession } from "@/lib/authz";
import { patchGridRowsCache } from "./get-product-rows";
import { updateGridRowsGamme } from "@/lib/grid-store";
import { enregistrerGammes } from "./enregistrer-gammes";
const SaveDraftsSchema = z.object({
codeFournisseur: z.string(),
@@ -39,49 +35,8 @@ export async function saveDraftChanges(
const finalUserId = userId && !isNaN(userId) ? userId : null;
try {
// Charger le dernier snapshot existant pour merger les changements
const existing = await db
.select()
.from(sessionSnapshots)
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
.orderBy(desc(sessionSnapshots.createdAt))
.limit(1);
const prevChanges: Record<string, { before: string | null; after: string }> =
existing.length > 0
? (existing[0].changes as Record<string, { before: string | null; after: string }>)
: {};
// Merger les nouveaux changements par-dessus l'existant
const mergedChanges = { ...prevChanges };
for (const c of changes) {
mergedChanges[c.codein] = {
before: c.codeGammeBefore,
after: c.codeGamme,
};
}
// Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur
await db.insert(sessionSnapshots).values({
userId: finalUserId,
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,
label: `Draft — ${new Date().toLocaleDateString("fr-FR")}`,
changes: mergedChanges,
summaryJson: null,
type: "snapshot",
});
// Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées :
// l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la
// réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer.
patchGridRowsCache(codeFournisseur, changes);
void updateGridRowsGamme(codeFournisseur, changes).catch((e) =>
console.error("[saveDraftChanges] mise à jour grid_rows KO:", (e as Error).message?.slice(0, 200)),
);
return { success: true, saved: changes.length };
const saved = await enregistrerGammes({ codeFournisseur, nomFournisseur, magasin, changes, userId: finalUserId });
return { success: true, saved };
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return { success: false, saved: 0, error: msg };
@@ -30,7 +30,6 @@ const PROFONDEURS = [100, 200, 500] as const;
export const PROFONDEUR_MAX = PROFONDEURS[PROFONDEURS.length - 1];
const nb = (v: number) => Math.round(v).toLocaleString("fr-FR");
const eur0 = (v: number) => `${Math.round(v).toLocaleString("fr-FR")} €`;
const eur2 = (v: number) => v.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
/**
@@ -51,18 +50,18 @@ interface Critere {
}
const CRITERES: Record<string, Critere> = {
caReseau: { label: "CA réseau", format: eur0, xlsx: '#,##0 "€"' },
caReseau: { label: "CA réseau", format: eur2, xlsx: '#,##0.00 "€"' },
qteReseau: { label: "Qté réseau", format: nb, xlsx: "#,##0" },
nbMagasinsReseau: { label: "Magasins réseau", format: nb, xlsx: "#,##0" },
tendanceReseau: { label: "Tendance réseau", format: (v) => `${v >= 0 ? "+" : ""}${Math.round(v * 100)} %`, xlsx: "0 %" },
prixMoyenReseau: { label: "PV moyen réseau", format: eur2, xlsx: '#,##0.00 "€"' },
prixVente: { label: "PV magasin", format: eur2, xlsx: '#,##0.00 "€"' },
caParMagasinReseau: { label: "CA / magasin réseau", format: eur0, xlsx: '#,##0 "€"' },
caParMagasinReseau: { label: "CA / magasin réseau", format: eur2, xlsx: '#,##0.00 "€"' },
margePctReseau: { label: "Marge % réseau", format: (v) => `${(Math.abs(v) <= 1 ? v * 100 : v).toFixed(1)} %`,
xlsx: '0.0 "%"', brut: (v) => (Math.abs(v) <= 1 ? v * 100 : v) },
totalQuantite: { label: "Qté 12 m", format: nb, xlsx: "#,##0" },
totalCa: { label: "CA 12 m", format: eur0, xlsx: '#,##0 "€"' },
totalMarge: { label: "Marge 12 m", format: eur0, xlsx: '#,##0 "€"' },
totalCa: { label: "CA 12 m", format: eur2, xlsx: '#,##0.00 "€"' },
totalMarge: { label: "Marge 12 m", format: eur2, xlsx: '#,##0.00 "€"' },
};
function libelleCritere(id: string | null): string {
@@ -19,7 +19,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { confirmer, toast } from "@/components/ui/feedback";
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
import {
exporterFichierGammes,
@@ -27,8 +27,11 @@ import {
exporterTousProduits,
imprimerPdf,
lignesModifiees,
synchroniserGammes,
} from "@/features/grid/lib/exports";
type Synchro = Awaited<ReturnType<typeof synchroniserGammes>>;
function Stat({ label, value, sub, subColor }: { label: string; value: string; sub?: string; subColor?: string }) {
return (
<div className="flex flex-col leading-tight">
@@ -101,33 +104,53 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
}
};
/**
* Gammes enregistrées depuis le chargement (API /api/v1, autre onglet) : l'écran
* les reprend avant tout export, sinon le fichier et l'Historique partaient de
* gammes périmées — et la copie dans l'Historique les effaçait.
*/
const alignerSurServeur = async (): Promise<Synchro> => {
if (!supplierCode) return { snapshotId: null, misesAJour: 0 };
const synchro = await synchroniserGammes(supplierCode);
if (synchro.misesAJour > 0) {
const n = synchro.misesAJour;
toast.info(`${n} gamme${n > 1 ? "s ont" : " a"} été modifiée${n > 1 ? "s" : ""} entre-temps côté serveur (API) : l'écran a été mis à jour.`);
}
return synchro;
};
/**
* Enregistre une copie de la session dans l'Historique.
* Renvoie l'identifiant créé, ou `null` en cas d'échec (déjà signalé).
* `synchro` : alignement déjà fait par l'appelant, sinon il est fait ici.
*/
const handleSnapshot = async (labelOverride?: string, type: "snapshot" | "export" = "snapshot") => {
const handleSnapshot = async (labelOverride?: string, type: "snapshot" | "export" = "snapshot", synchro?: Synchro) => {
if (rows.length === 0) return null;
const { draftChanges } = useGridStore.getState();
// Tous les changements : brouillons + gammes déjà enregistrées (≠ FF)
const changes = Object.fromEntries(
lignesModifiees().map(r => [
r.codein,
{ before: r.codeGammeInit, after: (draftChanges[r.codein] ?? r.codeGamme) as string },
])
);
try {
const { snapshotId: baseSnapshotId } = synchro ?? await alignerSurServeur();
// Relu après l'alignement : les lignes et les totaux ont pu changer.
const { draftChanges, rows: lignes, summary: totaux } = useGridStore.getState();
if (lignes.length === 0) return null;
// Tous les changements : brouillons + gammes déjà enregistrées (≠ FF)
const changes = Object.fromEntries(
lignesModifiees().map(r => [
r.codein,
{ before: r.codeGammeInit, after: (draftChanges[r.codein] ?? r.codeGamme) as string },
])
);
const res = await saveSnapshot({
codeFournisseur: filterFournisseur || rows[0].codeFournisseur,
nomFournisseur: rows[0].nomFournisseur,
codeFournisseur: filterFournisseur || lignes[0].codeFournisseur,
nomFournisseur: lignes[0].nomFournisseur,
magasin: activeMagasin || "TOTAL",
label: labelOverride || `Session ${rows[0].nomFournisseur} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`,
label: labelOverride || `Session ${lignes[0].nomFournisseur} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`,
changes,
type,
baseSnapshotId,
summary: {
totalRows: summary.totalRows,
totalCa: summary.totalCa,
totalMarge: summary.totalMarge,
tauxMargeGlobal: summary.tauxMargeGlobal,
totalRows: totaux.totalRows,
totalCa: totaux.totalCa,
totalMarge: totaux.totalMarge,
tauxMargeGlobal: totaux.tauxMargeGlobal,
},
});
if (!res.success) throw new Error(res.error);
@@ -139,12 +162,12 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
}
};
/** Lance un export avec indicateur et messages d'erreur communs. */
const lancer = async (cle: string, action: () => Promise<boolean>, vide: string) => {
/** Lance un export (écran aligné sur le serveur) avec indicateur et messages d'erreur communs. */
const lancer = async (cle: string, action: (synchro: Synchro) => Promise<boolean>, vide: string) => {
if (enCours) return;
setEnCours(cle);
try {
const ok = await action();
const ok = await action(await alignerSurServeur());
if (!ok) toast.info(vide);
} catch (err) {
toast.erreur(`L'export a échoué : ${messageErreur(err)}`);
@@ -165,10 +188,10 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
<div className="flex flex-wrap items-center gap-x-8 gap-y-1">
<Stat label="Produits actifs" value={fmtEntier(summary.totalRows)} sub={summary.totalProducts !== summary.totalRows ? `sur ${fmtEntier(summary.totalProducts)}` : undefined} subColor="var(--text-muted)" />
<Stat label="Quantités vendues (12 mois)" value={fmtEntier(summary.totalQuantite)} />
<Stat label="Chiffre d'affaires" value={fmtEur0(summary.totalCa)} />
<Stat label="Chiffre d'affaires" value={fmtEur2(summary.totalCa)} />
<Stat
label="Marge"
value={fmtEur0(summary.totalMarge)}
value={fmtEur2(summary.totalMarge)}
sub={`${fmtDecimal1(summary.tauxMargeGlobal)} %`}
subColor={couleurMarge(summary.tauxMargeGlobal)}
/>
@@ -189,11 +212,11 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
<DropdownMenuLabel className="text-xs font-medium text-[var(--text-secondary)]">Fichier d&apos;import des gammes</DropdownMenuLabel>
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => lancer("modifs", async () => {
onSelect={() => lancer("modifs", async (synchro) => {
if (lignesModifiees().length === 0) return false;
const nom = lignesModifiees()[0].nomFournisseur;
// Copie dans l'Historique, onglet « Exports »
await handleSnapshot(`Export ${nom} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, "export");
await handleSnapshot(`Export ${nom} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, "export", synchro);
return exporterFichierGammes(true);
}, "Aucune gamme n'a été modifiée par rapport à FF.")}
>
+49 -85
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, Layers } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -26,9 +26,10 @@ 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 { NomenclatureCaModal } from "@/features/grid/components/nomenclature-ca-modal";
import type { ProductRow, GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
import {
@@ -41,14 +42,11 @@ 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 { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { CLASSES_SANS_GAMME, trouverGamme } from "@/lib/gammes";
import { couleurMarge } from "@/lib/marge";
import { Button } from "@/components/ui/button";
@@ -553,77 +551,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);
@@ -794,6 +721,8 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
const [monthlyModal, setMonthlyModal] = useState<ProductRow | null>(null);
// Trous d'assortiment : ce qu'un magasin ne travaille pas, en haut du classement affiché
const [gapsOpen, setGapsOpen] = useState(false);
// CA par nomenclature et par magasin, face au réseau, sur les lignes affichées
const [nomenclaturesOpen, setNomenclaturesOpen] = useState(false);
const tableContainerRef = useRef<HTMLDivElement>(null);
// Les 12 mois viennent des données, pas de l'horloge du navigateur : les
@@ -965,7 +894,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
const val = row.original.caReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold text-emerald-600 dark:text-emerald-400">
{val != null ? fmtEur0(val) : "-"}
{val != null ? fmtEur2(val) : "-"}
</div>
);
},
@@ -1094,7 +1023,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
const val = row.original.caParMagasinReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
{val != null ? fmtEur0(val) : "-"}
{val != null ? fmtEur2(val) : "-"}
</div>
);
},
@@ -1184,14 +1113,14 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
id: "totalCa",
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalCa : (row.caByStore?.[activeMagasin] ?? 0),
header: () => <div className="text-center w-full" title="Chiffre d'affaires sur 12 mois">CA</div>,
size: 90,
size: 105,
cell: ({ row }: { row: { original: ProductRow } }) => {
const ca = activeMagasin === "TOTAL"
? row.original.totalCa
: (row.original.caByStore?.[activeMagasin] ?? 0);
return (
<div className="text-center tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
{fmtEntier(ca)}&nbsp;€
{fmtEur2(ca)}
</div>
);
},
@@ -1200,7 +1129,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
id: "totalMarge",
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalMarge : (row.margeByStore?.[activeMagasin] ?? 0),
header: () => <div className="text-center w-full" title="Marge en euros et taux de marge sur 12 mois">Marge</div>,
size: 110,
size: 115,
cell: ({ row }: { row: { original: ProductRow } }) => {
const marge = activeMagasin === "TOTAL"
? row.original.totalMarge
@@ -1212,7 +1141,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
return (
<div className="flex flex-col items-center justify-center">
<span className="tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
{fmtEntier(marge)}&nbsp;€
{fmtEur2(marge)}
</span>
<span className="tabular-nums text-xs font-semibold" style={{ color: couleurMarge(taux) }}>
{taux.toFixed(1)}%
@@ -1333,6 +1262,12 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
}));
}, [gapsOpen, tableRows, sorting]);
/** Les produits affichés (filtres et recherche compris), pour le CA par nomenclature. */
const lignesAffichees: ProductRow[] = useMemo(
() => (nomenclaturesOpen ? tableRows.map((r) => r.original) : []),
[nomenclaturesOpen, tableRows],
);
if (!isMounted) {
return (
<div
@@ -1352,6 +1287,17 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
<div className="h-full w-full relative">
{portalContainer && createPortal(
<div className="flex items-center gap-2">
{/* CA par nomenclature et par magasin, comparé au réseau. */}
<Button
variant="outline"
size="sm"
onClick={() => setNomenclaturesOpen(true)}
disabled={tableRows.length === 0}
title="Le CA de chaque nomenclature sur la période, par magasin, comparé au CA réseau"
>
<Layers />
<span className="hidden lg:inline">Nomenclatures</span>
</Button>
{/* Trous d'assortiment — lit le classement affiché, tri compris. */}
<Button
variant="outline"
@@ -1505,7 +1451,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}>
@@ -1520,6 +1473,17 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
/>
)}
</Dialog>
{/* CA par nomenclature : nos magasins face au réseau */}
<Dialog open={nomenclaturesOpen} onOpenChange={setNomenclaturesOpen}>
{nomenclaturesOpen && (
<NomenclatureCaModal
rows={lignesAffichees}
mois={MONTHS_12}
nomFournisseur={nomFournisseur}
onClose={() => setNomenclaturesOpen(false)}
/>
)}
</Dialog>
{/* Modal détail 12 mois (ventes / entrées / stock par magasin) */}
<Dialog open={monthlyModal !== null} onOpenChange={(open) => { if (!open) setMonthlyModal(null); }}>
{monthlyModal !== null && (
@@ -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 { fmtEur2 } 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={fmtEur2(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>
);
}
@@ -0,0 +1,516 @@
"use client";
/**
* CollectFlow — CA par nomenclature : nos magasins face au réseau.
*
* On part des lignes AFFICHÉES dans la Grille (filtres de nomenclature, de
* gamme et recherche compris) et on les regroupe par nomenclature (`code3`).
* La période est celle de la Grille : les 12 mois complets glissants, sur
* laquelle sont calés à la fois les totaux FF par magasin et les chiffres
* réseau Qlik.
*
* Comparer des montants bruts n'aurait pas de sens — le réseau compte ~270
* magasins, nous deux. La comparaison passe donc par deux rapports :
* - le poids de la nomenclature dans le CA du périmètre, chez nous et dans
* le réseau (écart en points : où notre mix diverge de celui du réseau) ;
* - l'indice de chaque magasin face au magasin moyen du réseau
* (CA réseau ÷ 270) : 100 = au niveau du magasin moyen.
*/
import React, { useMemo, useState } from "react";
import { ChevronDown, ChevronUp, ChevronsUpDown, FileSpreadsheet, Layers, Loader2, Search } from "lucide-react";
import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { TuileStat } from "@/features/grid/components/stat-tile";
import { formatMonthLabel } from "@/features/grid/lib/months";
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { MAGASINS, nomMagasin } from "@/lib/magasins";
import type { ProductRow } from "@/types/grid";
/** Une nomenclature et ses chiffres sur la période. */
interface LigneNomenclature {
code: string;
libelle: string;
nbProduits: number;
/** Produits vendus dans au moins un de nos magasins. */
nbVendus: number;
/** Produits sans chiffre réseau (non rapprochés dans Qlik). */
nbSansReseau: number;
caParSite: Record<string, number>;
caNous: number;
caReseau: number;
partNous: number | null;
partReseau: number | null;
/** Écart de poids, en points : partNous − partReseau. */
ecartPoints: number | null;
/** CA du magasin moyen du réseau (CA réseau ÷ nombre de magasins du réseau). */
caMagasinMoyenReseau: number;
}
type CleTri = "libelle" | "caNous" | "caReseau" | "partNous" | "partReseau" | "ecartPoints" | `site:${string}`;
const SANS_NOMENCLATURE = "—";
const fmtPct = (v: number | null) => (v == null ? "—" : `${fmtDecimal1(v * 100)} %`);
const fmtPoints = (v: number | null) => (v == null ? "—" : `${v >= 0 ? "+" : ""}${fmtDecimal1(v * 100)} pts`);
const fmtEurOuTiret = (v: number) => (v !== 0 ? fmtEur2(v) : "—");
/** Indice d'un magasin face au magasin moyen du réseau (100 = au niveau). */
function indice(ca: number, caMagasinMoyen: number): number | null {
if (caMagasinMoyen <= 0) return null;
return (ca / caMagasinMoyen) * 100;
}
function couleurIndice(v: number | null): string {
if (v == null) return "var(--text-muted)";
return v >= 100 ? "var(--accent-success)" : "var(--accent-error)";
}
function couleurEcart(v: number | null): string {
if (v == null || Math.abs(v) < 0.005) return "var(--text-muted)";
return v > 0 ? "var(--accent-success)" : "var(--accent-error)";
}
/** Magasins présents dans les données, à défaut nos magasins connus — dans l'ordre de `MAGASINS`. */
function sitesDe(rows: ProductRow[]): string[] {
const trouves = new Set<string>();
for (const r of rows) for (const s of Object.keys(r.caByStore ?? {})) trouves.add(s);
const connus = MAGASINS.map((m) => m.code);
const autres = [...trouves].filter((s) => !connus.includes(s)).sort();
return [...connus, ...autres];
}
function regrouper(rows: ProductRow[], sites: string[]): { lignes: LigneNomenclature[]; total: LigneNomenclature } {
const groupes = new Map<string, LigneNomenclature>();
const vide = (code: string, libelle: string): LigneNomenclature => ({
code,
libelle,
nbProduits: 0,
nbVendus: 0,
nbSansReseau: 0,
caParSite: Object.fromEntries(sites.map((s) => [s, 0])),
caNous: 0,
caReseau: 0,
partNous: null,
partReseau: null,
ecartPoints: null,
caMagasinMoyenReseau: 0,
});
const total = vide("", "Total");
for (const r of rows) {
const code = r.code3?.trim() || SANS_NOMENCLATURE;
let g = groupes.get(code);
if (!g) {
g = vide(code, r.libelle3?.trim() || (code === SANS_NOMENCLATURE ? "Sans nomenclature" : code));
groupes.set(code, g);
}
let caProduit = 0;
for (const s of sites) {
const ca = r.caByStore?.[s] ?? 0;
g.caParSite[s] += ca;
total.caParSite[s] += ca;
caProduit += ca;
}
// Lignes sans détail par magasin : le total du produit reste compté.
if (!r.caByStore && r.totalCa) caProduit = r.totalCa;
g.caNous += caProduit;
total.caNous += caProduit;
for (const cible of [g, total]) {
cible.nbProduits += 1;
if (caProduit > 0) cible.nbVendus += 1;
if (r.caReseau == null) cible.nbSansReseau += 1;
else cible.caReseau += r.caReseau;
}
}
const lignes = [...groupes.values()].filter((g) => g.caNous !== 0 || g.caReseau !== 0);
for (const g of [...lignes, total]) {
g.partNous = total.caNous > 0 ? g.caNous / total.caNous : null;
g.partReseau = total.caReseau > 0 ? g.caReseau / total.caReseau : null;
g.ecartPoints = g.partNous != null && g.partReseau != null ? g.partNous - g.partReseau : null;
g.caMagasinMoyenReseau = g.caReseau / NB_MAGASINS_RESEAU;
}
return { lignes, total };
}
function valeurTri(l: LigneNomenclature, cle: CleTri): number | string {
if (cle === "libelle") return l.libelle.toLowerCase();
if (cle.startsWith("site:")) return l.caParSite[cle.slice(5)] ?? 0;
const v = l[cle as Exclude<CleTri, "libelle" | `site:${string}`>];
// Valeur absente : toujours en bas, quel que soit le sens.
return v ?? Number.NaN;
}
/**
* Classeur Excel de la liste affichée — valeurs numériques brutes pour que le
* tableur puisse trier et sommer. `exceljs` est chargé à la demande.
*/
async function construireClasseur(
lignes: LigneNomenclature[],
total: LigneNomenclature,
sites: string[],
contexte: { fournisseur?: string; periode: string; nbLignesGrille: number },
): Promise<Blob> {
const { Workbook } = await import("exceljs");
const classeur = new Workbook();
classeur.creator = "CollectFlow";
classeur.created = new Date();
const feuille = classeur.addWorksheet("CA par nomenclature", { views: [{ state: "frozen", ySplit: 1 }] });
feuille.columns = [
{ header: "Code", key: "code", width: 12 },
{ header: "Nomenclature", key: "libelle", width: 36 },
{ header: "Produits", key: "nbProduits", width: 10 },
{ header: "Produits vendus", key: "nbVendus", width: 15 },
...sites.flatMap((s) => [
{ header: `CA ${nomMagasin(s, { court: true })}`, key: `ca_${s}`, width: 16 },
{ header: `Indice ${nomMagasin(s, { court: true })}`, key: `ind_${s}`, width: 14 },
]),
{ header: "CA nos magasins", key: "caNous", width: 16 },
{ header: "Poids chez nous", key: "partNous", width: 15 },
{ header: "CA réseau", key: "caReseau", width: 16 },
{ header: "CA magasin moyen réseau", key: "caMoyen", width: 22 },
{ header: "Poids réseau", key: "partReseau", width: 13 },
{ header: "Écart de poids (pts)", key: "ecart", width: 18 },
{ header: "Produits sans chiffre réseau", key: "nbSansReseau", width: 24 },
];
feuille.getRow(1).font = { bold: true, color: { argb: "FFFFFFFF" } };
feuille.getRow(1).fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } };
feuille.getRow(1).height = 20;
const ajouter = (l: LigneNomenclature) => feuille.addRow({
code: l.code,
libelle: l.libelle,
nbProduits: l.nbProduits,
nbVendus: l.nbVendus,
...Object.fromEntries(sites.flatMap((s) => {
const ind = indice(l.caParSite[s] ?? 0, l.caMagasinMoyenReseau);
return [[`ca_${s}`, l.caParSite[s] ?? 0], [`ind_${s}`, ind == null ? "" : Math.round(ind)]];
})),
caNous: l.caNous,
partNous: l.partNous ?? "",
caReseau: l.caReseau,
caMoyen: l.caMagasinMoyenReseau,
partReseau: l.partReseau ?? "",
ecart: l.ecartPoints == null ? "" : Math.round(l.ecartPoints * 1000) / 10,
nbSansReseau: l.nbSansReseau,
});
for (const l of lignes) ajouter(l);
const ligneTotal = ajouter(total);
ligneTotal.font = { bold: true };
const eur = '#,##0.00 "€"';
for (const cle of ["caNous", "caReseau", "caMoyen", ...sites.map((s) => `ca_${s}`)]) feuille.getColumn(cle).numFmt = eur;
feuille.getColumn("partNous").numFmt = "0.0 %";
feuille.getColumn("partReseau").numFmt = "0.0 %";
feuille.getColumn("ecart").numFmt = '+0.0;-0.0;0.0';
feuille.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: feuille.columns.length } };
const contexteFeuille = classeur.addWorksheet("Contexte");
contexteFeuille.columns = [{ width: 26 }, { width: 80 }];
for (const [cle, valeur] of [
["Extraction", "CA par nomenclature — nos magasins et réseau"],
["Fournisseur", contexte.fournisseur ?? "—"],
["Période", contexte.periode],
["Périmètre", `${contexte.nbLignesGrille} produits affichés dans la Grille (filtres compris)`],
["Poids", "part de la nomenclature dans le CA du périmètre (chez nous / dans le réseau)"],
["Indice magasin", `CA du magasin ÷ CA du magasin moyen du réseau (CA réseau ÷ ${NB_MAGASINS_RESEAU}) × 100`],
["Généré le", new Date().toLocaleString("fr-FR")],
]) {
contexteFeuille.addRow([cle, valeur]).getCell(1).font = { bold: true };
}
const buffer = await classeur.xlsx.writeBuffer();
return new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
}
function EnTete({ label, cle, tri, onTri, align = "right", title }: {
label: React.ReactNode;
cle: CleTri;
tri: { cle: CleTri; desc: boolean };
onTri: (cle: CleTri) => void;
align?: "left" | "right";
title?: string;
}) {
const actif = tri.cle === cle;
const Icone = !actif ? ChevronsUpDown : tri.desc ? ChevronDown : ChevronUp;
return (
<th
className={`px-2.5 py-1.5 font-semibold whitespace-nowrap ${align === "left" ? "text-left" : "text-right"}`}
style={{ color: actif ? "var(--text-primary)" : "var(--text-secondary)" }}
aria-sort={actif ? (tri.desc ? "descending" : "ascending") : "none"}
title={title}
>
<button
type="button"
onClick={() => onTri(cle)}
className={`inline-flex items-center gap-1 ${align === "left" ? "" : "flex-row-reverse"}`}
>
{label}
<Icone className="w-3 h-3 shrink-0 opacity-60" />
</button>
</th>
);
}
export function NomenclatureCaModal({ rows, mois, nomFournisseur, onClose }: {
/** Les produits affichés dans la Grille (filtres et recherche compris). */
rows: ProductRow[];
/** Les 12 clés de mois de la Grille, du plus ancien au plus récent. */
mois: string[];
nomFournisseur?: string;
onClose: () => void;
}) {
const sites = useMemo(() => sitesDe(rows), [rows]);
const { lignes, total } = useMemo(() => regrouper(rows, sites), [rows, sites]);
const [tri, setTri] = useState<{ cle: CleTri; desc: boolean }>({ cle: "caNous", desc: true });
const [recherche, setRecherche] = useState("");
const [exportEnCours, setExportEnCours] = useState(false);
const [exportErreur, setExportErreur] = useState<string | null>(null);
const changerTri = (cle: CleTri) =>
setTri((t) => (t.cle === cle ? { cle, desc: !t.desc } : { cle, desc: cle !== "libelle" }));
const affichees = useMemo(() => {
const q = recherche.trim().toLowerCase();
const filtrees = q
? lignes.filter((l) => l.libelle.toLowerCase().includes(q) || l.code.toLowerCase().includes(q))
: lignes;
const sens = tri.desc ? -1 : 1;
return [...filtrees].sort((a, b) => {
const va = valeurTri(a, tri.cle);
const vb = valeurTri(b, tri.cle);
if (typeof va === "string" || typeof vb === "string") return String(va).localeCompare(String(vb), "fr") * sens;
if (Number.isNaN(va)) return Number.isNaN(vb) ? 0 : 1;
if (Number.isNaN(vb)) return -1;
return (va - vb) * sens;
});
}, [lignes, recherche, tri]);
const periode = mois.length > 0
? `${formatMonthLabel(mois[0])} → ${formatMonthLabel(mois[mois.length - 1])}`
: "12 derniers mois";
const absentesChezNous = lignes.filter((l) => l.caNous <= 0 && l.caReseau > 0).length;
const indicesTotal = sites.map((s) => indice(total.caParSite[s] ?? 0, total.caMagasinMoyenReseau));
const exporter = async () => {
if (affichees.length === 0 || exportEnCours) return;
setExportEnCours(true);
setExportErreur(null);
try {
const blob = await construireClasseur(affichees, total, sites, {
fournisseur: nomFournisseur,
periode,
nbLignesGrille: rows.length,
});
const url = URL.createObjectURL(blob);
const lien = document.createElement("a");
lien.href = url;
lien.download = [
"CA_nomenclatures",
nomFournisseur?.replace(/[^\w]+/g, "_"),
new Date().toISOString().slice(0, 10),
].filter(Boolean).join("_") + ".xlsx";
lien.click();
URL.revokeObjectURL(url);
} catch (e) {
console.error("[CA par nomenclature] export Excel :", e);
setExportErreur("L'export a échoué. Réessayez, ou signalez-le si cela persiste.");
} finally {
setExportEnCours(false);
}
};
const celluleSite = (l: LigneNomenclature, s: string, gras = false) => {
const ca = l.caParSite[s] ?? 0;
const ind = indice(ca, l.caMagasinMoyenReseau);
return (
<td key={s} className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap">
<div className={gras ? "font-bold" : "font-semibold"} style={{ color: ca !== 0 ? "var(--text-primary)" : "var(--text-muted)" }}>
{fmtEurOuTiret(ca)}
</div>
<div className="text-[11px]" style={{ color: couleurIndice(ind) }} title="Indice face au magasin moyen du réseau (100 = au niveau)">
{ind == null ? "—" : `indice ${fmtEntier(ind)}`}
</div>
</td>
);
};
return (
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-6xl 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 flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<Layers className="w-5 h-5 shrink-0" style={{ color: "var(--accent)" }} />
CA par nomenclature
</DialogTitle>
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
{nomFournisseur ? <>{nomFournisseur} — </> : null}
<span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{periode}</span>
{` — ${fmtEntier(rows.length)} produits affichés dans la Grille, par magasin et face au réseau.`}
</p>
</DialogHeader>
<div className="flex flex-wrap gap-2">
<TuileStat label="Nomenclatures" valeur={fmtEntier(lignes.length)} indice="avec du CA chez nous ou dans le réseau" />
{sites.map((s, i) => (
<TuileStat
key={s}
label={`CA ${nomMagasin(s)}`}
valeur={fmtEur2(total.caParSite[s] ?? 0)}
indice={indicesTotal[i] == null ? undefined : `indice ${fmtEntier(indicesTotal[i]!)} face au magasin moyen du réseau`}
/>
))}
<TuileStat
label="CA réseau"
valeur={fmtEur2(total.caReseau)}
indice={`soit ${fmtEur2(total.caMagasinMoyenReseau)} par magasin moyen (${NB_MAGASINS_RESEAU} magasins)`}
/>
<TuileStat
label="Absentes chez nous"
valeur={fmtEntier(absentesChezNous)}
indice="nomenclatures vendues dans le réseau, sans CA dans nos magasins"
couleur={absentesChezNous > 0 ? "var(--accent-warning)" : undefined}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5" style={{ color: "var(--text-muted)" }} />
<input
type="search"
placeholder="Filtrer les nomenclatures…"
aria-label="Filtrer les nomenclatures"
value={recherche}
onChange={(e) => setRecherche(e.target.value)}
className="apple-input w-60"
style={{ paddingLeft: "32px" }}
/>
</div>
<button
type="button"
onClick={exporter}
disabled={affichees.length === 0 || exportEnCours}
title="Exporter la liste affichée au format Excel"
className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-md text-[12px] font-semibold border transition-all disabled:opacity-50"
style={{ background: "var(--bg-elevated)", borderColor: "var(--border-strong)", color: "var(--text-secondary)" }}
>
{exportEnCours ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5" />}
{exportEnCours ? "Génération…" : "Export Excel"}
</button>
</div>
{exportErreur && (
<div className="rounded-lg px-3 py-2 text-[12px]" style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
{exportErreur}
</div>
)}
{affichees.length === 0 ? (
<div className="rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
{lignes.length === 0
? "Aucune nomenclature avec du CA sur la période pour les produits affichés."
: "Aucune nomenclature ne correspond à ce filtre."}
</div>
) : (
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[12px]" style={{ minWidth: 900 }}>
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<EnTete label="Nomenclature" cle="libelle" tri={tri} onTri={changerTri} align="left" />
{sites.map((s) => (
<EnTete key={s} label={`CA ${nomMagasin(s, { court: true })}`} cle={`site:${s}`} tri={tri} onTri={changerTri}
title={`CA de ${nomMagasin(s)} sur la période, et son indice face au magasin moyen du réseau`} />
))}
<EnTete label="CA nos magasins" cle="caNous" tri={tri} onTri={changerTri} />
<EnTete label="Poids chez nous" cle="partNous" tri={tri} onTri={changerTri}
title="Part de la nomenclature dans le CA de nos magasins sur le périmètre affiché" />
<EnTete label="CA réseau" cle="caReseau" tri={tri} onTri={changerTri}
title={`CA dans les magasins du réseau ; en dessous, le CA du magasin moyen (÷ ${NB_MAGASINS_RESEAU})`} />
<EnTete label="Poids réseau" cle="partReseau" tri={tri} onTri={changerTri}
title="Part de la nomenclature dans le CA réseau sur le périmètre affiché" />
<EnTete label="Écart" cle="ecartPoints" tri={tri} onTri={changerTri}
title="Poids chez nous − poids réseau, en points : positif = nomenclature plus forte chez nous que dans le réseau" />
</tr>
</thead>
<tbody>
{affichees.map((l) => (
<tr key={l.code} style={{ borderTop: "1px solid var(--border)" }}>
<td className="px-2.5 py-1.5" style={{ color: "var(--text-primary)" }}>
<div className="font-semibold">{l.libelle}</div>
<div className="text-[11px] tabular-nums" style={{ color: "var(--text-muted)" }}>
{l.code !== SANS_NOMENCLATURE && <>{l.code} · </>}
{fmtEntier(l.nbVendus)} / {fmtEntier(l.nbProduits)} produits vendus
{l.nbSansReseau > 0 && (
<span title="Produits sans chiffre réseau (non rapprochés dans Qlik) : le CA réseau de la nomenclature est sous-estimé">
{" "}· {fmtEntier(l.nbSansReseau)} sans réseau
</span>
)}
</div>
</td>
{sites.map((s) => celluleSite(l, s))}
<td className="px-2.5 py-1.5 text-right tabular-nums font-bold whitespace-nowrap" style={{ color: l.caNous !== 0 ? "var(--text-primary)" : "var(--accent-warning)" }}>
{fmtEurOuTiret(l.caNous)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{fmtPct(l.partNous)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap">
<div className="font-semibold text-emerald-600 dark:text-emerald-400">{fmtEurOuTiret(l.caReseau)}</div>
<div className="text-[11px]" style={{ color: "var(--text-muted)" }}>
{l.caReseau !== 0 ? `${fmtEur2(l.caMagasinMoyenReseau)} / mag.` : ""}
</div>
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{fmtPct(l.partReseau)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums font-semibold whitespace-nowrap" style={{ color: couleurEcart(l.ecartPoints) }}>
{fmtPoints(l.ecartPoints)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ borderTop: "2px solid var(--border-strong)", background: "var(--bg-elevated)" }}>
<td className="px-2.5 py-1.5 font-bold" style={{ color: "var(--text-primary)" }}>
Total
<div className="text-[11px] font-normal tabular-nums" style={{ color: "var(--text-muted)" }}>
{fmtEntier(total.nbVendus)} / {fmtEntier(total.nbProduits)} produits vendus
</div>
</td>
{sites.map((s) => celluleSite(total, s, true))}
<td className="px-2.5 py-1.5 text-right tabular-nums font-bold whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
{fmtEur2(total.caNous)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{fmtPct(total.partNous)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap">
<div className="font-bold text-emerald-600 dark:text-emerald-400">{fmtEur2(total.caReseau)}</div>
<div className="text-[11px]" style={{ color: "var(--text-muted)" }}>{fmtEur2(total.caMagasinMoyenReseau)} / mag.</div>
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{fmtPct(total.partReseau)}
</td>
<td className="px-2.5 py-1.5" />
</tr>
</tfoot>
</table>
</div>
)}
<p className="text-xs leading-snug" style={{ color: "var(--text-muted)" }}>
Période : les 12 mois complets de la Grille, communs à nos magasins et au réseau. Le poids est la part de la
nomenclature dans le CA des produits affichés ; l&apos;écart compare notre mix à celui du réseau. L&apos;indice
rapporte le CA d&apos;un magasin à celui du magasin moyen du réseau (CA réseau ÷ {NB_MAGASINS_RESEAU}) : 100 = au niveau.
{total.nbSansReseau > 0 && ` ${fmtEntier(total.nbSansReseau)} produits n'ont pas de chiffre réseau : le CA réseau des nomenclatures concernées est sous-estimé.`}
</p>
</DialogContent>
);
}
+20 -3
View File
@@ -3,10 +3,12 @@
*
* Il y avait deux menus d'export (en-tête et barre du bas) aux droits différents.
* Les bibliothèques lourdes (exceljs, jspdf) ne sont chargées qu'au clic.
* Les lignes sont lues dans le store au moment de l'export.
* Les lignes sont lues dans le store au moment de l'export, après
* `synchroniserGammes` : l'API peut avoir changé des gammes depuis le chargement.
*/
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { lireGammesEnregistrees } from "@/features/grid/api/lire-gammes-enregistrees";
import type { ProductRow } from "@/types/grid";
function telecharger(blob: Blob, nomFichier: string) {
@@ -25,6 +27,20 @@ function gammeEffective(r: ProductRow, drafts: Record<string, string>): string |
return (drafts[r.codein] ?? r.codeGamme) as string | null;
}
/**
* Aligne les lignes affichées sur les gammes enregistrées côté serveur, sans
* recharger toute la Grille. À appeler avant un export ou une copie dans
* l'Historique. `snapshotId` est à transmettre à `saveSnapshot`.
*/
export async function synchroniserGammes(
codeFournisseur: string,
): Promise<{ snapshotId: number | null; misesAJour: number }> {
const res = await lireGammesEnregistrees(codeFournisseur);
if (!res.success) throw new Error(res.error);
const misesAJour = useGridStore.getState().applyServerGammes(res.gammes);
return { snapshotId: res.snapshotId, misesAJour };
}
/** Lignes dont la gamme diffère de la gamme enregistrée dans FF. */
export function lignesModifiees(): ProductRow[] {
const { rows, draftChanges } = useGridStore.getState();
@@ -143,6 +159,7 @@ export async function imprimerPdf(nomFournisseur: string): Promise<boolean> {
doc.setTextColor(100);
doc.text(`Export généré le : ${new Date().toLocaleDateString("fr-FR")}`, 14, 30);
const eur = (v: number) => v.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const head = [["Code-barres", "Code article", "Réf.", "Libellé", "Magasins réseau", "Qté", "CA", "Marge", "Gamme"]];
const body = rows.map((r) => [
r.gtin || r.codein,
@@ -151,8 +168,8 @@ export async function imprimerPdf(nomFournisseur: string): Promise<boolean> {
r.libelle1 ? r.libelle1.substring(0, 60) + (r.libelle1.length > 60 ? "..." : "") : "",
r.nbMagasinsReseau != null ? r.nbMagasinsReseau.toString() : "-",
Math.round(r.totalQuantite).toLocaleString("fr-FR"),
`${Math.round(r.totalCa).toLocaleString("fr-FR")} €`,
`${Math.round(r.totalMarge).toLocaleString("fr-FR")} €\n(${r.tauxMarge.toFixed(1)}%)`,
`${eur(r.totalCa)} €`,
`${eur(r.totalMarge)} €\n(${r.tauxMarge.toFixed(1)}%)`,
gammeEffective(r, draftChanges) || "-",
]);
+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 };
}
+29
View File
@@ -80,6 +80,8 @@ interface GridState {
clearDrafts: (codeins: string[]) => void;
/** Apply saved drafts onto row.codeGamme so change indicators persist after save */
applyDraftsToRows: (draftsToApply: Record<string, GammeCode>) => void;
/** Reporte les gammes enregistrées côté serveur ; renvoie le nombre de lignes changées. */
applyServerGammes: (gammes: Record<string, string>) => number;
setFilter: (key: Exclude<keyof GridFilters, "code3">, value: string | null) => void;
/** Nomenclatures retenues. `null` = aucun filtre, `[]` = rien de coché. */
setCode3Filter: (codes: string[] | null) => void;
@@ -240,6 +242,33 @@ export const useGridStore = create<GridState>()(
});
set({ rows, rowsByCodein: indexRows(rows), summary: computeSummary(rows, get().draftChanges, get().activeMagasin) });
},
/**
* Gammes enregistrées hors de cet écran (API /api/v1, autre onglet) : les
* lignes prennent la valeur qu'un rechargement leur donnerait. Une
* modification en cours qui tombe sur cette valeur n'a plus lieu d'être.
*/
applyServerGammes: (gammes) => {
const { rows, draftChanges, activeMagasin } = get();
const changees: string[] = [];
const next = rows.map((r) => {
const gamme = gammes[r.codein];
if (gamme === undefined || gamme === r.codeGamme) return r;
changees.push(r.codein);
return { ...r, codeGamme: gamme as GammeCode };
});
if (changees.length === 0) return 0;
const drafts = { ...draftChanges };
for (const codein of changees) {
if (drafts[codein] === gammes[codein]) delete drafts[codein];
}
set({
rows: next,
rowsByCodein: indexRows(next),
draftChanges: drafts,
summary: computeSummary(next, drafts, activeMagasin),
});
return changees.length;
},
setFilter: (key, value) => {
set((state) => ({ ...state, filters: { ...state.filters, [key]: value } }));
},
+48 -1
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { sql, and, eq } from "drizzle-orm";
import { verifierSession } from "@/lib/authz";
import { revalidatePath } from "next/cache";
import { lireDernierSnapshot, type GammesSnapshot } from "@/features/grid/api/enregistrer-gammes";
const SaveSnapshotSchema = z.object({
codeFournisseur: z.string(),
@@ -17,6 +18,11 @@ const SaveSnapshotSchema = z.object({
after: z.string(),
})),
type: z.enum(["snapshot", "export"]).optional(),
/**
* Snapshot sur lequel l'écran s'est aligné juste avant (cf. lireGammesEnregistrees),
* `null` s'il n'y en avait aucun. Absent : tout le dernier snapshot est repris.
*/
baseSnapshotId: z.number().int().nullable().optional(),
summary: z.object({
totalRows: z.number(),
totalCa: z.number(),
@@ -35,7 +41,7 @@ export async function saveSnapshot(raw: unknown) {
return { success: false, error: "Validation failed: " + parsed.error.issues.map((i: any) => i.message).join(", ") };
}
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data;
const { codeFournisseur, nomFournisseur, magasin, label, summary, type, baseSnapshotId } = parsed.data;
const rawUserId = acces.utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
@@ -43,6 +49,16 @@ export async function saveSnapshot(raw: unknown) {
const finalUserId = (userId && !isNaN(userId)) ? userId : null;
// Ce snapshot devient le dernier, celui que la Grille réapplique : il ne doit
// pas effacer les gammes enregistrées depuis que l'écran s'est aligné (API).
let changes = parsed.data.changes;
try {
changes = await reprendreGammesRecentes(codeFournisseur, changes, baseSnapshotId);
} catch (err) {
// Table absente ou base indisponible : l'insertion ci-dessous le dira.
console.error("[saveSnapshot] lecture du dernier snapshot KO:", err);
}
try {
const [created] = await db
.insert(sessionSnapshots)
@@ -112,3 +128,34 @@ export async function saveSnapshot(raw: unknown) {
}
}
}
/**
* Ajoute aux gammes de l'écran celles enregistrées depuis le snapshot `baseSnapshotId`
* (sur lequel l'écran s'est aligné) : ce sont des écritures que l'écran n'a pas vues,
* typiquement par l'API. Les autres gammes du dernier snapshot, l'écran les
* connaissait : son état (retours à la gamme FF compris) fait foi.
*/
async function reprendreGammesRecentes(
codeFournisseur: string,
changes: GammesSnapshot,
baseSnapshotId: number | null | undefined,
): Promise<GammesSnapshot> {
const dernier = await lireDernierSnapshot(codeFournisseur);
if (!dernier || dernier.id === baseSnapshotId) return changes;
let base: GammesSnapshot = {};
if (baseSnapshotId != null) {
const [ligne] = await db
.select({ changes: sessionSnapshots.changes })
.from(sessionSnapshots)
.where(and(eq(sessionSnapshots.id, baseSnapshotId), eq(sessionSnapshots.codeFournisseur, codeFournisseur)))
.limit(1);
base = (ligne?.changes ?? {}) as GammesSnapshot;
}
const fusion = { ...changes };
for (const [codein, delta] of Object.entries(dernier.changes)) {
if (delta?.after && delta.after !== base[codein]?.after) fusion[codein] = delta;
}
return fusion;
}
@@ -19,7 +19,7 @@ import { Button } from "@/components/ui/button";
import { StoreBadge } from "@/components/ui/badge";
import { EmptyState, ErrorState, Skeleton } from "@/components/ui/states";
import { confirmer, toast } from "@/components/ui/feedback";
import { fmtDecimal1, fmtEntier, fmtEur0 } from "@/lib/format";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
@@ -228,7 +228,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
<div>
<dt className="text-xs font-medium text-[var(--text-muted)]">Chiffre d&apos;affaires</dt>
<dd className="text-sm font-semibold tabular-nums text-[var(--text-primary)]">
{fmtEur0(resume.totalCa ?? 0)}
{fmtEur2(resume.totalCa ?? 0)}
</dd>
</div>
{taux != null && Number.isFinite(taux) && (
+126
View File
@@ -0,0 +1,126 @@
/**
* CollectFlow — Affectation de gammes via l'API `/api/v1`.
*
* L'API suit exactement le chemin de la Grille (`enregistrerGammes`) : la gamme est
* enregistrée dans le snapshot du fournisseur, reportée dans l'instantané
* `grid_rows` et visible immédiatement dans l'application. Comme dans la Grille,
* rien n'est écrit dans la base FF : `codeGammeServeur` reste la gamme d'origine
* jusqu'à l'import des gammes modifiées.
*
* Tout ou rien : si un seul article est inconnu chez le fournisseur, rien n'est
* enregistré et la réponse liste les articles en cause.
*/
import "server-only";
import type { NextRequest } from "next/server";
import { fail } from "@/lib/api-response";
import { getGridFreshness, getGridRowsGammes } from "@/lib/grid-store";
import { getProductRows } from "@/features/grid/api/get-product-rows";
import { enregistrerGammes, type ChangementGamme } from "@/features/grid/api/enregistrer-gammes";
export interface ResultatGamme {
codein: string;
/** Gamme désormais affectée. */
gamme: string;
/** Gamme courante avant l'appel (modifications enregistrées comprises). */
gammePrecedente: string | null;
/** Gamme présente en base FF, non modifiée par l'API. */
codeGammeServeur: string | null;
/** `false` quand l'article avait déjà cette gamme : rien n'a été écrit pour lui. */
modifie: boolean;
}
export interface AffectationGammes {
codeFournisseur: string;
nomFournisseur: string | null;
resultats: ResultatGamme[];
}
/** Lit un corps JSON, ou renvoie une réponse 400 prête à être retournée. */
export async function lireCorpsJson(req: NextRequest): Promise<unknown | Response> {
try {
return await req.json();
} catch {
return fail("bad_request", "Corps de requête JSON attendu (Content-Type: application/json).");
}
}
/**
* Affecte des gammes à des articles d'un fournisseur.
*
* Si le fournisseur n'a encore jamais été calculé, son instantané est calculé à la
* demande (`compute`), comme sur `/grid` : sans lui, impossible de vérifier que les
* articles lui appartiennent.
*/
export async function affecterGammes(input: {
codeFournisseur: string;
changes: ReadonlyArray<{ codein: string; gamme: string }>;
compute: boolean;
/** Nom de la clé d'API ou de l'utilisateur — repris dans le libellé du snapshot. */
auteur: string;
}): Promise<AffectationGammes | Response> {
const { codeFournisseur, compute, auteur } = input;
// Un même article cité deux fois : la dernière valeur l'emporte.
const demandes = new Map(input.changes.map((c) => [c.codein, c.gamme]));
const codeins = [...demandes.keys()];
if (compute && !(await getGridFreshness(codeFournisseur))) {
console.log(`[api/v1/gammes] instantané absent pour ${codeFournisseur} — calcul à la demande`);
try {
await getProductRows({ codeFournisseur, magasin: "TOTAL" });
} catch (e) {
console.error(`[api/v1/gammes] calcul KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(e));
return fail("internal_error", `Le calcul de la grille a échoué pour le fournisseur « ${codeFournisseur} ».`);
}
}
const actuelles = await getGridRowsGammes(codeFournisseur, codeins);
const inconnus = codeins.filter((c) => !actuelles.has(c));
if (inconnus.length > 0) {
return fail(
"not_found",
`${inconnus.length} article(s) introuvable(s) chez le fournisseur « ${codeFournisseur} » : aucune gamme n'a été enregistrée.`,
{ fournisseur: codeFournisseur, inconnus },
);
}
const resultats: ResultatGamme[] = [];
const changements: ChangementGamme[] = [];
let nomFournisseur: string | null = null;
for (const [codein, gamme] of demandes) {
const actuelle = actuelles.get(codein)!;
nomFournisseur ??= actuelle.nomFournisseur;
const modifie = actuelle.codeGamme !== gamme;
resultats.push({
codein,
gamme,
gammePrecedente: actuelle.codeGamme,
codeGammeServeur: actuelle.codeGammeInit,
modifie,
});
if (modifie) {
// Même « avant » que la Grille : l'état serveur, à défaut la gamme courante.
changements.push({ codein, codeGammeBefore: actuelle.codeGammeInit ?? actuelle.codeGamme, codeGamme: gamme });
}
}
try {
await enregistrerGammes({
codeFournisseur,
nomFournisseur,
magasin: "TOTAL",
changes: changements,
userId: null,
label: `API (${auteur}) — ${new Date().toLocaleDateString("fr-FR")}`,
});
} catch (e) {
console.error(`[api/v1/gammes] enregistrement KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(e));
return fail("internal_error", "L'enregistrement des gammes a échoué.");
}
console.log(`[api/v1/gammes] ${codeFournisseur} — ${changements.length} gamme(s) enregistrée(s) par ${auteur}`);
return { codeFournisseur, nomFournisseur, resultats };
}
+25
View File
@@ -8,6 +8,7 @@
import { z } from "zod";
import { GRID_SORT_KEYS, type GridSortKey } from "@/lib/grid-store";
import { GAMMES, type CodeGamme } from "@/lib/gammes";
const SORT_KEYS = GRID_SORT_KEYS as readonly string[];
@@ -116,6 +117,30 @@ export const productDetailSchema = z.object({
compute: z.enum(["0", "1"]).default("1"),
});
const CODES_GAMME = GAMMES.map((g) => g.code) as [CodeGamme, ...CodeGamme[]];
const gammeShape = z.enum(CODES_GAMME, {
message: `Gamme inconnue. Valeurs acceptées : ${CODES_GAMME.join(", ")}`,
});
/** `PUT /api/v1/products/:codein/gamme` — affecte ou change la gamme d'un article. */
export const productGammeBodySchema = z.object({
gamme: gammeShape,
/** Obligatoire seulement si l'article est référencé chez plusieurs fournisseurs. */
fournisseur: z.string().min(1).optional(),
/** `true` (défaut) : calcule l'instantané du fournisseur s'il n'existe pas encore. */
compute: z.boolean().default(true),
});
/** `POST /api/v1/gammes` — affecte des gammes à plusieurs articles d'un fournisseur. */
export const gammesBatchBodySchema = z.object({
fournisseur: z.string().min(1, "Champ 'fournisseur' requis"),
changes: z
.array(z.object({ codein: z.string().min(1), gamme: gammeShape }))
.min(1, "Au moins un changement attendu")
.max(10_000, "10 000 changements au maximum par appel"),
compute: z.boolean().default(true),
});
/**
* Projection de champs : `?fields=codein,libelle1,totalCa`.
*
+7 -4
View File
@@ -9,7 +9,7 @@
const nfEntier = new Intl.NumberFormat("fr-FR");
const nfDecimal1 = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 1 });
const nfEur0 = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
const nfDecimal2 = new Intl.NumberFormat("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const nfEur2 = new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
@@ -23,8 +23,11 @@ export const fmtEntier = (v: number): string => nfEntier.format(Math.round(v));
/** Nombre avec au plus une décimale : `12,5`. */
export const fmtDecimal1 = (v: number): string => nfDecimal1.format(v);
/** Montant en euros sans centimes : `12 345 €`. */
export const fmtEur0 = (v: number): string => nfEur0.format(v);
/**
* Montant au centime, sans symbole (colonnes déjà libellées « (€) ») : `12 345,67`.
* Les montants ne sont jamais arrondis à l'euro dans l'application.
*/
export const fmtDecimal2 = (v: number): string => nfDecimal2.format(v);
/** Montant en euros au centime (prix unitaires) : `3,49 €`. */
/** Montant en euros au centime (prix, CA, marge) : `12 345,67 €`. */
export const fmtEur2 = (v: number): string => nfEur2.format(v);
+49
View File
@@ -314,6 +314,55 @@ export async function getGridRowByCodein(
};
}
/** Fournisseurs chez lesquels un article figure dans l'instantané. */
export async function listGridSuppliersForCodein(
codein: string,
): Promise<Array<{ codeFournisseur: string; nomFournisseur: string | null }>> {
return db
.select({ codeFournisseur: gridRows.codeFournisseur, nomFournisseur: gridRows.nomFournisseur })
.from(gridRows)
.where(eq(gridRows.codein, codein))
.orderBy(asc(gridRows.codeFournisseur));
}
export interface GammeInstantane {
/** Gamme courante, modifications enregistrées comprises. */
codeGamme: string | null;
/** Gamme serveur au moment du calcul de la grille. */
codeGammeInit: string | null;
nomFournisseur: string | null;
}
/**
* Gammes d'articles d'un fournisseur dans l'instantané. Un codein absent de la
* carte n'appartient pas à ce fournisseur (ou n'a pas encore été calculé).
*/
export async function getGridRowsGammes(
codeFournisseur: string,
codeins: readonly string[],
): Promise<Map<string, GammeInstantane>> {
const out = new Map<string, GammeInstantane>();
const CHUNK = 5000; // bien en deçà des 65 535 paramètres liés de PostgreSQL
for (let i = 0; i < codeins.length; i += CHUNK) {
const found = await db
.select({
codein: gridRows.codein,
codeGamme: gridRows.codeGamme,
codeGammeInit: gridRows.codeGammeInit,
nomFournisseur: gridRows.nomFournisseur,
})
.from(gridRows)
.where(and(
eq(gridRows.codeFournisseur, codeFournisseur),
inArray(gridRows.codein, codeins.slice(i, i + CHUNK)),
));
for (const r of found) {
out.set(r.codein, { codeGamme: r.codeGamme, codeGammeInit: r.codeGammeInit, nomFournisseur: r.nomFournisseur });
}
}
return out;
}
/**
* Indique si un fournisseur a déjà été calculé au moins une fois.
* Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long.
+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 };
}