Compare commits

...
Author SHA1 Message Date
Claude 3519597fb7 Révision d'assortiment : CA par nomenclature, par magasin et face au réseau
Nouveau bouton « Nomenclatures » dans la barre d'outils de la Grille : il
ouvre une modale qui regroupe les produits affichés par nomenclature (code3)
et donne, sur les 12 mois de la Grille, le CA de chaque magasin, le CA de nos
magasins, le CA réseau, le poids de la nomenclature chez nous et dans le
réseau (écart en points) et l'indice de chaque magasin face au magasin moyen
du réseau. Tri par colonne, filtre texte, ligne de total et export Excel.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEXT61tGbsXHUDNBeghCXQ
2026-10-05 07:50:59 +00:00
23 changed files with 1150 additions and 110 deletions

No files matched your search

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