mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-18 20:56:43 +02:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1474c0cbb4 | ||
|
|
60a1d38b6f | ||
|
|
1f329b8595 | ||
|
|
a252636fc1 | ||
|
|
4c67fb7171 | ||
|
|
4340e2999b | ||
|
|
b373dabeaa | ||
|
|
efd6c00cf0 | ||
|
|
3519597fb7 | ||
|
|
3a0e3bad50 | ||
|
|
9448e68509 | ||
|
|
540ff7cf29 | ||
|
|
4a11bdc3b0 | ||
|
|
ad5da8f95d | ||
|
|
8e37940040 | ||
|
|
48860271a5 |
No files matched your search
@@ -212,6 +212,20 @@ async function main() {
|
||||
`);
|
||||
console.log("[DB Init] Table sync_fournisseurs is verified/created.");
|
||||
|
||||
// Gammes proposées par l'API /api/v1 : en attente de validation dans la Grille.
|
||||
await tempPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS "gammes_a_valider" (
|
||||
"code_fournisseur" varchar(20) NOT NULL,
|
||||
"codein" varchar(20) NOT NULL,
|
||||
"gamme" varchar(20) NOT NULL,
|
||||
"gamme_precedente" varchar(20),
|
||||
"auteur" varchar(100),
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("code_fournisseur", "codein")
|
||||
);
|
||||
`);
|
||||
console.log("[DB Init] Table gammes_a_valider is verified/created.");
|
||||
|
||||
await tempPool.end();
|
||||
console.log("[DB Init] Initialization successful. Exiting.");
|
||||
process.exit(0);
|
||||
|
||||
@@ -480,6 +480,8 @@ export function SynchronisationClient() {
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Ce sont des chiffres mensuels : les refaire chaque nuit coûterait du temps pour rien.
|
||||
Sont aussi refaits, sans attendre ce délai : les données d'avant le mois en cours
|
||||
(le dernier mois leur manque) et une extraction en échec, retentée la nuit suivante.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition, type ReactNode } from "react";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { DataTable, type DataColumn } from "@/components/ui/data-table";
|
||||
import { Segmented, type TabItem } from "@/components/ui/tabs";
|
||||
@@ -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,17 +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>;
|
||||
}
|
||||
|
||||
/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */
|
||||
function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className="flex flex-col items-end leading-tight">
|
||||
<span className="text-xs font-normal text-[var(--text-muted)]">{groupe}</span>
|
||||
<span className="inline-flex items-center gap-1">{children}</span>
|
||||
</span>
|
||||
);
|
||||
return <span className={cn("whitespace-nowrap", v === 0 && "text-[var(--text-muted)]")}>{fmtEur2(v)}</span>;
|
||||
}
|
||||
|
||||
export function AnalyticsClient({
|
||||
@@ -126,42 +116,40 @@ export function AnalyticsClient({
|
||||
sortValue: (r) => r.label,
|
||||
cell: (r) => <span className="font-medium">{r.label}</span>,
|
||||
footer: () => "Total",
|
||||
className: "min-w-[220px]",
|
||||
className: "min-w-[140px] sm:min-w-[220px]",
|
||||
sticky: true,
|
||||
label: true,
|
||||
},
|
||||
...GROUPES.flatMap((g): DataColumn<AnalyticsRow>[] => {
|
||||
const total = g.id === "total";
|
||||
const bord = "border-l border-[var(--border)]";
|
||||
// En-tête commun, séparation et teinte du total : gérés par DataTable.
|
||||
const commun = { group: g.titre, highlight: total, align: "right" as const };
|
||||
return [
|
||||
{
|
||||
...commun,
|
||||
id: `${g.id}-ca`,
|
||||
header: <EnTete groupe={g.titre}>{libelleMois}</EnTete>,
|
||||
header: libelleMois,
|
||||
hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMois}, retours déduits`,
|
||||
align: "right",
|
||||
sortValue: (r) => r[g.ca],
|
||||
cell: (r) => <Montant v={r[g.ca]} />,
|
||||
footer: (rows) => <Montant v={somme(rows, g.ca)} />,
|
||||
className: cn(bord, total && "font-semibold"),
|
||||
headerClassName: bord,
|
||||
className: cn(total && "font-semibold"),
|
||||
},
|
||||
{
|
||||
...commun,
|
||||
id: `${g.id}-n1`,
|
||||
header: (
|
||||
<EnTete groupe={g.titre}>
|
||||
{libelleMoisN1} · <Terme id="n1" />
|
||||
</EnTete>
|
||||
),
|
||||
header: <span className="inline-flex items-center gap-1">{libelleMoisN1} · <Terme id="n1" /></span>,
|
||||
hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMoisN1}, même mois l'année précédente`,
|
||||
align: "right",
|
||||
sortValue: (r) => r[g.caN1],
|
||||
cell: (r) => <Montant v={r[g.caN1]} />,
|
||||
footer: (rows) => <Montant v={somme(rows, g.caN1)} />,
|
||||
className: cn(total && "font-semibold"),
|
||||
},
|
||||
{
|
||||
...commun,
|
||||
id: `${g.id}-evolution`,
|
||||
header: <EnTete groupe={g.titre}>Évolution</EnTete>,
|
||||
header: "Évolution",
|
||||
hint: `Évolution du chiffre d'affaires (${g.titre}) entre ${libelleMoisN1} et ${libelleMois}`,
|
||||
align: "right",
|
||||
sortValue: (r) => evolution(r[g.ca], r[g.caN1]),
|
||||
cell: (r) => <DeltaBadge pct={evolution(r[g.ca], r[g.caN1])} />,
|
||||
footer: (rows) => <DeltaBadge pct={evolution(somme(rows, g.ca), somme(rows, g.caN1))} />,
|
||||
|
||||
@@ -141,7 +141,7 @@ export default async function AnalyticsPage(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-2xl">
|
||||
<div className="w-full min-w-0">
|
||||
<PageHeader
|
||||
icon={CalendarRange}
|
||||
title="Ventes par mois"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition, type ReactNode } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table";
|
||||
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";
|
||||
@@ -68,56 +68,45 @@ function CelluleStock({ v }: { v: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */
|
||||
function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className="flex flex-col items-end leading-tight">
|
||||
<span className="text-xs font-normal text-[var(--text-muted)]">{groupe}</span>
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function colonnesGroupe(g: Groupe): DataColumn<Ligne>[] {
|
||||
const total = g.id === "total";
|
||||
const bord = "border-l border-[var(--border)]";
|
||||
// En-tête commun, séparation et teinte du total : gérés par DataTable.
|
||||
const commun = { group: g.titre, highlight: total, align: "right" as const };
|
||||
return [
|
||||
{
|
||||
...commun,
|
||||
id: `${g.id}-qte`,
|
||||
header: <EnTete groupe={g.titre}>Qté</EnTete>,
|
||||
header: "Qté",
|
||||
hint: `Quantité vendue (${g.nom}) sur la période, retours déduits`,
|
||||
align: "right",
|
||||
sortValue: r => r[g.qte],
|
||||
cell: r => (r[g.qte] !== 0 ? fmtEntier(r[g.qte]) : vide),
|
||||
footer: rows => fmtEntier(somme(rows, g.qte)),
|
||||
className: cn(bord, total && "font-semibold"),
|
||||
headerClassName: bord,
|
||||
className: cn(total && "font-semibold"),
|
||||
},
|
||||
{
|
||||
...commun,
|
||||
id: `${g.id}-ca`,
|
||||
header: <EnTete groupe={g.titre}>CA TTC</EnTete>,
|
||||
header: "CA TTC",
|
||||
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)),
|
||||
className: cn("whitespace-nowrap", total && "font-semibold"),
|
||||
cell: r => (r[g.ca] !== 0 ? fmtEur2(r[g.ca]) : vide),
|
||||
footer: rows => fmtEur2(somme(rows, g.ca)),
|
||||
className: cn(total && "font-semibold"),
|
||||
},
|
||||
{
|
||||
...commun,
|
||||
id: `${g.id}-marge`,
|
||||
header: <EnTete groupe={g.titre}>% marge</EnTete>,
|
||||
header: "% marge",
|
||||
hint: `Taux de marge (${g.nom}) : marge divisée par le chiffre d'affaires TTC`,
|
||||
align: "right",
|
||||
sortValue: r => tauxMarge(r[g.ca], r[g.marge]),
|
||||
cell: r => <CelluleMarge taux={tauxMarge(r[g.ca], r[g.marge])} />,
|
||||
footer: rows => <CelluleMarge taux={tauxMarge(somme(rows, g.ca), somme(rows, g.marge))} />,
|
||||
className: "whitespace-nowrap",
|
||||
},
|
||||
{
|
||||
...commun,
|
||||
id: `${g.id}-stock`,
|
||||
header: <EnTete groupe={g.titre}>Stock</EnTete>,
|
||||
header: "Stock",
|
||||
hint: `Stock actuel (${g.nom}). En rouge : stock négatif`,
|
||||
align: "right",
|
||||
sortValue: r => r[g.stock],
|
||||
cell: r => <CelluleStock v={r[g.stock]} />,
|
||||
footer: rows => fmtEntier(somme(rows, g.stock)),
|
||||
@@ -133,6 +122,7 @@ const COLONNES: DataColumn<Ligne>[] = [
|
||||
cell: r => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codein}</span>,
|
||||
footer: () => "Total",
|
||||
className: "whitespace-nowrap",
|
||||
sticky: true,
|
||||
},
|
||||
{
|
||||
id: "libelle",
|
||||
@@ -140,7 +130,17 @@ const COLONNES: DataColumn<Ligne>[] = [
|
||||
sortValue: r => r.libelle.trim(),
|
||||
cell: r => <span className="font-medium">{r.libelle.trim()}</span>,
|
||||
footer: rows => <span className="whitespace-nowrap">{fmtEntier(rows.length)} articles</span>,
|
||||
className: "min-w-[220px]",
|
||||
className: "min-w-[140px] sm:min-w-[220px]",
|
||||
sticky: true,
|
||||
label: true,
|
||||
},
|
||||
{
|
||||
id: "reference",
|
||||
header: "Référence",
|
||||
hint: "Référence fournisseur de l'article",
|
||||
sortValue: r => r.reference,
|
||||
cell: r => (r.reference ? <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.reference}</span> : vide),
|
||||
className: "whitespace-nowrap",
|
||||
},
|
||||
{
|
||||
id: "fournisseur",
|
||||
@@ -170,7 +170,7 @@ const FILTRES: DataFilter<Ligne>[] = [
|
||||
{ id: "famille", label: "Famille", valueOf: famille, allLabel: "Toutes les familles" },
|
||||
];
|
||||
|
||||
const recherche = (r: Ligne) => [r.codein, r.libelle];
|
||||
const recherche = (r: Ligne) => [r.codein, r.reference, r.libelle];
|
||||
|
||||
interface Props {
|
||||
dateDebut: string;
|
||||
@@ -204,21 +204,22 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
feuille: "Meilleures ventes",
|
||||
fichier: `meilleures-ventes_${dateDebut}_${dateFin}`,
|
||||
entetes: [
|
||||
"Code", "Désignation", "Fournisseur", "Nomenclature",
|
||||
"Code", "Désignation", "Référence", "Fournisseur", "Nomenclature",
|
||||
...GROUPES.flatMap(g => [
|
||||
`Qté ${nomExport(g)}`, `CA TTC ${nomExport(g)}`, `% Marge ${nomExport(g)}`, `Stock ${nomExport(g)}`,
|
||||
]),
|
||||
],
|
||||
largeurs: [12, 40, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])],
|
||||
largeurs: [12, 40, 18, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])],
|
||||
lignes: rows.map(r => [
|
||||
r.codein,
|
||||
r.libelle.trim(),
|
||||
r.reference,
|
||||
r.fournisseur,
|
||||
r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "",
|
||||
...GROUPES.flatMap(g => [r[g.qte], r[g.ca], pct(r[g.ca], r[g.marge]), r[g.stock]]),
|
||||
]),
|
||||
totaux: [
|
||||
`TOTAL — ${rows.length} articles`, "", "", "",
|
||||
`TOTAL — ${rows.length} articles`, "", "", "", "",
|
||||
...GROUPES.flatMap(g => {
|
||||
const ca = somme(rows, g.ca);
|
||||
return [somme(rows, g.qte), ca, pct(ca, somme(rows, g.marge)), somme(rows, g.stock)];
|
||||
@@ -266,7 +267,7 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
columns={COLONNES}
|
||||
rowKey={r => r.codein}
|
||||
searchIn={recherche}
|
||||
searchPlaceholder="Code ou désignation…"
|
||||
searchPlaceholder="Code, référence ou désignation…"
|
||||
filters={FILTRES}
|
||||
pageSize={100}
|
||||
unite="produits"
|
||||
|
||||
@@ -13,6 +13,8 @@ export const metadata: Metadata = { title: "Meilleures ventes" };
|
||||
export interface HitParadePivotRow {
|
||||
codein: string;
|
||||
libelle: string;
|
||||
/** Référence fournisseur de l'article. */
|
||||
reference: string;
|
||||
fournisseur: string;
|
||||
nomenclature_code: string;
|
||||
nomenclature: string;
|
||||
@@ -38,6 +40,7 @@ function pivotHitParade(rows: HitParadeRow[]): HitParadePivotRow[] {
|
||||
map.set(row.codein, {
|
||||
codein: row.codein,
|
||||
libelle: row.libelle,
|
||||
reference: row.reference ?? "",
|
||||
fournisseur: row.fournisseur,
|
||||
nomenclature_code: row.nomenclature_code,
|
||||
nomenclature: row.nomenclature,
|
||||
@@ -100,7 +103,7 @@ export default async function HitParadePage(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-2xl">
|
||||
<div className="w-full min-w-0">
|
||||
<PageHeader
|
||||
icon={Trophy}
|
||||
title="Meilleures ventes"
|
||||
|
||||
@@ -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'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'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>}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -76,7 +76,7 @@ function StatutBadge({ statut }: { statut?: string }) {
|
||||
/** Intitulé d'abord, code de l'opération en petit dessous. */
|
||||
function Operation({ intitule, code }: { intitule: string; code: string }) {
|
||||
return (
|
||||
<div className="min-w-[220px]">
|
||||
<div className="min-w-[140px] sm:min-w-[220px]">
|
||||
<div className="font-medium text-[var(--text-primary)]">{intitule || "Opération sans intitulé"}</div>
|
||||
{code && <div className="text-xs text-[var(--text-muted)]">Code {code}</div>}
|
||||
</div>
|
||||
@@ -171,6 +171,8 @@ export function PublicitesClient({
|
||||
header: "Opération",
|
||||
sortValue: (p) => p.intitule,
|
||||
cell: (p) => <Operation intitule={p.intitule} code={p.tcr_code} />,
|
||||
sticky: true,
|
||||
label: true,
|
||||
footer: (rows) =>
|
||||
`Total${paginee ? " de la page" : ""} · ${fmtEntier(rows.length)} publicité${rows.length > 1 ? "s" : ""}`,
|
||||
},
|
||||
@@ -205,8 +207,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",
|
||||
@@ -261,6 +263,8 @@ export function PublicitesClient({
|
||||
header: "Opération",
|
||||
sortValue: (h) => h.intitule,
|
||||
cell: (h) => <Operation intitule={h.intitule} code={h.tcr_code} />,
|
||||
sticky: true,
|
||||
label: true,
|
||||
footer: (rows) => `Total · ${fmtEntier(rows.length)} opération${rows.length > 1 ? "s" : ""}`,
|
||||
},
|
||||
{
|
||||
@@ -297,8 +301,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",
|
||||
|
||||
@@ -120,7 +120,7 @@ export default async function PublicitesPage(props: {
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-2xl">
|
||||
<div className="w-full min-w-0">
|
||||
<PageHeader
|
||||
icon={Megaphone}
|
||||
title="Publicités"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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" }, …] }
|
||||
*
|
||||
* Propose la gamme de plusieurs articles d'un fournisseur en un appel. Les
|
||||
* propositions restent **à valider** dans la Grille (cf. lib/api-gammes).
|
||||
* 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,
|
||||
aValider: res.resultats.filter((r) => r.statut === "a_valider").length,
|
||||
enregistreLe: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,13 @@ 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) "
|
||||
+ "**proposent** une gamme. La proposition reste **à valider** : elle apparaît dans la Grille "
|
||||
+ "comme une modification non enregistrée, pour voir quels produits changent de gamme, et "
|
||||
+ "n'est appliquée qu'une fois validée par un utilisateur (« Enregistrer »). D'ici là, "
|
||||
+ "`codeGamme` est inchangée et la proposition figure dans `gammeAValider`. Une fois validée, "
|
||||
+ "`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 +151,10 @@ 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 validées dans CollectFlow comprises." },
|
||||
gammeAValider: { type: ["string", "null"], description: "Gamme proposée par l'API et pas encore validée dans la Grille. `null` = aucune proposition en attente." },
|
||||
totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
|
||||
totalQuantite: { type: "number" },
|
||||
totalMarge: { type: "number" },
|
||||
@@ -162,6 +176,17 @@ 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 proposée." },
|
||||
gammePrecedente: { type: ["string", "null"], description: "Gamme enregistrée au moment de l'appel." },
|
||||
codeGammeServeur: { type: ["string", "null"], description: "Gamme en base FF, non modifiée par l'API." },
|
||||
modifie: { type: "boolean", description: "true = proposition déposée, à valider dans la Grille. false = l'article a déjà cette gamme (une proposition en attente pour lui est retirée)." },
|
||||
statut: { type: "string", enum: ["a_valider", "inchangee"], description: "`a_valider` : en attente de validation dans la Grille." },
|
||||
},
|
||||
},
|
||||
Trend: {
|
||||
type: ["object", "null"],
|
||||
description:
|
||||
@@ -266,7 +291,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 +391,119 @@ export async function GET(req: NextRequest) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/products/{codein}/gamme": {
|
||||
put: {
|
||||
operationId: "changerGammeProduit",
|
||||
summary: "Proposer la gamme d'un produit (à valider)",
|
||||
description:
|
||||
"Propose une gamme pour un article. Elle reste à valider dans la Grille, où elle apparaît "
|
||||
+ "comme une modification non enregistrée. 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 proposée, à valider",
|
||||
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: "Proposer la gamme de plusieurs produits (à valider)",
|
||||
description:
|
||||
"Propose en un appel les gammes de plusieurs articles d'un même fournisseur, à valider "
|
||||
+ "dans la Grille. Tout ou rien : si un article est inconnu chez le fournisseur, rien n'est proposé "
|
||||
+ "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 proposées, à valider",
|
||||
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,72 @@
|
||||
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"?: "…" }
|
||||
*
|
||||
* Propose une gamme pour un article. La proposition reste **à valider** : elle
|
||||
* apparaît dans la Grille comme une modification non enregistrée, et `codeGamme`
|
||||
* ne change qu'une fois validée (« Enregistrer » dans la Grille).
|
||||
*
|
||||
* `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() } },
|
||||
);
|
||||
}
|
||||
+32
-1
@@ -578,4 +578,35 @@
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
}
|
||||
/* ── Tableau de données commun (components/ui/data-table.tsx) ──
|
||||
Fonds opaques partout : les colonnes figées recouvrent les cellules qui
|
||||
défilent dessous. La ligne porte la couleur, les cellules en héritent. */
|
||||
.dt-table {
|
||||
/* Une ligne sur deux, plus soutenue : suivre une ligne sur un écran large. */
|
||||
--dt-zebra: color-mix(in srgb, var(--text-primary) 3.5%, var(--bg-surface));
|
||||
/* Survol teinté de la couleur d'accent : la ligne pointée se distingue de l'alternance. */
|
||||
--dt-hover: color-mix(in srgb, var(--accent) 10%, var(--bg-surface));
|
||||
}
|
||||
.dt-row {
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
.dt-row:nth-child(even) {
|
||||
background-color: var(--dt-zebra);
|
||||
}
|
||||
.dt-row:hover {
|
||||
background-color: var(--dt-hover);
|
||||
}
|
||||
.dt-sticky {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
background-color: inherit;
|
||||
}
|
||||
/* Ombre portée sur la dernière colonne figée : on voit ce qui passe dessous. */
|
||||
.dt-sticky-last {
|
||||
box-shadow: inset -1px 0 0 var(--border-strong), 4px 0 6px -4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
/* Teinte superposée au fond de la ligne (alternance et survol conservés). */
|
||||
.dt-highlight {
|
||||
background-image: linear-gradient(var(--accent-bg), var(--accent-bg));
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
* en réimplémentaient chacune une partie (13 tableaux faits à la main).
|
||||
*/
|
||||
|
||||
import { useDeferredValue, useMemo, useState, type ReactNode } from "react";
|
||||
import { useDeferredValue, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { ChevronDown, ChevronUp, ChevronsUpDown, Download, Loader2, RotateCcw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SearchInput, Select } from "@/components/ui/form-controls";
|
||||
@@ -33,6 +33,26 @@ export interface DataColumn<T> {
|
||||
footer?: (rows: readonly T[]) => ReactNode;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
/**
|
||||
* Colonne de libellé (désignation, fournisseur…) : largeur naturelle, avec
|
||||
* retour à la ligne au-delà de 28rem. Le tableau ne s'étire jamais au-delà
|
||||
* de son contenu : sur un écran très large, le libellé reste collé à ses
|
||||
* chiffres au lieu d'en être séparé par un grand vide.
|
||||
*/
|
||||
label?: boolean;
|
||||
/**
|
||||
* Colonne figée à gauche pendant le défilement horizontal. Seules les
|
||||
* premières colonnes consécutives peuvent l'être.
|
||||
*/
|
||||
sticky?: boolean;
|
||||
/**
|
||||
* Groupe de colonnes (un magasin, « Total »…) : un en-tête commun est
|
||||
* affiché au-dessus des colonnes consécutives du même groupe, et un trait
|
||||
* vertical sépare les groupes.
|
||||
*/
|
||||
group?: string;
|
||||
/** Fond légèrement teinté, pour faire ressortir un groupe (le total…). */
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
export interface DataFilter<T> {
|
||||
@@ -198,6 +218,130 @@ export function DataTable<T>({
|
||||
const alignement = (a?: "left" | "right" | "center") =>
|
||||
a === "right" ? "text-right justify-end" : a === "center" ? "text-center justify-center" : "text-left";
|
||||
|
||||
// Colonnes figées : seulement les premières, consécutives.
|
||||
let nbFigees = 0;
|
||||
while (nbFigees < columns.length && columns[nbFigees].sticky) nbFigees++;
|
||||
|
||||
// Début de groupe : trait vertical à gauche de la colonne.
|
||||
const debutGroupe = (i: number) => Boolean(columns[i].group) && columns[i - 1]?.group !== columns[i].group;
|
||||
const avecGroupes = columns.some((c) => c.group);
|
||||
|
||||
// Décalage gauche de chaque colonne figée = largeur cumulée des précédentes,
|
||||
// mesurée sur l'en-tête et exposée en variables CSS sur le tableau.
|
||||
const tableRef = useRef<HTMLTableElement>(null);
|
||||
const [decalages, setDecalages] = useState<number[]>([]);
|
||||
useLayoutEffect(() => {
|
||||
const table = tableRef.current;
|
||||
if (!table || nbFigees === 0) return;
|
||||
const mesurer = () => {
|
||||
const out: number[] = [];
|
||||
let gauche = 0;
|
||||
for (let i = 0; i < nbFigees; i++) {
|
||||
out.push(gauche);
|
||||
const th = table.querySelector<HTMLElement>(`thead th[data-col="${i}"]`);
|
||||
gauche += th?.getBoundingClientRect().width ?? 0;
|
||||
}
|
||||
setDecalages((prev) => (prev.length === out.length && prev.every((v, i) => v === out[i]) ? prev : out));
|
||||
};
|
||||
mesurer();
|
||||
const obs = new ResizeObserver(mesurer);
|
||||
obs.observe(table);
|
||||
return () => obs.disconnect();
|
||||
}, [nbFigees, total, columns]);
|
||||
|
||||
const styleFigee = (i: number): CSSProperties | undefined =>
|
||||
i < nbFigees ? { left: decalages[i] ?? 0 } : undefined;
|
||||
|
||||
/** Classes communes à toutes les cellules d'une colonne (en-tête, corps, totaux). */
|
||||
const classesColonne = (c: DataColumn<T>, i: number) => cn(
|
||||
i < nbFigees && "dt-sticky",
|
||||
i === nbFigees - 1 && "dt-sticky-last",
|
||||
c.highlight && "dt-highlight",
|
||||
debutGroupe(i) && "border-l border-[var(--border-strong)]",
|
||||
);
|
||||
|
||||
/** Contenu d'une cellule de libellé, borné en largeur (cf. `label`). */
|
||||
const borne = (c: DataColumn<T>, i: number, contenu: ReactNode) =>
|
||||
c.label ? (
|
||||
// Libellé figé : plus étroit sur mobile pour laisser voir les chiffres.
|
||||
<div className={i < nbFigees ? "max-w-[45vw] md:max-w-[28rem]" : "max-w-[28rem]"}>{contenu}</div>
|
||||
) : contenu;
|
||||
|
||||
const enTete = (c: DataColumn<T>, i: number, rowSpan?: number) => {
|
||||
const triable = Boolean(c.sortValue);
|
||||
const actif = tri?.id === c.id;
|
||||
const contenu = (
|
||||
<span className={cn("inline-flex items-center gap-1", alignement(c.align))}>
|
||||
{c.header}
|
||||
{triable && (
|
||||
actif
|
||||
? (tri!.dir === "asc" ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />)
|
||||
: <ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<th
|
||||
key={c.id}
|
||||
scope="col"
|
||||
data-col={i}
|
||||
rowSpan={rowSpan}
|
||||
aria-sort={actif ? (tri!.dir === "asc" ? "ascending" : "descending") : undefined}
|
||||
style={styleFigee(i)}
|
||||
className={cn(
|
||||
"whitespace-nowrap border-b border-[var(--border-strong)] px-3 py-2.5 text-[13px] font-semibold text-[var(--text-secondary)] align-bottom",
|
||||
alignement(c.align),
|
||||
classesColonne(c, i),
|
||||
c.headerClassName,
|
||||
)}
|
||||
>
|
||||
{triable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => basculerTri(c.id)}
|
||||
className="hover:text-[var(--text-primary)]"
|
||||
title={typeof c.hint === "string" ? c.hint : undefined}
|
||||
>
|
||||
{contenu}
|
||||
</button>
|
||||
) : c.hint ? (
|
||||
<Tooltip content={c.hint}><span className="cursor-help">{contenu}</span></Tooltip>
|
||||
) : contenu}
|
||||
</th>
|
||||
);
|
||||
};
|
||||
|
||||
// Première ligne d'en-tête quand il y a des groupes : un titre par groupe,
|
||||
// et les colonnes sans groupe sur deux lignes.
|
||||
const ligneGroupes: ReactNode[] = [];
|
||||
if (avecGroupes) {
|
||||
for (let i = 0; i < columns.length;) {
|
||||
const c = columns[i];
|
||||
if (!c.group) {
|
||||
ligneGroupes.push(enTete(c, i, 2));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (j < columns.length && columns[j].group === c.group) j++;
|
||||
ligneGroupes.push(
|
||||
<th
|
||||
key={`groupe-${i}`}
|
||||
scope="colgroup"
|
||||
colSpan={j - i}
|
||||
className={cn(
|
||||
"whitespace-nowrap border-b border-l border-[var(--border)] px-3 pt-2 pb-1 text-center text-[13px] font-semibold text-[var(--text-primary)]",
|
||||
"border-l-[var(--border-strong)]",
|
||||
columns.slice(i, j).every((x) => x.highlight) && "dt-highlight",
|
||||
)}
|
||||
>
|
||||
{c.group}
|
||||
</th>,
|
||||
);
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* Barre d'outils */}
|
||||
@@ -249,52 +393,22 @@ export function DataTable<T>({
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="overflow-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
|
||||
// Largeur du contenu, sans dépasser l'écran (au-delà : défilement).
|
||||
className="w-fit max-w-full overflow-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--bg-elevated)]">
|
||||
<tr>
|
||||
{columns.map((c) => {
|
||||
const triable = Boolean(c.sortValue);
|
||||
const actif = tri?.id === c.id;
|
||||
const contenu = (
|
||||
<span className={cn("inline-flex items-center gap-1", alignement(c.align))}>
|
||||
{c.header}
|
||||
{triable && (
|
||||
actif
|
||||
? (tri!.dir === "asc" ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />)
|
||||
: <ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<th
|
||||
key={c.id}
|
||||
scope="col"
|
||||
aria-sort={actif ? (tri!.dir === "asc" ? "ascending" : "descending") : undefined}
|
||||
className={cn(
|
||||
"whitespace-nowrap border-b border-[var(--border-strong)] px-3 py-2.5 text-[13px] font-semibold text-[var(--text-secondary)]",
|
||||
alignement(c.align),
|
||||
c.headerClassName,
|
||||
)}
|
||||
>
|
||||
{triable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => basculerTri(c.id)}
|
||||
className="hover:text-[var(--text-primary)]"
|
||||
title={typeof c.hint === "string" ? c.hint : undefined}
|
||||
>
|
||||
{contenu}
|
||||
</button>
|
||||
) : c.hint ? (
|
||||
<Tooltip content={c.hint}><span className="cursor-help">{contenu}</span></Tooltip>
|
||||
) : contenu}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
<table ref={tableRef} className="dt-table text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
{avecGroupes ? (
|
||||
<>
|
||||
<tr className="bg-[var(--bg-elevated)]">{ligneGroupes}</tr>
|
||||
<tr className="bg-[var(--bg-elevated)]">
|
||||
{columns.map((c, i) => (c.group ? enTete(c, i) : null))}
|
||||
</tr>
|
||||
</>
|
||||
) : (
|
||||
<tr className="bg-[var(--bg-elevated)]">{columns.map((c, i) => enTete(c, i))}</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{lignesAffichees.map((r, i) => (
|
||||
@@ -302,39 +416,43 @@ export function DataTable<T>({
|
||||
key={rowKey(r, i)}
|
||||
onClick={onRowClick ? () => onRowClick(r) : undefined}
|
||||
className={cn(
|
||||
"border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--bg-elevated)]",
|
||||
"dt-row border-b border-[var(--border)] last:border-b-0",
|
||||
onRowClick && "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
{columns.map((c, ci) => (
|
||||
<td
|
||||
key={c.id}
|
||||
style={styleFigee(ci)}
|
||||
className={cn(
|
||||
"px-3 py-2 text-[var(--text-primary)]",
|
||||
c.align === "right" && "text-right tabular-nums",
|
||||
c.align === "right" && "text-right tabular-nums whitespace-nowrap",
|
||||
c.align === "center" && "text-center",
|
||||
classesColonne(c, ci),
|
||||
c.className,
|
||||
)}
|
||||
>
|
||||
{c.cell(r)}
|
||||
{borne(c, ci, c.cell(r))}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{showFooter && (
|
||||
<tfoot className="sticky bottom-0 bg-[var(--bg-elevated)] font-semibold">
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<tfoot className="sticky bottom-0 z-10 font-semibold">
|
||||
<tr className="bg-[var(--bg-elevated)]">
|
||||
{columns.map((c, ci) => (
|
||||
<td
|
||||
key={c.id}
|
||||
style={styleFigee(ci)}
|
||||
className={cn(
|
||||
"border-t border-[var(--border-strong)] px-3 py-2.5 text-[var(--text-primary)]",
|
||||
c.align === "right" && "text-right tabular-nums",
|
||||
c.align === "right" && "text-right tabular-nums whitespace-nowrap",
|
||||
c.align === "center" && "text-center",
|
||||
classesColonne(c, ci),
|
||||
)}
|
||||
>
|
||||
{c.footer ? c.footer(triees) : null}
|
||||
{c.footer ? borne(c, ci, c.footer(triees)) : null}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
+24
-1
@@ -1,4 +1,4 @@
|
||||
import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean } from "drizzle-orm/pg-core";
|
||||
import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean, primaryKey } from "drizzle-orm/pg-core";
|
||||
|
||||
export const ventesProduits = pgTable("ventes_produits", {
|
||||
id: serial("id").primaryKey(),
|
||||
@@ -283,3 +283,26 @@ export const aiSupplierContext = pgTable("ai_supplier_context", {
|
||||
/** When it was last updated */
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Gammes proposées par l'API `/api/v1`, en attente de validation.
|
||||
*
|
||||
* L'API n'enregistre plus directement : chaque proposition attend ici qu'un
|
||||
* utilisateur l'examine dans la Grille, où elle apparaît comme une modification
|
||||
* non enregistrée. « Enregistrer » la valide (snapshot + `grid_rows`), « Annuler
|
||||
* les modifications » la rejette ; dans les deux cas la ligne est supprimée.
|
||||
* Une seule proposition par article et fournisseur : la dernière l'emporte.
|
||||
*/
|
||||
export const gammesAValider = pgTable("gammes_a_valider", {
|
||||
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
|
||||
codein: varchar("codein", { length: 20 }).notNull(),
|
||||
/** Gamme proposée. */
|
||||
gamme: varchar("gamme", { length: 20 }).notNull(),
|
||||
/** Gamme enregistrée au moment de la proposition. */
|
||||
gammePrecedente: varchar("gamme_precedente", { length: 20 }),
|
||||
/** Nom de la clé d'API à l'origine de la proposition. */
|
||||
auteur: varchar("auteur", { length: 100 }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.codeFournisseur, table.codein] }),
|
||||
]);
|
||||
@@ -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: "Proposer la gamme d'un produit, à valider dans la Révision d'assortiment — corps : { \"gamme\": \"A\" }" },
|
||||
{ method: "POST", path: "/gammes", desc: "Proposer la gamme de plusieurs produits d'un fournisseur en un appel, à valider dans la Révision d'assortiment" },
|
||||
{ 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,80 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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 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,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { verifierSession } from "@/lib/authz";
|
||||
import { listerGammesAValider, supprimerGammesAValider, type GammeAValider } from "@/lib/gammes-a-valider";
|
||||
|
||||
/** Gammes proposées par l'API pour un fournisseur, en attente de validation. */
|
||||
export async function getGammesAValider(
|
||||
codeFournisseur: string,
|
||||
): Promise<{ success: boolean; propositions: GammeAValider[]; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, propositions: [], error: acces.message };
|
||||
try {
|
||||
return { success: true, propositions: await listerGammesAValider(String(codeFournisseur)) };
|
||||
} catch (err) {
|
||||
return { success: false, propositions: [], error: err instanceof Error ? err.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
const RejetSchema = z.object({
|
||||
codeFournisseur: z.string().min(1),
|
||||
codeins: z.array(z.string()),
|
||||
});
|
||||
|
||||
/** Rejette des propositions de l'API (« Annuler les modifications » dans la Grille). */
|
||||
export async function rejeterGammesAValider(raw: unknown): Promise<{ success: boolean; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
const parsed = RejetSchema.safeParse(raw);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed: " + parsed.error.message };
|
||||
try {
|
||||
await supprimerGammesAValider(parsed.data.codeFournisseur, parsed.data.codeins);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"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";
|
||||
import { supprimerGammesAValider } from "@/lib/gammes-a-valider";
|
||||
|
||||
const SaveDraftsSchema = z.object({
|
||||
codeFournisseur: z.string(),
|
||||
@@ -19,6 +16,11 @@ const SaveDraftsSchema = z.object({
|
||||
codeGamme: z.string(),
|
||||
})
|
||||
),
|
||||
/**
|
||||
* Propositions de l'API examinées dans la Grille : validées (présentes dans
|
||||
* `changes`) ou écartées. Elles quittent la file « à valider ».
|
||||
*/
|
||||
gammesAValiderTraitees: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export async function saveDraftChanges(
|
||||
@@ -32,56 +34,16 @@ export async function saveDraftChanges(
|
||||
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
|
||||
}
|
||||
|
||||
const { codeFournisseur, nomFournisseur, magasin, changes } = parsed.data;
|
||||
const { codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees = [] } = parsed.data;
|
||||
|
||||
const rawUserId = acces.utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
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 });
|
||||
await supprimerGammesAValider(codeFournisseur, gammesAValiderTraitees);
|
||||
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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
||||
import { ChevronDown, Download, FileSpreadsheet, FileText, History, Loader2, RotateCcw, Save, Table2 } from "lucide-react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { saveSnapshot } from "@/features/snapshots/api/save-snapshot";
|
||||
import { rejeterGammesAValider } from "@/features/grid/api/gammes-a-valider-actions";
|
||||
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -19,13 +20,14 @@ 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,
|
||||
exporterGammeA,
|
||||
exporterTousProduits,
|
||||
imprimerPdf,
|
||||
LIGNES_MAX_PAR_FICHIER_GAMMES,
|
||||
lignesModifiees,
|
||||
} from "@/features/grid/lib/exports";
|
||||
|
||||
@@ -41,6 +43,14 @@ function Stat({ label, value, sub, subColor }: { label: string; value: string; s
|
||||
);
|
||||
}
|
||||
|
||||
/** Prévient quand le fichier d'import des gammes a été découpé ; vrai si quelque chose a été exporté. */
|
||||
function signalerDecoupage(nbFichiers: number): boolean {
|
||||
if (nbFichiers > 1) {
|
||||
toast.info(`Export découpé en ${nbFichiers} fichiers de ${fmtEntier(LIGNES_MAX_PAR_FICHIER_GAMMES)} lignes maximum : intégrez-les un par un.`);
|
||||
}
|
||||
return nbFichiers > 0;
|
||||
}
|
||||
|
||||
function messageErreur(err: unknown): string {
|
||||
if (isStaleServerActionError(err)) return STALE_ACTION_MESSAGE;
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
@@ -62,7 +72,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [enCours, setEnCours] = useState<string | null>(null);
|
||||
|
||||
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL");
|
||||
const { save, hasDrafts, count, nbAValider } = useSaveDrafts(activeMagasin || "TOTAL");
|
||||
|
||||
const supplierCode = filterFournisseur || rows[0]?.codeFournisseur;
|
||||
const lastQlikUpdate = useMemo(() => {
|
||||
@@ -77,7 +87,9 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await save();
|
||||
if (result.success) {
|
||||
if (result.success && result.saved === 0) {
|
||||
toast.info("Propositions de l'API écartées.");
|
||||
} else if (result.success) {
|
||||
toast.succes(`${result.saved} gamme${result.saved > 1 ? "s" : ""} enregistrée${result.saved > 1 ? "s" : ""}.`);
|
||||
} else {
|
||||
toast.erreur(`Enregistrement impossible : ${result.error ?? "erreur inconnue"}`);
|
||||
@@ -89,16 +101,36 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
const pluriel = count > 1 ? "s" : "";
|
||||
const message = count > 0
|
||||
? `Les ${count} gamme${pluriel} modifiée${pluriel} et non enregistrée${pluriel} reviendront à leur valeur précédente.`
|
||||
: "";
|
||||
const ok = await confirmer({
|
||||
titre: "Annuler les modifications ?",
|
||||
message: `Les ${count} gamme${count > 1 ? "s" : ""} modifiée${count > 1 ? "s" : ""} et non enregistrée${count > 1 ? "s" : ""} reviendront à leur valeur précédente.`,
|
||||
message: nbAValider > 0
|
||||
? `${message} ${nbAValider > 1 ? `Les ${nbAValider} gammes proposées` : "La gamme proposée"} par l'API ${nbAValider > 1 ? "seront rejetées" : "sera rejetée"}.`.trim()
|
||||
: message,
|
||||
libelleConfirmer: "Annuler les modifications",
|
||||
danger: true,
|
||||
});
|
||||
if (ok) {
|
||||
resetDrafts();
|
||||
toast.info("Modifications annulées.");
|
||||
if (!ok) return;
|
||||
if (nbAValider > 0) {
|
||||
const { gammesAValider, rowsByCodein, retirerGammesAValider } = useGridStore.getState();
|
||||
const codeins = Object.keys(gammesAValider).filter((c) => rowsByCodein[c]);
|
||||
const codeFournisseur = filterFournisseur || rows[0]?.codeFournisseur;
|
||||
try {
|
||||
const res = codeFournisseur
|
||||
? await rejeterGammesAValider({ codeFournisseur, codeins })
|
||||
: { success: false, error: "fournisseur inconnu" };
|
||||
if (!res.success) throw new Error(res.error);
|
||||
retirerGammesAValider(codeins);
|
||||
} catch (err) {
|
||||
toast.erreur(`Les propositions de l'API n'ont pas pu être rejetées : ${messageErreur(err)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
resetDrafts();
|
||||
toast.info("Modifications annulées.");
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -165,10 +197,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)}
|
||||
/>
|
||||
@@ -194,7 +226,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
|
||||
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");
|
||||
return exporterFichierGammes(true);
|
||||
return exporterFichierGammes(true).then(signalerDecoupage);
|
||||
}, "Aucune gamme n'a été modifiée par rapport à FF.")}
|
||||
>
|
||||
<FileSpreadsheet />
|
||||
@@ -205,7 +237,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={() => lancer("complet", () => exporterFichierGammes(false), "Aucun produit à exporter.")}
|
||||
onSelect={() => lancer("complet", () => exporterFichierGammes(false).then(signalerDecoupage), "Aucun produit à exporter.")}
|
||||
>
|
||||
<FileSpreadsheet />
|
||||
<div className="flex flex-col">
|
||||
@@ -256,6 +288,14 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{nbAValider > 0 && (
|
||||
<span
|
||||
className="rounded-lg border px-2.5 py-1 text-[13px] font-medium border-[var(--accent-warning)] text-[var(--accent-warning)]"
|
||||
title="Gammes proposées par l'API : elles apparaissent comme des modifications non enregistrées. « Enregistrer » les valide, « Annuler » les rejette."
|
||||
>
|
||||
{nbAValider} gamme{nbAValider > 1 ? "s" : ""} proposée{nbAValider > 1 ? "s" : ""} par l'API à valider
|
||||
</span>
|
||||
)}
|
||||
{hasDrafts && (
|
||||
<Button variant="ghost" onClick={handleReset} disabled={isPending}>
|
||||
<RotateCcw /> Annuler
|
||||
@@ -265,9 +305,11 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Save />}
|
||||
{isPending
|
||||
? "Enregistrement…"
|
||||
: hasDrafts
|
||||
: count > 0
|
||||
? `Enregistrer ${count} modification${count > 1 ? "s" : ""}`
|
||||
: "Aucune modification"}
|
||||
: hasDrafts
|
||||
? `Écarter ${nbAValider > 1 ? "les propositions" : "la proposition"} de l'API`
|
||||
: "Aucune modification"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolba
|
||||
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
|
||||
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useStorePatch } from "@/features/grid/hooks/use-store-patch";
|
||||
import { getGammesAValider } from "@/features/grid/api/gammes-a-valider-actions";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -231,6 +232,26 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
return () => controller.abort();
|
||||
}, [codeFournisseur, refreshRequest, setRows, isMounted]);
|
||||
|
||||
// Gammes proposées par l'API : relues une fois les lignes du fournisseur
|
||||
// chargées (ou retrouvées en mémoire), puis affichées comme modifications
|
||||
// non enregistrées, à valider.
|
||||
const lignesChargeesLe = useGridStore((s) => (s.rowsMeta?.key === rowsKeyFor(codeFournisseur) ? s.rowsMeta.loadedAt : null));
|
||||
useEffect(() => {
|
||||
if (!isMounted || lignesChargeesLe === null) return;
|
||||
let annule = false;
|
||||
getGammesAValider(codeFournisseur)
|
||||
.then((res) => {
|
||||
if (annule) return;
|
||||
if (!res.success) {
|
||||
console.error("[grid] gammes à valider KO:", res.error);
|
||||
return;
|
||||
}
|
||||
useGridStore.getState().chargerGammesAValider(res.propositions);
|
||||
})
|
||||
.catch((e) => console.error("[grid] gammes à valider KO:", e));
|
||||
return () => { annule = true; };
|
||||
}, [codeFournisseur, lignesChargeesLe, isMounted]);
|
||||
|
||||
// Compléments de l'API FF pour le magasin choisi, appliqués à leur arrivée.
|
||||
useStorePatch(codeFournisseur, isMounted);
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -246,6 +244,9 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
|
||||
const isDraft = useGridStore((s) => s.draftChanges[codein] !== undefined);
|
||||
const effectiveGamme = useGridStore((s) => s.draftChanges[codein] ?? row.codeGamme);
|
||||
const setDraftGamme = useGridStore((s) => s.setDraftGamme);
|
||||
// Proposition de l'API affichée telle quelle : à valider.
|
||||
const proposition = useGridStore((s) => s.gammesAValider[codein]);
|
||||
const proposeeParApi = isDraft && proposition !== undefined && proposition.gamme === effectiveGamme;
|
||||
|
||||
const isModified = row.codeGamme !== row.codeGammeInit && row.codeGammeInit !== null;
|
||||
const displayValue = (!effectiveGamme || effectiveGamme.trim() === "") ? "Aucune" : effectiveGamme;
|
||||
@@ -269,6 +270,14 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
|
||||
isDraft={isDraft}
|
||||
onChange={(g: GammeCode) => setDraftGamme(codein, g)}
|
||||
/>
|
||||
{proposeeParApi && (
|
||||
<div
|
||||
className="absolute -right-2 -top-2 z-10 rounded px-1 text-[9px] font-bold leading-[14px] text-white shadow-sm bg-[var(--accent-warning)]"
|
||||
title={`Proposée par l'API${proposition.auteur ? ` (${proposition.auteur})` : ""} le ${new Date(proposition.proposeeLe).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })} — à valider`}
|
||||
>
|
||||
API
|
||||
</div>
|
||||
)}
|
||||
{isModified && !isDraft && (
|
||||
<div className="absolute -right-2 -top-2 bg-emerald-500 text-white rounded-full p-0.5 shadow-sm z-10 animate-in zoom-in-50" title="Modifié et validé">
|
||||
<Check className="w-2.5 h-2.5" />
|
||||
@@ -553,77 +562,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 +732,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 +905,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 +1034,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 +1124,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)} €
|
||||
{fmtEur2(ca)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -1200,7 +1140,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 +1152,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)} €
|
||||
{fmtEur2(marge)}
|
||||
</span>
|
||||
<span className="tabular-nums text-xs font-semibold" style={{ color: couleurMarge(taux) }}>
|
||||
{taux.toFixed(1)}%
|
||||
@@ -1333,6 +1273,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 +1298,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 +1462,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 +1484,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'est pas encore synchronisé depuis Qlik : la période s'arrête un mois plus tôt.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TuileStat
|
||||
label="Tendance"
|
||||
valeur={verdict}
|
||||
indice={`${trendLabel(pct, trend.nouveau)} · ${trend.surQteParMagasin ? "sur la qté par magasin" : "sur les volumes"}`}
|
||||
couleur={couleur}
|
||||
icone={Fleche}
|
||||
/>
|
||||
{trend.perStore && moyenneReseau != null && (
|
||||
<TuileStat
|
||||
label="Qté / magasin / mois"
|
||||
valeur={fmtRythme(trend.perStore[dernier])}
|
||||
indice={`dernier mois · ${fmtRythme(moyenneReseau)} en moyenne`}
|
||||
/>
|
||||
)}
|
||||
<TuileStat
|
||||
label="Magasins vendeurs"
|
||||
valeur={fmt(magasinsDernierMois)}
|
||||
indice={`dernier mois · ${fmt((magasinsDernierMois / NB_MAGASINS_RESEAU) * 100)} % du réseau`}
|
||||
/>
|
||||
{row.caReseau != null && row.caReseau > 0 && (
|
||||
<TuileStat
|
||||
label="CA réseau 12 m"
|
||||
valeur={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'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'écart compare notre mix à celui du réseau. L'indice
|
||||
rapporte le CA d'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>
|
||||
);
|
||||
}
|
||||
@@ -23,9 +23,15 @@ export function useSaveDrafts(magasin: string) {
|
||||
for (const codein in s.draftChanges) if (s.rowsByCodein[codein]) n++;
|
||||
return n;
|
||||
});
|
||||
/** Propositions de l'API en attente sur les lignes chargées. */
|
||||
const nbAValider = useGridStore((s) => {
|
||||
let n = 0;
|
||||
for (const codein in s.gammesAValider) if (s.rowsByCodein[codein]) n++;
|
||||
return n;
|
||||
});
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const { draftChanges, rowsByCodein, applyDraftsToRows, clearDrafts } = useGridStore.getState();
|
||||
const { draftChanges, rowsByCodein, rows, gammesAValider, applyDraftsToRows, clearDrafts, retirerGammesAValider } = useGridStore.getState();
|
||||
const activeDrafts = modificationsActives(draftChanges, rowsByCodein);
|
||||
// Index par code article : `rows.find` dans la boucle coûtait
|
||||
// (modifications × lignes) comparaisons sur un gros fournisseur.
|
||||
@@ -37,15 +43,18 @@ export function useSaveDrafts(magasin: string) {
|
||||
codeGamme: codeGamme as GammeCode,
|
||||
};
|
||||
});
|
||||
// Propositions de l'API examinées : enregistrées si elles sont restées en
|
||||
// modification, écartées sinon. Dans les deux cas, elles sont traitées.
|
||||
const gammesAValiderTraitees = Object.keys(gammesAValider).filter((codein) => rowsByCodein[codein]);
|
||||
|
||||
if (changes.length === 0) return { success: true, saved: 0 };
|
||||
if (changes.length === 0 && gammesAValiderTraitees.length === 0) return { success: true, saved: 0 };
|
||||
|
||||
// Fournisseur déduit de la première ligne modifiée
|
||||
const firstRow = rowsByCodein[changes[0].codein];
|
||||
const firstRow = rowsByCodein[changes[0]?.codein ?? gammesAValiderTraitees[0]] ?? rows[0];
|
||||
const codeFournisseur = firstRow?.codeFournisseur ?? "";
|
||||
const nomFournisseur = firstRow?.nomFournisseur ?? "";
|
||||
|
||||
const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes });
|
||||
const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees });
|
||||
|
||||
if (result.success) {
|
||||
// Report des valeurs enregistrées sur row.codeGamme AVANT de vider les
|
||||
@@ -53,9 +62,10 @@ export function useSaveDrafts(magasin: string) {
|
||||
// codeGammeInit) reste juste.
|
||||
applyDraftsToRows(activeDrafts);
|
||||
clearDrafts(Object.keys(activeDrafts));
|
||||
retirerGammesAValider(gammesAValiderTraitees);
|
||||
}
|
||||
return result;
|
||||
}, [magasin]);
|
||||
|
||||
return { save, hasDrafts: count > 0, count };
|
||||
return { save, hasDrafts: count > 0 || nbAValider > 0, count, nbAValider };
|
||||
}
|
||||
@@ -32,14 +32,24 @@ export function lignesModifiees(): ProductRow[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fichier d'import des gammes (CODE FOURNISSEUR, GENCOD, GAMME), généré par le
|
||||
* serveur. `seulementModifiees` : uniquement les gammes changées.
|
||||
* Renvoie `false` s'il n'y a rien à exporter.
|
||||
* Nombre maximal de produits par fichier d'import des gammes. Le logiciel de
|
||||
* mise à jour des gammes refuse les fichiers trop gros (~28 000 lignes) : au-delà,
|
||||
* l'export est découpé en plusieurs fichiers à intégrer un par un.
|
||||
*/
|
||||
export async function exporterFichierGammes(seulementModifiees: boolean): Promise<boolean> {
|
||||
export const LIGNES_MAX_PAR_FICHIER_GAMMES = 5000;
|
||||
|
||||
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Fichier d'import des gammes (CODE FOURNISSEUR, CODEIN, GAMME), généré par le
|
||||
* serveur. `seulementModifiees` : uniquement les gammes changées.
|
||||
* Découpé en plusieurs fichiers de `LIGNES_MAX_PAR_FICHIER_GAMMES` lignes au plus.
|
||||
* Renvoie le nombre de fichiers téléchargés (0 s'il n'y a rien à exporter).
|
||||
*/
|
||||
export async function exporterFichierGammes(seulementModifiees: boolean): Promise<number> {
|
||||
const { rows, draftChanges } = useGridStore.getState();
|
||||
const lignes = seulementModifiees ? lignesModifiees() : rows;
|
||||
if (lignes.length === 0) return false;
|
||||
if (lignes.length === 0) return 0;
|
||||
|
||||
const nomFournisseur = lignes[0].nomFournisseur;
|
||||
const changes = lignes.map((r) => ({
|
||||
@@ -49,16 +59,25 @@ export async function exporterFichierGammes(seulementModifiees: boolean): Promis
|
||||
gamme: gammeEffective(r, draftChanges) as string,
|
||||
}));
|
||||
|
||||
const res = await fetch("/api/export/modified-gammes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nomFournisseur, changes }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`le serveur a répondu ${res.status}`);
|
||||
|
||||
const prefixe = seulementModifiees ? "Modifications_Gammes" : "Export_Complet";
|
||||
telecharger(await res.blob(), `${prefixe}_${nomFichierSur(nomFournisseur)}_${aujourdhui()}.xlsx`);
|
||||
return true;
|
||||
const base = `${prefixe}_${nomFichierSur(nomFournisseur)}_${aujourdhui()}`;
|
||||
const nbFichiers = Math.ceil(changes.length / LIGNES_MAX_PAR_FICHIER_GAMMES);
|
||||
|
||||
for (let i = 0; i < nbFichiers; i++) {
|
||||
const paquet = changes.slice(i * LIGNES_MAX_PAR_FICHIER_GAMMES, (i + 1) * LIGNES_MAX_PAR_FICHIER_GAMMES);
|
||||
const res = await fetch("/api/export/modified-gammes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nomFournisseur, changes: paquet }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`le serveur a répondu ${res.status}`);
|
||||
|
||||
const suffixe = nbFichiers > 1 ? `_partie_${i + 1}_sur_${nbFichiers}` : "";
|
||||
telecharger(await res.blob(), `${base}${suffixe}.xlsx`);
|
||||
// Les navigateurs ignorent les téléchargements trop rapprochés
|
||||
if (i < nbFichiers - 1) await pause(500);
|
||||
}
|
||||
return nbFichiers;
|
||||
}
|
||||
|
||||
/** Tous les produits avec gamme avant/après et chiffres sur 12 mois (Excel, généré par le serveur). */
|
||||
@@ -143,6 +162,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 +171,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) || "-",
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface RowsMeta {
|
||||
patchedStores: string[];
|
||||
}
|
||||
|
||||
/** Gamme proposée par l'API, en attente de validation (cf. lib/gammes-a-valider). */
|
||||
export interface GammeProposee {
|
||||
gamme: string;
|
||||
auteur: string | null;
|
||||
/** Date ISO de la proposition. */
|
||||
proposeeLe: string;
|
||||
}
|
||||
|
||||
/** Clé des lignes chargées pour un fournisseur (toujours « tous magasins »). */
|
||||
export const rowsKeyFor = (codeFournisseur: string) => `${codeFournisseur}:TOTAL`;
|
||||
|
||||
@@ -25,6 +33,20 @@ interface GridState {
|
||||
rowsByCodein: Record<string, ProductRow>;
|
||||
/** Draft edits: codein → new GammeCode */
|
||||
draftChanges: Record<string, GammeCode>;
|
||||
/**
|
||||
* Propositions de l'API en attente pour le fournisseur affiché (codein →
|
||||
* proposition). Non persisté : relu sur le serveur à chaque chargement.
|
||||
*/
|
||||
gammesAValider: Record<string, GammeProposee>;
|
||||
/**
|
||||
* Range les propositions de l'API et les reporte en modifications non
|
||||
* enregistrées — sauf là où l'utilisateur a déjà sa propre modification, et
|
||||
* sauf celles déjà connues (une proposition écartée à la main ne revient pas
|
||||
* à chaque retour sur la Grille).
|
||||
*/
|
||||
chargerGammesAValider: (propositions: ReadonlyArray<{ codein: string } & GammeProposee>) => void;
|
||||
/** Retire des propositions traitées (validées ou rejetées). */
|
||||
retirerGammesAValider: (codeins: string[]) => void;
|
||||
filters: GridFilters;
|
||||
summary: GridSummary;
|
||||
displayDensity: "compact" | "normal" | "comfortable";
|
||||
@@ -141,6 +163,7 @@ export const useGridStore = create<GridState>()(
|
||||
rowsByCodein: {},
|
||||
activeMagasin: "TOTAL",
|
||||
draftChanges: {},
|
||||
gammesAValider: {},
|
||||
filters: {
|
||||
magasin: null,
|
||||
codeFournisseur: null,
|
||||
@@ -211,6 +234,28 @@ export const useGridStore = create<GridState>()(
|
||||
}, 80);
|
||||
},
|
||||
|
||||
chargerGammesAValider: (propositions) => {
|
||||
const { rowsByCodein, draftChanges, gammesAValider: connues } = get();
|
||||
const gammesAValider: Record<string, GammeProposee> = {};
|
||||
const nouvelles: Record<string, GammeCode> = {};
|
||||
for (const p of propositions) {
|
||||
const row = rowsByCodein[p.codein];
|
||||
if (!row) continue;
|
||||
gammesAValider[p.codein] = { gamme: p.gamme, auteur: p.auteur, proposeeLe: p.proposeeLe };
|
||||
const dejaConnue = connues[p.codein]?.proposeeLe === p.proposeeLe;
|
||||
if (!dejaConnue && draftChanges[p.codein] === undefined && p.gamme !== row.codeGamme) {
|
||||
nouvelles[p.codein] = p.gamme as GammeCode;
|
||||
}
|
||||
}
|
||||
set({ gammesAValider });
|
||||
if (Object.keys(nouvelles).length > 0) get().batchSetDraftGamme(nouvelles);
|
||||
},
|
||||
retirerGammesAValider: (codeins) => {
|
||||
const gammesAValider = { ...get().gammesAValider };
|
||||
codeins.forEach((id) => delete gammesAValider[id]);
|
||||
set({ gammesAValider });
|
||||
},
|
||||
|
||||
resetDrafts: () => {
|
||||
set({ draftChanges: {} });
|
||||
if (summaryTimer) clearTimeout(summaryTimer);
|
||||
|
||||
@@ -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'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) && (
|
||||
|
||||
+12
-1
@@ -22,6 +22,7 @@ import "server-only";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
|
||||
import { pgGetGammesByCodeins } from "@/lib/pg-ff-client";
|
||||
import { gammesAValiderParArticle } from "@/lib/gammes-a-valider";
|
||||
// Module de la Grille : l'API doit renvoyer exactement la tendance affichée dans
|
||||
// l'application, pas une seconde implémentation qui divergerait.
|
||||
import { computeNetworkTrend, trendLabel } from "@/features/grid/lib/network-trend";
|
||||
@@ -51,6 +52,11 @@ export interface EnrichedProductRow extends ProductRow {
|
||||
* `null` si l'article n'a aucune gamme en base.
|
||||
*/
|
||||
codeGammeServeur: string | null;
|
||||
/**
|
||||
* Gamme proposée par l'API et pas encore validée dans la Grille.
|
||||
* `null` = aucune proposition en attente.
|
||||
*/
|
||||
gammeAValider: string | null;
|
||||
/** Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas. */
|
||||
network: NetworkMetricCached | null;
|
||||
/**
|
||||
@@ -73,7 +79,7 @@ export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow
|
||||
const codesCentraux = [...new Set(rows.map((r) => r.codeCentrale).filter((c): c is string => Boolean(c)))];
|
||||
const codeins = [...new Set(rows.map((r) => r.codein).filter(Boolean))];
|
||||
|
||||
const [network, gammes] = await Promise.all([
|
||||
const [network, gammes, aValider] = await Promise.all([
|
||||
codesCentraux.length
|
||||
? getNetworkMetricsByCodeCentrale(codesCentraux).catch((e) => {
|
||||
console.error("[api-enrich] métriques réseau KO:", (e as Error).message?.slice(0, 160));
|
||||
@@ -86,6 +92,10 @@ export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow
|
||||
return new Map<string, string>();
|
||||
})
|
||||
: Promise.resolve(new Map<string, string>()),
|
||||
gammesAValiderParArticle(codeins).catch((e) => {
|
||||
console.error("[api-enrich] gammes à valider KO:", (e as Error).message?.slice(0, 160));
|
||||
return new Map<string, string>();
|
||||
}),
|
||||
]);
|
||||
|
||||
return rows.map((row) => {
|
||||
@@ -97,6 +107,7 @@ export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow
|
||||
const enriched: EnrichedProductRow = {
|
||||
...row,
|
||||
codeGammeServeur: gammeServeur,
|
||||
gammeAValider: aValider.get(`${row.codeFournisseur}|${row.codein}`) ?? null,
|
||||
network: metrics,
|
||||
trend: null, // renseignée plus bas, une fois la série mensuelle connue
|
||||
};
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* CollectFlow — Affectation de gammes via l'API `/api/v1`.
|
||||
*
|
||||
* L'API n'enregistre pas la gamme : elle la **propose**. La proposition est
|
||||
* rangée dans `gammes_a_valider` et apparaît dans la Grille comme une
|
||||
* modification non enregistrée — on y voit donc quels produits changent de gamme
|
||||
* avant de valider (« Enregistrer », même chemin que toute modification de la
|
||||
* Grille) ou de rejeter (« Annuler les modifications »). Tant qu'elle n'est pas
|
||||
* validée, `codeGamme` reste inchangée et la proposition est exposée dans
|
||||
* `gammeAValider`.
|
||||
*
|
||||
* Tout ou rien : si un seul article est inconnu chez le fournisseur, rien n'est
|
||||
* proposé 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 { proposerGammes, type PropositionGamme } from "@/lib/gammes-a-valider";
|
||||
|
||||
export interface ResultatGamme {
|
||||
codein: string;
|
||||
/** Gamme proposée. */
|
||||
gamme: string;
|
||||
/** Gamme enregistrée au moment de l'appel (modifications validées comprises). */
|
||||
gammePrecedente: string | null;
|
||||
/** Gamme présente en base FF, non modifiée par l'API. */
|
||||
codeGammeServeur: string | null;
|
||||
/**
|
||||
* `true` = la gamme change : proposition déposée, à valider dans la Grille.
|
||||
* `false` = l'article a déjà cette gamme : rien à valider (une proposition
|
||||
* antérieure en attente pour lui est retirée).
|
||||
*/
|
||||
modifie: boolean;
|
||||
/** `a_valider` quand `modifie`, sinon `inchangee`. */
|
||||
statut: "a_valider" | "inchangee";
|
||||
}
|
||||
|
||||
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).");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Propose des gammes pour des articles d'un fournisseur (à valider dans la Grille).
|
||||
*
|
||||
* 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 — affiché dans la Grille avec la proposition. */
|
||||
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 propositions: PropositionGamme[] = [];
|
||||
const annules: string[] = [];
|
||||
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,
|
||||
statut: modifie ? "a_valider" : "inchangee",
|
||||
});
|
||||
if (modifie) propositions.push({ codein, gamme, gammePrecedente: actuelle.codeGamme });
|
||||
else annules.push(codein);
|
||||
}
|
||||
|
||||
try {
|
||||
await proposerGammes({ codeFournisseur, propositions, annules, auteur });
|
||||
} catch (e) {
|
||||
console.error(`[api/v1/gammes] proposition KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(e));
|
||||
return fail("internal_error", "L'enregistrement des gammes à valider a échoué.");
|
||||
}
|
||||
|
||||
console.log(`[api/v1/gammes] ${codeFournisseur} — ${propositions.length} gamme(s) à valider proposée(s) par ${auteur}`);
|
||||
return { codeFournisseur, nomFournisseur, resultats };
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* CollectFlow — Gammes proposées par l'API, en attente de validation.
|
||||
*
|
||||
* L'API `/api/v1` ne change plus une gamme directement : elle dépose une
|
||||
* proposition, que la Grille affiche comme une modification non enregistrée.
|
||||
* L'utilisateur voit ainsi quels produits changent de gamme, puis valide
|
||||
* (« Enregistrer ») ou rejette (« Annuler les modifications »).
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { gammesAValider } from "@/db/schema";
|
||||
|
||||
export interface PropositionGamme {
|
||||
codein: string;
|
||||
/** Gamme proposée. */
|
||||
gamme: string;
|
||||
/** Gamme enregistrée au moment de la proposition. */
|
||||
gammePrecedente: string | null;
|
||||
}
|
||||
|
||||
export interface GammeAValider extends PropositionGamme {
|
||||
auteur: string | null;
|
||||
/** Date ISO de la proposition. */
|
||||
proposeeLe: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dépose des propositions (la dernière l'emporte pour un même article) et retire
|
||||
* celles des articles `annules` — redemander la gamme déjà enregistrée annule la
|
||||
* proposition en attente.
|
||||
*/
|
||||
export async function proposerGammes(input: {
|
||||
codeFournisseur: string;
|
||||
propositions: PropositionGamme[];
|
||||
annules: string[];
|
||||
auteur: string;
|
||||
}): Promise<void> {
|
||||
const { codeFournisseur, propositions, annules, auteur } = input;
|
||||
await db.transaction(async (tx) => {
|
||||
if (annules.length > 0) {
|
||||
await tx
|
||||
.delete(gammesAValider)
|
||||
.where(and(eq(gammesAValider.codeFournisseur, codeFournisseur), inArray(gammesAValider.codein, annules)));
|
||||
}
|
||||
if (propositions.length > 0) {
|
||||
const maintenant = new Date();
|
||||
await tx
|
||||
.insert(gammesAValider)
|
||||
.values(propositions.map((p) => ({
|
||||
codeFournisseur,
|
||||
codein: p.codein,
|
||||
gamme: p.gamme,
|
||||
gammePrecedente: p.gammePrecedente,
|
||||
auteur: auteur.slice(0, 100),
|
||||
createdAt: maintenant,
|
||||
})))
|
||||
.onConflictDoUpdate({
|
||||
target: [gammesAValider.codeFournisseur, gammesAValider.codein],
|
||||
set: {
|
||||
gamme: sql`excluded.gamme`,
|
||||
gammePrecedente: sql`excluded.gamme_precedente`,
|
||||
auteur: sql`excluded.auteur`,
|
||||
createdAt: sql`excluded.created_at`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Propositions en attente pour un fournisseur. */
|
||||
export async function listerGammesAValider(codeFournisseur: string): Promise<GammeAValider[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(gammesAValider)
|
||||
.where(eq(gammesAValider.codeFournisseur, codeFournisseur));
|
||||
return rows.map((r) => ({
|
||||
codein: r.codein,
|
||||
gamme: r.gamme,
|
||||
gammePrecedente: r.gammePrecedente,
|
||||
auteur: r.auteur,
|
||||
proposeeLe: r.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Propositions en attente pour une liste d'articles, indexées par `fournisseur|codein`. */
|
||||
export async function gammesAValiderParArticle(codeins: string[]): Promise<Map<string, string>> {
|
||||
if (codeins.length === 0) return new Map();
|
||||
const rows = await db
|
||||
.select({ codeFournisseur: gammesAValider.codeFournisseur, codein: gammesAValider.codein, gamme: gammesAValider.gamme })
|
||||
.from(gammesAValider)
|
||||
.where(inArray(gammesAValider.codein, codeins));
|
||||
return new Map(rows.map((r) => [`${r.codeFournisseur}|${r.codein}`, r.gamme]));
|
||||
}
|
||||
|
||||
/** Retire des propositions, une fois validées ou rejetées dans la Grille. */
|
||||
export async function supprimerGammesAValider(codeFournisseur: string, codeins: string[]): Promise<void> {
|
||||
if (codeins.length === 0) return;
|
||||
await db
|
||||
.delete(gammesAValider)
|
||||
.where(and(eq(gammesAValider.codeFournisseur, codeFournisseur), inArray(gammesAValider.codein, codeins)));
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -880,6 +880,8 @@ export async function pgGetCaByNomenclature(
|
||||
export interface HitParadeRow {
|
||||
codein: string;
|
||||
libelle: string;
|
||||
/** Référence fournisseur de l'article (artfou1.reference du fournisseur principal). */
|
||||
reference: string;
|
||||
fournisseur: string;
|
||||
nomenclature_code: string;
|
||||
nomenclature: string;
|
||||
@@ -929,12 +931,13 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis
|
||||
SELECT DISTINCT ON (TRIM(a.codein::text))
|
||||
TRIM(a.codein::text) AS codein,
|
||||
a.libelle1::text AS libelle,
|
||||
COALESCE(TRIM(af.reference::text), '') AS reference,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||||
COALESCE(n.code, '')::text AS nomenclature_code,
|
||||
COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature
|
||||
FROM articles a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT af1.code
|
||||
SELECT af1.code, af1.reference
|
||||
FROM artfou1 af1
|
||||
WHERE af1.art_no_id = a.no_id AND af1.preference = 1
|
||||
ORDER BY af1.code
|
||||
@@ -959,6 +962,7 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis
|
||||
SELECT
|
||||
v.codein,
|
||||
COALESCE(ar.libelle, '') AS libelle,
|
||||
COALESCE(ar.reference, '') AS reference,
|
||||
COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur,
|
||||
COALESCE(ar.nomenclature_code, '') AS nomenclature_code,
|
||||
COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* CollectFlow — Chromium et file d'attente partagés par tous les accès Qlik
|
||||
* (extraction réseau `qlik-playwright.ts`, recherche produit `qlik-search.ts`).
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { chromium, type Browser } from "playwright-core";
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
/**
|
||||
* Chromium partagé entre les extractions.
|
||||
*
|
||||
* Un navigateur tombé (plantage, mémoire, arrêt du processus enfant) restait
|
||||
* mémorisé : toutes les extractions suivantes échouaient sur « Target closed »
|
||||
* jusqu'au redémarrage du serveur. On le relance donc dès qu'il est déconnecté.
|
||||
*/
|
||||
export async function getQlikBrowser(): Promise<Browser> {
|
||||
if (browserPromise) {
|
||||
const existant = await browserPromise.catch(() => null);
|
||||
if (existant?.isConnected()) return existant;
|
||||
browserPromise = null;
|
||||
}
|
||||
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
|
||||
const lancement = chromium.launch({
|
||||
headless: true,
|
||||
executablePath: execPath || undefined,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
|
||||
});
|
||||
browserPromise = lancement;
|
||||
lancement
|
||||
.then((b) => b.on("disconnected", () => {
|
||||
console.warn("[qlik] Chromium déconnecté — il sera relancé à la prochaine extraction");
|
||||
if (browserPromise === lancement) browserPromise = null;
|
||||
}))
|
||||
.catch(() => { if (browserPromise === lancement) browserPromise = null; });
|
||||
return lancement;
|
||||
}
|
||||
|
||||
/**
|
||||
* File unique des extractions.
|
||||
*
|
||||
* Le serveur Qlik abandonne les requêtes dès qu'il est trop sollicité. Une
|
||||
* synchro manuelle lancée pendant la synchro de nuit, ou deux recherches
|
||||
* produit simultanées, ouvraient deux sessions Engine en parallèle — et les
|
||||
* deux échouaient. Les extractions passent désormais l'une après l'autre.
|
||||
*/
|
||||
let fileExtractions: Promise<unknown> = Promise.resolve();
|
||||
let extractionsEnAttente = 0;
|
||||
|
||||
export function dansLaFileQlik<T>(libelle: string, tache: () => Promise<T>): Promise<T> {
|
||||
if (extractionsEnAttente > 0) {
|
||||
console.log(`[qlik] ${libelle} : en attente de ${extractionsEnAttente} extraction(s) en cours`);
|
||||
}
|
||||
extractionsEnAttente++;
|
||||
const resultat = fileExtractions.then(tache, tache);
|
||||
fileExtractions = resultat.then(() => undefined, () => undefined).finally(() => { extractionsEnAttente--; });
|
||||
return resultat;
|
||||
}
|
||||
+27
-16
@@ -13,23 +13,10 @@
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { chromium, type Browser } from "playwright-core";
|
||||
import { getQlikBrowser as getBrowser, dansLaFileQlik as dansLaFile } from "@/lib/qlik-browser";
|
||||
import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric, type QlikMonthMetrics } from "@/lib/qlik-client";
|
||||
import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range";
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
if (!browserPromise) {
|
||||
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
|
||||
browserPromise = chromium.launch({
|
||||
headless: true,
|
||||
executablePath: execPath || undefined,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
|
||||
}).catch((e) => { browserPromise = null; throw e; });
|
||||
}
|
||||
return browserPromise;
|
||||
}
|
||||
import { assainirMetrique, controlerExtraction } from "@/lib/qlik-validation";
|
||||
|
||||
interface InPageResult {
|
||||
ok: boolean;
|
||||
@@ -86,7 +73,17 @@ function uniqueOrdered(values: number[]): number[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function fetchNetworkMetricsPlaywright(
|
||||
export function fetchNetworkMetricsPlaywright(
|
||||
codeCentraux?: string[],
|
||||
cfg: QlikConfig = getQlikConfig(),
|
||||
dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(),
|
||||
fournisseur?: string,
|
||||
): Promise<Map<string, NetworkMetric>> {
|
||||
const libelle = fournisseur ? `fournisseur ${fournisseur}` : `${(codeCentraux ?? []).length} code(s)`;
|
||||
return dansLaFile(libelle, () => extraire(codeCentraux, cfg, dateFilter, fournisseur));
|
||||
}
|
||||
|
||||
async function extraire(
|
||||
codeCentraux?: string[],
|
||||
cfg: QlikConfig = getQlikConfig(),
|
||||
dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(),
|
||||
@@ -2477,6 +2474,20 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
if (exVar) console.log(`[qlik-pw] exemple VARIE ${exVar.codeCentrale}:`, JSON.stringify(exVar.qteByMonth));
|
||||
if (exFlat) console.log(`[qlik-pw] exemple IDENTIQUE ${exFlat.codeCentrale}:`, JSON.stringify(exFlat.qteByMonth));
|
||||
console.log(`[qlik-pw] ${out.size} produits réseau (cube ${result.size})`);
|
||||
|
||||
// Contrôle de plausibilité AVANT toute écriture : des données complètes
|
||||
// mais fausses (dernier mois pas encore chargé, mesure insensible au
|
||||
// mois) laisseraient le cache faux jusqu'à la synchro suivante.
|
||||
const corrections = [...out.values()].flatMap(assainirMetrique);
|
||||
if (corrections.length) {
|
||||
console.warn(`[qlik-pw] ${corrections.length} valeur(s) impossible(s) corrigée(s) : ${corrections.slice(0, 5).join(" ; ")}`);
|
||||
}
|
||||
if (dateFilter) {
|
||||
const controle = controlerExtraction(out.values(), monthRanges.map((m) => m.label));
|
||||
for (const a of controle.avertissements) console.warn(`[qlik-pw][contrôle] ${a}`);
|
||||
if (!controle.ok) throw new Error(`[qlik-pw] ${controle.motif} — cache inchangé`);
|
||||
console.log(`[qlik-pw][contrôle] extraction validée (${out.size} codes, ${monthRanges.length} mois)`);
|
||||
}
|
||||
// Échantillon brut pour vérifier que les 4 nouvelles mesures (cols 4-7) renvoient des valeurs
|
||||
const sample = (result.rows ?? []).slice(0, 3).map((r) => ({
|
||||
code: r[0], ca: r[1], qte: r[2], nbMag: r[3], caMag: r[4], margePct: r[5],
|
||||
|
||||
+14
-16
@@ -35,7 +35,7 @@
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { chromium, type Browser } from "playwright-core";
|
||||
import { getQlikBrowser as getBrowser, dansLaFileQlik } from "@/lib/qlik-browser";
|
||||
import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client";
|
||||
import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
||||
|
||||
@@ -112,20 +112,6 @@ const CHAMPS_FOURNISSEUR_PREFERES = ["Fournisseur", "code_fournisseur"];
|
||||
*/
|
||||
const CHAMP_CODE_CENTRALE_ALT = "article_no_centrale";
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
if (!browserPromise) {
|
||||
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
|
||||
browserPromise = chromium.launch({
|
||||
headless: true,
|
||||
executablePath: execPath || undefined,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
|
||||
}).catch((e) => { browserPromise = null; throw e; });
|
||||
}
|
||||
return browserPromise;
|
||||
}
|
||||
|
||||
interface InPageSearchResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
@@ -152,10 +138,22 @@ interface InPageSearchResult {
|
||||
* ensuite extraites par `fetchNetworkMetricsPlaywright`, qui sait déjà produire
|
||||
* le détail mensuel sur 12 mois glissants.
|
||||
*/
|
||||
export async function searchQlikArticles(
|
||||
export function searchQlikArticles(
|
||||
term: string,
|
||||
limit: number = QLIK_SEARCH_MAX_RESULTS,
|
||||
cfg: QlikConfig = getQlikConfig(),
|
||||
): Promise<QlikSearchOutcome> {
|
||||
// Terme trop court : réponse vide immédiate, sans attendre la file.
|
||||
if (term.trim().length < 3) return rechercher(term, limit, cfg);
|
||||
// Même file que les extractions : deux sessions Engine simultanées font
|
||||
// abandonner le serveur Qlik, et les deux requêtes échouent.
|
||||
return dansLaFileQlik(`recherche « ${term.trim()} »`, () => rechercher(term, limit, cfg));
|
||||
}
|
||||
|
||||
async function rechercher(
|
||||
term: string,
|
||||
limit: number,
|
||||
cfg: QlikConfig,
|
||||
): Promise<QlikSearchOutcome> {
|
||||
const cleaned = term.trim();
|
||||
if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null, periode: null };
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* CollectFlow — Contrôles de plausibilité d'une extraction réseau Qlik.
|
||||
*
|
||||
* L'extracteur vérifie déjà que les 12 mois de la fenêtre sont servis. Il ne
|
||||
* peut pas voir deux pièges, observés ou attendus en production, qui donnent
|
||||
* des chiffres faux mais complets :
|
||||
*
|
||||
* 1. **Dernier mois pas encore chargé dans Qlik.** Les premiers jours du mois,
|
||||
* le mois qui vient de se terminer peut n'être chargé qu'en partie. Il est
|
||||
* « couvert » (quelques ventes), donc accepté — et toutes les tendances du
|
||||
* fournisseur plongent d'un coup, jusqu'à la synchro suivante, une semaine
|
||||
* ou plus après.
|
||||
* 2. **Mesure insensible au mois.** Quand une mesure ignore la sélection de
|
||||
* dates, chaque mois reçoit la même valeur (le total de l'année). Le
|
||||
* diagnostic existait dans les journaux ; rien n'empêchait l'écriture.
|
||||
*
|
||||
* Dans les deux cas, garder le cache précédent vaut mieux qu'écrire des
|
||||
* données fausses : l'extraction est refusée, et le planificateur la reprend
|
||||
* une nuit suivante.
|
||||
*
|
||||
* Module pur (aucune dépendance d'exécution) : testable hors serveur.
|
||||
*/
|
||||
|
||||
import type { NetworkMetric } from "@/lib/qlik-client";
|
||||
|
||||
export interface ControleExtraction {
|
||||
ok: boolean;
|
||||
/** Raison du refus, lisible par l'administrateur (statut de synchro). */
|
||||
motif?: string;
|
||||
/** Anomalies corrigées ou tolérées, à journaliser. */
|
||||
avertissements: string[];
|
||||
}
|
||||
|
||||
export interface OptionsControle {
|
||||
/**
|
||||
* Jusqu'à quel jour du mois le dernier mois est soupçonné d'être incomplet.
|
||||
* Au-delà, une baisse est prise pour ce qu'elle est : une baisse.
|
||||
*/
|
||||
jourLimiteDernierMois?: number;
|
||||
/** Part minimale du dernier mois par rapport à la moyenne des 3 précédents. */
|
||||
ratioDernierMoisMin?: number;
|
||||
/** Volume mensuel moyen en dessous duquel le contrôle n'a pas de sens. */
|
||||
volumeMinimal?: number;
|
||||
/** Part maximale de séries plates (même valeur 12 mois) parmi les séries vendues. */
|
||||
partSeriesPlatesMax?: number;
|
||||
}
|
||||
|
||||
const DEFAUTS: Required<OptionsControle> = {
|
||||
jourLimiteDernierMois: 10,
|
||||
ratioDernierMoisMin: 0.4,
|
||||
volumeMinimal: 30,
|
||||
partSeriesPlatesMax: 0.5,
|
||||
};
|
||||
|
||||
const fini = (v: unknown): number => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Corrige sur place les valeurs impossibles d'une métrique (NaN, infini,
|
||||
* nombre de magasins négatif) et renvoie la liste des corrections.
|
||||
*/
|
||||
export function assainirMetrique(m: NetworkMetric): string[] {
|
||||
const corrections: string[] = [];
|
||||
const champs = ["caReseau", "qteReseau", "nbMagasinsReseau", "caParMagasinReseau", "margePctReseau"] as const;
|
||||
for (const champ of champs) {
|
||||
if (!Number.isFinite(Number(m[champ]))) {
|
||||
corrections.push(`${m.codeCentrale} : ${champ} non numérique (${String(m[champ])}) remplacé par 0`);
|
||||
m[champ] = 0;
|
||||
}
|
||||
}
|
||||
if (m.nbMagasinsReseau < 0) {
|
||||
corrections.push(`${m.codeCentrale} : nombre de magasins négatif (${m.nbMagasinsReseau}) remplacé par 0`);
|
||||
m.nbMagasinsReseau = 0;
|
||||
}
|
||||
m.nbMagasinsReseau = Math.round(m.nbMagasinsReseau);
|
||||
m.caParMagasinReseau = m.nbMagasinsReseau > 0 ? m.caReseau / m.nbMagasinsReseau : 0;
|
||||
|
||||
if (m.qteByMonth) {
|
||||
for (const [mois, v] of Object.entries(m.qteByMonth)) {
|
||||
if (!Number.isFinite(Number(v))) {
|
||||
corrections.push(`${m.codeCentrale} : quantité ${mois} non numérique remplacée par 0`);
|
||||
m.qteByMonth[mois] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.metricsByMonth) {
|
||||
for (const [mois, v] of Object.entries(m.metricsByMonth)) {
|
||||
m.metricsByMonth[mois] = {
|
||||
qte: fini(v?.qte),
|
||||
ca: fini(v?.ca),
|
||||
nbMag: Math.max(0, Math.round(fini(v?.nbMag))),
|
||||
caMag: fini(v?.caMag),
|
||||
margePct: fini(v?.margePct),
|
||||
};
|
||||
}
|
||||
}
|
||||
return corrections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contrôle d'ensemble d'une extraction 12 mois, AVANT écriture du cache.
|
||||
*
|
||||
* `fenetre` : les mois attendus (« YYYY-MM »), du plus ancien au plus récent.
|
||||
*/
|
||||
export function controlerExtraction(
|
||||
metriques: Iterable<NetworkMetric>,
|
||||
fenetre: string[],
|
||||
now: Date = new Date(),
|
||||
options: OptionsControle = {},
|
||||
): ControleExtraction {
|
||||
const o = { ...DEFAUTS, ...options };
|
||||
const avertissements: string[] = [];
|
||||
const liste = [...metriques];
|
||||
if (liste.length === 0 || fenetre.length < 4) return { ok: true, avertissements };
|
||||
|
||||
// ── 1. Séries plates : la mesure a ignoré la sélection de mois ──────────
|
||||
let vendues = 0;
|
||||
let plates = 0;
|
||||
for (const m of liste) {
|
||||
const valeurs = fenetre.map((mois) => fini(m.qteByMonth?.[mois]));
|
||||
if (!valeurs.some((v) => v !== 0)) continue;
|
||||
vendues++;
|
||||
if (valeurs.every((v) => v === valeurs[0])) plates++;
|
||||
}
|
||||
if (vendues >= 5 && plates / vendues > o.partSeriesPlatesMax) {
|
||||
return {
|
||||
ok: false,
|
||||
motif: `${plates} séries sur ${vendues} ont la même quantité les 12 mois : la mesure Qlik ignore le mois, données refusées`,
|
||||
avertissements,
|
||||
};
|
||||
}
|
||||
if (plates > 0) avertissements.push(`${plates} série(s) identique(s) sur 12 mois parmi ${vendues} vendues`);
|
||||
|
||||
// ── 2. Dernier mois manifestement incomplet ─────────────────────────────
|
||||
const totaux = fenetre.map((mois) => liste.reduce((t, m) => t + fini(m.qteByMonth?.[mois]), 0));
|
||||
const dernier = totaux[totaux.length - 1];
|
||||
const precedents = totaux.slice(-4, -1);
|
||||
const reference = precedents.reduce((t, v) => t + v, 0) / precedents.length;
|
||||
if (
|
||||
now.getDate() <= o.jourLimiteDernierMois &&
|
||||
reference >= o.volumeMinimal &&
|
||||
dernier < reference * o.ratioDernierMoisMin
|
||||
) {
|
||||
const pct = Math.round((dernier / reference) * 100);
|
||||
return {
|
||||
ok: false,
|
||||
motif: `le dernier mois (${fenetre[fenetre.length - 1]}) ne pèse que ${pct} % des 3 mois précédents : `
|
||||
+ `probablement pas encore entièrement chargé dans Qlik, données refusées (nouvel essai une prochaine nuit)`,
|
||||
avertissements,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, avertissements };
|
||||
}
|
||||
Reference in new issue
Block a user