Gammes de l'API à valider dans la Grille, analyses pleine largeur, référence dans Meilleures ventes

- API /api/v1 : PUT /products/:codein/gamme et POST /gammes déposent une
  proposition (table gammes_a_valider) au lieu d'enregistrer. La Grille la
  charge comme modification non enregistrée (badge « API ») : Enregistrer
  la valide, Annuler la rejette. Lecture : champ gammeAValider, statut
  a_valider dans la réponse ; OpenAPI mise à jour.
- Meilleures ventes, Ventes par mois, Publicités : plus de largeur max
  centrée, les pages occupent toute la largeur.
- Meilleures ventes : colonne Référence (référence fournisseur), recherche
  et export Excel compris.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaniLFvegAZNXFrFFSStqa
This commit is contained in:
Claude committed 2026-10-06 09:27:07 +00:00
1 parent efd6c00cf0
commit b373dabeaa
21 files changed
+413 -73

No files matched your search

+1 -1
View File
@@ -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"
+14 -5
View File
@@ -134,6 +134,14 @@ const COLONNES: DataColumn<Ligne>[] = [
footer: () => "Total",
className: "whitespace-nowrap",
},
{
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: "libelle",
header: "Désignation",
@@ -170,7 +178,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 +212,22 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
feuille: "Meilleures ventes",
fichier: `meilleures-ventes_${dateDebut}_${dateFin}`,
entetes: [
"Code", "Désignation", "Fournisseur", "Nomenclature",
"Code", "Référence", "Désignation", "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, 18, 40, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])],
lignes: rows.map(r => [
r.codein,
r.reference,
r.libelle.trim(),
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 +275,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"
+4 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+3 -2
View File
@@ -12,8 +12,8 @@ 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.
* 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) {
@@ -42,6 +42,7 @@ export async function POST(req: NextRequest) {
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(),
},
});
+19 -14
View File
@@ -72,9 +72,11 @@ export async function GET(req: NextRequest) {
+ "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée "
+ "(`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.",
+ "**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: [] }],
@@ -151,7 +153,8 @@ export async function GET(req: NextRequest) {
"Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour "
+ "raisonner. `null` = aucune gamme. Valeurs : " + legendeGammes + ".",
},
codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications enregistrées dans CollectFlow (Grille ou API) comprises." },
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" },
@@ -177,10 +180,11 @@ export async function GET(req: NextRequest) {
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." },
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: "false = l'article avait déjà cette gamme, rien n'a été écrit." },
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: {
@@ -390,9 +394,10 @@ export async function GET(req: NextRequest) {
"/products/{codein}/gamme": {
put: {
operationId: "changerGammeProduit",
summary: "Affecter ou changer la gamme d'un produit",
summary: "Proposer la gamme d'un produit (à valider)",
description:
"Enregistre la gamme d'un article, comme la Grille. Valeurs : " + legendeGammes + ". "
"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.",
@@ -417,7 +422,7 @@ export async function GET(req: NextRequest) {
},
responses: {
"200": {
description: "Gamme enregistrée",
description: "Gamme proposée, à valider",
content: {
"application/json": {
schema: {
@@ -444,10 +449,10 @@ export async function GET(req: NextRequest) {
"/gammes": {
post: {
operationId: "changerGammes",
summary: "Affecter ou changer la gamme de plusieurs produits",
summary: "Proposer la gamme de plusieurs produits (à valider)",
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é "
"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: {
@@ -480,7 +485,7 @@ export async function GET(req: NextRequest) {
},
responses: {
"200": {
description: "Gammes enregistrées",
description: "Gammes proposées, à valider",
content: {
"application/json": {
schema: {
@@ -13,8 +13,9 @@ 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`.
* 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é.