mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 18:58:17 +02:00
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
156 lines
6.4 KiB
TypeScript
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],
|
|
};
|
|
}
|