Lot 5 : changement de magasin instantané et contrôle des droits

Grille
- Corrige le lot 4 : au changement de magasin, les chiffres et le tri
  affichés suivent tout de suite (nouvelle liste), la sélection est gardée.
- Lignes toujours chargées « Nos 2 magasins » ; changement de magasin sans
  aller-retour serveur (URL mise à jour par l'historique du navigateur).
- Complément de l'API FF par magasin servi à part (/api/grid/rows/store-patch),
  appliqué à son arrivée, calcul identique à l'ancien (lib/store-patch.ts).
- La recherche ne reconstruit plus toutes les lignes à chaque frappe ; la
  sélection n'est remise à zéro qu'au changement de fournisseur.

Sécurité
- lib/authz.ts : session relue en base (cache 1 min), rôle de la base
  prioritaire ; variantes pour actions, routes et pages.
- Paramètres réservés aux administrateurs ; session vérifiée dans les actions
  Grille, Commandes, Historique et les routes ; diagnostics réservés admin.
- Mots de passe et clés d'IA plus envoyés au navigateur ; champ vide = mot de
  passe conservé ; plus de mot de passe dans le localStorage ; l'enregistrement
  de la base n'efface plus les clés d'IA.
- admin/admin créé seulement si aucun utilisateur ; fichier de secours utilisé
  uniquement si la base est injoignable ; data/ ignoré par git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
Claude committed 2026-10-04 06:39:49 +00:00
1 parent b7ee413d67
commit 26b9364232
44 files changed
+1155 -531

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
/**
* Complément des chiffres d'un magasin par l'API FF (ventes mensuelles).
*
* Le calcul SQL de la Grille peut manquer les ventes d'un magasin pour certains
* articles (quantité à 0 pour ce magasin). L'API FF les connaît : on en tire,
* pour ces articles seulement, les séries et totaux du magasin.
*
* Module sans dépendance d'exécution : utilisé côté serveur (calcul du
* complément) comme côté navigateur (application aux lignes affichées).
*/
import type { ProductRow } from "@/types/grid";
import type { FfMensuelEntry } from "@/lib/api-ff-client";
/** Chiffres d'un article pour le magasin complété. */
export interface StorePatchEntry {
codein: string;
/** Ventes par mois (« YYYYMM »). */
sales: Record<string, number>;
/** Stock de fin de mois, reporté sur les mois sans donnée. */
stock: Record<string, number>;
receptions: Record<string, number>;
quantite: number;
ca: number;
marge: number;
}
export interface StorePatch {
magasin: string;
/** Fenêtre des 12 mois du calcul : un complément d'un autre mois est ignoré. */
periods: string[];
entries: StorePatchEntry[];
}
/** Articles à compléter : connus de FF (noid) et sans aucune vente SQL dans ce magasin. */
export function storePatchCandidates(rows: ProductRow[], magasin: string): ProductRow[] {
return rows.filter((row) => !!row.noid && (row.quantiteByStore?.[magasin] ?? 0) === 0);
}
/** Chiffres du magasin tirés de l'API, pour les articles où elle montre des ventes. */
export function buildStorePatchEntries(
retenus: ProductRow[],
magasin: string,
periods: string[],
mensuel: Map<string, FfMensuelEntry[]>,
): StorePatchEntry[] {
const resultat: StorePatchEntry[] = [];
for (const row of retenus) {
const entries = (mensuel.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
if (entries.length === 0) continue;
const byPeriod = new Map<string, { qty: number; ca: number; marge: number; stock: number; receptions: number }>();
for (const entry of entries) {
const period = entry.mois.replace("-", "");
if (!periods.includes(period)) continue;
// qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
// les nie au lieu de Math.abs pour que les retours restent déduits.
const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0);
const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0);
const marge = Number(entry.ventes?.marge ?? 0) || 0;
const stock = Number(entry.stock_fin_mois ?? 0) || 0;
const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0;
byPeriod.set(period, { qty, ca, marge, stock, receptions });
}
const apiQty = [...byPeriod.values()].reduce((sum, value) => sum + value.qty, 0);
if (apiQty === 0) continue;
const e: StorePatchEntry = { codein: row.codein, sales: {}, stock: {}, receptions: {}, quantite: 0, ca: 0, marge: 0 };
let lastStock = 0;
for (const period of periods) {
const value = byPeriod.get(period);
e.sales[period] = value?.qty ?? 0;
e.receptions[period] = value?.receptions ?? 0;
if (value) lastStock = value.stock;
e.stock[period] = lastStock;
e.quantite += value?.qty ?? 0;
e.ca += value?.ca ?? 0;
e.marge += value?.marge ?? 0;
}
resultat.push(e);
}
return resultat;
}
/**
* Application historique, côté serveur (`/api/grid/rows?magasin=…`) : corrige le
* magasin ET reporte les écarts sur les totaux « tous magasins » de la ligne.
* Renvoie le nombre de lignes corrigées.
*/
export function applyStorePatchInPlace(
rows: ProductRow[],
magasin: string,
periods: string[],
entries: StorePatchEntry[],
): number {
const parCode = new Map(entries.map((e) => [e.codein, e]));
let corrigees = 0;
for (const row of rows) {
const e = parCode.get(row.codein);
if (!e) continue;
row.sales12mByStore ??= {};
row.stock12mByStore ??= {};
row.receptions12mByStore ??= {};
row.caByStore ??= {};
row.quantiteByStore ??= {};
row.margeByStore ??= {};
row.sales12mByStore[magasin] ??= {};
row.stock12mByStore[magasin] ??= {};
row.receptions12mByStore[magasin] ??= {};
for (const period of periods) {
const currentQty = row.sales12mByStore[magasin][period] ?? 0;
const nextQty = e.sales[period] ?? 0;
row.sales12mByStore[magasin][period] = nextQty;
row.receptions12mByStore[magasin][period] = e.receptions[period] ?? 0;
row.stock12mByStore[magasin][period] = e.stock[period] ?? 0;
row.sales12m[period] = (row.sales12m[period] ?? 0) + (nextQty - currentQty);
}
const deltaTotalQty = e.quantite - (row.quantiteByStore[magasin] ?? 0);
const deltaTotalCa = e.ca - (row.caByStore[magasin] ?? 0);
const deltaTotalMarge = e.marge - (row.margeByStore[magasin] ?? 0);
row.quantiteByStore[magasin] = e.quantite;
row.caByStore[magasin] = e.ca;
row.margeByStore[magasin] = e.marge;
row.totalQuantite += deltaTotalQty;
row.totalCa += deltaTotalCa;
row.totalMarge += deltaTotalMarge;
row.tauxMarge = row.totalCa > 0 ? (row.totalMarge / row.totalCa) * 100 : 0;
if (!row.workingStores.includes(magasin)) row.workingStores.push(magasin);
corrigees++;
}
return corrigees;
}
/**
* Application dans le navigateur : nouvelle ligne, chiffres du magasin seuls.
* Les totaux « tous magasins » ne bougent pas, pour que la vue « Nos 2
* magasins » reste identique quel que soit le magasin consulté avant.
*/
export function withStorePatch(row: ProductRow, magasin: string, e: StorePatchEntry): ProductRow {
return {
...row,
sales12mByStore: { ...row.sales12mByStore, [magasin]: e.sales },
stock12mByStore: { ...row.stock12mByStore, [magasin]: e.stock },
receptions12mByStore: { ...row.receptions12mByStore, [magasin]: e.receptions },
quantiteByStore: { ...row.quantiteByStore, [magasin]: e.quantite },
caByStore: { ...row.caByStore, [magasin]: e.ca },
margeByStore: { ...row.margeByStore, [magasin]: e.marge },
workingStores: row.workingStores.includes(magasin) ? row.workingStores : [...row.workingStores, magasin],
};
}