Files
CollectFlow/src/features/grid/lib/store-patch.ts
T
Claude f104719054 Interface : passage de Next.js à une SPA React (Vite + React Router)
Le serveur étant réécrit en Rust, l'interface devient une application
React 19 côté navigateur, compilée par Vite dans dist/ et servie par le
serveur Rust (STATIC_DIR). Composants, styles et logique cliente
inchangés : seule la plomberie Next.js est remplacée.

- Outillage : Vite 8, @vitejs/plugin-react, Tailwind 4 via PostCSS,
  React Router 7 (mode déclaratif), index.html, src/main.tsx, alias @/,
  proxy /api → localhost:5643 en développement, ESLint sans plugin Next.
- Adaptations (src/shims) : next/link, next/navigation (useRouter avec
  refresh, usePathname/useSearchParams suivant aussi les replaceState de
  l'interface), next-auth/react (session lue sur /api/auth/session,
  déconnexion par /api/auth/logout).
- Actions serveur → POST /api/actions/{nom} (src/lib/rpc.ts) : mêmes noms,
  mêmes signatures, mêmes types. La connexion passe par /api/auth/login.
- Pages → table des routes (src/router.tsx) : données lues sur
  /api/pages/{nom} avec Suspense (loading.tsx au premier chargement,
  page conservée pendant une navigation ou un router.refresh()),
  error.tsx en frontière d'erreur, not-found.tsx, titres d'onglet,
  pages d'administration réservées aux administrateurs.
- Suppression du code serveur Next.js (routes /api, accès base, Qlik,
  authentification, middleware…) ; types utiles à l'interface recopiés
  dans src/types/ff.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:30:20 +00:00

156 lines
6.4 KiB
TypeScript

/**
* 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 "@/types/ff";
/** 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],
};
}