mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-18 20:56:43 +02:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3519597fb7 | ||
|
|
3a0e3bad50 | ||
|
|
9448e68509 | ||
|
|
540ff7cf29 | ||
|
|
4a11bdc3b0 | ||
|
|
ad5da8f95d | ||
|
|
8e37940040 | ||
|
|
48860271a5 | ||
|
|
acd51ecb88 | ||
|
|
af5933b5a7 | ||
|
|
26b9364232 |
No files matched your search
@@ -41,3 +41,6 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
.playwright-mcp/
|
||||
|
||||
# données locales (configuration avec mots de passe, comptes de secours)
|
||||
/data/
|
||||
Binary file not shown.
@@ -376,3 +376,91 @@ Autres changements :
|
||||
- Première ouverture d'un gros fournisseur après la synchro de la nuit : elle doit être quasi immédiate.
|
||||
- Changement de magasin : la Grille reste affichée, puis les chiffres se mettent à jour.
|
||||
- Enregistrer, quitter la Grille, puis y revenir : les gammes enregistrées sont bien là.
|
||||
|
||||
## Suivi — Lot 5 réalisé (correctif Grille et sécurité)
|
||||
|
||||
**Grille : changement de magasin**
|
||||
- **Corrige une régression du lot 4** : les lignes déjà affichées gardaient les chiffres de l'ancien magasin, et un tri actif n'était pas réappliqué, jusqu'à la fin du rechargement. Désormais, la liste est reconstruite à chaque changement de magasin : les chiffres et le tri sont justes tout de suite, et la sélection est conservée.
|
||||
- **Plus d'aller-retour serveur** : la Grille charge toujours « Nos 2 magasins », dont les lignes portent déjà le détail de chaque magasin. Changer de magasin met seulement à jour l'URL, qui reste partageable.
|
||||
- **Complément de l'API FF demandé à part** (`/api/grid/rows/store-patch`) et appliqué à son arrivée. Il couvre les articles sans vente SQL dans le magasin, comme avant.
|
||||
- Le calcul est identique à l'ancien : comparaison automatique sur un jeu d'essai.
|
||||
- Il ne modifie que les chiffres du magasin, pas les totaux « Nos 2 magasins ».
|
||||
- Il n'est demandé qu'une fois par magasin et par chargement.
|
||||
- **Sélection** : remise à zéro seulement au changement de fournisseur.
|
||||
- **La recherche ne reconstruit plus toutes les lignes** à chaque frappe.
|
||||
|
||||
**Sécurité**
|
||||
- **Contrôle commun `lib/authz.ts`** :
|
||||
- le compte est relu en base, avec un cache d'une minute ; un compte supprimé ou rétrogradé perd ses droits sans attendre l'expiration de sa session ;
|
||||
- si la base est injoignable, le jeton de session fait foi.
|
||||
- **Paramètres** : actions réservées aux administrateurs, avec la même vérification sur les pages Paramètres et Synchronisation.
|
||||
- **Toutes les autres actions et routes vérifient la session** : Grille, Commandes, Historique, exports, statut FF.
|
||||
- **Routes de diagnostic** (`/api/diag*`, `/api/test-db`) : réservées aux administrateurs, sans trace d'erreur technique.
|
||||
- **Mots de passe et clés d'IA** :
|
||||
- ils ne sont plus envoyés au navigateur ; un champ laissé vide garde le mot de passe enregistré ;
|
||||
- le mot de passe PostgreSQL n'est plus conservé dans le `localStorage`, et l'ancien est effacé ;
|
||||
- enregistrer la connexion PostgreSQL n'efface plus les clés d'IA.
|
||||
- **Compte admin/admin** :
|
||||
- il n'est créé qu'au premier démarrage (aucun utilisateur en base) ;
|
||||
- le fichier de secours `data/users.json` ne sert que si la base est injoignable ;
|
||||
- `data/` est ignoré par git.
|
||||
|
||||
**À savoir après la mise en ligne**
|
||||
- Un utilisateur promu administrateur doit se reconnecter pour accéder à Paramètres.
|
||||
- Après avoir configuré la base depuis le compte de secours, il faut se reconnecter.
|
||||
- Un mot de passe enregistré ne peut plus être effacé depuis l'écran ; seul son remplacement est possible.
|
||||
|
||||
## Suivi — Lot 6 réalisé (performances restantes et nettoyage)
|
||||
|
||||
**Grille**
|
||||
- **Écriture des réglages dans le navigateur** : au plus une par demi-seconde, et immédiate quand la page est quittée. Avant, chaque changement d'état en déclenchait une. Mesure : 8 changements de gamme donnent 1 écriture au lieu de 16.
|
||||
- **Calculs faits une seule fois par ligne** : la tendance réseau (au lieu d'un calcul par case et par rendu) et le texte de recherche (au lieu d'un calcul par ligne et par frappe). La petite courbe de tendance est mémorisée.
|
||||
- **Barre du bas** : elle ne suit plus que le nombre de modifications ; le détail est lu au moment d'enregistrer.
|
||||
|
||||
**Commandes fournisseurs**
|
||||
- **Montants de franco** : demandés à l'API FF 6 fournisseurs à la fois, au lieu de tous en même temps.
|
||||
- **Dernière réception** : calculée pour les seuls fournisseurs du cadencier. Sur la base de test, les résultats sont identiques au calcul complet.
|
||||
|
||||
**Nettoyage**
|
||||
- **Dépendances retirées** : `@tanstack/react-query`, `@google/genai`, `ai`, `react-markdown`, `remark-gfm`, `xlsx`, `@radix-ui/react-dropdown-menu`. Un `npm ci` complet a été vérifié.
|
||||
- **`pnpm-lock.yaml` supprimé** : Docker et le README utilisent npm.
|
||||
- **Code mort retiré** :
|
||||
- `ui/textarea`, `debug/api/init-db`, `score/store` ;
|
||||
- `getGridData` et `getAvailableNomenclature` ;
|
||||
- 5 fonctions de l'ancien client HTTP FF, `pgGetArticlesByCodeCentrale` et `libelleGamme`.
|
||||
- **Journaux à la racine supprimés** : `build.log`, `build_logs.txt`, `error.txt`, `tsc_errors.log`.
|
||||
|
||||
**Non fait volontairement**
|
||||
- **Enrichissement de l'Accueil en SQL** :
|
||||
- le cache de la page sert la version précédente pendant son recalcul, donc le gain serait faible ;
|
||||
- et rien ne garantit que la base FF relie chaque mouvement à son fournisseur (colonne détectée au cas par cas) : le fournisseur affiché pourrait changer.
|
||||
- À reprendre après le diagnostic du lot 7.
|
||||
- **Largeurs de colonnes en variables CSS** : peu de gain maintenant que l'écriture des réglages est différée.
|
||||
|
||||
## Suivi — Lot 7 réalisé (diagnostic de la base FF)
|
||||
|
||||
Les réécritures SQL restantes demandent de connaître la vraie base FF : types des colonnes, index, volumes. Ce dépôt ne les contient pas. Elles concernent :
|
||||
- `TRIM(a.codein::text)` dans une quinzaine de requêtes ;
|
||||
- `TO_CHAR(datmvt)` dans les regroupements ;
|
||||
- la jointure par code plutôt que par identifiant dans Meilleures ventes.
|
||||
|
||||
Le script `scripts/diagnostic-ff.js` les relève, en lecture seule :
|
||||
- version et réglages de PostgreSQL ;
|
||||
- taille des tables et date de dernière analyse ;
|
||||
- types des colonnes de jointure et de filtre ;
|
||||
- index existants ;
|
||||
- nombre de codes article contenant des espaces (pour savoir si `TRIM()` est utile) ;
|
||||
- plans d'exécution de six requêtes représentatives : recherche d'un article par code avec et sans `TRIM`, Meilleures ventes sur 7 jours, dernière réception, ventes mensuelles d'un fournisseur, stock sans vente.
|
||||
|
||||
**À lancer sur le serveur de production** (conteneur `web` du docker-compose) :
|
||||
|
||||
```bash
|
||||
docker compose exec web node scripts/diagnostic-ff.js > diagnostic-ff.txt
|
||||
# Avec les durées réelles (exécute les requêtes, à faire hors heures d'ouverture) :
|
||||
docker compose exec web node scripts/diagnostic-ff.js --analyze > diagnostic-ff-analyze.txt
|
||||
```
|
||||
|
||||
Le fichier produit ne contient aucun mot de passe, seulement des noms de tables, de colonnes et d'index, et des plans d'exécution. Une fois transmis, il permet de décider :
|
||||
- quels `TRIM()` retirer ;
|
||||
- quels index proposer à l'administrateur de la base FF ;
|
||||
- s'il faut remplacer l'enrichissement HTTP de l'Accueil.
|
||||
Generated
+104
-2002
File diff suppressed because it is too large.
Load diff
@@ -12,12 +12,8 @@
|
||||
"docker:build": "docker-compose up -d --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.49.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"ai": "^6.0.97",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
@@ -34,11 +30,8 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"ws": "^8.21.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
|
||||
Generated
-11081
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* CollectFlow — Diagnostic des requêtes sur la base FF (lecture seule).
|
||||
*
|
||||
* Sert à préparer l'optimisation des requêtes lentes (Meilleures ventes, fiche
|
||||
* produit, stocks, commandes) : le schéma et les index des tables FF ne sont pas
|
||||
* dans le dépôt, il faut donc les relever sur la vraie base avant de réécrire
|
||||
* quoi que ce soit.
|
||||
*
|
||||
* Usage (sur le serveur, ou dans le conteneur Docker de l'application) :
|
||||
*
|
||||
* node scripts/diagnostic-ff.js > diagnostic-ff.txt
|
||||
* node scripts/diagnostic-ff.js --analyze > diagnostic-ff.txt
|
||||
*
|
||||
* Sans option, rien n'est exécuté : seuls les plans prévus (EXPLAIN) sont lus.
|
||||
* `--analyze` exécute réellement les requêtes analysées pour mesurer leur durée
|
||||
* (EXPLAIN ANALYZE) : à lancer de préférence en dehors des heures d'ouverture.
|
||||
* La session est en lecture seule et chaque requête est coupée au-delà de 2 min.
|
||||
*
|
||||
* Connexion : `data/.db-config.json` (réglée dans Paramètres), sinon DATABASE_URL.
|
||||
*/
|
||||
const { Pool } = require("pg");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ANALYZE = process.argv.includes("--analyze");
|
||||
const TABLES = ["articles", "mvtart", "cube_stock", "artfou1", "fouident", "nomenclature", "grid_rows", "session_snapshots"];
|
||||
const COLONNES = {
|
||||
articles: ["no_id", "codein", "artcentrale", "nom_no_id", "libelle1"],
|
||||
mvtart: ["no_id", "artnoid", "site", "datmvt", "genremvt"],
|
||||
cube_stock: ["artnoid", "site", "qte"],
|
||||
artfou1: ["no_id", "art_no_id", "code", "preference"],
|
||||
fouident: ["code"],
|
||||
};
|
||||
|
||||
function connexion() {
|
||||
try {
|
||||
const fichier = path.join(path.resolve(__dirname, ".."), "data", ".db-config.json");
|
||||
if (fs.existsSync(fichier)) {
|
||||
const config = JSON.parse(fs.readFileSync(fichier, "utf-8"));
|
||||
if (config.url) return config.url;
|
||||
}
|
||||
} catch {
|
||||
/* configuration illisible : on tente la variable d'environnement */
|
||||
}
|
||||
return process.env.DATABASE_URL || "";
|
||||
}
|
||||
|
||||
function titre(texte) {
|
||||
console.log(`\n${"=".repeat(78)}\n${texte}\n${"=".repeat(78)}`);
|
||||
}
|
||||
|
||||
async function section(nom, fn) {
|
||||
titre(nom);
|
||||
const debut = Date.now();
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
console.log(` (indisponible : ${e.message})`);
|
||||
}
|
||||
console.log(` [${Date.now() - debut} ms]`);
|
||||
}
|
||||
|
||||
async function plan(client, nom, requete, params = []) {
|
||||
await section(`PLAN — ${nom}`, async () => {
|
||||
const options = ANALYZE ? "ANALYZE, BUFFERS, FORMAT TEXT" : "FORMAT TEXT";
|
||||
const { rows } = await client.query(`EXPLAIN (${options}) ${requete}`, params);
|
||||
for (const r of rows) console.log(` ${r["QUERY PLAN"]}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Requêtes reprises de src/lib/pg-ff-client.ts (même forme, paramètres d'exemple).
|
||||
const HIT_PARADE = `
|
||||
WITH ventes AS (
|
||||
SELECT TRIM(a.codein::text) AS codein, m.site,
|
||||
SUM(-m.qtemvt)::float AS qte_vendue, SUM(-m.mntmvtttc)::float AS ca_ttc, SUM(m.margemvt)::float AS marge
|
||||
FROM mvtart m JOIN articles a ON a.no_id = m.artnoid
|
||||
WHERE m.datmvt BETWEEN $1::date AND $2::date AND m.site IN ('292', '579') AND m.genremvt = 3 AND a.codein IS NOT NULL
|
||||
GROUP BY TRIM(a.codein::text), m.site
|
||||
),
|
||||
attrs AS (
|
||||
SELECT DISTINCT ON (TRIM(a.codein::text)) TRIM(a.codein::text) AS codein, a.libelle1::text AS libelle,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur
|
||||
FROM articles a
|
||||
LEFT JOIN LATERAL (SELECT af1.code FROM artfou1 af1 WHERE af1.art_no_id = a.no_id AND af1.preference = 1 ORDER BY af1.code LIMIT 1) af ON TRUE
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes)
|
||||
ORDER BY TRIM(a.codein::text), a.no_id DESC
|
||||
),
|
||||
stock_agg AS (
|
||||
SELECT TRIM(a2.codein::text) AS codein, SUM(cs.qte)::float AS stock_total
|
||||
FROM cube_stock cs JOIN articles a2 ON a2.no_id = cs.artnoid
|
||||
WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes)
|
||||
GROUP BY TRIM(a2.codein::text)
|
||||
)
|
||||
SELECT v.*, ar.libelle, ar.fournisseur, s.stock_total
|
||||
FROM ventes v LEFT JOIN attrs ar ON ar.codein = v.codein LEFT JOIN stock_agg s ON s.codein = v.codein
|
||||
ORDER BY v.ca_ttc DESC`;
|
||||
|
||||
const DERNIERE_RECEPTION = `
|
||||
SELECT af.code AS codefou, m.site, MAX(m.datmvt) AS derniere
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
|
||||
WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND m.datmvt IS NOT NULL
|
||||
GROUP BY af.code, m.site`;
|
||||
|
||||
const MENSUEL_FOURNISSEUR = `
|
||||
SELECT a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, SUM(m.qtemvt)
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = $1) af ON af.art_no_id = a.no_id
|
||||
WHERE m.datmvt BETWEEN $2::date AND $3::date AND m.site IN ('292', '579')
|
||||
GROUP BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')`;
|
||||
|
||||
const STOCK_SANS_VENTE = `
|
||||
SELECT a.codein, m.site, MAX(m.datmvt)
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site
|
||||
WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND m.datmvt < date_trunc('month', CURRENT_DATE)
|
||||
AND NOT EXISTS (SELECT 1 FROM mvtart mv2 WHERE mv2.artnoid = m.artnoid AND mv2.site = m.site AND mv2.genremvt = 3)
|
||||
GROUP BY a.codein, m.site, cs.qte
|
||||
HAVING COALESCE(cs.qte::float, 0) <> 0`;
|
||||
|
||||
async function main() {
|
||||
const url = connexion();
|
||||
if (!url) {
|
||||
console.error("Aucune connexion : ni data/.db-config.json ni DATABASE_URL.");
|
||||
process.exit(1);
|
||||
}
|
||||
const pool = new Pool({ connectionString: url, max: 1, application_name: "collectflow-diagnostic" });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("SET default_transaction_read_only = on");
|
||||
await client.query("SET statement_timeout = '120s'");
|
||||
await client.query("SET max_parallel_workers_per_gather = 0"); // comme l'application
|
||||
|
||||
console.log(`Diagnostic FF — ${new Date().toISOString()} — mode ${ANALYZE ? "EXPLAIN ANALYZE (requêtes exécutées)" : "EXPLAIN (plans seuls)"}`);
|
||||
|
||||
await section("Serveur PostgreSQL et réglages", async () => {
|
||||
const { rows } = await client.query(`
|
||||
SELECT name, setting, unit FROM pg_settings
|
||||
WHERE name IN ('server_version', 'work_mem', 'shared_buffers', 'effective_cache_size',
|
||||
'random_page_cost', 'max_parallel_workers_per_gather', 'default_statistics_target')
|
||||
ORDER BY name`);
|
||||
for (const r of rows) console.log(` ${r.name.padEnd(32)} ${r.setting}${r.unit ? ` ${r.unit}` : ""}`);
|
||||
});
|
||||
|
||||
await section("Taille des tables (lignes estimées, taille totale avec index)", async () => {
|
||||
const { rows } = await client.query(`
|
||||
SELECT c.relname, c.reltuples::bigint AS lignes, pg_size_pretty(pg_total_relation_size(c.oid)) AS taille,
|
||||
s.last_analyze, s.last_autoanalyze
|
||||
FROM pg_class c LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
|
||||
WHERE c.relkind IN ('r', 'p', 'm', 'v') AND c.relname = ANY($1)
|
||||
ORDER BY c.relname`, [TABLES]);
|
||||
const trouvees = new Set(rows.map((r) => r.relname));
|
||||
for (const r of rows) {
|
||||
const analyse = r.last_analyze || r.last_autoanalyze;
|
||||
console.log(` ${r.relname.padEnd(20)} ${String(r.lignes).padStart(12)} lignes ${String(r.taille).padStart(10)} dernière analyse : ${analyse ? new Date(analyse).toISOString() : "jamais"}`);
|
||||
}
|
||||
for (const t of TABLES) if (!trouvees.has(t)) console.log(` ${t.padEnd(20)} (absente)`);
|
||||
});
|
||||
|
||||
await section("Types des colonnes utilisées dans les jointures et filtres", async () => {
|
||||
for (const [table, colonnes] of Object.entries(COLONNES)) {
|
||||
const { rows } = await client.query(`
|
||||
SELECT column_name, data_type, character_maximum_length, table_schema
|
||||
FROM information_schema.columns WHERE table_name = $1 AND column_name = ANY($2)
|
||||
ORDER BY column_name`, [table, colonnes]);
|
||||
for (const r of rows) {
|
||||
console.log(` ${`${r.table_schema}.${table}.${r.column_name}`.padEnd(36)} ${r.data_type}${r.character_maximum_length ? `(${r.character_maximum_length})` : ""}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await section("Index existants", async () => {
|
||||
const { rows } = await client.query(`
|
||||
SELECT tablename, indexname, indexdef FROM pg_indexes
|
||||
WHERE tablename = ANY($1) ORDER BY tablename, indexname`, [TABLES]);
|
||||
for (const r of rows) console.log(` ${r.tablename.padEnd(18)} ${r.indexdef}`);
|
||||
if (rows.length === 0) console.log(" (aucun index trouvé sur ces tables)");
|
||||
});
|
||||
|
||||
await section("Codes article avec espaces (rend TRIM() nécessaire ou non)", async () => {
|
||||
const { rows } = await client.query(`
|
||||
SELECT count(*) AS total,
|
||||
count(*) FILTER (WHERE codein::text <> TRIM(codein::text)) AS avec_espaces,
|
||||
count(DISTINCT TRIM(codein::text)) AS distincts
|
||||
FROM articles`);
|
||||
const r = rows[0];
|
||||
console.log(` codein : ${r.total} articles — ${r.avec_espaces} avec espaces — ${r.distincts} codes distincts`);
|
||||
try {
|
||||
const { rows: c } = await client.query(`
|
||||
SELECT count(*) FILTER (WHERE artcentrale::text <> TRIM(artcentrale::text)) AS avec_espaces
|
||||
FROM articles WHERE artcentrale IS NOT NULL`);
|
||||
console.log(` artcentrale : ${c[0].avec_espaces} avec espaces`);
|
||||
} catch (e) {
|
||||
console.log(` artcentrale : (indisponible : ${e.message})`);
|
||||
}
|
||||
});
|
||||
|
||||
// Paramètres d'exemple tirés de la base elle-même.
|
||||
let codein = null;
|
||||
let codefou = null;
|
||||
try {
|
||||
codein = (await client.query("SELECT TRIM(codein::text) AS c FROM articles WHERE codein IS NOT NULL LIMIT 1")).rows[0]?.c ?? null;
|
||||
codefou = (await client.query("SELECT code FROM artfou1 WHERE preference = 1 GROUP BY code ORDER BY count(*) DESC LIMIT 1")).rows[0]?.code ?? null;
|
||||
} catch {
|
||||
/* tables absentes : les plans concernés le signaleront */
|
||||
}
|
||||
const hier = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
||||
const ilYa7Jours = new Date(Date.now() - 7 * 86_400_000).toISOString().slice(0, 10);
|
||||
const ilYa1An = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
|
||||
|
||||
await plan(client, `article par code, avec TRIM (codein ${codein})`,
|
||||
"SELECT a.no_id FROM articles a WHERE TRIM(a.codein::text) = $1", [codein ?? ""]);
|
||||
await plan(client, `article par code, sans TRIM (codein ${codein})`,
|
||||
"SELECT a.no_id FROM articles a WHERE a.codein::text = $1", [codein ?? ""]);
|
||||
await plan(client, `Meilleures ventes, 7 derniers jours (${ilYa7Jours} → ${hier})`, HIT_PARADE, [ilYa7Jours, hier]);
|
||||
await plan(client, "Dernière réception par fournisseur (Commandes)", DERNIERE_RECEPTION);
|
||||
await plan(client, `Ventes mensuelles d'un fournisseur sur 12 mois (fournisseur ${codefou})`, MENSUEL_FOURNISSEUR, [codefou ?? "", ilYa1An, hier]);
|
||||
await plan(client, "Stock sans vente (forme simplifiée)", STOCK_SANS_VENTE);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("Diagnostic interrompu :", e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -480,6 +480,8 @@ export function SynchronisationClient() {
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Ce sont des chiffres mensuels : les refaire chaque nuit coûterait du temps pour rien.
|
||||
Sont aussi refaits, sans attendre ce délai : les données d'avant le mois en cours
|
||||
(le dernier mois leur manque) et une extraction en échec, retentée la nuit suivante.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifierAdmin } from "@/lib/authz";
|
||||
import { SynchronisationClient } from "./client";
|
||||
|
||||
export const metadata: Metadata = { title: "Synchronisation" };
|
||||
@@ -6,9 +8,10 @@ export const metadata: Metadata = { title: "Synchronisation" };
|
||||
/**
|
||||
* Paramétrage de la synchronisation nocturne des fournisseurs.
|
||||
*
|
||||
* L'accès admin est déjà imposé par le middleware (préfixe /admin) ; les routes
|
||||
* /api/admin/sync revérifient le rôle côté serveur.
|
||||
* L'accès admin est déjà imposé par le middleware (préfixe /admin), sur le rôle
|
||||
* du jeton ; la page le revérifie en base, comme les routes /api/admin/sync.
|
||||
*/
|
||||
export default function SynchronisationPage() {
|
||||
export default async function SynchronisationPage() {
|
||||
if (!(await verifierAdmin()).ok) redirect("/dashboard");
|
||||
return <SynchronisationClient />;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)} %` },
|
||||
];
|
||||
@@ -227,7 +227,7 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
|
||||
() => computeNetworkTrend(reseau?.qteByMonth ?? null, nbMagByMonth),
|
||||
[reseau, nbMagByMonth],
|
||||
);
|
||||
const magasinsParMois = useMemo(() => computeStoresSeries(nbMagByMonth), [nbMagByMonth]);
|
||||
const magasinsParMois = useMemo(() => computeStoresSeries(nbMagByMonth, trend.labels), [nbMagByMonth, trend.labels]);
|
||||
const comparatif = useMemo(() => computeComparatif(fiche), [fiche]);
|
||||
const verdict = indiceVerdict(comparatif.indiceQte);
|
||||
|
||||
@@ -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'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'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>}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -30,7 +30,7 @@ import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-st
|
||||
import {
|
||||
testDatabaseConnection,
|
||||
saveDatabaseSettings,
|
||||
getSavedDatabaseConfig,
|
||||
getParametresAffichables,
|
||||
saveQlikSettings,
|
||||
testQlikConnection,
|
||||
saveFfApiSettings,
|
||||
@@ -45,12 +45,13 @@ import { ServerLogs } from "@/features/settings/components/server-logs";
|
||||
/**
|
||||
* Configuration enregistrée, lue UNE fois pour toute la page : trois sections
|
||||
* (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions
|
||||
* serveur s'exécutent les unes après les autres.
|
||||
* serveur s'exécutent les unes après les autres. Elle ne contient aucun mot de
|
||||
* passe : seulement l'indication qu'il y en a un d'enregistré.
|
||||
*/
|
||||
let savedConfigPromise: ReturnType<typeof getSavedDatabaseConfig> | null = null;
|
||||
function loadSavedConfig(force = false): ReturnType<typeof getSavedDatabaseConfig> {
|
||||
let savedConfigPromise: ReturnType<typeof getParametresAffichables> | null = null;
|
||||
function loadSavedConfig(force = false): ReturnType<typeof getParametresAffichables> {
|
||||
if (force || !savedConfigPromise) {
|
||||
savedConfigPromise = getSavedDatabaseConfig().catch((e) => {
|
||||
savedConfigPromise = getParametresAffichables().catch((e) => {
|
||||
savedConfigPromise = null;
|
||||
throw e;
|
||||
});
|
||||
@@ -142,6 +143,13 @@ function ResultatTest({ test }: { test: EtatTest }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Texte indicatif d'un champ mot de passe, selon qu'un mot de passe est déjà enregistré. */
|
||||
function placeholderMotDePasse(enregistre: boolean): string {
|
||||
return enregistre ? "•••••• (inchangé)" : "••••••••";
|
||||
}
|
||||
|
||||
const AIDE_MOT_DE_PASSE_ENREGISTRE = "Laissez vide pour conserver le mot de passe enregistré.";
|
||||
|
||||
// ── PostgreSQL ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||
@@ -152,9 +160,12 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||
user, setUser,
|
||||
password, setPassword,
|
||||
ssl, setSsl,
|
||||
getDatabaseUrl,
|
||||
motDePasseEnregistre, setMotDePasseEnregistre,
|
||||
} = useDbSettingsStore();
|
||||
|
||||
// Mot de passe vide : le serveur reprend celui qui est enregistré.
|
||||
const champs = () => ({ host, port, database, user, password: password || undefined, ssl });
|
||||
|
||||
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
|
||||
const [enregistrement, setEnregistrement] = useState(false);
|
||||
const [rechargement, setRechargement] = useState(false);
|
||||
@@ -162,7 +173,7 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||
const tester = async () => {
|
||||
setTest({ etat: "en-cours" });
|
||||
try {
|
||||
const res = await testDatabaseConnection(getDatabaseUrl());
|
||||
const res = await testDatabaseConnection(champs());
|
||||
setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Erreur de connexion inconnue." });
|
||||
} catch (e) {
|
||||
setTest({ etat: "erreur", message: messageErreur(e) });
|
||||
@@ -172,9 +183,14 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||
const enregistrer = async () => {
|
||||
setEnregistrement(true);
|
||||
try {
|
||||
const res = await saveDatabaseSettings(getDatabaseUrl());
|
||||
if (res.success) toast.succes("Connexion à la base PostgreSQL enregistrée.");
|
||||
else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`);
|
||||
const res = await saveDatabaseSettings(champs());
|
||||
if (res.success) {
|
||||
toast.succes("Connexion à la base PostgreSQL enregistrée.");
|
||||
if (password) {
|
||||
setPassword("");
|
||||
setMotDePasseEnregistre(true);
|
||||
}
|
||||
} else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`);
|
||||
} catch (e) {
|
||||
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
||||
} finally {
|
||||
@@ -223,8 +239,8 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||
<Input id="pg-utilisateur" className="w-full font-mono" placeholder="postgres" value={user}
|
||||
onChange={(e) => setUser(e.target.value)} autoComplete="off" />
|
||||
</Champ>
|
||||
<Champ id="pg-mdp" label="Mot de passe">
|
||||
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder="••••••••"
|
||||
<Champ id="pg-mdp" label="Mot de passe" aide={motDePasseEnregistre ? AIDE_MOT_DE_PASSE_ENREGISTRE : undefined}>
|
||||
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder={placeholderMotDePasse(motDePasseEnregistre)}
|
||||
value={password || ""} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
|
||||
</Champ>
|
||||
</div>
|
||||
@@ -283,8 +299,8 @@ function SectionFfApi() {
|
||||
useEffect(() => {
|
||||
loadSavedConfig()
|
||||
.then((cfg) => {
|
||||
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
|
||||
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
|
||||
setSavedUrl(cfg.ffApiBaseUrl || null);
|
||||
if (cfg.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
|
||||
})
|
||||
.catch(() => { /* réglage optionnel ; l'échec de lecture est signalé par la page */ });
|
||||
}, []);
|
||||
@@ -418,7 +434,9 @@ function SectionFfApi() {
|
||||
function SectionQlik() {
|
||||
const [host, setHost] = useState("");
|
||||
const [user, setUser] = useState("");
|
||||
// Toujours vide au chargement : le mot de passe enregistré ne quitte pas le serveur.
|
||||
const [password, setPassword] = useState("");
|
||||
const [motDePasseEnregistre, setMotDePasseEnregistre] = useState(false);
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
|
||||
@@ -426,10 +444,9 @@ function SectionQlik() {
|
||||
useEffect(() => {
|
||||
loadSavedConfig()
|
||||
.then((c) => {
|
||||
if (!c) return;
|
||||
setHost(c.qlikHost ?? "");
|
||||
setUser(c.qlikUser ?? "");
|
||||
setPassword(c.qlikPassword ?? "");
|
||||
setHost(c.qlik.host);
|
||||
setUser(c.qlik.user);
|
||||
setMotDePasseEnregistre(c.qlik.motDePasseEnregistre);
|
||||
})
|
||||
.catch(() => { /* l'échec de lecture est signalé par la page */ });
|
||||
}, []);
|
||||
@@ -437,9 +454,14 @@ function SectionQlik() {
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await saveQlikSettings(host.trim(), user.trim(), password);
|
||||
if (res.success) toast.succes("Identifiants Qlik enregistrés.");
|
||||
else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`);
|
||||
const res = await saveQlikSettings(host.trim(), user.trim(), password || undefined);
|
||||
if (res.success) {
|
||||
toast.succes("Identifiants Qlik enregistrés.");
|
||||
if (password) {
|
||||
setPassword("");
|
||||
setMotDePasseEnregistre(true);
|
||||
}
|
||||
} else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`);
|
||||
} catch (e) {
|
||||
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
||||
} finally {
|
||||
@@ -450,14 +472,15 @@ function SectionQlik() {
|
||||
const handleTest = async () => {
|
||||
setTest({ etat: "en-cours" });
|
||||
try {
|
||||
const res = await testQlikConnection(host.trim(), user.trim(), password);
|
||||
const res = await testQlikConnection(host.trim(), user.trim(), password || undefined);
|
||||
setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Échec de la connexion." });
|
||||
} catch (e) {
|
||||
setTest({ etat: "erreur", message: messageErreur(e) });
|
||||
}
|
||||
};
|
||||
|
||||
const testImpossible = !user || !password;
|
||||
// Mot de passe vide accepté s'il y en a un d'enregistré : le serveur le reprend.
|
||||
const testImpossible = !user || (!password && !motDePasseEnregistre);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -488,9 +511,9 @@ function SectionQlik() {
|
||||
<Input id="qlik-utilisateur" type="text" placeholder="FFSCH" value={user}
|
||||
onChange={(e) => setUser(e.target.value)} className="w-full font-mono" autoComplete="off" />
|
||||
</Champ>
|
||||
<Champ id="qlik-mdp" label="Mot de passe">
|
||||
<Champ id="qlik-mdp" label="Mot de passe" aide={motDePasseEnregistre ? AIDE_MOT_DE_PASSE_ENREGISTRE : undefined}>
|
||||
<div className="relative">
|
||||
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
|
||||
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder={placeholderMotDePasse(motDePasseEnregistre)} value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
|
||||
<Button
|
||||
type="button"
|
||||
@@ -542,24 +565,21 @@ export function ParametresClient({ onglet: ongletDemande }: { onglet?: string })
|
||||
const setUser = useDbSettingsStore((s) => s.setUser);
|
||||
const setPassword = useDbSettingsStore((s) => s.setPassword);
|
||||
const setSsl = useDbSettingsStore((s) => s.setSsl);
|
||||
const setMotDePasseEnregistre = useDbSettingsStore((s) => s.setMotDePasseEnregistre);
|
||||
|
||||
const reloadFromServer = useCallback(async (force = false) => {
|
||||
const config = await loadSavedConfig(force);
|
||||
if (config?.url) {
|
||||
// Parser l'URL pour remettre dans le store
|
||||
try {
|
||||
const url = new URL(config.url.replace("postgres://", "http://")); // URL parser helper
|
||||
setHost(url.hostname);
|
||||
setPort(url.port || "5432");
|
||||
setDatabase(url.pathname.slice(1).split("?")[0]);
|
||||
setUser(url.username);
|
||||
setPassword(decodeURIComponent(url.password));
|
||||
setSsl(config.url.includes("sslmode=require"));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse saved URL", e);
|
||||
}
|
||||
const { db } = await loadSavedConfig(force);
|
||||
// Le mot de passe n'est jamais renvoyé : champ vide = conserver celui enregistré.
|
||||
setPassword("");
|
||||
setMotDePasseEnregistre(db?.motDePasseEnregistre ?? false);
|
||||
if (db) {
|
||||
setHost(db.host);
|
||||
setPort(db.port);
|
||||
setDatabase(db.database);
|
||||
setUser(db.user);
|
||||
setSsl(db.ssl);
|
||||
}
|
||||
}, [setDatabase, setHost, setPassword, setPort, setSsl, setUser]);
|
||||
}, [setDatabase, setHost, setMotDePasseEnregistre, setPassword, setPort, setSsl, setUser]);
|
||||
|
||||
useEffect(() => {
|
||||
// `isMounted` sert à éviter un écart d'hydratation (réglages PostgreSQL
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifierAdmin } from "@/lib/authz";
|
||||
import { ParametresClient } from "./client";
|
||||
|
||||
export const metadata: Metadata = { title: "Paramètres" };
|
||||
@@ -11,6 +13,9 @@ export const metadata: Metadata = { title: "Paramètres" };
|
||||
* lien se partage et survive au rechargement, puis rend la page cliente.
|
||||
*/
|
||||
export default async function SettingsPage(props: { searchParams: Promise<{ onglet?: string }> }) {
|
||||
// Le middleware filtre déjà sur le rôle du jeton ; ici, rôle relu en base.
|
||||
if (!(await verifierAdmin()).ok) redirect("/dashboard");
|
||||
|
||||
const { onglet } = await props.searchParams;
|
||||
return <ParametresClient onglet={onglet} />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
import { listGridSuppliers } from "@/lib/grid-store";
|
||||
@@ -45,15 +45,6 @@ function publicJob(j: WarmupJob | null) {
|
||||
return j;
|
||||
}
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runWarmup(j: WarmupJob, staleHours: number): Promise<void> {
|
||||
try {
|
||||
const fournisseurs = await pgGetFournisseurs();
|
||||
@@ -102,8 +93,8 @@ async function runWarmup(j: WarmupJob, staleHours: number): Promise<void> {
|
||||
|
||||
/** GET /api/admin/grid-warmup — avancement du préchauffage. */
|
||||
export async function GET() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
@@ -112,8 +103,8 @@ export async function GET() {
|
||||
* Démarre le préchauffage en arrière-plan et répond immédiatement.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
if (job?.status === "running") {
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
import { db } from "@/db";
|
||||
import { syncFournisseurs } from "@/db/schema";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET — la liste complète des fournisseurs paramétrés, avec l'état de leur
|
||||
* dernière synchronisation.
|
||||
@@ -25,8 +16,8 @@ async function requireAdmin(): Promise<NextResponse | null> {
|
||||
* besoin de la vue d'ensemble pour décider qui cadencer.
|
||||
*/
|
||||
export async function GET() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -65,8 +56,8 @@ const patchSchema = z.object({
|
||||
* trompeur, alors même qu'il vient d'être rouvert volontairement.
|
||||
*/
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
import { readSyncSettings, saveSyncSettings } from "@/features/admin/api/sync-settings";
|
||||
import {
|
||||
getSyncSchedulerState,
|
||||
@@ -15,19 +15,10 @@ export const dynamic = "force-dynamic";
|
||||
// Un lancement manuel peut enchaîner plusieurs fournisseurs.
|
||||
export const maxDuration = 300;
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** GET — réglages, état courant, et prochaine ouverture de fenêtre. */
|
||||
export async function GET() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const settings = await readSyncSettings();
|
||||
const maintenant = new Date();
|
||||
@@ -55,8 +46,8 @@ const settingsSchema = z.object({
|
||||
|
||||
/** PATCH — met à jour les réglages. */
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
@@ -87,8 +78,8 @@ const actionSchema = z.object({
|
||||
|
||||
/** POST — démarre un round manuellement, l'arrête, ou resynchronise la liste. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
|
||||
@@ -9,6 +10,9 @@ const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
* Transfère les query params site et codefou si présents.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
|
||||
@@ -31,6 +32,9 @@ async function probe(url: string) {
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const acces = await adminOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
|
||||
// Date range: 12 derniers mois
|
||||
@@ -55,7 +59,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
if (!firstCodein) {
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=1`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
|
||||
if (list.length > 0) firstCodein = (list[0] as Record<string, unknown>).codein as string ?? null;
|
||||
@@ -80,13 +84,13 @@ export async function GET(req: NextRequest) {
|
||||
referentiel_forced,
|
||||
] = await Promise.all([
|
||||
probe(`${FF_API_BASE}/api/fournisseurs?limit=2`),
|
||||
probe(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=2`),
|
||||
probe(`${FF_API_BASE}/api/mouvements/articles?codefou=${codefou}&dateDebut=${dateDebut}&dateFin=${dateFin}&limit=2`),
|
||||
probe(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=2`),
|
||||
probe(`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&limit=2`),
|
||||
// Test mensuel avec no_id auto-résolu
|
||||
firstCodein
|
||||
? (async () => {
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=1`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
|
||||
const noid = list[0] ? (list[0] as Record<string, unknown>).no_id as string : null;
|
||||
@@ -99,7 +103,7 @@ export async function GET(req: NextRequest) {
|
||||
firstCodein
|
||||
? (async () => {
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=5`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=5`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
|
||||
const art = (list[4] ?? list[0]) as Record<string, unknown> | undefined;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { pgDiagFournisseurDerniereEntree } from "@/lib/pg-ff-client";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,6 +14,9 @@ export const dynamic = "force-dynamic";
|
||||
* blanche `MVTART_FOU_CANDIDATES` si le nom diffère.
|
||||
*/
|
||||
export async function GET() {
|
||||
const acces = await adminOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
try {
|
||||
return NextResponse.json(await pgDiagFournisseurDerniereEntree());
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import ExcelJS from "exceljs";
|
||||
import { z } from "zod";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
const ExportSchema = z.object({
|
||||
nomFournisseur: z.string(),
|
||||
@@ -19,6 +20,9 @@ const ExportSchema = z.object({
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = ExportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import ExcelJS from "exceljs";
|
||||
import { z } from "zod";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
const ExportModifiedGammesSchema = z.object({
|
||||
nomFournisseur: z.string(),
|
||||
@@ -13,6 +14,9 @@ const ExportModifiedGammesSchema = z.object({
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = ExportModifiedGammesSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { testFfApiConnection } from "@/features/settings/actions";
|
||||
import { diagnostiquerApiFf } from "@/features/settings/ff-api-diagnostic";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -13,7 +14,10 @@ export const dynamic = "force-dynamic";
|
||||
* détail, impossible à distinguer d'une simple erreur d'URL.
|
||||
*/
|
||||
export async function GET() {
|
||||
const res = await testFfApiConnection();
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const res = await diagnostiquerApiFf();
|
||||
if (!res.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "API FF Nancy non disponible", url: res.url, detail: res.error },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
import type { GridFilters } from "@/types/grid";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -11,6 +12,9 @@ function writeNdjson(controller: ReadableStreamDefaultController<Uint8Array>, va
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const codeFournisseur = searchParams.get("fournisseur");
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getStorePatch } from "@/features/grid/api/get-product-rows";
|
||||
import { MAGASINS } from "@/lib/magasins";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/grid/rows/store-patch?fournisseur=…&magasin=…
|
||||
*
|
||||
* Complément de l'API FF pour un magasin (cf. `features/grid/lib/store-patch.ts`).
|
||||
* La Grille charge toujours les lignes « tous magasins » et applique ce
|
||||
* complément à son arrivée : le changement de magasin n'attend plus rien.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const codeFournisseur = request.nextUrl.searchParams.get("fournisseur");
|
||||
const magasin = request.nextUrl.searchParams.get("magasin") ?? "";
|
||||
// Un code inconnu ferait de chaque article un candidat : autant d'appels
|
||||
// inutiles à l'API FF. « Tous magasins » n'a pas de complément.
|
||||
if (!codeFournisseur || !MAGASINS.some((m) => m.code === magasin)) {
|
||||
return Response.json({ error: "Paramètres fournisseur et magasin requis." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const patch = await getStorePatch(codeFournisseur, magasin);
|
||||
return Response.json(patch, { headers: { "Cache-Control": "no-store" } });
|
||||
} catch (error) {
|
||||
console.error(`[store-patch] ${codeFournisseur}/${magasin} KO:`, (error as Error).message?.slice(0, 200));
|
||||
return Response.json({ error: "Complément indisponible." }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -11,24 +11,15 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
import { listCaptures, readCapture } from "@/lib/log-capture";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const refus = await requireAdmin();
|
||||
if (refus) return refus;
|
||||
const refus = await adminOuReponse();
|
||||
if (refus instanceof Response) return refus;
|
||||
|
||||
const id = req.nextUrl.searchParams.get("id");
|
||||
if (!id) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client";
|
||||
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
|
||||
|
||||
@@ -12,14 +12,10 @@ export const dynamic = "force-dynamic";
|
||||
* et performance locale (quantités par magasin). Chargé à la demande depuis la
|
||||
* fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille,
|
||||
* on ne veut pas la payer au chargement de la page.
|
||||
*
|
||||
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const raw = req.nextUrl.searchParams.get("nomNoId");
|
||||
const nomNoId = Number(raw);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits";
|
||||
|
||||
// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on
|
||||
@@ -21,23 +21,18 @@ export const dynamic = "force-dynamic";
|
||||
* d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is
|
||||
* not valid JSON ». Même schéma que `POST /api/qlik/sync`.
|
||||
*
|
||||
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
|
||||
* Contrôle de session explicite en plus du middleware : il répond ici en JSON,
|
||||
* et relit le compte en base (cf. lib/authz).
|
||||
*/
|
||||
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return null;
|
||||
}
|
||||
|
||||
function lireTerme(req: NextRequest): string | null {
|
||||
const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
|
||||
return q.length >= 3 ? q : null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const denied = await sessionOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const q = lireTerme(req);
|
||||
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
||||
@@ -54,8 +49,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const denied = await sessionOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const q = lireTerme(req);
|
||||
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { adminOuReponse, sessionOuReponse } from "@/lib/authz";
|
||||
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
||||
@@ -121,34 +121,6 @@ function idleJob(fournisseur: string, codeCentrale?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie l'auth admin. Renvoie une réponse 403/401 si refusée, sinon null.
|
||||
*/
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie qu'une session existe (sans exiger le rôle admin).
|
||||
*
|
||||
* Utilisé par le mode produit : le middleware Next ne couvre PAS les routes
|
||||
* `/api/*`, le contrôle doit donc être fait ici explicitement.
|
||||
*/
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex de validation d'un code centrale "raisonnable".
|
||||
*
|
||||
@@ -337,8 +309,8 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
// Mode produit : ouvert à tout utilisateur connecté.
|
||||
if (codeCentrale) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const denied = await sessionOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
const job = jobs.get(jobKeyForProduct(codeCentrale));
|
||||
if (!job) {
|
||||
return NextResponse.json({ success: true, ...idleJob("", codeCentrale) });
|
||||
@@ -347,8 +319,8 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Mode fournisseur : admin uniquement (inchangé).
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
if (!fournisseur) {
|
||||
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
|
||||
@@ -371,8 +343,8 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ───
|
||||
if (codeCentraleParam) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const denied = await sessionOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
// Même validation que la sync fournisseur (trim, rejet des vides / "-" /
|
||||
// codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik.
|
||||
@@ -414,8 +386,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// ─── Mode fournisseur : admin uniquement (inchangé) ──────────────────────
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||
if (!fournisseur) {
|
||||
|
||||
@@ -2,8 +2,12 @@ import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { pgGetStockForCodeins } from '@/lib/pg-ff-client';
|
||||
import { adminOuReponse } from '@/lib/authz';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const acces = await adminOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const codein = searchParams.get('codein') || '334152';
|
||||
@@ -24,7 +28,9 @@ export async function GET(request: Request) {
|
||||
stock: Object.fromEntries(map),
|
||||
fou: fouResult.rows,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e.message, stack: e.stack }, { status: 500 });
|
||||
} catch (e) {
|
||||
// Pas de pile d'appels dans la réponse : elle reste dans les journaux du serveur.
|
||||
console.error('[api/test-db]', e);
|
||||
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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() } },
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -11,18 +11,10 @@
|
||||
import { db } from "@/db";
|
||||
import { apiKeys } from "@/db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { requireAdmin } from "@/lib/authz";
|
||||
import { generateApiKey } from "@/lib/api-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
async function ensureAdmin() {
|
||||
const session = await auth();
|
||||
if ((session?.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
throw new Error("Accès refusé : Droits administrateur requis.");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export interface ApiKeyRow {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -36,7 +28,7 @@ export interface ApiKeyRow {
|
||||
|
||||
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
|
||||
export async function getApiKeys(): Promise<ApiKeyRow[]> {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
@@ -67,7 +59,7 @@ export async function createApiKey(
|
||||
name: string,
|
||||
role: "admin" | "user" = "user",
|
||||
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
|
||||
const session = await ensureAdmin();
|
||||
const moi = await requireAdmin();
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." };
|
||||
@@ -80,7 +72,7 @@ export async function createApiKey(
|
||||
keyPrefix,
|
||||
keyHash,
|
||||
role,
|
||||
createdBy: (session?.user as { name?: string | null } | undefined)?.name ?? null,
|
||||
createdBy: moi.username || null,
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
return { success: true, key, keyPrefix };
|
||||
@@ -92,7 +84,7 @@ export async function createApiKey(
|
||||
|
||||
/** Révoque une clé : elle est refusée dès l'appel suivant, mais reste listée. */
|
||||
export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
try {
|
||||
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
|
||||
revalidatePath("/settings");
|
||||
@@ -105,7 +97,7 @@ export async function revokeApiKey(id: number): Promise<{ success: boolean; erro
|
||||
|
||||
/** Supprime définitivement une clé révoquée (nettoyage de la liste). */
|
||||
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
try {
|
||||
await db.delete(apiKeys).where(eq(apiKeys.id, id));
|
||||
revalidatePath("/settings");
|
||||
|
||||
@@ -140,19 +140,36 @@ async function prochainSql(depuis: Date) {
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Délai avant de retenter un fournisseur Qlik en échec : la nuit suivante. */
|
||||
const QLIK_REPRISE_ECHEC_MS = 18 * 3600 * 1000;
|
||||
|
||||
/**
|
||||
* Le plus ancien fournisseur Qlik dont les données dépassent `qlikMinJours`.
|
||||
* Le seuil en jours, plutôt que la fenêtre courante, évite de relancer chaque
|
||||
* nuit une extraction de plusieurs minutes pour des agrégats mensuels.
|
||||
* Le plus ancien fournisseur Qlik à rafraîchir. Il l'est si :
|
||||
* - ses données dépassent `qlikMinJours` (agrégats mensuels : inutile de
|
||||
* relancer chaque nuit une extraction de plusieurs minutes) ;
|
||||
* - ou elles datent d'avant le mois en cours : la fenêtre de 12 mois a glissé,
|
||||
* son dernier mois manque, sa tendance n'est plus à jour ;
|
||||
* - ou sa dernière extraction a échoué il y a plus de 18 h. Un échec attendait
|
||||
* jusqu'ici `qlikMinJours` (7 jours) avant un nouvel essai, alors que la
|
||||
* plupart sont passagers (serveur Qlik chargé, dernier mois pas encore
|
||||
* chargé). Le plus ancien passant en premier, un fournisseur qui échoue à
|
||||
* chaque fois repart en fin de file et ne bloque personne.
|
||||
*/
|
||||
async function prochainQlik(qlikMinJours: number, maintenant: Date) {
|
||||
const seuil = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000);
|
||||
const seuilAge = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000);
|
||||
const debutMois = new Date(maintenant.getFullYear(), maintenant.getMonth(), 1);
|
||||
const seuil = seuilAge > debutMois ? seuilAge : debutMois;
|
||||
const seuilEchec = new Date(maintenant.getTime() - QLIK_REPRISE_ECHEC_MS);
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(syncFournisseurs)
|
||||
.where(and(
|
||||
eq(syncFournisseurs.actifQlik, true),
|
||||
or(isNull(syncFournisseurs.dernierQlikAt), lt(syncFournisseurs.dernierQlikAt, seuil)) as SQL,
|
||||
or(
|
||||
isNull(syncFournisseurs.dernierQlikAt),
|
||||
lt(syncFournisseurs.dernierQlikAt, seuil),
|
||||
and(eq(syncFournisseurs.dernierQlikStatut, "echec"), lt(syncFournisseurs.dernierQlikAt, seuilEchec)),
|
||||
) as SQL,
|
||||
))
|
||||
.orderBy(asc(sql`${syncFournisseurs.dernierQlikAt} nulls first`))
|
||||
.limit(1);
|
||||
|
||||
@@ -4,24 +4,14 @@ import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { hashPassword } from "@/features/auth/logic/auth-logic";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { requireAdmin } from "@/lib/authz";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur actuel est un administrateur.
|
||||
*/
|
||||
async function ensureAdmin() {
|
||||
const session = await auth();
|
||||
if ((session?.user as any)?.role !== "admin") {
|
||||
throw new Error("Accès refusé : Droits administrateur requis.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère tous les utilisateurs (Admin seulement).
|
||||
*/
|
||||
export async function getUsers() {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
return db.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
@@ -34,7 +24,7 @@ export async function getUsers() {
|
||||
* Crée un nouvel utilisateur.
|
||||
*/
|
||||
export async function createUser(username: string, password: string, role: "admin" | "user" = "user") {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
|
||||
try {
|
||||
await db.insert(users).values({
|
||||
@@ -54,10 +44,8 @@ export async function createUser(username: string, password: string, role: "admi
|
||||
* Supprime un utilisateur.
|
||||
*/
|
||||
export async function deleteUser(id: number) {
|
||||
await ensureAdmin();
|
||||
|
||||
const session = await auth();
|
||||
if (Number((session?.user as any)?.id) === id) {
|
||||
const moi = await requireAdmin();
|
||||
if (Number(moi.id) === id) {
|
||||
return { success: false, error: "Vous ne pouvez pas supprimer votre propre compte." };
|
||||
}
|
||||
|
||||
@@ -65,7 +53,7 @@ export async function deleteUser(id: number) {
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
revalidatePath("/settings");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return { success: false, error: "Erreur lors de la suppression." };
|
||||
}
|
||||
}
|
||||
@@ -74,13 +62,13 @@ export async function deleteUser(id: number) {
|
||||
* Met à jour le mot de passe d'un utilisateur.
|
||||
*/
|
||||
export async function updatePassword(id: number, newPassword: string) {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
try {
|
||||
await db.update(users)
|
||||
.set({ passwordHash: hashPassword(newPassword) })
|
||||
.where(eq(users.id, id));
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return { success: false, error: "Erreur lors de la mise à jour du mot de passe." };
|
||||
}
|
||||
}
|
||||
@@ -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)]">
|
||||
|
||||
@@ -1,15 +1,72 @@
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { hashPassword, getFallbackUsers, saveFallbackUsers } from "./auth-logic";
|
||||
import { count, eq, sql } from "drizzle-orm";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
/** Amorçage en cours ou réussi pour ce processus (remis à null en cas d'échec). */
|
||||
let amorcage: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* Checks if the users table is empty.
|
||||
* If so, creates a default admin account (admin/admin).
|
||||
* Also ensures the table exists (auto-repair).
|
||||
* Prépare les comptes au premier démarrage, une seule fois par processus.
|
||||
*
|
||||
* - Base joignable : crée la table `users` si besoin (auto-réparation), puis le
|
||||
* compte admin/admin **uniquement si la table est vide**. Un admin supprimé ou
|
||||
* renommé n'est donc pas recréé à chaque connexion. Le fichier de secours
|
||||
* `data/users.json` n'est pas touché.
|
||||
* - Base injoignable (première installation, base pas encore configurée) : on
|
||||
* s'assure que le fichier de secours contient un administrateur, et on
|
||||
* retentera la base à la prochaine connexion.
|
||||
*
|
||||
* Renvoie true si le compte admin/admin vient d'être créé en base.
|
||||
*/
|
||||
export async function ensureAdminExists() {
|
||||
// 1. Always ensure local JSON fallback has at least one admin
|
||||
export function ensureAdminExists(): Promise<boolean> {
|
||||
if (!amorcage) {
|
||||
amorcage = amorcer().catch((err: unknown) => {
|
||||
// Échec : on retentera à la prochaine connexion.
|
||||
amorcage = null;
|
||||
console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", (err as Error)?.message ?? err);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return amorcage;
|
||||
}
|
||||
|
||||
async function amorcer(): Promise<boolean> {
|
||||
console.log("[AUTH] Testing database connection for seeding...");
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
} catch (err) {
|
||||
assurerAdminDeSecours();
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Auto-repair: Ensure table exists
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
const [{ nb }] = await db.select({ nb: sql<number>`count(*)::int` }).from(users);
|
||||
if (nb > 0) return false;
|
||||
|
||||
console.log("[AUTH] Table users vide : création du compte admin/admin.");
|
||||
// Si un autre processus l'a créé entre-temps, l'unicité du nom fait échouer
|
||||
// l'insertion : on la laisse sans effet plutôt que de relancer l'amorçage.
|
||||
const crees = await db.insert(users).values({
|
||||
username: "admin",
|
||||
passwordHash: hashPassword("admin"),
|
||||
role: "admin",
|
||||
}).onConflictDoNothing().returning({ id: users.id });
|
||||
return crees.length > 0;
|
||||
}
|
||||
|
||||
/** Base injoignable : le fichier de secours doit contenir au moins un administrateur. */
|
||||
function assurerAdminDeSecours() {
|
||||
const fallbackUsers = getFallbackUsers();
|
||||
if (fallbackUsers.length === 0) {
|
||||
console.log("[AUTH] Initializing local JSON fallback with admin/admin...");
|
||||
@@ -20,39 +77,4 @@ export async function ensureAdminExists() {
|
||||
role: "admin"
|
||||
}]);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[AUTH] Testing database connection for seeding...");
|
||||
await db.execute(sql`SELECT 1`);
|
||||
|
||||
// 2. Auto-repair: Ensure table exists
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
// 3. Sync admin to DB
|
||||
const adminUser = await db.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, "admin"))
|
||||
.limit(1);
|
||||
|
||||
if (adminUser.length === 0) {
|
||||
console.log("[AUTH] Seeding admin to DB...");
|
||||
await db.insert(users).values({
|
||||
username: "admin",
|
||||
passwordHash: hashPassword("admin"),
|
||||
role: "admin"
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", err.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { commandeCadences } from "@/db/schema";
|
||||
import { getDerniereReceptionCached, getFournisseursCached } from "@/lib/ff-cache";
|
||||
import { requireSession, verifierSession } from "@/lib/authz";
|
||||
|
||||
export type CadenceStatut = "a_commander" | "bientot" | "ok" | "inconnu";
|
||||
|
||||
@@ -37,6 +38,7 @@ function calcStatut(joursRestants: number | null): CadenceStatut {
|
||||
|
||||
/** Liste des fournisseurs (référentiel) pour le sélecteur de cadence. */
|
||||
export async function getFournisseursPourCadence(): Promise<{ code: string; nom: string }[]> {
|
||||
await requireSession();
|
||||
return getFournisseursCached();
|
||||
}
|
||||
|
||||
@@ -45,13 +47,14 @@ export async function getFournisseursPourCadence(): Promise<{ code: string; nom:
|
||||
* de la dernière réception (FF Nancy).
|
||||
*/
|
||||
export async function listCadences(): Promise<CadenceView[]> {
|
||||
await requireSession();
|
||||
let rows: typeof commandeCadences.$inferSelect[] = [];
|
||||
let receptions = new Map<string, string>();
|
||||
try {
|
||||
[rows, receptions] = await Promise.all([
|
||||
db.select().from(commandeCadences),
|
||||
getDerniereReceptionCached(),
|
||||
]);
|
||||
rows = await db.select().from(commandeCadences);
|
||||
// Réceptions des seuls fournisseurs du cadencier : la lecture sur tout
|
||||
// l'historique de tous les fournisseurs était la plus lourde de la page.
|
||||
receptions = await getDerniereReceptionCached(rows.map((r) => r.codeFournisseur));
|
||||
} catch (e) {
|
||||
console.error("[cadence] listCadences error:", (e as Error).message);
|
||||
return [];
|
||||
@@ -95,6 +98,9 @@ export interface UpsertCadenceInput {
|
||||
|
||||
/** Crée ou met à jour la cadence d'un fournisseur sur un site. */
|
||||
export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: boolean; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { ok: false, error: acces.message };
|
||||
|
||||
const codefou = input.codefou?.trim();
|
||||
const site = input.site?.trim();
|
||||
const intervalle = Math.round(Number(input.intervalleSemaines));
|
||||
@@ -132,7 +138,10 @@ export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: bo
|
||||
}
|
||||
|
||||
/** Active / désactive une cadence sans la supprimer. */
|
||||
export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean }> {
|
||||
export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { ok: false, error: acces.message };
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(commandeCadences)
|
||||
@@ -147,7 +156,10 @@ export async function toggleCadence(codefou: string, site: string, actif: boolea
|
||||
}
|
||||
|
||||
/** Supprime définitivement une cadence. */
|
||||
export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean }> {
|
||||
export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { ok: false, error: acces.message };
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(commandeCadences)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export async function initSnapshotTable() {
|
||||
try {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS session_snapshots (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code_fournisseur VARCHAR(20) NOT NULL,
|
||||
nom_fournisseur VARCHAR(255),
|
||||
magasin VARCHAR(20) NOT NULL,
|
||||
changes JSONB NOT NULL,
|
||||
summary_json JSONB,
|
||||
label TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("Init DB error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { getSitesFromApi } from "@/lib/api-ff-client";
|
||||
import { getFournisseursCached } from "@/lib/ff-cache";
|
||||
import { getProductRows } from "./api/get-product-rows";
|
||||
import type { ProductRow, GridFilters } from "@/types/grid";
|
||||
import { requireSession } from "@/lib/authz";
|
||||
|
||||
/**
|
||||
* Get the list of all suppliers from PostgreSQL (fouadr1).
|
||||
*/
|
||||
export async function getFournisseurs() {
|
||||
await requireSession();
|
||||
// Relu à chaque navigation dans la Grille (changement de fournisseur, de
|
||||
// magasin…) : le référentiel ne change qu'avec la recopie nocturne.
|
||||
return getFournisseursCached();
|
||||
@@ -18,24 +18,6 @@ export async function getFournisseurs() {
|
||||
* Get the list of all stores (sites) from the FF Nancy API.
|
||||
*/
|
||||
export async function getMagasins() {
|
||||
await requireSession();
|
||||
return getSitesFromApi();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nomenclature filter is disabled — code3 is not available from the API.
|
||||
* Returns an empty hierarchy so the sidebar filter is hidden gracefully.
|
||||
*/
|
||||
export async function getAvailableNomenclature() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product data for a specific supplier and store.
|
||||
*/
|
||||
export async function getGridData(
|
||||
codeFournisseur: string,
|
||||
magasin: string = "TOTAL",
|
||||
filters?: Partial<GridFilters>
|
||||
): Promise<ProductRow[]> {
|
||||
return getProductRows({ codeFournisseur, magasin, filters });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -19,6 +19,13 @@ import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib
|
||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée.
|
||||
import { readGridSnapshot, upsertGridRows } from "@/lib/grid-store";
|
||||
import {
|
||||
applyStorePatchInPlace,
|
||||
buildStorePatchEntries,
|
||||
storePatchCandidates,
|
||||
type StorePatch,
|
||||
type StorePatchEntry,
|
||||
} from "@/features/grid/lib/store-patch";
|
||||
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
@@ -210,11 +217,17 @@ async function serveFromSnapshot(input: GetProductRowsInput, cacheKey: string):
|
||||
if (!snapshot || snapshot.rows.length === 0) return null;
|
||||
|
||||
const { rows } = snapshot;
|
||||
const [changes] = await Promise.all([
|
||||
// Les colonnes réseau de l'instantané datent de son calcul : on les relit dans
|
||||
// le cache Qlik, comme le calcul en direct. Sans cela, une synchro Qlik faite
|
||||
// depuis (au changement de mois notamment) restait invisible jusqu'au
|
||||
// prochain recalcul, et la tendance réseau disparaissait.
|
||||
const [changes, network] = await Promise.all([
|
||||
loadLatestSnapshotChanges(codeFournisseur),
|
||||
fetchNetworkMetrics(rows.map((r) => r.codeCentrale ?? "")),
|
||||
refreshGammeInit(rows, codeFournisseur),
|
||||
]);
|
||||
applySnapshotChanges(rows, changes);
|
||||
enrichWithNetworkMetrics(rows, network);
|
||||
|
||||
if (magasin !== "TOTAL") {
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
@@ -538,7 +551,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
}
|
||||
|
||||
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
|
||||
enrichWithNetworkMetrics(productMap, await networkPromise);
|
||||
enrichWithNetworkMetrics(productMap.values(), await networkPromise);
|
||||
|
||||
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
|
||||
// La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle.
|
||||
@@ -568,28 +581,27 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileSelectedStoreFromMensuelApi(
|
||||
/** Articles complétés par l'API FF, au plus, lors d'un changement de magasin. */
|
||||
function plafondComplement(): number {
|
||||
return Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
|
||||
}
|
||||
|
||||
/** Interroge l'API FF pour les articles du magasin à compléter (cf. store-patch). */
|
||||
async function fetchStorePatchEntries(
|
||||
rows: ProductRow[],
|
||||
magasin: string,
|
||||
dateDebut: string,
|
||||
dateFin: string,
|
||||
sortedPeriods: string[]
|
||||
) {
|
||||
if (magasin === "TOTAL") return;
|
||||
|
||||
const candidates = rows.filter((row) => {
|
||||
if (!row.noid) return false;
|
||||
const storeQty = row.quantiteByStore?.[magasin] ?? 0;
|
||||
return storeQty === 0;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) return;
|
||||
periods: string[],
|
||||
): Promise<StorePatchEntry[]> {
|
||||
const candidates = storePatchCandidates(rows, magasin);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
// Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur,
|
||||
// les candidats se comptent par milliers et le changement de magasin se fige
|
||||
// plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté
|
||||
// plutôt que de tronquer en silence.
|
||||
const PLAFOND = Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
|
||||
const PLAFOND = plafondComplement();
|
||||
const retenus = candidates.slice(0, PLAFOND);
|
||||
if (candidates.length > retenus.length) {
|
||||
console.warn(
|
||||
@@ -598,88 +610,31 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
);
|
||||
}
|
||||
|
||||
const mensuelMap = await getMensuelByArticles(
|
||||
retenus.map((row) => ({
|
||||
codein: row.codein,
|
||||
libelle1: row.libelle1,
|
||||
codefou: row.codeFournisseur,
|
||||
noid: row.noid,
|
||||
})),
|
||||
dateDebut,
|
||||
dateFin,
|
||||
25
|
||||
);
|
||||
return buildStorePatchEntries(retenus, magasin, periods, mensuelMap);
|
||||
}
|
||||
|
||||
async function reconcileSelectedStoreFromMensuelApi(
|
||||
rows: ProductRow[],
|
||||
magasin: string,
|
||||
dateDebut: string,
|
||||
dateFin: string,
|
||||
sortedPeriods: string[]
|
||||
) {
|
||||
if (magasin === "TOTAL") return;
|
||||
try {
|
||||
const mensuelMap = await getMensuelByArticles(
|
||||
retenus.map((row) => ({
|
||||
codein: row.codein,
|
||||
libelle1: row.libelle1,
|
||||
codefou: row.codeFournisseur,
|
||||
noid: row.noid,
|
||||
})),
|
||||
dateDebut,
|
||||
dateFin,
|
||||
25
|
||||
);
|
||||
|
||||
let fixedRows = 0;
|
||||
for (const row of retenus) {
|
||||
const entries = (mensuelMap.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 (!sortedPeriods.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;
|
||||
|
||||
row.sales12mByStore ??= {};
|
||||
row.stock12mByStore ??= {};
|
||||
row.receptions12mByStore ??= {};
|
||||
row.caByStore ??= {};
|
||||
row.quantiteByStore ??= {};
|
||||
row.margeByStore ??= {};
|
||||
row.sales12mByStore[magasin] ??= {};
|
||||
row.stock12mByStore[magasin] ??= {};
|
||||
row.receptions12mByStore[magasin] ??= {};
|
||||
|
||||
let storeQty = 0;
|
||||
let storeCa = 0;
|
||||
let storeMarge = 0;
|
||||
let lastStock = 0;
|
||||
|
||||
for (const period of sortedPeriods) {
|
||||
const value = byPeriod.get(period);
|
||||
const currentQty = row.sales12mByStore[magasin][period] ?? 0;
|
||||
const nextQty = value?.qty ?? 0;
|
||||
const deltaQty = nextQty - currentQty;
|
||||
|
||||
row.sales12mByStore[magasin][period] = nextQty;
|
||||
row.receptions12mByStore[magasin][period] = value?.receptions ?? 0;
|
||||
if (value) lastStock = value.stock;
|
||||
row.stock12mByStore[magasin][period] = lastStock;
|
||||
|
||||
row.sales12m[period] = (row.sales12m[period] ?? 0) + deltaQty;
|
||||
storeQty += nextQty;
|
||||
storeCa += value?.ca ?? 0;
|
||||
storeMarge += value?.marge ?? 0;
|
||||
}
|
||||
|
||||
const deltaTotalQty = storeQty - (row.quantiteByStore[magasin] ?? 0);
|
||||
const deltaTotalCa = storeCa - (row.caByStore[magasin] ?? 0);
|
||||
const deltaTotalMarge = storeMarge - (row.margeByStore[magasin] ?? 0);
|
||||
|
||||
row.quantiteByStore[magasin] = storeQty;
|
||||
row.caByStore[magasin] = storeCa;
|
||||
row.margeByStore[magasin] = storeMarge;
|
||||
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);
|
||||
fixedRows++;
|
||||
}
|
||||
|
||||
const entries = await fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, sortedPeriods);
|
||||
const fixedRows = applyStorePatchInPlace(rows, magasin, sortedPeriods, entries);
|
||||
if (fixedRows > 0) {
|
||||
console.log(`[getProductRows] ${fixedRows} produits corrigés via API mensuelle pour magasin ${magasin}`);
|
||||
}
|
||||
@@ -688,6 +643,46 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compléments par magasin, rattachés au tableau de lignes « tous magasins » en
|
||||
* cache : ils vivent et meurent avec lui (expiration, « Actualiser », nouvel
|
||||
* enregistrement), sans autre invalidation à gérer.
|
||||
*/
|
||||
const storePatches = new WeakMap<ProductRow[], Map<string, Promise<StorePatch>>>();
|
||||
|
||||
/**
|
||||
* Complément de l'API FF pour un magasin, calculé sur les lignes « tous
|
||||
* magasins » (jamais modifiées ici). La Grille l'applique à son arrivée, sans
|
||||
* recharger les lignes. Les demandes simultanées partagent le même calcul, et
|
||||
* un échec n'est jamais gardé.
|
||||
*/
|
||||
export async function getStorePatch(codeFournisseur: string, magasin: string): Promise<StorePatch> {
|
||||
const enCache = gridRowsCache.get(`${codeFournisseur}:TOTAL`);
|
||||
const rows = enCache && Date.now() - enCache.createdAt < GRID_ROWS_CACHE_TTL_MS
|
||||
? enCache.rows
|
||||
: await getProductRows({ codeFournisseur, magasin: "TOTAL" });
|
||||
|
||||
let parMagasin = storePatches.get(rows);
|
||||
if (!parMagasin) {
|
||||
parMagasin = new Map();
|
||||
storePatches.set(rows, parMagasin);
|
||||
}
|
||||
let patch = parMagasin.get(magasin);
|
||||
if (!patch) {
|
||||
const periods = last12Periods();
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
const enCours = fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, periods)
|
||||
.then((entries): StorePatch => ({ magasin, periods, entries }));
|
||||
const table = parMagasin;
|
||||
enCours.catch(() => {
|
||||
if (table.get(magasin) === enCours) table.delete(magasin);
|
||||
});
|
||||
parMagasin.set(magasin, enCours);
|
||||
patch = enCours;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
|
||||
*
|
||||
@@ -764,11 +759,11 @@ async function fetchNetworkMetrics(
|
||||
* n'est pas encore disponible.
|
||||
*/
|
||||
function enrichWithNetworkMetrics(
|
||||
productMap: Map<string, ProductRow>,
|
||||
products: Iterable<ProductRow>,
|
||||
metrics: Map<string, NetworkMetricCached> | null,
|
||||
): void {
|
||||
if (!metrics || metrics.size === 0) return;
|
||||
for (const product of productMap.values()) {
|
||||
for (const product of products) {
|
||||
const m = product.codeCentrale ? metrics.get(product.codeCentrale) : undefined;
|
||||
if (!m) continue;
|
||||
product.caReseau = m.caReseau;
|
||||
|
||||
@@ -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 { auth } from "@/lib/auth";
|
||||
import { patchGridRowsCache } from "./get-product-rows";
|
||||
import { updateGridRowsGamme } from "@/lib/grid-store";
|
||||
import { verifierSession } from "@/lib/authz";
|
||||
import { enregistrerGammes } from "./enregistrer-gammes";
|
||||
|
||||
const SaveDraftsSchema = z.object({
|
||||
codeFournisseur: z.string(),
|
||||
@@ -24,6 +20,9 @@ const SaveDraftsSchema = z.object({
|
||||
export async function saveDraftChanges(
|
||||
raw: unknown
|
||||
): Promise<{ success: boolean; saved: number; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, saved: 0, error: acces.message };
|
||||
|
||||
const parsed = SaveDraftsSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
|
||||
@@ -31,55 +30,13 @@ export async function saveDraftChanges(
|
||||
|
||||
const { codeFournisseur, nomFournisseur, magasin, changes } = parsed.data;
|
||||
|
||||
const session = await auth();
|
||||
const rawUserId = (session?.user as { id?: string | number })?.id;
|
||||
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
|
||||
const rawUserId = acces.utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
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,
|
||||
@@ -62,8 +62,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [enCours, setEnCours] = useState<string | null>(null);
|
||||
|
||||
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
|
||||
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL", visibleCodeins);
|
||||
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL");
|
||||
|
||||
const supplierCode = filterFournisseur || rows[0]?.codeFournisseur;
|
||||
const lastQlikUpdate = useMemo(() => {
|
||||
@@ -166,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)}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,8 @@ import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
|
||||
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
|
||||
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
||||
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useStorePatch } from "@/features/grid/hooks/use-store-patch";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -61,6 +62,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const setFilter = useGridStore((s) => s.setFilter);
|
||||
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
|
||||
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
|
||||
@@ -107,18 +109,21 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const controller = new AbortController();
|
||||
const params = new URLSearchParams();
|
||||
params.set("fournisseur", codeFournisseur);
|
||||
params.set("magasin", magasin || "TOTAL");
|
||||
// Toujours « tous magasins » : les lignes portent le détail de chaque
|
||||
// magasin, le changement de magasin se fait donc sans rien recharger
|
||||
// (les compléments de l'API FF arrivent à part, cf. useStorePatch).
|
||||
params.set("magasin", "TOTAL");
|
||||
// Les filtres de nomenclature (code1/code2/code3) ne sont pas transmis : le
|
||||
// serveur ne s'en sert pas et la Grille les applique en local. Les faire
|
||||
// voyager relançait un chargement complet à chaque changement.
|
||||
const forceRefresh = refreshRequest !== servedRefreshRef.current;
|
||||
if (forceRefresh) params.set("refresh", "1");
|
||||
|
||||
const cle = `${codeFournisseur}:${magasin || "TOTAL"}`;
|
||||
const cle = rowsKeyFor(codeFournisseur);
|
||||
const { rowsMeta, rows: lignesEnMemoire, setRowsMeta } = useGridStore.getState();
|
||||
|
||||
// Retour sur la Grille (même fournisseur, même magasin, chargée il y a
|
||||
// moins de 10 min) : les lignes sont encore en mémoire, rien à retélécharger.
|
||||
// Retour sur la Grille (même fournisseur, chargée il y a moins de 10 min) :
|
||||
// les lignes sont encore en mémoire, rien à retélécharger.
|
||||
if (!forceRefresh && rowsMeta?.key === cle && lignesEnMemoire.length > 0
|
||||
&& Date.now() - rowsMeta.loadedAt < 10 * 60 * 1000) {
|
||||
setRowsLoaded(lignesEnMemoire.length);
|
||||
@@ -127,11 +132,9 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
return;
|
||||
}
|
||||
|
||||
// Changement de magasin (ou rechargement) sur le même fournisseur : la
|
||||
// Grille reste affichée — les lignes portent déjà le détail par magasin,
|
||||
// la bascule est immédiate — et les chiffres complétés pour ce magasin
|
||||
// remplacent l'affichage d'un bloc, une fois reçus.
|
||||
const memeFournisseur = rowsMeta?.key.startsWith(`${codeFournisseur}:`) === true && lignesEnMemoire.length > 0;
|
||||
// « Actualiser » (ou lignes de plus de 10 min) sur le même fournisseur : la
|
||||
// Grille reste affichée, et les nouvelles lignes la remplacent d'un bloc.
|
||||
const memeFournisseur = rowsMeta?.key === cle && lignesEnMemoire.length > 0;
|
||||
|
||||
const accumulatedRows: ProductRow[] = [];
|
||||
let lastFlush = 0;
|
||||
@@ -210,7 +213,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
}
|
||||
|
||||
flush(loaded, total, true);
|
||||
setRowsMeta({ key: cle, loadedAt: Date.now() });
|
||||
setRowsMeta({ key: cle, loadedAt: Date.now(), patchedStores: [] });
|
||||
if (forceRefresh) servedRefreshRef.current = refreshRequest;
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -226,9 +229,13 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
loadRows();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [codeFournisseur, magasin, refreshRequest, setRows, isMounted]);
|
||||
}, [codeFournisseur, refreshRequest, setRows, isMounted]);
|
||||
|
||||
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
|
||||
// Compléments de l'API FF pour le magasin choisi, appliqués à leur arrivée.
|
||||
useStorePatch(codeFournisseur, isMounted);
|
||||
|
||||
// Magasin initial (ou porté par un lien) : la prop vient de l'URL. Les
|
||||
// changements suivants passent par le store et ne re-rendent pas la page.
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
setActiveMagasin(magasin || "TOTAL");
|
||||
@@ -259,7 +266,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
</h1>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-2 pl-4 shrink-0 border-l border-[var(--border)]">
|
||||
<MetricPill label="Magasin" value={nomMagasin(magasin)} />
|
||||
<MetricPill label="Magasin" value={nomMagasin(activeMagasin)} />
|
||||
<MetricPill label="Produits" value={nbReferences.toLocaleString("fr-FR")} accent />
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,6 +334,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-h-0 min-w-0">
|
||||
<HeatmapGrid
|
||||
codeFournisseur={codeFournisseur}
|
||||
onSelectionChange={setSelectedCodeins}
|
||||
isAdmin={isAdmin}
|
||||
nomFournisseur={nomFournisseur}
|
||||
|
||||
@@ -97,11 +97,15 @@ function GridFilterBarInner({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
|
||||
// Changement de magasin sans aller-retour serveur : les lignes portent déjà
|
||||
// le détail par magasin. L'URL suit (lien partageable, retour par le menu),
|
||||
// via l'historique du navigateur, que Next répercute sur useSearchParams.
|
||||
const handleStoreSelect = (code: string) => {
|
||||
if (code === activeMagasin) return;
|
||||
setActiveMagasin(code);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("magasin", code);
|
||||
router.replace(`/grid?${params.toString()}`, { scroll: false });
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
RowSelectionState,
|
||||
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, TrendingUp, TrendingDown, Minus, 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,
|
||||
@@ -25,9 +26,10 @@ import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { GammeSelect } from "@/features/grid/components/gamme-select";
|
||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||
import { TuileStat } from "@/features/grid/components/stat-tile";
|
||||
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 {
|
||||
@@ -40,18 +42,18 @@ import {
|
||||
} from "@/features/grid/lib/months";
|
||||
import {
|
||||
computeNetworkTrend,
|
||||
computeStoresSeries,
|
||||
trendLabel,
|
||||
TREND_COLOR,
|
||||
NB_MAGASINS_RESEAU,
|
||||
} from "@/features/grid/lib/network-trend";
|
||||
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
|
||||
import { TrendSparkline } from "@/features/grid/components/network-charts";
|
||||
import type { NetworkTrend } from "@/features/grid/lib/network-trend";
|
||||
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";
|
||||
|
||||
interface HeatmapGridProps {
|
||||
/** Fournisseur affiché : la sélection est remise à zéro quand il change. */
|
||||
codeFournisseur: string;
|
||||
onSelectionChange?: (codeins: string[]) => void;
|
||||
isAdmin?: boolean;
|
||||
/** Sert à nommer et documenter les extractions issues de la Grille. */
|
||||
@@ -60,6 +62,39 @@ interface HeatmapGridProps {
|
||||
|
||||
const EMPTY_DRAFT_CHANGES: Record<string, GammeCode> = {};
|
||||
|
||||
/*
|
||||
* Valeurs dérivées calculées une fois par ligne. Une ligne reçue ne change plus
|
||||
* (une modification produit un nouvel objet) : on peut les garder tant qu'elle
|
||||
* existe, sans rien invalider. Avant, la tendance était recalculée à chaque
|
||||
* rendu de chaque case, et le texte de recherche à chaque frappe.
|
||||
*/
|
||||
const tendances = new WeakMap<ProductRow, NetworkTrend>();
|
||||
function tendanceDe(row: ProductRow): NetworkTrend {
|
||||
let t = tendances.get(row);
|
||||
if (!t) {
|
||||
t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
|
||||
tendances.set(row, t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
const textesRecherche = new WeakMap<ProductRow, string>();
|
||||
function texteRecherche(row: ProductRow): string {
|
||||
let t = textesRecherche.get(row);
|
||||
if (t === undefined) {
|
||||
// Saut de ligne entre les champs : une recherche ne peut pas en contenir,
|
||||
// donc aucune correspondance « à cheval » sur deux champs.
|
||||
t = [row.libelle1, row.codein, row.reference, row.gtin].map((v) => String(v ?? "")).join("\n").toLowerCase();
|
||||
textesRecherche.set(row, t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Recherche sur libellé, code, référence et code-barres. */
|
||||
const filtreRecherche: FilterFn<ProductRow> = (row, _columnId, valeur: string) => texteRecherche(row.original).includes(valeur);
|
||||
// Saisie mise en minuscules une seule fois par frappe, pas une fois par ligne.
|
||||
filtreRecherche.resolveFilterValue = (valeur) => String(valeur ?? "").toLowerCase();
|
||||
|
||||
const QLIK_NETWORK_COLUMN_IDS = new Set<string>([
|
||||
"caReseau",
|
||||
"qteReseau",
|
||||
@@ -516,77 +551,6 @@ interface CellDetailData {
|
||||
receptions: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte « tendance réseau » : le verdict en tuiles, puis les trois séries
|
||||
* mensuelles en petits multiples.
|
||||
*
|
||||
* La couleur de tendance (vert / rouge / gris) ne vit QUE dans la tuile de
|
||||
* verdict, accompagnée d'une flèche et d'un libellé : un état ne se signale
|
||||
* jamais par la couleur seule. Les courbes, elles, portent des teintes
|
||||
* d'identité — sans quoi la même couleur voudrait dire deux choses.
|
||||
*/
|
||||
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
|
||||
const trend = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
|
||||
const { values, labels, direction, pct } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
const magasins = computeStoresSeries(row.nbMagReseauByMonth);
|
||||
const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
|
||||
const dernier = values.length - 1;
|
||||
const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const verdict = trend.nouveau
|
||||
? "Nouveau"
|
||||
: pct != null
|
||||
? `${pct >= 0 ? "+" : ""}${Math.round(pct * 100)} %`
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-2xl" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle1}
|
||||
</DialogTitle>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Réseau · 12 mois glissants, mois en cours exclu
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{!trend.hasData ? (
|
||||
<div className="mt-3 rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
|
||||
Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
<TuileStat
|
||||
label="Tendance"
|
||||
valeur={verdict}
|
||||
indice={trend.surQteParMagasin
|
||||
? `${trendLabel(pct, trend.nouveau)} · sur la qté/magasin`
|
||||
: `${trendLabel(pct, trend.nouveau)} · sur les volumes (magasins inconnus)`}
|
||||
couleur={color}
|
||||
icone={Fleche}
|
||||
/>
|
||||
{trend.perStore && (
|
||||
<TuileStat
|
||||
label="Qté / magasin"
|
||||
valeur={fmt(trend.perStore[dernier], trend.perStore[dernier] < 10 ? 1 : 0)}
|
||||
indice={`dernier mois · ${fmt(trend.perStore.reduce((t, v) => t + v, 0) / trend.perStore.length, 1)} en moyenne`}
|
||||
/>
|
||||
)}
|
||||
<TuileStat
|
||||
label="Magasins vendeurs"
|
||||
valeur={magasins ? fmt(magasins.values[dernier]) : fmt(row.nbMagasinsReseau ?? 0)}
|
||||
indice={`sur ${NB_MAGASINS_RESEAU} du réseau`}
|
||||
/>
|
||||
</div>
|
||||
<NetworkLineChart labels={labels} values={values} stores={magasins?.values} perStore={trend.perStore} />
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; activeMagasin: string; onClose: () => void }) {
|
||||
const hasReceptions = (d.receptions ?? 0) > 0;
|
||||
const storeLabel = activeMagasin === "TOTAL" ? "Total réseau" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin);
|
||||
@@ -699,10 +663,14 @@ function VentilationParMagasin({ d }: { d: CellDetailData }) {
|
||||
|
||||
// =========================================================================
|
||||
|
||||
function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
|
||||
function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
|
||||
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
|
||||
// Champs de filtre lus un par un : la recherche ne doit pas recalculer
|
||||
// `filteredData` (donc reconstruire toutes les lignes) à chaque frappe.
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const filters = useGridStore((s) => s.filters);
|
||||
const code3 = useGridStore((s) => s.filters.code3);
|
||||
const codeGamme = useGridStore((s) => s.filters.codeGamme);
|
||||
const search = useGridStore((s) => s.filters.search);
|
||||
const displayDensity = useGridStore((s) => s.displayDensity);
|
||||
const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES);
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
@@ -713,12 +681,16 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
|
||||
// Filtre client-side par code3 (famille) et codeGamme
|
||||
const filteredData = useMemo(() => {
|
||||
const { code3, codeGamme } = filters;
|
||||
// Changement de magasin : les accesseurs de tri (quantité, CA, marge, prix)
|
||||
// dépendent du magasin, et TanStack garde la valeur de chaque ligne en cache
|
||||
// sans jamais retrier sur un changement de colonnes. Un nouveau tableau
|
||||
// donne de nouvelles lignes : tri juste, et cases redessinées aussitôt.
|
||||
void activeMagasin;
|
||||
// Garde : un état persisté d'une version antérieure peut encore contenir
|
||||
// une chaîne. La migration du store le corrige, mais un `new Set("320211")`
|
||||
// produirait un ensemble de caractères et viderait la Grille sans rien dire.
|
||||
const codes = Array.isArray(code3) ? code3 : typeof code3 === "string" ? [code3] : null;
|
||||
if (!codes && !codeGamme) return rows;
|
||||
if (!codes && !codeGamme) return rows.slice();
|
||||
// Set plutôt que includes() : la liste peut compter des dizaines de
|
||||
// nomenclatures, et le filtre est réévalué pour chaque ligne.
|
||||
const nomenclatures = codes ? new Set(codes) : null;
|
||||
@@ -731,7 +703,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [rows, filters, draftChanges]);
|
||||
}, [rows, code3, codeGamme, draftChanges, activeMagasin]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
@@ -749,6 +721,8 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
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
|
||||
@@ -775,10 +749,12 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
[]
|
||||
);
|
||||
|
||||
// Reset selection when rows change (e.g. supplier change)
|
||||
// Sélection remise à zéro au changement de fournisseur seulement : elle est
|
||||
// indexée par code article, et survit donc à un rechargement des lignes, à un
|
||||
// enregistrement ou au complément des chiffres d'un magasin.
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [rows]);
|
||||
}, [codeFournisseur]);
|
||||
|
||||
const columns = React.useMemo<ColumnDef<ProductRow>[]>(() => [
|
||||
{
|
||||
@@ -918,7 +894,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
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>
|
||||
);
|
||||
},
|
||||
@@ -956,7 +932,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
{
|
||||
id: "tendanceReseau",
|
||||
accessorFn: (row) => {
|
||||
const t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
|
||||
const t = tendanceDe(row);
|
||||
if (!t.hasData) return Number.NEGATIVE_INFINITY;
|
||||
// Un produit « nouveau » n'a pas de pourcentage mais c'est la plus
|
||||
// forte progression possible : il doit remonter en tête du tri, pas
|
||||
@@ -967,7 +943,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
header: () => <div className="text-center w-full" title="Évolution des ventes du réseau sur 12 mois — cliquer pour le détail">Tendance<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
|
||||
size: 78,
|
||||
cell: ({ row }) => {
|
||||
const trend = computeNetworkTrend(row.original.qteReseauByMonth, row.original.nbMagReseauByMonth);
|
||||
const trend = tendanceDe(row.original);
|
||||
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
// Cliquable → modal des ventes réseau mois par mois (12 derniers mois).
|
||||
return (
|
||||
@@ -1047,7 +1023,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
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>
|
||||
);
|
||||
},
|
||||
@@ -1137,14 +1113,14 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
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)} €
|
||||
{fmtEur2(ca)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -1153,7 +1129,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
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
|
||||
@@ -1165,7 +1141,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
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)} €
|
||||
{fmtEur2(marge)}
|
||||
</span>
|
||||
<span className="tabular-nums text-xs font-semibold" style={{ color: couleurMarge(taux) }}>
|
||||
{taux.toFixed(1)}%
|
||||
@@ -1198,7 +1174,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility, columnSizing },
|
||||
state: { sorting, globalFilter: search, rowSelection, columnVisibility, columnSizing },
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
@@ -1215,14 +1191,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
// et GTIN de la ligne) : un seul passage par ligne suffit, au lieu d'un par
|
||||
// colonne filtrable (une quinzaine) à chaque frappe.
|
||||
getColumnCanGlobalFilter: (column) => column.id === "codein",
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const search = String(filterValue).toLowerCase();
|
||||
const libelle = String(row.original.libelle1 || "").toLowerCase();
|
||||
const code = String(row.original.codein || "").toLowerCase();
|
||||
const reference = String(row.original.reference || "").toLowerCase();
|
||||
const gtin = String(row.original.gtin || "").toLowerCase();
|
||||
return libelle.includes(search) || code.includes(search) || reference.includes(search) || gtin.includes(search);
|
||||
},
|
||||
globalFilterFn: filtreRecherche,
|
||||
});
|
||||
|
||||
const { rows: tableRows } = table.getRowModel();
|
||||
@@ -1249,7 +1218,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
// Delay update to next tick to ensure we're out of any render cycles
|
||||
setTimeout(() => onSelectionChange(selectedCodeins), 0);
|
||||
}
|
||||
}, [table, rowSelection, onSelectionChange, filteredData, filters.search]);
|
||||
}, [table, rowSelection, onSelectionChange, filteredData, search]);
|
||||
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
@@ -1293,6 +1262,12 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
}));
|
||||
}, [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
|
||||
@@ -1312,6 +1287,17 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
<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"
|
||||
@@ -1465,7 +1451,14 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
{cellDetail !== null && <CellDetailModal d={cellDetail!} activeMagasin={activeMagasin} onClose={() => setCellDetail(null)} />}
|
||||
</Dialog>
|
||||
<Dialog open={networkModal !== null} onOpenChange={(open) => { if (!open) setNetworkModal(null); }}>
|
||||
{networkModal !== null && <NetworkMonthlyModal row={networkModal} onClose={() => setNetworkModal(null)} />}
|
||||
{networkModal !== null && (
|
||||
<NetworkMonthlyModal
|
||||
row={networkModal}
|
||||
trend={tendanceDe(networkModal)}
|
||||
mois={MONTHS_12}
|
||||
onClose={() => setNetworkModal(null)}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
{/* Trous d'assortiment sur le haut du classement affiché */}
|
||||
<Dialog open={gapsOpen} onOpenChange={setGapsOpen}>
|
||||
@@ -1480,6 +1473,17 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
/>
|
||||
)}
|
||||
</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 && (
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
* produit (`/produits`). Aucun changement de comportement.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { memo, useState } from "react";
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import { fmtMonthShort } from "@/features/grid/lib/months";
|
||||
import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend";
|
||||
|
||||
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
|
||||
export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
|
||||
function TrendSparklineInner({ trend }: { trend: NetworkTrend }) {
|
||||
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
const { values, direction, pct, nouveau } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
@@ -51,6 +51,9 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Mémorisée : la tendance d'une ligne est calculée une fois et ne change plus. */
|
||||
export const TrendSparkline = memo(TrendSparklineInner);
|
||||
|
||||
/**
|
||||
* Couleurs des séries : slots 1-3 de la palette catégorielle validée, exposés en
|
||||
* variables CSS pour que le mode sombre soit un jeu de pas CHOISI et vérifié, et
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Carte « tendance réseau », ouverte depuis la colonne Tendance.
|
||||
*
|
||||
* Trois étages, dans l'ordre où on se pose les questions :
|
||||
* 1. la lecture en une phrase (le produit progresse-t-il dans le réseau ?) ;
|
||||
* 2. le réseau : verdict, rythme par magasin, diffusion, puis les courbes ;
|
||||
* 3. nos magasins : stock, ventes, couverture, comparés au rythme du réseau.
|
||||
*
|
||||
* Sans le troisième étage, la carte disait comment le produit marche AILLEURS
|
||||
* sans dire où on en est CHEZ NOUS — il fallait rouvrir un autre détail pour
|
||||
* savoir s'il restait du stock.
|
||||
*
|
||||
* La couleur de tendance (vert / rouge / gris) ne vit que dans la tuile de
|
||||
* verdict et la colonne « vs réseau », toujours accompagnée d'un signe ou d'un
|
||||
* libellé : un état ne se signale jamais par la couleur seule.
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
import { TrendingUp, TrendingDown, Minus, Store, Warehouse } from "lucide-react";
|
||||
import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { TuileStat } from "@/features/grid/components/stat-tile";
|
||||
import { NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||
import {
|
||||
computeStoresSeries,
|
||||
trendLabel,
|
||||
TREND_COLOR,
|
||||
TREND_FLAT,
|
||||
TREND_WINDOW,
|
||||
NB_MAGASINS_RESEAU,
|
||||
type NetworkTrend,
|
||||
} from "@/features/grid/lib/network-trend";
|
||||
import { formatDate, formatMonthLabel, qlikMonthToFf } from "@/features/grid/lib/months";
|
||||
import { MAGASINS, LIBELLE_TOUS_MAGASINS } from "@/lib/magasins";
|
||||
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 });
|
||||
/** Une décimale sous 10, aucune au-delà : « 2,4 » mais « 37 ». */
|
||||
const fmtRythme = (v: number) => fmt(v, Math.abs(v) < 10 ? 1 : 0);
|
||||
const moyenne = (xs: number[]) => (xs.length ? xs.reduce((t, v) => t + v, 0) / xs.length : 0);
|
||||
const pctTexte = (p: number) => `${p >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(p * 100)))} %`;
|
||||
|
||||
/** Mois de stock au rythme des ventes ; sans vente, le ratio n'existe pas et on le dit. */
|
||||
function couvertureTexte(stock: number, parMois: number): string {
|
||||
if (parMois <= 0) return stock > 0 ? "sans vente" : "—";
|
||||
const c = stock / parMois;
|
||||
if (c < 1) return "< 1 mois";
|
||||
if (c > 24) return "> 24 mois";
|
||||
return `${fmt(c, c < 10 ? 1 : 0)} mois`;
|
||||
}
|
||||
|
||||
interface LigneMagasin {
|
||||
cle: string;
|
||||
nom: string;
|
||||
stock: number;
|
||||
ventes: number;
|
||||
parMois: number;
|
||||
derniereEntree?: string;
|
||||
/** Écart à la moyenne réseau par magasin, `null` si non comparable. */
|
||||
vsReseau: number | null;
|
||||
total?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nos magasins, un par ligne, puis l'ensemble.
|
||||
*
|
||||
* « vs réseau » compare nos ventes mensuelles moyennes à celles d'un magasin
|
||||
* vendeur moyen du réseau sur la même période : c'est la seule assiette
|
||||
* commune (le réseau n'a pas de stock par magasin à nous offrir).
|
||||
*/
|
||||
function lignesMagasins(row: ProductRow, mois: string[], moyenneReseau: number | null): LigneMagasin[] {
|
||||
const dernier = mois[mois.length - 1];
|
||||
const ecart = (parMois: number) =>
|
||||
moyenneReseau != null && moyenneReseau > 0 ? parMois / moyenneReseau - 1 : null;
|
||||
|
||||
const lignes: LigneMagasin[] = MAGASINS.map((m) => {
|
||||
const ventes = mois.reduce((t, k) => t + (row.sales12mByStore?.[m.code]?.[k] ?? 0), 0);
|
||||
const parMois = ventes / mois.length;
|
||||
return {
|
||||
cle: m.code,
|
||||
nom: m.nom,
|
||||
stock: row.stock12mByStore?.[m.code]?.[dernier] ?? 0,
|
||||
ventes,
|
||||
parMois,
|
||||
derniereEntree: row.derniereLivraisonByStore?.[m.code],
|
||||
vsReseau: ventes > 0 ? ecart(parMois) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const ventes = mois.reduce((t, k) => t + (row.sales12m?.[k] ?? 0), 0);
|
||||
lignes.push({
|
||||
cle: "TOTAL",
|
||||
nom: LIBELLE_TOUS_MAGASINS,
|
||||
stock: row.stock12m?.[dernier] ?? lignes.reduce((t, l) => t + l.stock, 0),
|
||||
ventes,
|
||||
parMois: ventes / mois.length,
|
||||
derniereEntree: row.derniereLivraison,
|
||||
// Comparer deux magasins cumulés à UN magasin réseau n'aurait pas de sens.
|
||||
vsReseau: null,
|
||||
total: true,
|
||||
});
|
||||
return lignes;
|
||||
}
|
||||
|
||||
function TableauNosMagasins({ lignes, finPeriode }: { lignes: LigneMagasin[]; finPeriode: string }) {
|
||||
const entete = "px-2.5 py-2 font-semibold whitespace-nowrap";
|
||||
return (
|
||||
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[13px] tabular-nums" style={{ minWidth: 560 }} aria-label="Stock et ventes de nos magasins">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)", color: "var(--text-secondary)" }}>
|
||||
<th className={`${entete} text-left`}>Magasin</th>
|
||||
<th className={`${entete} text-right`} title="Stock de fin de mois du dernier mois complet">Stock fin {finPeriode}</th>
|
||||
<th className={`${entete} text-right`}>Ventes 12 m</th>
|
||||
<th className={`${entete} text-right`}>Par mois</th>
|
||||
<th className={`${entete} text-right`} title="Combien de mois le stock tient au rythme des 12 derniers mois">Couverture</th>
|
||||
<th className={`${entete} text-right`} title="Nos ventes par mois comparées à celles d'un magasin vendeur moyen du réseau">vs réseau</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lignes.map((l) => {
|
||||
const couleurEcart = l.vsReseau == null || Math.abs(l.vsReseau) <= TREND_FLAT
|
||||
? "var(--text-secondary)"
|
||||
: TREND_COLOR[l.vsReseau > 0 ? "up" : "down"];
|
||||
return (
|
||||
<tr
|
||||
key={l.cle}
|
||||
style={{
|
||||
borderTop: `1px solid ${l.total ? "var(--border-strong)" : "var(--border)"}`,
|
||||
background: l.total ? "var(--bg-elevated)" : undefined,
|
||||
}}
|
||||
>
|
||||
<td className="px-2.5 py-2 whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
|
||||
<div className={l.total ? "font-bold" : "font-semibold"}>{l.nom}</div>
|
||||
<div className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
{l.derniereEntree ? `dernière entrée ${formatDate(l.derniereEntree)}` : "aucune entrée enregistrée"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right font-bold" style={{ color: l.stock > 0 ? "var(--text-primary)" : "var(--text-muted)" }}>
|
||||
{fmt(l.stock)}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right" style={{ color: l.ventes !== 0 ? "var(--text-primary)" : "var(--text-muted)" }}>
|
||||
{fmt(l.ventes)}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right" style={{ color: "var(--text-secondary)" }}>
|
||||
{l.ventes !== 0 ? fmtRythme(l.parMois) : "—"}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right" style={{ color: "var(--text-secondary)" }}>
|
||||
{couvertureTexte(l.stock, l.parMois)}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-right font-semibold" style={{ color: couleurEcart }}>
|
||||
{l.vsReseau != null ? pctTexte(l.vsReseau) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Titre de section : icône + intitulé + précision en clair. */
|
||||
function TitreSection({ icone: Icone, titre, detail }: {
|
||||
icone: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
titre: string;
|
||||
detail?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<Icone className="w-4 h-4 shrink-0 self-center" style={{ color: "var(--text-muted)" }} />
|
||||
<h3 className="text-[14px] font-bold" style={{ color: "var(--text-primary)" }}>{titre}</h3>
|
||||
{detail && <span className="text-[12px]" style={{ color: "var(--text-muted)" }}>{detail}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* La tendance dite en une phrase, avec ses deux chiffres : on la lit avant
|
||||
* d'avoir à interpréter les tuiles et les courbes.
|
||||
*/
|
||||
function phraseReseau(trend: NetworkTrend): string {
|
||||
if (trend.nouveau) {
|
||||
return `Dans le réseau, le produit est nouveau : aucune vente sur les ${TREND_WINDOW} premiers mois, des ventes sur les ${TREND_WINDOW} derniers.`;
|
||||
}
|
||||
const serie = trend.perStore ?? trend.values;
|
||||
const n = serie.length;
|
||||
const debut = moyenne(serie.slice(0, TREND_WINDOW));
|
||||
const fin = moyenne(serie.slice(n - TREND_WINDOW));
|
||||
const unite = trend.perStore ? "par magasin vendeur et par mois" : "par mois dans tout le réseau";
|
||||
const sens = trend.direction === "up" ? "progresse" : trend.direction === "down" ? "recule" : "est stable";
|
||||
return `Dans le réseau, le produit ${sens} : ${fmtRythme(fin)} unités ${unite} sur les ${TREND_WINDOW} derniers mois, contre ${fmtRythme(debut)} sur les ${TREND_WINDOW} premiers.`;
|
||||
}
|
||||
|
||||
export function NetworkMonthlyModal({ row, trend, mois, onClose }: {
|
||||
row: ProductRow;
|
||||
trend: NetworkTrend;
|
||||
/** Fenêtre des ventes FF de la Grille (clés « YYYYMM »). */
|
||||
mois: string[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { values, labels, direction, pct } = trend;
|
||||
const couleur = TREND_COLOR[direction];
|
||||
const magasins = computeStoresSeries(row.nbMagReseauByMonth, labels);
|
||||
const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
const dernier = values.length - 1;
|
||||
|
||||
const verdict = trend.nouveau ? "Nouveau" : pct != null ? pctTexte(pct) : "—";
|
||||
const moyenneReseau = trend.perStore ? moyenne(trend.perStore) : null;
|
||||
const magasinsDernierMois = magasins ? magasins.values[dernier] : (row.nbMagasinsReseau ?? 0);
|
||||
|
||||
const lignes = lignesMagasins(row, mois, moyenneReseau);
|
||||
const finPeriode = mois.length ? formatMonthLabel(mois[mois.length - 1]) : "";
|
||||
const nous = lignes[lignes.length - 1];
|
||||
|
||||
const periodeReseau = labels.length
|
||||
? `${formatMonthLabel(qlikMonthToFf(labels[0]))} → ${formatMonthLabel(qlikMonthToFf(labels[labels.length - 1]))}`
|
||||
: "12 mois glissants";
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
// Même gabarit que le détail 12 mois : grille à une colonne, sinon le
|
||||
// tableau le plus large dimensionne la fenêtre et déborde.
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-3xl 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" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle1}
|
||||
</DialogTitle>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
{row.codein}
|
||||
{row.reference ? ` · réf. ${row.reference}` : ""}
|
||||
{row.codeCentrale ? ` · centrale ${row.codeCentrale}` : ""}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Lecture en clair : réseau, puis nous. */}
|
||||
<div className="rounded-xl px-4 py-3 space-y-1 text-[14px] leading-relaxed" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}>
|
||||
{trend.hasData
|
||||
? <p>{phraseReseau(trend)}</p>
|
||||
: <p style={{ color: "var(--text-muted)" }}>Pas encore de détail mensuel réseau pour ce produit : relancez une synchronisation Qlik.</p>}
|
||||
<p style={{ color: "var(--text-secondary)" }}>
|
||||
Chez nous : {fmt(nous.ventes)} unités vendues en 12 mois, {fmt(nous.stock)} en stock fin {finPeriode}
|
||||
{nous.ventes > 0 ? ` (${couvertureTexte(nous.stock, nous.parMois)} de couverture)` : ""}
|
||||
{row.commandesEnCours ? `, ${fmt(row.commandesEnCours)} en commande` : ""}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{trend.hasData && (
|
||||
<section className="space-y-2">
|
||||
<TitreSection
|
||||
icone={TrendingUp}
|
||||
titre="Réseau"
|
||||
detail={`${NB_MAGASINS_RESEAU} magasins · ${periodeReseau}, mois en cours exclu`}
|
||||
/>
|
||||
{trend.enRetard && (
|
||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
Le dernier mois n'est pas encore synchronisé depuis Qlik : la période s'arrête un mois plus tôt.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TuileStat
|
||||
label="Tendance"
|
||||
valeur={verdict}
|
||||
indice={`${trendLabel(pct, trend.nouveau)} · ${trend.surQteParMagasin ? "sur la qté par magasin" : "sur les volumes"}`}
|
||||
couleur={couleur}
|
||||
icone={Fleche}
|
||||
/>
|
||||
{trend.perStore && moyenneReseau != null && (
|
||||
<TuileStat
|
||||
label="Qté / magasin / mois"
|
||||
valeur={fmtRythme(trend.perStore[dernier])}
|
||||
indice={`dernier mois · ${fmtRythme(moyenneReseau)} en moyenne`}
|
||||
/>
|
||||
)}
|
||||
<TuileStat
|
||||
label="Magasins vendeurs"
|
||||
valeur={fmt(magasinsDernierMois)}
|
||||
indice={`dernier mois · ${fmt((magasinsDernierMois / NB_MAGASINS_RESEAU) * 100)} % du réseau`}
|
||||
/>
|
||||
{row.caReseau != null && row.caReseau > 0 && (
|
||||
<TuileStat
|
||||
label="CA réseau 12 m"
|
||||
valeur={fmtEur2(row.caReseau)}
|
||||
indice={row.margePctReseau ? `marge ${fmt(row.margePctReseau * 100, 1)} %` : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<NetworkLineChart labels={labels} values={values} stores={magasins?.values} perStore={trend.perStore} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="space-y-2">
|
||||
<TitreSection
|
||||
icone={Store}
|
||||
titre="Nos magasins"
|
||||
detail={`stock et ventes sur nos 12 derniers mois${moyenneReseau != null ? ` · réseau : ${fmtRythme(moyenneReseau)} / magasin / mois` : ""}`}
|
||||
/>
|
||||
<TableauNosMagasins lignes={lignes} finPeriode={finPeriode} />
|
||||
{row.stockActuel != null && (
|
||||
<p className="text-[12px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
<Warehouse className="w-3.5 h-3.5 shrink-0" />
|
||||
Stock disponible aujourd'hui, tous magasins : <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{fmt(row.stockActuel)}</span>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -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'écart compare notre mix à celui du réseau. L'indice
|
||||
rapporte le CA d'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>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { saveDraftChanges } from "@/features/grid/api/save-draft-changes";
|
||||
import { GammeCode } from "@/types/grid";
|
||||
|
||||
/** Modifications portant sur les lignes chargées (le fournisseur affiché). */
|
||||
function modificationsActives(draftChanges: Record<string, GammeCode>, rowsByCodein: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(draftChanges).filter(([codein]) => rowsByCodein[codein]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the payload from Zustand draftChanges and calls the Server Action.
|
||||
* Filters changes to only include those in the provided codeins list (e.g. current supplier).
|
||||
* Enregistre les gammes modifiées du fournisseur affiché (server action).
|
||||
*
|
||||
* Le composant ne s'abonne qu'au NOMBRE de modifications : l'abonnement aux
|
||||
* modifications elles-mêmes le redessinait à chaque changement de gamme. Le
|
||||
* détail est lu au moment d'enregistrer.
|
||||
*/
|
||||
export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
|
||||
const draftChanges = useGridStore((s) => s.draftChanges);
|
||||
const rowsByCodein = useGridStore((s) => s.rowsByCodein);
|
||||
const resetDrafts = useGridStore((s) => s.resetDrafts);
|
||||
const clearDrafts = useGridStore((s) => s.clearDrafts);
|
||||
const applyDraftsToRows = useGridStore((s) => s.applyDraftsToRows);
|
||||
|
||||
// Only consider changes that are in the filter list (if provided)
|
||||
const filterCodeinSet = useMemo(
|
||||
() => filterCodeins ? new Set(filterCodeins) : null,
|
||||
[filterCodeins]
|
||||
);
|
||||
const activeDrafts = useMemo(
|
||||
() => filterCodeinSet
|
||||
? Object.fromEntries(Object.entries(draftChanges).filter(([codein]) => filterCodeinSet.has(codein)))
|
||||
: draftChanges,
|
||||
[draftChanges, filterCodeinSet]
|
||||
);
|
||||
|
||||
const count = Object.keys(activeDrafts).length;
|
||||
export function useSaveDrafts(magasin: string) {
|
||||
const count = useGridStore((s) => {
|
||||
let n = 0;
|
||||
for (const codein in s.draftChanges) if (s.rowsByCodein[codein]) n++;
|
||||
return n;
|
||||
});
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const { draftChanges, rowsByCodein, applyDraftsToRows, clearDrafts } = useGridStore.getState();
|
||||
const activeDrafts = modificationsActives(draftChanges, rowsByCodein);
|
||||
// Index par code article : `rows.find` dans la boucle coûtait
|
||||
// (modifications × lignes) comparaisons sur un gros fournisseur.
|
||||
const changes = Object.entries(activeDrafts).map(([codein, codeGamme]) => {
|
||||
@@ -44,25 +40,22 @@ export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
|
||||
|
||||
if (changes.length === 0) return { success: true, saved: 0 };
|
||||
|
||||
// Derive supplier info from the first matching row
|
||||
const firstRow = changes.length > 0 ? rowsByCodein[changes[0].codein] : undefined;
|
||||
// Fournisseur déduit de la première ligne modifiée
|
||||
const firstRow = rowsByCodein[changes[0].codein];
|
||||
const codeFournisseur = firstRow?.codeFournisseur ?? "";
|
||||
const nomFournisseur = firstRow?.nomFournisseur ?? "";
|
||||
|
||||
const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes });
|
||||
|
||||
if (result.success) {
|
||||
// Apply saved values onto row.codeGamme BEFORE clearing drafts
|
||||
// so that isModified (codeGamme !== codeGammeInit) stays true
|
||||
// Report des valeurs enregistrées sur row.codeGamme AVANT de vider les
|
||||
// modifications, pour que l'indicateur « modifié » (codeGamme !==
|
||||
// codeGammeInit) reste juste.
|
||||
applyDraftsToRows(activeDrafts);
|
||||
if (filterCodeins) {
|
||||
clearDrafts(Object.keys(activeDrafts));
|
||||
} else {
|
||||
resetDrafts();
|
||||
}
|
||||
clearDrafts(Object.keys(activeDrafts));
|
||||
}
|
||||
return result;
|
||||
}, [activeDrafts, rowsByCodein, magasin, resetDrafts, clearDrafts, applyDraftsToRows, filterCodeins]);
|
||||
}, [magasin]);
|
||||
|
||||
return { save, hasDrafts: count > 0, count };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { CODE_TOUS_MAGASINS } from "@/lib/magasins";
|
||||
import type { StorePatch } from "@/features/grid/lib/store-patch";
|
||||
|
||||
/**
|
||||
* Demande le complément de l'API FF du magasin affiché, une fois les lignes
|
||||
* chargées, et l'applique à son arrivée. Une seule demande par magasin et par
|
||||
* chargement : revenir sur un magasin déjà complété ne coûte rien.
|
||||
*/
|
||||
export function useStorePatch(codeFournisseur: string, enabled: boolean) {
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
const rowsMeta = useGridStore((s) => s.rowsMeta);
|
||||
const applyStorePatch = useGridStore((s) => s.applyStorePatch);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || activeMagasin === CODE_TOUS_MAGASINS || !rowsMeta) return;
|
||||
if (rowsMeta.key !== rowsKeyFor(codeFournisseur) || rowsMeta.patchedStores.includes(activeMagasin)) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const gen = { key: rowsMeta.key, loadedAt: rowsMeta.loadedAt };
|
||||
const params = new URLSearchParams({ fournisseur: codeFournisseur, magasin: activeMagasin });
|
||||
fetch(`/api/grid/rows/store-patch?${params}`, { signal: controller.signal, cache: "no-store" })
|
||||
.then((res) => (res.ok ? (res.json() as Promise<StorePatch>) : null))
|
||||
.then((patch) => {
|
||||
if (patch && !controller.signal.aborted) applyStorePatch(gen, patch);
|
||||
})
|
||||
// Échec ou abandon : les chiffres SQL restent affichés, et la demande
|
||||
// repartira au prochain passage sur ce magasin.
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [enabled, codeFournisseur, activeMagasin, rowsMeta, applyStorePatch]);
|
||||
}
|
||||
@@ -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) || "-",
|
||||
]);
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ export type NetworkTrend = {
|
||||
pct: number | null;
|
||||
/** Rien sur les 4 premiers mois, des ventes sur les 4 derniers. */
|
||||
nouveau: boolean;
|
||||
/**
|
||||
* `true` si la fenêtre s'arrête un mois plus tôt que prévu : le cache Qlik
|
||||
* n'a pas encore été resynchronisé depuis le changement de mois.
|
||||
*/
|
||||
enRetard: boolean;
|
||||
hasData: boolean;
|
||||
};
|
||||
|
||||
@@ -71,6 +76,37 @@ export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||
flat: "#94a3b8",
|
||||
};
|
||||
|
||||
/** Les 12 clés de la fenêtre sont-elles explicitement présentes et chiffrées ? */
|
||||
function fenetreComplete(serie: Record<string, number>, labels: string[]): boolean {
|
||||
return labels.every((label) =>
|
||||
Object.prototype.hasOwnProperty.call(serie, label) &&
|
||||
Number.isFinite(Number(serie[label])),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les 12 mois réellement disponibles dans une série Qlik : la fenêtre du jour,
|
||||
* sinon celle qui s'arrête un mois plus tôt.
|
||||
*
|
||||
* Le repli couvre les jours qui suivent un changement de mois : la fenêtre a
|
||||
* glissé, mais le cache Qlik n'est resynchronisé que fournisseur par
|
||||
* fournisseur, la nuit. Sans lui, toutes les tendances disparaissaient d'un
|
||||
* coup le 1er du mois. Ce sont toujours 12 mois complets et extraits — aucun
|
||||
* mois n'est inventé — simplement décalés d'un cran. `null` si aucune des deux
|
||||
* fenêtres n'est complète.
|
||||
*/
|
||||
export function fenetreDisponible(
|
||||
serie: Record<string, number> | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): { labels: string[]; enRetard: boolean } | null {
|
||||
if (!serie) return null;
|
||||
const labels = buildRolling12QlikMonths(now);
|
||||
if (fenetreComplete(serie, labels)) return { labels, enRetard: false };
|
||||
const precedente = buildRolling12QlikMonths(new Date(now.getFullYear(), now.getMonth() - 1, 1));
|
||||
if (fenetreComplete(serie, precedente)) return { labels: precedente, enRetard: true };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tendance réseau : variation entre les **4 premiers** et les **4 derniers**
|
||||
* mois de la fenêtre de 12 mois glissants, mois en cours exclu.
|
||||
@@ -79,7 +115,8 @@ export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||
* présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance
|
||||
* réellement glissante. Un mois en cours (partiel), ou plus vieux que 12 mois,
|
||||
* présent dans le cache est donc **ignoré** — sinon la pente est faussée par un
|
||||
* mois tronqué.
|
||||
* mois tronqué. Seule tolérance : un cache pas encore resynchronisé depuis le
|
||||
* changement de mois, lu sur ses 12 mois complets (cf. `fenetreDisponible`).
|
||||
*
|
||||
* Une série n'est affichée que si les **12 clés sont explicitement présentes**.
|
||||
* L'extracteur Qlik écrit lui-même les mois sans faits à 0 après avoir validé
|
||||
@@ -129,15 +166,11 @@ export function computeNetworkTrend(
|
||||
): NetworkTrend {
|
||||
const empty: NetworkTrend = {
|
||||
values: [], labels: [], perStore: null, surQteParMagasin: false,
|
||||
direction: "flat", pct: null, nouveau: false, hasData: false,
|
||||
direction: "flat", pct: null, nouveau: false, enRetard: false, hasData: false,
|
||||
};
|
||||
if (!qteByMonth) return empty;
|
||||
const labels = buildRolling12QlikMonths(now);
|
||||
const complet = labels.every((label) =>
|
||||
Object.prototype.hasOwnProperty.call(qteByMonth, label) &&
|
||||
Number.isFinite(Number(qteByMonth[label])),
|
||||
);
|
||||
if (!complet) return empty;
|
||||
const fenetre = fenetreDisponible(qteByMonth, now);
|
||||
if (!qteByMonth || !fenetre) return empty;
|
||||
const { labels, enRetard } = fenetre;
|
||||
|
||||
const values = labels.map((l) => Number(qteByMonth[l]));
|
||||
const n = values.length;
|
||||
@@ -145,7 +178,7 @@ export function computeNetworkTrend(
|
||||
// Quantité par magasin vendeur : l'indicateur qui isole la performance du
|
||||
// produit de sa diffusion. Un mois sans magasin vendeur vaut 0 — c'est le
|
||||
// seul choix cohérent avec une quantité elle-même nulle.
|
||||
const magasins = computeStoresSeries(nbMagByMonth, now);
|
||||
const magasins = computeStoresSeries(nbMagByMonth, labels);
|
||||
const perStore = magasins
|
||||
? values.map((v, i) => (magasins.values[i] > 0 ? v / magasins.values[i] : 0))
|
||||
: null;
|
||||
@@ -172,7 +205,7 @@ export function computeNetworkTrend(
|
||||
direction = "up";
|
||||
}
|
||||
|
||||
return { values, labels, perStore, surQteParMagasin: perStore != null, direction, pct, nouveau, hasData: true };
|
||||
return { values, labels, perStore, surQteParMagasin: perStore != null, direction, pct, nouveau, enRetard, hasData: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,15 +223,11 @@ export function computeNetworkTrend(
|
||||
*/
|
||||
export function computeStoresSeries(
|
||||
nbMagByMonth?: Record<string, number> | null,
|
||||
now: Date = new Date(),
|
||||
/** Mois de la courbe des quantités (`trend.labels`), pour rester alignés. */
|
||||
labels?: string[],
|
||||
): { values: number[]; labels: string[] } | null {
|
||||
if (!nbMagByMonth) return null;
|
||||
const labels = buildRolling12QlikMonths(now);
|
||||
const complet = labels.every((label) =>
|
||||
Object.prototype.hasOwnProperty.call(nbMagByMonth, label) &&
|
||||
Number.isFinite(Number(nbMagByMonth[label])),
|
||||
);
|
||||
if (!complet) return null;
|
||||
if (!nbMagByMonth || !labels || labels.length === 0) return null;
|
||||
if (!fenetreComplete(nbMagByMonth, labels)) return null;
|
||||
return { values: labels.map((l) => Number(nbMagByMonth[l])), labels };
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,21 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { createThrottledJSONStorage } from "@/lib/throttled-json-storage";
|
||||
import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/grid";
|
||||
import { withStorePatch, type StorePatch } from "@/features/grid/lib/store-patch";
|
||||
import { getMonthsFromRows } from "@/features/grid/lib/months";
|
||||
|
||||
/** Ce que décrit `rowsMeta` : les lignes d'un fournisseur, tous magasins. */
|
||||
export interface RowsMeta {
|
||||
key: string;
|
||||
loadedAt: number;
|
||||
/** Magasins dont le complément de l'API FF est déjà appliqué à ces lignes. */
|
||||
patchedStores: string[];
|
||||
}
|
||||
|
||||
/** Clé des lignes chargées pour un fournisseur (toujours « tous magasins »). */
|
||||
export const rowsKeyFor = (codeFournisseur: string) => `${codeFournisseur}:TOTAL`;
|
||||
|
||||
interface GridState {
|
||||
/** Source data from server */
|
||||
@@ -25,12 +39,17 @@ interface GridState {
|
||||
refreshRequest: number;
|
||||
requestRefresh: () => void;
|
||||
/**
|
||||
* Ce que contiennent `rows` (fournisseur:magasin) et quand elles ont été
|
||||
* chargées. Non persisté : sert à ne pas retélécharger la Grille quand on y
|
||||
* revient, et à changer de magasin sans vider l'écran.
|
||||
* Ce que contiennent `rows` et quand elles ont été chargées. Non persisté :
|
||||
* sert à ne pas retélécharger la Grille quand on y revient, et à savoir quels
|
||||
* magasins ont déjà reçu leur complément.
|
||||
*/
|
||||
rowsMeta: { key: string; loadedAt: number } | null;
|
||||
setRowsMeta: (meta: { key: string; loadedAt: number } | null) => void;
|
||||
rowsMeta: RowsMeta | null;
|
||||
setRowsMeta: (meta: RowsMeta | null) => void;
|
||||
/**
|
||||
* Applique le complément d'un magasin, s'il concerne encore les lignes
|
||||
* affichées (`gen` = clé et date de chargement au moment de la demande).
|
||||
*/
|
||||
applyStorePatch: (gen: { key: string; loadedAt: number }, patch: StorePatch) => void;
|
||||
/** Persisted column visibility state */
|
||||
columnVisibility: Record<string, boolean>;
|
||||
/** Persisted column sizing state */
|
||||
@@ -231,6 +250,34 @@ export const useGridStore = create<GridState>()(
|
||||
setActiveGridQuery: (query) => set({ activeGridQuery: sansParamRefresh(query) }),
|
||||
requestRefresh: () => set((state) => ({ refreshRequest: state.refreshRequest + 1 })),
|
||||
setRowsMeta: (meta) => set({ rowsMeta: meta }),
|
||||
applyStorePatch: (gen, patch) => {
|
||||
const { rows, rowsByCodein, rowsMeta } = get();
|
||||
if (!rowsMeta || rowsMeta.key !== gen.key || rowsMeta.loadedAt !== gen.loadedAt) return;
|
||||
const patchedStores = rowsMeta.patchedStores.includes(patch.magasin)
|
||||
? rowsMeta.patchedStores
|
||||
: [...rowsMeta.patchedStores, patch.magasin];
|
||||
// Complément calculé sur une autre fenêtre de 12 mois (changement de
|
||||
// mois entre les deux requêtes) : on le laisse de côté.
|
||||
const mois = getMonthsFromRows(rows);
|
||||
const memeFenetre = !mois || mois.join(",") === patch.periods.join(",");
|
||||
const parCode = new Map(patch.entries.map((e) => [e.codein, e]));
|
||||
if (!memeFenetre || !patch.entries.some((e) => rowsByCodein[e.codein])) {
|
||||
set({ rowsMeta: { ...rowsMeta, patchedStores } });
|
||||
return;
|
||||
}
|
||||
// Nouvelles lignes pour les seuls articles complétés : les autres
|
||||
// gardent leur objet, et donc leur rendu mémorisé.
|
||||
const nouvelles = rows.map((r) => {
|
||||
const e = parCode.get(r.codein);
|
||||
return e ? withStorePatch(r, patch.magasin, e) : r;
|
||||
});
|
||||
set({ rows: nouvelles, rowsByCodein: indexRows(nouvelles), rowsMeta: { ...rowsMeta, patchedStores } });
|
||||
if (summaryTimer) clearTimeout(summaryTimer);
|
||||
summaryTimer = setTimeout(() => {
|
||||
const state = get();
|
||||
set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) });
|
||||
}, 80);
|
||||
},
|
||||
restoreSnapshot: (changes) => {
|
||||
set({ draftChanges: changes, summary: computeSummary(get().rows, changes, get().activeMagasin) });
|
||||
},
|
||||
@@ -271,6 +318,9 @@ export const useGridStore = create<GridState>()(
|
||||
}),
|
||||
{
|
||||
name: "collectflow-grid-storage",
|
||||
// Écriture différée (500 ms) : `persist` réécrivait le localStorage à
|
||||
// chaque changement d'état, y compris pendant un redimensionnement.
|
||||
storage: createThrottledJSONStorage<GridPersisted>(500),
|
||||
/**
|
||||
* v1 — `filters.code3` est passé d'une chaîne unique à `string[] | null`
|
||||
* (filtre multi-nomenclatures). Sans migration, la valeur déjà présente
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
/**
|
||||
* Score Settings Store — v4 (simplifié)
|
||||
*
|
||||
* Le score hybride v4 utilise des paliers absolus (Volume 50pts + CA 30pts + Marge 20pts + Bonus ±10pts).
|
||||
* Il n'y a plus de pondérations configurables par l'utilisateur.
|
||||
* Ce store est conservé pour la compatibilité mais n'a plus de paramètres ajustables.
|
||||
*/
|
||||
|
||||
interface ScoreSettingsState {
|
||||
_version: number;
|
||||
}
|
||||
|
||||
export const useScoreSettingsStore = create<ScoreSettingsState>()(
|
||||
persist(
|
||||
() => ({
|
||||
_version: 4,
|
||||
}),
|
||||
{
|
||||
name: "collectflow-score-settings",
|
||||
}
|
||||
)
|
||||
);
|
||||
+129
-123
@@ -1,43 +1,114 @@
|
||||
"use server";
|
||||
|
||||
/**
|
||||
* Actions des Paramètres — réservées aux administrateurs.
|
||||
*
|
||||
* Aucun secret ne repart vers le navigateur : les mots de passe (base, Qlik) et
|
||||
* les clés d'IA restent dans `data/.db-config.json`. Un mot de passe laissé vide
|
||||
* dans le formulaire signifie « conserver celui qui est enregistré ».
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { requireAdmin, verifierAdmin } from "@/lib/authz";
|
||||
import { CONFIG_FILE, readConfig, writeConfig } from "./config-file";
|
||||
import { diagnostiquerApiFf } from "./ff-api-diagnostic";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
|
||||
|
||||
export interface DbConfig {
|
||||
url: string;
|
||||
openRouterKey?: string;
|
||||
openRouterModel?: string;
|
||||
aiProvider?: "openrouter" | "google";
|
||||
googleAiKey?: string;
|
||||
googleAiModel?: string;
|
||||
/** Qlik Sense — données réseau */
|
||||
qlikHost?: string;
|
||||
qlikUser?: string;
|
||||
qlikPassword?: string;
|
||||
/**
|
||||
* API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et
|
||||
* au rattrapage des ventes par magasin dans la Grille. Configurable ici pour
|
||||
* ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer
|
||||
* sans redéployer.
|
||||
*/
|
||||
ffApiBaseUrl?: string;
|
||||
export interface ChampsBaseDeDonnees {
|
||||
host: string;
|
||||
port: string;
|
||||
database: string;
|
||||
user: string;
|
||||
/** Vide ou absent : le mot de passe enregistré est conservé. */
|
||||
password?: string;
|
||||
ssl: boolean;
|
||||
}
|
||||
|
||||
/** Lit la config existante (ou {} si absente). */
|
||||
async function readConfig(): Promise<Partial<DbConfig>> {
|
||||
/** Ce que le formulaire peut afficher : jamais de mot de passe ni de clé. */
|
||||
export interface ParametresAffichables {
|
||||
db: {
|
||||
host: string;
|
||||
port: string;
|
||||
database: string;
|
||||
user: string;
|
||||
ssl: boolean;
|
||||
motDePasseEnregistre: boolean;
|
||||
} | null;
|
||||
qlik: { host: string; user: string; motDePasseEnregistre: boolean };
|
||||
ffApiBaseUrl: string;
|
||||
}
|
||||
|
||||
/** Décode un composant d'URL, ou le garde tel quel s'il n'est pas encodé proprement. */
|
||||
function decoder(valeur: string): string {
|
||||
try {
|
||||
const data = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
return JSON.parse(data);
|
||||
return decodeURIComponent(valeur);
|
||||
} catch {
|
||||
return {};
|
||||
return valeur;
|
||||
}
|
||||
}
|
||||
|
||||
export async function testDatabaseConnection(url: string) {
|
||||
/** Découpe l'URL `postgres://` enregistrée en champs du formulaire. */
|
||||
function lireUrlPostgres(url: string) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: u.port || "5432",
|
||||
database: decoder(u.pathname.slice(1)),
|
||||
user: decoder(u.username),
|
||||
password: decoder(u.password),
|
||||
ssl: url.includes("sslmode=require"),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("[Settings] URL de base enregistrée illisible :", (e as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function construireUrlPostgres(champs: ChampsBaseDeDonnees, motDePasse: string): string {
|
||||
const user = encodeURIComponent(champs.user.trim());
|
||||
const identifiants = motDePasse ? `${user}:${encodeURIComponent(motDePasse)}` : user;
|
||||
const port = champs.port.trim() || "5432";
|
||||
return `postgres://${identifiants}@${champs.host.trim()}:${port}/${champs.database.trim()}${champs.ssl ? "?sslmode=require" : ""}`;
|
||||
}
|
||||
|
||||
/** Mot de passe saisi, sinon celui de l'URL enregistrée. */
|
||||
async function motDePasseBase(champs: ChampsBaseDeDonnees): Promise<string> {
|
||||
if (champs.password) return champs.password;
|
||||
const { url } = await readConfig();
|
||||
return (url && lireUrlPostgres(url)?.password) || "";
|
||||
}
|
||||
|
||||
/** Réglages enregistrés, sans aucun secret. */
|
||||
export async function getParametresAffichables(): Promise<ParametresAffichables> {
|
||||
await requireAdmin();
|
||||
const config = await readConfig();
|
||||
const base = config.url ? lireUrlPostgres(config.url) : null;
|
||||
return {
|
||||
db: base
|
||||
? {
|
||||
host: base.host,
|
||||
port: base.port,
|
||||
database: base.database,
|
||||
user: base.user,
|
||||
ssl: base.ssl,
|
||||
motDePasseEnregistre: base.password !== "",
|
||||
}
|
||||
: null,
|
||||
qlik: {
|
||||
host: config.qlikHost ?? "",
|
||||
user: config.qlikUser ?? "",
|
||||
// Même ordre de priorité que getQlikConfig() : réglage enregistré, puis variable d'environnement.
|
||||
motDePasseEnregistre: Boolean(config.qlikPassword || process.env.QLIK_PWD),
|
||||
},
|
||||
ffApiBaseUrl: config.ffApiBaseUrl ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function testDatabaseConnection(champs: ChampsBaseDeDonnees) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
const url = construireUrlPostgres(champs, await motDePasseBase(champs));
|
||||
console.log("Testing connection to:", url.replace(/:([^@]+)@/, ":****@"));
|
||||
const pool = new Pool({
|
||||
connectionString: url,
|
||||
@@ -58,24 +129,15 @@ export async function testDatabaseConnection(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveDatabaseSettings(
|
||||
url: string,
|
||||
openRouterKey?: string,
|
||||
openRouterModel?: string,
|
||||
aiProvider?: "openrouter" | "google",
|
||||
googleAiKey?: string,
|
||||
googleAiModel?: string,
|
||||
) {
|
||||
export async function saveDatabaseSettings(champs: ChampsBaseDeDonnees) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
try {
|
||||
// Merge avec l'existant pour préserver les autres réglages (ex: Qlik)
|
||||
const existing = await readConfig();
|
||||
const config: DbConfig = { ...existing, url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
|
||||
|
||||
// S'assurer que le dossier data existe
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
|
||||
const url = construireUrlPostgres(champs, await motDePasseBase(champs));
|
||||
// Seule l'URL change : les clés d'IA et les réglages Qlik / API FF sont conservés.
|
||||
console.log(`[Settings] Saving config to ${CONFIG_FILE}`);
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
await writeConfig({ url });
|
||||
console.log("[Settings] Database configuration saved successfully.");
|
||||
|
||||
// Refresh the shared DB instance immediately
|
||||
@@ -90,12 +152,13 @@ export async function saveDatabaseSettings(
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword: string) {
|
||||
/** Mot de passe vide ou absent : celui qui est enregistré est conservé. */
|
||||
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword?: string) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
try {
|
||||
const existing = await readConfig();
|
||||
const config = { ...existing, qlikHost, qlikUser, qlikPassword } as DbConfig;
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
await writeConfig({ qlikHost, qlikUser, ...(qlikPassword ? { qlikPassword } : {}) });
|
||||
console.log("[Settings] Qlik configuration saved.");
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
@@ -105,10 +168,15 @@ export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikP
|
||||
}
|
||||
}
|
||||
|
||||
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword: string) {
|
||||
/** Mot de passe vide ou absent : on teste avec celui qui est enregistré. */
|
||||
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword?: string) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
try {
|
||||
const { getQlikConfig, qlikNtlmSession } = await import("@/lib/qlik-client");
|
||||
const cfg = { ...getQlikConfig(), user: qlikUser, password: qlikPassword };
|
||||
const enregistre = getQlikConfig();
|
||||
const cfg = { ...enregistre, user: qlikUser, password: qlikPassword || enregistre.password };
|
||||
if (qlikHost) cfg.host = qlikHost;
|
||||
await qlikNtlmSession(cfg);
|
||||
return { success: true };
|
||||
@@ -120,15 +188,15 @@ export async function testQlikConnection(qlikHost: string, qlikUser: string, qli
|
||||
|
||||
/** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */
|
||||
export async function saveFfApiSettings(ffApiBaseUrl: string) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
try {
|
||||
const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, "");
|
||||
if (cleaned && !/^https?:\/\//i.test(cleaned)) {
|
||||
return { success: false, error: "L'URL doit commencer par http:// ou https://" };
|
||||
}
|
||||
const existing = await readConfig();
|
||||
const config = { ...existing, ffApiBaseUrl: cleaned || undefined } as DbConfig;
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
await writeConfig({ ffApiBaseUrl: cleaned || undefined });
|
||||
// Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde.
|
||||
const { resetFfApiBaseCache } = await import("@/lib/api-ff-client");
|
||||
resetFfApiBaseCache();
|
||||
@@ -141,71 +209,9 @@ export async function saveFfApiSettings(ffApiBaseUrl: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
|
||||
*
|
||||
* L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
|
||||
* si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
|
||||
* donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
|
||||
*/
|
||||
export async function testFfApiConnection(ffApiBaseUrl?: string) {
|
||||
const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, "");
|
||||
const url = `${base}/api/sync/status`;
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const ms = Date.now() - started;
|
||||
if (!res.ok) {
|
||||
return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` };
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` };
|
||||
}
|
||||
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
|
||||
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
|
||||
const { normalizeSyncStatus } = await import("@/lib/api-ff-client");
|
||||
const status = normalizeSyncStatus(body);
|
||||
if (!status) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` };
|
||||
}
|
||||
return { success: true, url, ms, status };
|
||||
} catch (error: unknown) {
|
||||
const ms = Date.now() - started;
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const name = error instanceof Error ? error.name : "";
|
||||
// fetch masque la cause réelle derrière « fetch failed » : on la déplie.
|
||||
const cause = (error as { cause?: { code?: string; message?: string } })?.cause;
|
||||
let hint = raw;
|
||||
if (name === "TimeoutError" || name === "AbortError") {
|
||||
hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`;
|
||||
} else if (cause?.code === "ENOTFOUND") {
|
||||
hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`;
|
||||
} else if (cause?.code === "ECONNREFUSED") {
|
||||
hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`;
|
||||
} else if (cause?.code) {
|
||||
hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`;
|
||||
}
|
||||
console.error(`[Settings] FF API test KO (${url}):`, raw);
|
||||
return { success: false, url, error: hint };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSavedDatabaseConfig(): Promise<DbConfig | null> {
|
||||
try {
|
||||
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
|
||||
console.log(`[Settings] Config file not found at ${CONFIG_FILE}`);
|
||||
return null;
|
||||
}
|
||||
const data = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
console.log(`[Settings] Config read from ${CONFIG_FILE}. Key present: ${!!config.openRouterKey}, Model: ${config.openRouterModel || "default"}`);
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error(`[Settings] Error reading config from ${CONFIG_FILE}:`, error);
|
||||
return null;
|
||||
}
|
||||
/** Teste l'API FF Nancy (voir `diagnostiquerApiFf`). */
|
||||
export async function testFfApiConnection(ffApiBaseUrl?: string): Promise<Awaited<ReturnType<typeof diagnostiquerApiFf>>> {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, url: "", error: acces.message };
|
||||
return diagnostiquerApiFf(ffApiBaseUrl);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* CollectFlow — Fichier de configuration `data/.db-config.json`.
|
||||
*
|
||||
* Module **serveur**, pas fichier d'actions : ses fonctions ne doivent pas être
|
||||
* appelables depuis le navigateur (le fichier contient des mots de passe et des
|
||||
* clés d'API). Les actions de `./actions.ts` les utilisent après contrôle du rôle.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export const DATA_DIR = path.join(process.cwd(), "data");
|
||||
export const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
|
||||
|
||||
export interface DbConfig {
|
||||
url: string;
|
||||
openRouterKey?: string;
|
||||
openRouterModel?: string;
|
||||
aiProvider?: "openrouter" | "google";
|
||||
googleAiKey?: string;
|
||||
googleAiModel?: string;
|
||||
/** Qlik Sense — données réseau */
|
||||
qlikHost?: string;
|
||||
qlikUser?: string;
|
||||
qlikPassword?: string;
|
||||
/**
|
||||
* API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et
|
||||
* au rattrapage des ventes par magasin dans la Grille. Configurable ici pour
|
||||
* ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer
|
||||
* sans redéployer.
|
||||
*/
|
||||
ffApiBaseUrl?: string;
|
||||
}
|
||||
|
||||
/** Lit la config existante (ou {} si absente). */
|
||||
export async function readConfig(): Promise<Partial<DbConfig>> {
|
||||
try {
|
||||
const data = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne `patch` dans la config enregistrée : les clés absentes du patch sont
|
||||
* conservées telles quelles (une clé passée à `undefined` est retirée).
|
||||
*/
|
||||
export async function writeConfig(patch: Partial<DbConfig>): Promise<void> {
|
||||
const existing = await readConfig();
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existing, ...patch }, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* CollectFlow — Diagnostic de l'API FF Nancy.
|
||||
*
|
||||
* Partagé par le bouton « Tester » des Paramètres (administrateurs) et par la
|
||||
* route `/api/ff-status` (tout utilisateur connecté). Module serveur et non
|
||||
* fichier d'actions : chaque appelant fait son propre contrôle d'accès.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { readConfig } from "./config-file";
|
||||
|
||||
/**
|
||||
* Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
|
||||
*
|
||||
* L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
|
||||
* si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
|
||||
* donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
|
||||
*/
|
||||
export async function diagnostiquerApiFf(ffApiBaseUrl?: string) {
|
||||
const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, "");
|
||||
const url = `${base}/api/sync/status`;
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const ms = Date.now() - started;
|
||||
if (!res.ok) {
|
||||
return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` };
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` };
|
||||
}
|
||||
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
|
||||
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
|
||||
const { normalizeSyncStatus } = await import("@/lib/api-ff-client");
|
||||
const status = normalizeSyncStatus(body);
|
||||
if (!status) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` };
|
||||
}
|
||||
return { success: true, url, ms, status };
|
||||
} catch (error: unknown) {
|
||||
const ms = Date.now() - started;
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const name = error instanceof Error ? error.name : "";
|
||||
// fetch masque la cause réelle derrière « fetch failed » : on la déplie.
|
||||
const cause = (error as { cause?: { code?: string; message?: string } })?.cause;
|
||||
let hint = raw;
|
||||
if (name === "TimeoutError" || name === "AbortError") {
|
||||
hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`;
|
||||
} else if (cause?.code === "ENOTFOUND") {
|
||||
hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`;
|
||||
} else if (cause?.code === "ECONNREFUSED") {
|
||||
hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`;
|
||||
} else if (cause?.code) {
|
||||
hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`;
|
||||
}
|
||||
console.error(`[Settings] FF API test KO (${url}):`, raw);
|
||||
return { success: false, url, error: hint };
|
||||
}
|
||||
}
|
||||
@@ -8,29 +8,33 @@ export interface DatabaseSettings {
|
||||
port: string;
|
||||
database: string;
|
||||
user: string;
|
||||
/** Saisie en cours uniquement : jamais enregistré dans le navigateur. */
|
||||
password?: string;
|
||||
ssl: boolean;
|
||||
}
|
||||
|
||||
interface DatabaseSettingsState extends DatabaseSettings {
|
||||
/** Un mot de passe est enregistré sur le serveur (lu au chargement de la page, jamais mémorisé). */
|
||||
motDePasseEnregistre: boolean;
|
||||
setHost: (host: string) => void;
|
||||
setPort: (port: string) => void;
|
||||
setDatabase: (database: string) => void;
|
||||
setUser: (user: string) => void;
|
||||
setPassword: (password: string) => void;
|
||||
setSsl: (ssl: boolean) => void;
|
||||
getDatabaseUrl: () => string;
|
||||
setMotDePasseEnregistre: (motDePasseEnregistre: boolean) => void;
|
||||
}
|
||||
|
||||
export const useDbSettingsStore = create<DatabaseSettingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
(set) => ({
|
||||
host: "localhost",
|
||||
port: "5432",
|
||||
database: "collectflow",
|
||||
user: "postgres",
|
||||
password: "",
|
||||
ssl: false,
|
||||
motDePasseEnregistre: false,
|
||||
|
||||
setHost: (host) => set({ host }),
|
||||
setPort: (port) => set({ port }),
|
||||
@@ -38,16 +42,19 @@ export const useDbSettingsStore = create<DatabaseSettingsState>()(
|
||||
setUser: (user) => set({ user }),
|
||||
setPassword: (password) => set({ password }),
|
||||
setSsl: (ssl) => set({ ssl }),
|
||||
|
||||
getDatabaseUrl: () => {
|
||||
const { user, password, host, port, database, ssl } = get();
|
||||
const auth = password ? `${user}:${password}` : user;
|
||||
const url = `postgres://${auth}@${host}:${port}/${database}${ssl ? "?sslmode=require" : ""}`;
|
||||
return url;
|
||||
},
|
||||
setMotDePasseEnregistre: (motDePasseEnregistre) => set({ motDePasseEnregistre }),
|
||||
}),
|
||||
{
|
||||
name: "collectflow-db-settings",
|
||||
// Le mot de passe reste sur le serveur : seuls les autres champs sont mémorisés.
|
||||
partialize: ({ host, port, database, user, ssl }) => ({ host, port, database, user, ssl }),
|
||||
// Version 1 : les versions précédentes enregistraient le mot de passe en clair.
|
||||
version: 1,
|
||||
migrate: (persisted) => {
|
||||
const reste = { ...(persisted as Partial<DatabaseSettings> | undefined) };
|
||||
delete reste.password;
|
||||
return reste as DatabaseSettingsState;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -3,12 +3,14 @@
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { verifierSession } from "@/lib/authz";
|
||||
|
||||
export async function deleteSnapshot(id: number) {
|
||||
const session = await auth();
|
||||
const rawUserId = (session?.user as any)?.id;
|
||||
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
const rawUserId = acces.utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
|
||||
console.log(`[deleteSnapshot] Request for ID: ${id}, Session UserId: ${userId}`);
|
||||
|
||||
@@ -29,8 +31,9 @@ export async function deleteSnapshot(id: number) {
|
||||
|
||||
console.log(`[deleteSnapshot] Successfully deleted ID: ${id}`);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
// Le détail technique reste dans les journaux du serveur.
|
||||
console.error("[deleteSnapshot] DB Error:", err);
|
||||
return { success: false, error: err.message || "Delete failed" };
|
||||
return { success: false, error: "erreur technique sur le serveur" };
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { requireSession } from "@/lib/authz";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
|
||||
export async function getSnapshots(type?: "snapshot" | "export") {
|
||||
const utilisateur = await requireSession();
|
||||
try {
|
||||
const session = await auth();
|
||||
const rawUserId = (session?.user as any)?.id;
|
||||
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
|
||||
const rawUserId = utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
|
||||
// Si on a un userId, on filtre par celui-ci.
|
||||
// Sinon, on filtre par userId IS NULL (mode anonyme/local)
|
||||
@@ -24,8 +24,8 @@ export async function getSnapshots(type?: "snapshot" | "export") {
|
||||
.from(sessionSnapshots)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(sessionSnapshots.createdAt));
|
||||
} catch (err: any) {
|
||||
const msg = (err?.message || String(err)).split("\n")[0];
|
||||
} catch (err) {
|
||||
const msg = ((err as Error)?.message || String(err)).split("\n")[0];
|
||||
console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err);
|
||||
// Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot »
|
||||
// au lieu de signaler que le chargement a échoué.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { z } from "zod";
|
||||
import { sql, and, eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { verifierSession } from "@/lib/authz";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
const SaveSnapshotSchema = z.object({
|
||||
@@ -26,6 +26,9 @@ const SaveSnapshotSchema = z.object({
|
||||
});
|
||||
|
||||
export async function saveSnapshot(raw: unknown) {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
const parsed = SaveSnapshotSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
console.error("Snapshot validation failed:", parsed.error.format());
|
||||
@@ -33,9 +36,8 @@ export async function saveSnapshot(raw: unknown) {
|
||||
}
|
||||
|
||||
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data;
|
||||
const session = await auth();
|
||||
const rawUserId = (session?.user as any)?.id;
|
||||
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
|
||||
const rawUserId = acces.utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
|
||||
console.log(`[saveSnapshot] User: ${userId} (raw: ${rawUserId}), Supplier: ${codeFournisseur}, Type: ${type}`);
|
||||
|
||||
@@ -59,7 +61,7 @@ export async function saveSnapshot(raw: unknown) {
|
||||
revalidatePath("/snapshots");
|
||||
revalidatePath("/exports");
|
||||
return { success: true, snapshotId: created?.id };
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
console.error("Initial snapshot save failed, attempting auto-repair...", err);
|
||||
|
||||
try {
|
||||
@@ -103,14 +105,10 @@ export async function saveSnapshot(raw: unknown) {
|
||||
.returning({ id: sessionSnapshots.id });
|
||||
|
||||
return { success: true, snapshotId: retryCreated?.id };
|
||||
} catch (repairErr: any) {
|
||||
} catch (repairErr) {
|
||||
// Le détail technique (les deux erreurs) reste dans les journaux du serveur.
|
||||
console.error("Snapshot auto-repair/retry failed:", repairErr);
|
||||
const origMsg = (err.message || String(err)).split('\n')[0];
|
||||
const retryMsg = (repairErr.message || String(repairErr)).split('\n')[0];
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur initiale: ${origMsg} | Erreur après réparation: ${retryMsg}`
|
||||
};
|
||||
return { success: false, error: "erreur technique sur le serveur" };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -117,7 +117,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
setSnapshots((prev) => prev.filter((x) => x.id !== s.id));
|
||||
toast.succes(estExport ? "L'export a été retiré de votre historique." : "La session a été supprimée de votre historique.");
|
||||
} else {
|
||||
toast.erreur(`La suppression a échoué : ${res.error ?? "erreur inconnue"}.`);
|
||||
toast.erreur(`La suppression a échoué : ${(res.error ?? "erreur inconnue").replace(/\.$/, "")}.`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isStaleServerActionError(err)) void proposerRechargement();
|
||||
@@ -228,7 +228,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-[var(--text-muted)]">Chiffre d'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) && (
|
||||
|
||||
@@ -291,52 +291,6 @@ export function buildLast12MonthsRange(): { dateDebut: string; dateFin: string }
|
||||
};
|
||||
}
|
||||
|
||||
/** "2025-03-15" → "202503" */
|
||||
export function dateToYYYYMM(isoDate: string): string {
|
||||
return isoDate.slice(0, 7).replace("-", "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fournisseurs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Extrait le code fournisseur depuis n'importe quelle forme de réponse API */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractFouCode(f: any): string {
|
||||
return f.codefou ?? f.CodeFou ?? f.code_fournisseur ?? f.codeFournisseur ?? f.code ?? String(f.id ?? "");
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractFouNom(f: any): string {
|
||||
return f.nomfou ?? f.NomFou ?? f.nom_fournisseur ?? f.nomFournisseur ?? f.nom ?? f.libelle ?? f.name ?? "Inconnu";
|
||||
}
|
||||
|
||||
export async function getFournisseursFromApi(
|
||||
search?: string
|
||||
): Promise<{ code: string; nom: string }[]> {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
try {
|
||||
const url = search
|
||||
? `${FF_API_BASE}/api/fournisseurs?search=${encodeURIComponent(search)}&limit=500`
|
||||
: `${FF_API_BASE}/api/fournisseurs?limit=500`;
|
||||
|
||||
console.log(`[api-ff] GET ${url}`);
|
||||
const res = await fetch(url, ffFetchInit());
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} on ${url}`);
|
||||
const data = await res.json();
|
||||
|
||||
// Log premier item pour diagnostic
|
||||
const rawList = extractList(data);
|
||||
console.log(`[api-ff] fournisseurs: ${rawList.length} items, sample keys:`, rawList[0] ? Object.keys(rawList[0] as object) : "empty");
|
||||
|
||||
return rawList
|
||||
.map((f: unknown) => ({ code: extractFouCode(f), nom: extractFouNom(f) }))
|
||||
.filter((f: { code: string; nom: string }) => f.code);
|
||||
} catch (err) {
|
||||
console.error("[api-ff] getFournisseursFromApi error:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Articles
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -370,63 +324,6 @@ export async function getArticlesByFournisseur(
|
||||
// Mouvements (ventes + stock12m)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getMouvementsByFournisseur(
|
||||
codefou: string,
|
||||
dateDebut: string,
|
||||
dateFin: string
|
||||
): Promise<FfMouvement[]> {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
const raw = await fetchAllPages<unknown>(
|
||||
(page) =>
|
||||
`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
|
||||
extractList,
|
||||
1000
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return raw.map((r: any): FfMouvement => ({
|
||||
codein: r.codein ?? r.Codein ?? r.code_article ?? "",
|
||||
genremvt: Number(r.genremvt ?? r.GenreMvt ?? r.genre_mvt ?? r.type ?? 0),
|
||||
datemvt: r.datmvt ?? r.datemvt ?? r.DateMvt ?? r.date_mvt ?? r.date ?? "",
|
||||
qte: Number(r.qtemvt ?? r.qte ?? r.Qte ?? r.quantite ?? 0),
|
||||
montant: Number(r.mntmvtht ?? r.montant ?? r.Montant ?? r.montant_mvt ?? r.ca ?? 0),
|
||||
marge: Number(r.margemvt ?? r.marge ?? r.Marge ?? r.marge_mvt ?? 0),
|
||||
qtestock: Number(r.qtestock ?? r.QteStock ?? r.qte_stock ?? r.stock ?? 0),
|
||||
site: r.site ?? r.Site ?? r.magasin ?? r.code_magasin ?? r.codesite ?? "",
|
||||
codefou_reel: r.codefou_reel ?? undefined,
|
||||
nom_fournisseur_reel: r.nom_fournisseur_reel ?? undefined,
|
||||
})).filter(m => m.codein && m.datemvt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les mouvements pour une plage de dates (sans filtre fournisseur).
|
||||
* Utilisé pour extraire codefou_reel / nom_fournisseur_reel par codein.
|
||||
*/
|
||||
export async function getMouvementsForDate(
|
||||
dateDebut: string,
|
||||
dateFin: string
|
||||
): Promise<FfMouvement[]> {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
const raw = await fetchAllPages<unknown>(
|
||||
(page) =>
|
||||
`${FF_API_BASE}/api/mouvements/articles?dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
|
||||
extractList,
|
||||
1000
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return raw.map((r: any): FfMouvement => ({
|
||||
codein: r.codein ?? r.Codein ?? r.code_article ?? "",
|
||||
genremvt: Number(r.genremvt ?? r.GenreMvt ?? r.genre_mvt ?? r.type ?? 0),
|
||||
datemvt: r.datmvt ?? r.datemvt ?? r.DateMvt ?? r.date_mvt ?? r.date ?? "",
|
||||
qte: Number(r.qtemvt ?? r.qte ?? r.Qte ?? r.quantite ?? 0),
|
||||
montant: Number(r.mntmvtht ?? r.montant ?? r.Montant ?? r.montant_mvt ?? r.ca ?? 0),
|
||||
marge: Number(r.margemvt ?? r.marge ?? r.Marge ?? r.marge_mvt ?? 0),
|
||||
qtestock: Number(r.qtestock ?? r.QteStock ?? r.qte_stock ?? r.stock ?? 0),
|
||||
site: r.site ?? r.Site ?? r.magasin ?? r.code_magasin ?? r.codesite ?? "",
|
||||
codefou_reel: r.codefou_reel ?? undefined,
|
||||
nom_fournisseur_reel: r.nom_fournisseur_reel ?? undefined,
|
||||
})).filter(m => m.codein && m.datemvt);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mensuel — stock fin de mois + ventes + réceptions par article et par site
|
||||
// GET /api/articles/:no_id/mensuel?dateDebut=&dateFin=
|
||||
@@ -608,18 +505,6 @@ export async function getCommandesByFournisseur(
|
||||
// Statut de synchronisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getSyncStatus(): Promise<FfSyncStatus | null> {
|
||||
try {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
const res = await fetch(`${FF_API_BASE}/api/sync/status`, ffFetchInit());
|
||||
if (!res.ok) return null;
|
||||
return normalizeSyncStatus(await res.json());
|
||||
} catch (err) {
|
||||
console.error("[api-ff] getSyncStatus error:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sites (magasins)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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`.
|
||||
*
|
||||
|
||||
+4
-6
@@ -24,7 +24,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
try {
|
||||
console.log(`[AUTH] Checking user "${credentials.username}"...`);
|
||||
|
||||
let user: any = null;
|
||||
let user: { id: number | string; username: string; passwordHash: string; role: string } | undefined;
|
||||
|
||||
// Try DB first
|
||||
try {
|
||||
@@ -33,12 +33,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
.where(eq(users.username, credentials.username as string));
|
||||
user = dbUser;
|
||||
console.log(`[AUTH] DB check: User found: ${!!user}`);
|
||||
} catch (dbErr) {
|
||||
} catch {
|
||||
// Base injoignable uniquement : un compte absent de la base ne doit
|
||||
// pas pouvoir se connecter avec le fichier de secours (admin/admin).
|
||||
console.warn("[AUTH] DB unreachable, falling back to JSON.");
|
||||
}
|
||||
|
||||
// Fallback to JSON if DB failed or user not found
|
||||
if (!user) {
|
||||
const fallbackUsers = getFallbackUsers();
|
||||
user = fallbackUsers.find(u => u.username === credentials.username);
|
||||
console.log(`[AUTH] JSON check: User found: ${!!user}`);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* CollectFlow — Contrôle d'accès commun aux actions serveur, routes et pages.
|
||||
*
|
||||
* Le jeton de session (JWT) seul ne suffit pas : il reste valide jusqu'à son
|
||||
* expiration, même si le compte a été supprimé ou rétrogradé entre-temps. On
|
||||
* relit donc l'utilisateur dans la table `users` (avec un cache court), et le
|
||||
* rôle enregistré en base l'emporte sur celui du jeton.
|
||||
*
|
||||
* Base injoignable : on se contente du jeton (mode dégradé), sans quoi le compte
|
||||
* de secours `data/users.json` ne pourrait plus rien faire — y compris configurer
|
||||
* la base.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
|
||||
export interface UtilisateurCourant {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const MESSAGE_SESSION = "Session expirée : reconnectez-vous.";
|
||||
const MESSAGE_ADMIN = "Action réservée aux administrateurs.";
|
||||
|
||||
/** Refus d'accès. `statut` sert aux routes : 401 sans session valide, 403 sans le rôle requis. */
|
||||
export class AccesRefuse extends Error {
|
||||
constructor(message: string, readonly statut: 401 | 403 = 403) {
|
||||
super(message);
|
||||
this.name = "AccesRefuse";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relecture du compte en base ──────────────────────────────────────────────
|
||||
|
||||
/** Durée pendant laquelle une relecture de la table `users` est réutilisée. */
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
/** Après un échec de connexion, on ne réessaie pas avant ce délai (chaque essai peut durer 10 s). */
|
||||
const PAUSE_BASE_INJOIGNABLE_MS = 15_000;
|
||||
|
||||
interface FicheUtilisateur {
|
||||
exists: boolean;
|
||||
role: string;
|
||||
username: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, FicheUtilisateur>();
|
||||
let baseInjoignableDepuis = 0;
|
||||
|
||||
/** Plus grand entier accepté par la colonne `users.id` (serial). */
|
||||
const ID_MAX = 2_147_483_647;
|
||||
|
||||
/**
|
||||
* Fiche de l'utilisateur en base, ou `null` si la base n'a pas pu répondre.
|
||||
* Un identifiant qui ne peut pas exister (compte de secours « 0 », valeur non
|
||||
* numérique) est cherché tel quel : introuvable si la base répond.
|
||||
*/
|
||||
async function lireFiche(id: string): Promise<FicheUtilisateur | null> {
|
||||
const maintenant = Date.now();
|
||||
const enCache = cache.get(id);
|
||||
if (enCache && maintenant - enCache.at < CACHE_TTL_MS) return enCache;
|
||||
if (maintenant - baseInjoignableDepuis < PAUSE_BASE_INJOIGNABLE_MS) return null;
|
||||
|
||||
const n = /^\d+$/.test(id) ? Number(id) : -1;
|
||||
const idNumerique = Number.isSafeInteger(n) && n <= ID_MAX ? n : -1;
|
||||
|
||||
try {
|
||||
const [ligne] = await db
|
||||
.select({ username: users.username, role: users.role })
|
||||
.from(users)
|
||||
.where(eq(users.id, idNumerique))
|
||||
.limit(1);
|
||||
const fiche: FicheUtilisateur = ligne
|
||||
? { exists: true, role: ligne.role, username: ligne.username, at: maintenant }
|
||||
: { exists: false, role: "", username: "", at: maintenant };
|
||||
cache.set(id, fiche);
|
||||
return fiche;
|
||||
} catch (e) {
|
||||
baseInjoignableDepuis = maintenant;
|
||||
console.warn("[authz] Base injoignable, contrôle sur le seul jeton de session :", (e as Error).message?.slice(0, 200));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contrôles ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Utilisateur connecté, relu en base. Lève `AccesRefuse` (401) sinon. */
|
||||
export async function requireSession(): Promise<UtilisateurCourant> {
|
||||
const session = await auth();
|
||||
const jeton = session?.user as { id?: string | number; name?: string | null; role?: string } | undefined;
|
||||
if (!jeton) throw new AccesRefuse(MESSAGE_SESSION, 401);
|
||||
|
||||
const id = jeton.id != null ? String(jeton.id) : "";
|
||||
const fiche = await lireFiche(id);
|
||||
|
||||
if (!fiche) {
|
||||
// Mode dégradé : le jeton fait foi.
|
||||
return { id, username: jeton.name ?? "", role: jeton.role ?? "user" };
|
||||
}
|
||||
if (!fiche.exists) throw new AccesRefuse(MESSAGE_SESSION, 401);
|
||||
return { id, username: fiche.username, role: fiche.role };
|
||||
}
|
||||
|
||||
/** Administrateur connecté. Lève `AccesRefuse` (401 ou 403) sinon. */
|
||||
export async function requireAdmin(): Promise<UtilisateurCourant> {
|
||||
const utilisateur = await requireSession();
|
||||
if (utilisateur.role !== "admin") throw new AccesRefuse(MESSAGE_ADMIN, 403);
|
||||
return utilisateur;
|
||||
}
|
||||
|
||||
// ── Variantes sans exception ─────────────────────────────────────────────────
|
||||
|
||||
export type Autorisation =
|
||||
| { ok: true; utilisateur: UtilisateurCourant }
|
||||
| { ok: false; message: string; statut: 401 | 403 };
|
||||
|
||||
async function verifier(controle: () => Promise<UtilisateurCourant>): Promise<Autorisation> {
|
||||
try {
|
||||
return { ok: true, utilisateur: await controle() };
|
||||
} catch (e) {
|
||||
if (e instanceof AccesRefuse) return { ok: false, message: e.message, statut: e.statut };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pour les actions qui renvoient un résultat `{ success: false, error }` plutôt
|
||||
* que de lever une exception, et pour les pages (le `redirect()` doit être appelé
|
||||
* hors d'un try/catch).
|
||||
*/
|
||||
export function verifierSession(): Promise<Autorisation> {
|
||||
return verifier(requireSession);
|
||||
}
|
||||
|
||||
export function verifierAdmin(): Promise<Autorisation> {
|
||||
return verifier(requireAdmin);
|
||||
}
|
||||
|
||||
// ── Variantes pour les routes ────────────────────────────────────────────────
|
||||
|
||||
function reponse(a: Autorisation): UtilisateurCourant | Response {
|
||||
return a.ok ? a.utilisateur : NextResponse.json({ error: a.message }, { status: a.statut });
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilisateur connecté, ou réponse JSON 401 à renvoyer telle quelle :
|
||||
*
|
||||
* const acces = await sessionOuReponse();
|
||||
* if (acces instanceof Response) return acces;
|
||||
*/
|
||||
export async function sessionOuReponse(): Promise<UtilisateurCourant | Response> {
|
||||
return reponse(await verifierSession());
|
||||
}
|
||||
|
||||
/** Administrateur connecté, ou réponse JSON 401 / 403 à renvoyer telle quelle. */
|
||||
export async function adminOuReponse(): Promise<UtilisateurCourant | Response> {
|
||||
return reponse(await verifierAdmin());
|
||||
}
|
||||
+8
-5
@@ -100,12 +100,15 @@ export function getFournisseursCached(): Promise<{ code: string; nom: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Dernière réception par fournisseur et magasin (cadencier). Agrège tout
|
||||
* l'historique de `mvtart` : de loin la lecture la plus lourde de la page
|
||||
* Commandes. Vide = erreur avalée, non gardé.
|
||||
* Dernière réception par fournisseur et magasin, pour les fournisseurs du
|
||||
* cadencier seulement : de loin la lecture la plus lourde de la page Commandes.
|
||||
* Clé = liste triée, donc recalculée quand une cadence est ajoutée ou retirée.
|
||||
* Vide = erreur avalée (ou aucune réception), non gardé.
|
||||
*/
|
||||
export function getDerniereReceptionCached(): Promise<Map<string, string>> {
|
||||
return cachedFF("derniere-reception", () => pgGetDerniereReceptionParFournisseur(), {
|
||||
export function getDerniereReceptionCached(codes: string[]): Promise<Map<string, string>> {
|
||||
const liste = [...new Set(codes.map((c) => c.trim()))].sort();
|
||||
if (liste.length === 0) return Promise.resolve(new Map());
|
||||
return cachedFF(`derniere-reception:${liste.join(",")}`, () => pgGetDerniereReceptionParFournisseur(liste), {
|
||||
cacheIf: (map) => map.size > 0,
|
||||
});
|
||||
}
|
||||
+7
-4
@@ -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);
|
||||
@@ -58,9 +58,3 @@ export function trouverGamme(code: string | null | undefined): Gamme | undefined
|
||||
if (!code) return undefined;
|
||||
return GAMMES.find((g) => g.code === code.trim());
|
||||
}
|
||||
|
||||
/** « A — Cœur », ou « Sans gamme ». */
|
||||
export function libelleGamme(code: string | null | undefined): string {
|
||||
const g = trouverGamme(code);
|
||||
return g ? `${g.code} — ${g.nom}` : "Sans gamme";
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+52
-73
@@ -25,6 +25,28 @@ let _nomenclatureParentCol: string | null | undefined = undefined; // undefined
|
||||
// la transaction BEGIN / SET LOCAL / COMMIT qui entourait chaque requête coûtait
|
||||
// trois allers-retours de plus à chacune.
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* `fn` sur chaque élément, au plus `simultanes` à la fois, résultats dans l'ordre
|
||||
* (même forme que Promise.allSettled). Évite de lancer des centaines d'appels à
|
||||
* l'API FF d'un coup, qui la saturent et finissent en délais dépassés.
|
||||
*/
|
||||
async function parLots<T, R>(items: T[], simultanes: number, fn: (item: T) => Promise<R>): Promise<PromiseSettledResult<R>[]> {
|
||||
const resultats: PromiseSettledResult<R>[] = new Array(items.length);
|
||||
let suivant = 0;
|
||||
const ouvrier = async () => {
|
||||
while (suivant < items.length) {
|
||||
const i = suivant++;
|
||||
try {
|
||||
resultats[i] = { status: "fulfilled", value: await fn(items[i]) };
|
||||
} catch (reason) {
|
||||
resultats[i] = { status: "rejected", reason };
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(simultanes, items.length) }, ouvrier));
|
||||
return resultats;
|
||||
}
|
||||
|
||||
async function pgNoParallel(query: SQL): Promise<{ rows: unknown[] }> {
|
||||
const r = await db.execute(query);
|
||||
return { rows: r.rows };
|
||||
@@ -156,55 +178,6 @@ export async function pgGetArticlesByFournisseur(codefou: string): Promise<PgArt
|
||||
return (result.rows as unknown as PgArticle[]).filter(r => r.codein);
|
||||
}
|
||||
|
||||
/** Article local (FF Nancy) résolu depuis un code centrale — page Recherche réseau. */
|
||||
export interface PgArticleLocal {
|
||||
codein: string;
|
||||
codeCentrale: string;
|
||||
libelle1?: string;
|
||||
codefou?: string;
|
||||
nomfou?: string;
|
||||
pa?: number;
|
||||
pv_central?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout des codes centraux vers NOS articles locaux.
|
||||
*
|
||||
* Sert à distinguer, dans les résultats de recherche Qlik, les produits que nous
|
||||
* référençons (« Chez moi ») de ceux qui n'existent que dans le réseau. Un code
|
||||
* absent de la Map = produit inconnu de FF Nancy.
|
||||
*/
|
||||
export async function pgGetArticlesByCodeCentrale(codes: string[]): Promise<Map<string, PgArticleLocal>> {
|
||||
const unique = [...new Set(codes.map(c => String(c ?? "").trim()).filter(Boolean))];
|
||||
if (unique.length === 0) return new Map();
|
||||
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT DISTINCT ON (a.artcentrale)
|
||||
TRIM(a.codein::text) AS codein,
|
||||
a.artcentrale AS "codeCentrale",
|
||||
a.libelle1,
|
||||
af.code AS codefou,
|
||||
fi.nom AS nomfou,
|
||||
pa.pa,
|
||||
ai.prix_vente_mini AS pv_central
|
||||
FROM articles a
|
||||
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
|
||||
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
|
||||
WHERE TRIM(a.artcentrale::text) IN (${sql.join(unique.map(c => sql`${c}`), sql`, `)})
|
||||
AND a.codein IS NOT NULL
|
||||
ORDER BY a.artcentrale, af.no_id
|
||||
`);
|
||||
|
||||
const map = new Map<string, PgArticleLocal>();
|
||||
for (const r of result.rows as unknown as PgArticleLocal[]) {
|
||||
if (r.codeCentrale) map.set(String(r.codeCentrale).trim(), r);
|
||||
}
|
||||
console.log(`[pg-ff] pgGetArticlesByCodeCentrale: ${map.size}/${unique.length} codes connus localement`);
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Données mensuelles (ventes + stock + réceptions)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1426,29 +1399,28 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
|
||||
return Number(resumeItem?.franco_ht ?? resumeItem?.franco ?? 0);
|
||||
};
|
||||
|
||||
const details = await Promise.allSettled(
|
||||
uniqueCodeFous.map(async codefou => {
|
||||
// 1. Essai sans filtre site
|
||||
try {
|
||||
const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, ffFetchInit());
|
||||
if (res2.ok) {
|
||||
const detail = await res2.json();
|
||||
const f = extractFranco(detail);
|
||||
if (f > 0) return { codefou, franco: f };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
// 2. Fallback site=000
|
||||
try {
|
||||
const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, ffFetchInit());
|
||||
if (res3.ok) {
|
||||
const detail = await res3.json();
|
||||
const f = extractFranco(detail);
|
||||
if (f > 0) return { codefou, franco: f };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { codefou, franco: 0 };
|
||||
})
|
||||
);
|
||||
// 6 fournisseurs à la fois : chacun peut coûter deux appels successifs.
|
||||
const details = await parLots(uniqueCodeFous, 6, async (codefou) => {
|
||||
// 1. Essai sans filtre site
|
||||
try {
|
||||
const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, ffFetchInit());
|
||||
if (res2.ok) {
|
||||
const detail = await res2.json();
|
||||
const f = extractFranco(detail);
|
||||
if (f > 0) return { codefou, franco: f };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
// 2. Fallback site=000
|
||||
try {
|
||||
const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, ffFetchInit());
|
||||
if (res3.ok) {
|
||||
const detail = await res3.json();
|
||||
const f = extractFranco(detail);
|
||||
if (f > 0) return { codefou, franco: f };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { codefou, franco: 0 };
|
||||
});
|
||||
|
||||
// Map codefou → franco enrichi
|
||||
const francoMap = new Map<string, number>();
|
||||
@@ -1490,8 +1462,14 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
|
||||
* Clé de la Map : `${codefou}|${site}`. Map vide en cas d'erreur
|
||||
* (l'UI affichera alors « échéance inconnue »).
|
||||
*/
|
||||
export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string, string>> {
|
||||
export async function pgGetDerniereReceptionParFournisseur(codes?: string[]): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>();
|
||||
// Liste fournie (fournisseurs du cadencier) : on n'agrège que leurs articles,
|
||||
// au lieu de tout l'historique de réceptions de tous les fournisseurs.
|
||||
if (codes && codes.length === 0) return map;
|
||||
const filtreFournisseurs = codes
|
||||
? sql`AND TRIM(af.code) IN (${sql.join(codes.map((c) => sql`${c.trim()}`), sql`, `)})`
|
||||
: sql``;
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT
|
||||
@@ -1504,6 +1482,7 @@ export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string
|
||||
WHERE m.genremvt IN (1, 2)
|
||||
AND m.site IN ('292', '579')
|
||||
AND m.datmvt IS NOT NULL
|
||||
${filtreFournisseurs}
|
||||
GROUP BY af.code, m.site
|
||||
`);
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* CollectFlow — Chromium et file d'attente partagés par tous les accès Qlik
|
||||
* (extraction réseau `qlik-playwright.ts`, recherche produit `qlik-search.ts`).
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { chromium, type Browser } from "playwright-core";
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
/**
|
||||
* Chromium partagé entre les extractions.
|
||||
*
|
||||
* Un navigateur tombé (plantage, mémoire, arrêt du processus enfant) restait
|
||||
* mémorisé : toutes les extractions suivantes échouaient sur « Target closed »
|
||||
* jusqu'au redémarrage du serveur. On le relance donc dès qu'il est déconnecté.
|
||||
*/
|
||||
export async function getQlikBrowser(): Promise<Browser> {
|
||||
if (browserPromise) {
|
||||
const existant = await browserPromise.catch(() => null);
|
||||
if (existant?.isConnected()) return existant;
|
||||
browserPromise = null;
|
||||
}
|
||||
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
|
||||
const lancement = chromium.launch({
|
||||
headless: true,
|
||||
executablePath: execPath || undefined,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
|
||||
});
|
||||
browserPromise = lancement;
|
||||
lancement
|
||||
.then((b) => b.on("disconnected", () => {
|
||||
console.warn("[qlik] Chromium déconnecté — il sera relancé à la prochaine extraction");
|
||||
if (browserPromise === lancement) browserPromise = null;
|
||||
}))
|
||||
.catch(() => { if (browserPromise === lancement) browserPromise = null; });
|
||||
return lancement;
|
||||
}
|
||||
|
||||
/**
|
||||
* File unique des extractions.
|
||||
*
|
||||
* Le serveur Qlik abandonne les requêtes dès qu'il est trop sollicité. Une
|
||||
* synchro manuelle lancée pendant la synchro de nuit, ou deux recherches
|
||||
* produit simultanées, ouvraient deux sessions Engine en parallèle — et les
|
||||
* deux échouaient. Les extractions passent désormais l'une après l'autre.
|
||||
*/
|
||||
let fileExtractions: Promise<unknown> = Promise.resolve();
|
||||
let extractionsEnAttente = 0;
|
||||
|
||||
export function dansLaFileQlik<T>(libelle: string, tache: () => Promise<T>): Promise<T> {
|
||||
if (extractionsEnAttente > 0) {
|
||||
console.log(`[qlik] ${libelle} : en attente de ${extractionsEnAttente} extraction(s) en cours`);
|
||||
}
|
||||
extractionsEnAttente++;
|
||||
const resultat = fileExtractions.then(tache, tache);
|
||||
fileExtractions = resultat.then(() => undefined, () => undefined).finally(() => { extractionsEnAttente--; });
|
||||
return resultat;
|
||||
}
|
||||
+27
-16
@@ -13,23 +13,10 @@
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { chromium, type Browser } from "playwright-core";
|
||||
import { getQlikBrowser as getBrowser, dansLaFileQlik as dansLaFile } from "@/lib/qlik-browser";
|
||||
import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric, type QlikMonthMetrics } from "@/lib/qlik-client";
|
||||
import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range";
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
if (!browserPromise) {
|
||||
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
|
||||
browserPromise = chromium.launch({
|
||||
headless: true,
|
||||
executablePath: execPath || undefined,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
|
||||
}).catch((e) => { browserPromise = null; throw e; });
|
||||
}
|
||||
return browserPromise;
|
||||
}
|
||||
import { assainirMetrique, controlerExtraction } from "@/lib/qlik-validation";
|
||||
|
||||
interface InPageResult {
|
||||
ok: boolean;
|
||||
@@ -86,7 +73,17 @@ function uniqueOrdered(values: number[]): number[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function fetchNetworkMetricsPlaywright(
|
||||
export function fetchNetworkMetricsPlaywright(
|
||||
codeCentraux?: string[],
|
||||
cfg: QlikConfig = getQlikConfig(),
|
||||
dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(),
|
||||
fournisseur?: string,
|
||||
): Promise<Map<string, NetworkMetric>> {
|
||||
const libelle = fournisseur ? `fournisseur ${fournisseur}` : `${(codeCentraux ?? []).length} code(s)`;
|
||||
return dansLaFile(libelle, () => extraire(codeCentraux, cfg, dateFilter, fournisseur));
|
||||
}
|
||||
|
||||
async function extraire(
|
||||
codeCentraux?: string[],
|
||||
cfg: QlikConfig = getQlikConfig(),
|
||||
dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(),
|
||||
@@ -2477,6 +2474,20 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
if (exVar) console.log(`[qlik-pw] exemple VARIE ${exVar.codeCentrale}:`, JSON.stringify(exVar.qteByMonth));
|
||||
if (exFlat) console.log(`[qlik-pw] exemple IDENTIQUE ${exFlat.codeCentrale}:`, JSON.stringify(exFlat.qteByMonth));
|
||||
console.log(`[qlik-pw] ${out.size} produits réseau (cube ${result.size})`);
|
||||
|
||||
// Contrôle de plausibilité AVANT toute écriture : des données complètes
|
||||
// mais fausses (dernier mois pas encore chargé, mesure insensible au
|
||||
// mois) laisseraient le cache faux jusqu'à la synchro suivante.
|
||||
const corrections = [...out.values()].flatMap(assainirMetrique);
|
||||
if (corrections.length) {
|
||||
console.warn(`[qlik-pw] ${corrections.length} valeur(s) impossible(s) corrigée(s) : ${corrections.slice(0, 5).join(" ; ")}`);
|
||||
}
|
||||
if (dateFilter) {
|
||||
const controle = controlerExtraction(out.values(), monthRanges.map((m) => m.label));
|
||||
for (const a of controle.avertissements) console.warn(`[qlik-pw][contrôle] ${a}`);
|
||||
if (!controle.ok) throw new Error(`[qlik-pw] ${controle.motif} — cache inchangé`);
|
||||
console.log(`[qlik-pw][contrôle] extraction validée (${out.size} codes, ${monthRanges.length} mois)`);
|
||||
}
|
||||
// Échantillon brut pour vérifier que les 4 nouvelles mesures (cols 4-7) renvoient des valeurs
|
||||
const sample = (result.rows ?? []).slice(0, 3).map((r) => ({
|
||||
code: r[0], ca: r[1], qte: r[2], nbMag: r[3], caMag: r[4], margePct: r[5],
|
||||
|
||||
+14
-16
@@ -35,7 +35,7 @@
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { chromium, type Browser } from "playwright-core";
|
||||
import { getQlikBrowser as getBrowser, dansLaFileQlik } from "@/lib/qlik-browser";
|
||||
import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client";
|
||||
import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
||||
|
||||
@@ -112,20 +112,6 @@ const CHAMPS_FOURNISSEUR_PREFERES = ["Fournisseur", "code_fournisseur"];
|
||||
*/
|
||||
const CHAMP_CODE_CENTRALE_ALT = "article_no_centrale";
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
if (!browserPromise) {
|
||||
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
|
||||
browserPromise = chromium.launch({
|
||||
headless: true,
|
||||
executablePath: execPath || undefined,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
|
||||
}).catch((e) => { browserPromise = null; throw e; });
|
||||
}
|
||||
return browserPromise;
|
||||
}
|
||||
|
||||
interface InPageSearchResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
@@ -152,10 +138,22 @@ interface InPageSearchResult {
|
||||
* ensuite extraites par `fetchNetworkMetricsPlaywright`, qui sait déjà produire
|
||||
* le détail mensuel sur 12 mois glissants.
|
||||
*/
|
||||
export async function searchQlikArticles(
|
||||
export function searchQlikArticles(
|
||||
term: string,
|
||||
limit: number = QLIK_SEARCH_MAX_RESULTS,
|
||||
cfg: QlikConfig = getQlikConfig(),
|
||||
): Promise<QlikSearchOutcome> {
|
||||
// Terme trop court : réponse vide immédiate, sans attendre la file.
|
||||
if (term.trim().length < 3) return rechercher(term, limit, cfg);
|
||||
// Même file que les extractions : deux sessions Engine simultanées font
|
||||
// abandonner le serveur Qlik, et les deux requêtes échouent.
|
||||
return dansLaFileQlik(`recherche « ${term.trim()} »`, () => rechercher(term, limit, cfg));
|
||||
}
|
||||
|
||||
async function rechercher(
|
||||
term: string,
|
||||
limit: number,
|
||||
cfg: QlikConfig,
|
||||
): Promise<QlikSearchOutcome> {
|
||||
const cleaned = term.trim();
|
||||
if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null, periode: null };
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* CollectFlow — Contrôles de plausibilité d'une extraction réseau Qlik.
|
||||
*
|
||||
* L'extracteur vérifie déjà que les 12 mois de la fenêtre sont servis. Il ne
|
||||
* peut pas voir deux pièges, observés ou attendus en production, qui donnent
|
||||
* des chiffres faux mais complets :
|
||||
*
|
||||
* 1. **Dernier mois pas encore chargé dans Qlik.** Les premiers jours du mois,
|
||||
* le mois qui vient de se terminer peut n'être chargé qu'en partie. Il est
|
||||
* « couvert » (quelques ventes), donc accepté — et toutes les tendances du
|
||||
* fournisseur plongent d'un coup, jusqu'à la synchro suivante, une semaine
|
||||
* ou plus après.
|
||||
* 2. **Mesure insensible au mois.** Quand une mesure ignore la sélection de
|
||||
* dates, chaque mois reçoit la même valeur (le total de l'année). Le
|
||||
* diagnostic existait dans les journaux ; rien n'empêchait l'écriture.
|
||||
*
|
||||
* Dans les deux cas, garder le cache précédent vaut mieux qu'écrire des
|
||||
* données fausses : l'extraction est refusée, et le planificateur la reprend
|
||||
* une nuit suivante.
|
||||
*
|
||||
* Module pur (aucune dépendance d'exécution) : testable hors serveur.
|
||||
*/
|
||||
|
||||
import type { NetworkMetric } from "@/lib/qlik-client";
|
||||
|
||||
export interface ControleExtraction {
|
||||
ok: boolean;
|
||||
/** Raison du refus, lisible par l'administrateur (statut de synchro). */
|
||||
motif?: string;
|
||||
/** Anomalies corrigées ou tolérées, à journaliser. */
|
||||
avertissements: string[];
|
||||
}
|
||||
|
||||
export interface OptionsControle {
|
||||
/**
|
||||
* Jusqu'à quel jour du mois le dernier mois est soupçonné d'être incomplet.
|
||||
* Au-delà, une baisse est prise pour ce qu'elle est : une baisse.
|
||||
*/
|
||||
jourLimiteDernierMois?: number;
|
||||
/** Part minimale du dernier mois par rapport à la moyenne des 3 précédents. */
|
||||
ratioDernierMoisMin?: number;
|
||||
/** Volume mensuel moyen en dessous duquel le contrôle n'a pas de sens. */
|
||||
volumeMinimal?: number;
|
||||
/** Part maximale de séries plates (même valeur 12 mois) parmi les séries vendues. */
|
||||
partSeriesPlatesMax?: number;
|
||||
}
|
||||
|
||||
const DEFAUTS: Required<OptionsControle> = {
|
||||
jourLimiteDernierMois: 10,
|
||||
ratioDernierMoisMin: 0.4,
|
||||
volumeMinimal: 30,
|
||||
partSeriesPlatesMax: 0.5,
|
||||
};
|
||||
|
||||
const fini = (v: unknown): number => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Corrige sur place les valeurs impossibles d'une métrique (NaN, infini,
|
||||
* nombre de magasins négatif) et renvoie la liste des corrections.
|
||||
*/
|
||||
export function assainirMetrique(m: NetworkMetric): string[] {
|
||||
const corrections: string[] = [];
|
||||
const champs = ["caReseau", "qteReseau", "nbMagasinsReseau", "caParMagasinReseau", "margePctReseau"] as const;
|
||||
for (const champ of champs) {
|
||||
if (!Number.isFinite(Number(m[champ]))) {
|
||||
corrections.push(`${m.codeCentrale} : ${champ} non numérique (${String(m[champ])}) remplacé par 0`);
|
||||
m[champ] = 0;
|
||||
}
|
||||
}
|
||||
if (m.nbMagasinsReseau < 0) {
|
||||
corrections.push(`${m.codeCentrale} : nombre de magasins négatif (${m.nbMagasinsReseau}) remplacé par 0`);
|
||||
m.nbMagasinsReseau = 0;
|
||||
}
|
||||
m.nbMagasinsReseau = Math.round(m.nbMagasinsReseau);
|
||||
m.caParMagasinReseau = m.nbMagasinsReseau > 0 ? m.caReseau / m.nbMagasinsReseau : 0;
|
||||
|
||||
if (m.qteByMonth) {
|
||||
for (const [mois, v] of Object.entries(m.qteByMonth)) {
|
||||
if (!Number.isFinite(Number(v))) {
|
||||
corrections.push(`${m.codeCentrale} : quantité ${mois} non numérique remplacée par 0`);
|
||||
m.qteByMonth[mois] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.metricsByMonth) {
|
||||
for (const [mois, v] of Object.entries(m.metricsByMonth)) {
|
||||
m.metricsByMonth[mois] = {
|
||||
qte: fini(v?.qte),
|
||||
ca: fini(v?.ca),
|
||||
nbMag: Math.max(0, Math.round(fini(v?.nbMag))),
|
||||
caMag: fini(v?.caMag),
|
||||
margePct: fini(v?.margePct),
|
||||
};
|
||||
}
|
||||
}
|
||||
return corrections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contrôle d'ensemble d'une extraction 12 mois, AVANT écriture du cache.
|
||||
*
|
||||
* `fenetre` : les mois attendus (« YYYY-MM »), du plus ancien au plus récent.
|
||||
*/
|
||||
export function controlerExtraction(
|
||||
metriques: Iterable<NetworkMetric>,
|
||||
fenetre: string[],
|
||||
now: Date = new Date(),
|
||||
options: OptionsControle = {},
|
||||
): ControleExtraction {
|
||||
const o = { ...DEFAUTS, ...options };
|
||||
const avertissements: string[] = [];
|
||||
const liste = [...metriques];
|
||||
if (liste.length === 0 || fenetre.length < 4) return { ok: true, avertissements };
|
||||
|
||||
// ── 1. Séries plates : la mesure a ignoré la sélection de mois ──────────
|
||||
let vendues = 0;
|
||||
let plates = 0;
|
||||
for (const m of liste) {
|
||||
const valeurs = fenetre.map((mois) => fini(m.qteByMonth?.[mois]));
|
||||
if (!valeurs.some((v) => v !== 0)) continue;
|
||||
vendues++;
|
||||
if (valeurs.every((v) => v === valeurs[0])) plates++;
|
||||
}
|
||||
if (vendues >= 5 && plates / vendues > o.partSeriesPlatesMax) {
|
||||
return {
|
||||
ok: false,
|
||||
motif: `${plates} séries sur ${vendues} ont la même quantité les 12 mois : la mesure Qlik ignore le mois, données refusées`,
|
||||
avertissements,
|
||||
};
|
||||
}
|
||||
if (plates > 0) avertissements.push(`${plates} série(s) identique(s) sur 12 mois parmi ${vendues} vendues`);
|
||||
|
||||
// ── 2. Dernier mois manifestement incomplet ─────────────────────────────
|
||||
const totaux = fenetre.map((mois) => liste.reduce((t, m) => t + fini(m.qteByMonth?.[mois]), 0));
|
||||
const dernier = totaux[totaux.length - 1];
|
||||
const precedents = totaux.slice(-4, -1);
|
||||
const reference = precedents.reduce((t, v) => t + v, 0) / precedents.length;
|
||||
if (
|
||||
now.getDate() <= o.jourLimiteDernierMois &&
|
||||
reference >= o.volumeMinimal &&
|
||||
dernier < reference * o.ratioDernierMoisMin
|
||||
) {
|
||||
const pct = Math.round((dernier / reference) * 100);
|
||||
return {
|
||||
ok: false,
|
||||
motif: `le dernier mois (${fenetre[fenetre.length - 1]}) ne pèse que ${pct} % des 3 mois précédents : `
|
||||
+ `probablement pas encore entièrement chargé dans Qlik, données refusées (nouvel essai une prochaine nuit)`,
|
||||
avertissements,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, avertissements };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { createJSONStorage, type PersistStorage, type StorageValue } from "zustand/middleware";
|
||||
|
||||
/**
|
||||
* Stockage `persist` de zustand qui n'écrit dans le localStorage qu'au plus une
|
||||
* fois par `delaiMs`.
|
||||
*
|
||||
* `persist` réécrit (et resérialise) l'état à chaque `set`, quel que soit le
|
||||
* champ modifié : deux fois par changement de gamme, et à chaque mouvement de
|
||||
* souris pendant qu'on élargit une colonne. Ici, seule la dernière valeur est
|
||||
* écrite, et elle l'est aussitôt quand la page est quittée ou masquée.
|
||||
*/
|
||||
export function createThrottledJSONStorage<S>(delaiMs = 500): PersistStorage<S> | undefined {
|
||||
// Rendu serveur : même repli que zustand par défaut (rien n'est persisté).
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const inner = createJSONStorage<S>(() => window.localStorage);
|
||||
if (!inner) return undefined;
|
||||
|
||||
let enAttente: { name: string; value: StorageValue<S> } | null = null;
|
||||
let minuteur: number | null = null;
|
||||
|
||||
const ecrire = () => {
|
||||
if (minuteur !== null) {
|
||||
clearTimeout(minuteur);
|
||||
minuteur = null;
|
||||
}
|
||||
if (!enAttente) return;
|
||||
const { name, value } = enAttente;
|
||||
enAttente = null;
|
||||
try {
|
||||
inner.setItem(name, value);
|
||||
} catch {
|
||||
/* quota dépassé ou stockage bloqué : ignoré, comme avant */
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pagehide", ecrire);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") ecrire();
|
||||
});
|
||||
|
||||
return {
|
||||
getItem: (name) => (enAttente?.name === name ? enAttente.value : inner.getItem(name)),
|
||||
setItem: (name, value) => {
|
||||
enAttente = { name, value };
|
||||
if (minuteur === null) minuteur = window.setTimeout(ecrire, delaiMs);
|
||||
},
|
||||
removeItem: (name) => {
|
||||
if (enAttente?.name === name) enAttente = null;
|
||||
inner.removeItem(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
next.config.ts(9,3): error TS2353: Object literal may only specify known properties, and 'eslint' does not exist in type 'NextConfig'.
|
||||
src/features/grid/components/supplier-ai-context-modal.tsx(4,24): error TS2307: Cannot find module '@/components/ui/button' or its corresponding type declarations.
|
||||
src/features/grid/components/supplier-ai-context-modal.tsx(5,114): error TS2307: Cannot find module '@/components/ui/dialog' or its corresponding type declarations.
|
||||
src/features/grid/components/supplier-ai-context-modal.tsx(6,26): error TS2307: Cannot find module '@/components/ui/textarea' or its corresponding type declarations.
|
||||
src/features/grid/components/supplier-ai-context-modal.tsx(87,36): error TS7006: Parameter 'e' implicitly has an 'any' type.
|
||||
src/lib/mock-data.ts(66,19): error TS2345: Argument of type '{ codein: string; codeFournisseur: string; nomFournisseur: string; libelle1: string; gtin: string; reference: string; code3: string; libelle3: string; codeGamme: string; codeGammeDraft: null; sales12m: Record<...>; ... 5 more ...; aiRecommendation: null; }' is not assignable to parameter of type 'ProductRow'.
|
||||
Type '{ codein: string; codeFournisseur: string; nomFournisseur: string; libelle1: string; gtin: string; reference: string; code3: string; libelle3: string; codeGamme: string; codeGammeDraft: null; sales12m: Record<...>; ... 5 more ...; aiRecommendation: null; }' is missing the following properties from type 'ProductRow': code1, libelleNiveau1, code2, libelleNiveau2, and 2 more.
|
||||
Reference in new issue
Block a user