mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Merge pull request #4 from R0m1k3/feat/qlik-network
feat(grid): replace ranking/AI/score with Qlik network data
This commit is contained in:
34 files changed
+1043
-2925
No files matched your search
@@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
.playwright-mcp/
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Intégration Qlik Sense — données réseau (~270 magasins)
|
||||||
|
|
||||||
|
Remplace les heuristiques (ranking / IA / score) par les vraies données réseau
|
||||||
|
La Foir'Fouille : **CA réseau, Qté vendue réseau, Nb magasins** par produit.
|
||||||
|
|
||||||
|
## Architecture livrée
|
||||||
|
|
||||||
|
| Élément | Fichier |
|
||||||
|
|---------|---------|
|
||||||
|
| Connecteur Qlik (NTLM + QIX websocket) | `src/lib/qlik-client.ts` |
|
||||||
|
| Cache lecture/upsert | `src/lib/qlik-network-cache.ts` |
|
||||||
|
| Table cache | `qlik_network_metrics` (`src/db/schema.ts`, créée par `scripts/db-init.js`) |
|
||||||
|
| Route de sync (admin) | `POST /api/qlik/sync` (`src/app/api/qlik/sync/route.ts`) |
|
||||||
|
| Bouton UI | `src/features/grid/components/sync-qlik-button.tsx` |
|
||||||
|
| Jointure grille | `enrichWithNetworkMetrics()` dans `src/features/grid/api/get-product-rows.ts` (Phase 8) |
|
||||||
|
| Colonnes grille | CA réseau / Qté réseau / Magasins (/270) / % présence (`heatmap-grid.tsx`) |
|
||||||
|
|
||||||
|
Retirés : ranking (champs/query/colonnes), analyse IA (routes `/api/ai/*`, `bulk-ai-analyzer`,
|
||||||
|
dossier `ai-copilot`), score (`score-engine.ts`, colonne score).
|
||||||
|
|
||||||
|
## Auth — NTLM + ticket SSO (✅ TESTÉ, marche)
|
||||||
|
|
||||||
|
Flux implémenté dans `qlik-client.ts` / `scripts/qlik-discover.mjs` :
|
||||||
|
1. `GET /hub/` (non authentifié) → 302, on extrait le `targetId`.
|
||||||
|
2. **NTLM** (login `FFSCH`, **domaine vide**, pas `FOIRFOUILLE`) sur
|
||||||
|
`/internal_windows_authentication/?targetId=…` → 302 vers `/hub/?qlikTicket=XXX`.
|
||||||
|
3. `GET /hub/?qlikTicket=XXX` → Qlik pose le cookie **`X-Qlik-Session`**.
|
||||||
|
4. Cookie + Xrfkey → QRS REST (✅) et Engine websocket.
|
||||||
|
|
||||||
|
⚠️ Domaine NTLM **vide** (comme `requests_ntlm("FFSCH", pwd)`). Forcer `FOIRFOUILLE` → 401.
|
||||||
|
Testé depuis l'extérieur : auth + QRS OK, **147 apps** côté admin (2 visibles pour FFSCH).
|
||||||
|
|
||||||
|
## Discovery (✅ FAITE)
|
||||||
|
|
||||||
|
**App réseau article-niveau = "Magasins Vision Consolidée"** (stream Magasin Big Data) :
|
||||||
|
- `QLIK_APP_NETWORK = 9872ee6e-d64a-4b43-984a-076bf1f7f647`
|
||||||
|
- dim `QLIK_DIM_CODE_ARTICLE_ID = fcd239e5-288b-4830-a047-0e3d7665d971` (**Article Code**)
|
||||||
|
- CA `QLIK_MEAS_CA_ID = 43a76088-86fa-402e-a80e-0efd7701b3e1` (**CA N**)
|
||||||
|
- Qté `QLIK_MEAS_QTE_ID = 7b40caf1-be4b-4811-8d45-50acde33e715` (**Quantité N**)
|
||||||
|
- nb mag `QLIK_MEAS_NBMAG_ID = 8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b` (**Magasin Ventes Nb N**)
|
||||||
|
|
||||||
|
Ces GUID sont déjà les **défauts** dans `qlik-client.ts` (hypercube par `qLibraryId`).
|
||||||
|
(App "CA Foirfouille" stream Réseau = `65b3ad21-…` : pas de dimension article → écartée.)
|
||||||
|
|
||||||
|
⚠️ **À confirmer par un échantillon de hypercube** (5 lignes) : que `Article Code` = code
|
||||||
|
centrale `10000XXXXXX`, et que `Magasin Ventes Nb N` = nb magasins vendeurs. L'agent hermes
|
||||||
|
(Playwright) peut le faire.
|
||||||
|
|
||||||
|
### Limite extraction depuis l'extérieur
|
||||||
|
Le **websocket Engine renvoie 403** depuis un environnement externe (le proxy Qlik refuse
|
||||||
|
l'upgrade ws hors contexte navigateur ; l'extraction hermes qui marche réutilise une session
|
||||||
|
Playwright). Sur le **réseau corporate** (où tournera CollectFlow / où `/hub/` renvoie 401
|
||||||
|
NTLM direct), le ws raw devrait passer. À valider en déployant `POST /api/qlik/sync` sur leur
|
||||||
|
infra interne.
|
||||||
|
|
||||||
|
## Variables d'environnement (`.env.local`)
|
||||||
|
|
||||||
|
```
|
||||||
|
QLIK_HOST=reporting-magasins.lafoirfouille.fr
|
||||||
|
QLIK_USER=FFSCH
|
||||||
|
QLIK_PWD=<mot de passe courant>
|
||||||
|
QLIK_DOMAIN= # VIDE (ne pas mettre FOIRFOUILLE — NTLM sans domaine)
|
||||||
|
QLIK_TLS_INSECURE=true
|
||||||
|
# Défauts déjà en dur dans qlik-client.ts (discovery 2026-06-20) :
|
||||||
|
QLIK_APP_NETWORK=9872ee6e-d64a-4b43-984a-076bf1f7f647
|
||||||
|
QLIK_DIM_CODE_ARTICLE_ID=fcd239e5-288b-4830-a047-0e3d7665d971
|
||||||
|
QLIK_MEAS_CA_ID=43a76088-86fa-402e-a80e-0efd7701b3e1
|
||||||
|
QLIK_MEAS_QTE_ID=7b40caf1-be4b-4811-8d45-50acde33e715
|
||||||
|
QLIK_MEAS_NBMAG_ID=8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b
|
||||||
|
```
|
||||||
|
|
||||||
|
## RESTE À FAIRE
|
||||||
|
|
||||||
|
1. **Confirmer la sémantique des champs** (échantillon hypercube, via l'agent hermes/Playwright
|
||||||
|
ou en déployant `/api/qlik/sync` en interne) : `Article Code` = code centrale `10000XXXXXX` ?
|
||||||
|
`Magasin Ventes Nb N` = nb magasins vendeurs ? App bien réseau (270 magasins) ?
|
||||||
|
|
||||||
|
2. **Exécuter le sync depuis le réseau interne** : le websocket Engine renvoie 403 depuis
|
||||||
|
l'extérieur. Déployer `POST /api/qlik/sync` sur l'infra interne FF pour valider l'extraction.
|
||||||
|
|
||||||
|
3. **Code centrale = `articles.artcentrale`** ✅ RÉSOLU. Confirmé via l'API FF
|
||||||
|
`GET /api/articles/{no_id}` → champ `artcentrale` (ex `"10000167303"` = `10000`+6 chiffres).
|
||||||
|
Ajouté au SELECT de `pgGetArticlesByFournisseur` (`a.artcentrale AS "codeCentrale"`) → propagé
|
||||||
|
dans `ProductRow.codeCentrale` → jointure `enrichWithNetworkMetrics`. ⚠️ Vide pour la plupart
|
||||||
|
des articles (seuls les référencés centralement en ont un). À valider que la valeur Qlik
|
||||||
|
"Article Code" est bien au même format sur le premier vrai sync.
|
||||||
|
|
||||||
|
Tant que le sync interne n'est pas exécuté, la grille fonctionne en **dégradation propre** :
|
||||||
|
colonnes réseau vides, aucune erreur.
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
serverExternalPackages: ["pg"],
|
serverExternalPackages: ["pg", "ws", "httpntlm"],
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: "50mb",
|
bodySizeLimit: "50mb",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
|
"httpntlm": "^1.8.13",
|
||||||
"jspdf": "^4.2.0",
|
"jspdf": "^4.2.0",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"ws": "^8.21.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
|
|||||||
Generated
+96
-4
@@ -38,6 +38,9 @@ importers:
|
|||||||
exceljs:
|
exceljs:
|
||||||
specifier: ^4.4.0
|
specifier: ^4.4.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
|
httpntlm:
|
||||||
|
specifier: ^1.8.13
|
||||||
|
version: 1.8.13
|
||||||
jspdf:
|
jspdf:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
@@ -77,6 +80,9 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
|
ws:
|
||||||
|
specifier: ^8.21.0
|
||||||
|
version: 8.21.0
|
||||||
xlsx:
|
xlsx:
|
||||||
specifier: ^0.18.5
|
specifier: ^0.18.5
|
||||||
version: 0.18.5
|
version: 0.18.5
|
||||||
@@ -102,6 +108,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19
|
specifier: ^19
|
||||||
version: 19.2.3(@types/react@19.2.14)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
|
'@types/ws':
|
||||||
|
specifier: ^8.18.1
|
||||||
|
version: 8.18.1
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: ^0.31.9
|
specifier: ^0.31.9
|
||||||
version: 0.31.10
|
version: 0.31.10
|
||||||
@@ -895,89 +904,105 @@ packages:
|
|||||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-linux-arm64@0.34.5':
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-arm@0.34.5':
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-ppc64@0.34.5':
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-riscv64@0.34.5':
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-s390x@0.34.5':
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-x64@0.34.5':
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-wasm32@0.34.5':
|
'@img/sharp-wasm32@0.34.5':
|
||||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||||
@@ -1093,24 +1118,28 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.1.6':
|
'@next/swc-linux-arm64-musl@16.1.6':
|
||||||
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
|
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.1.6':
|
'@next/swc-linux-x64-gnu@16.1.6':
|
||||||
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
|
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.1.6':
|
'@next/swc-linux-x64-musl@16.1.6':
|
||||||
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
|
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.1.6':
|
'@next/swc-win32-arm64-msvc@16.1.6':
|
||||||
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
|
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
|
||||||
@@ -1945,24 +1974,28 @@ packages:
|
|||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
|
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
|
||||||
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
|
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
||||||
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
|
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
|
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
|
||||||
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
|
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
|
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
|
||||||
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
|
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
|
||||||
@@ -2097,6 +2130,9 @@ packages:
|
|||||||
'@types/validate-npm-package-name@4.0.2':
|
'@types/validate-npm-package-name@4.0.2':
|
||||||
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
|
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.59.3':
|
'@typescript-eslint/eslint-plugin@8.59.3':
|
||||||
resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==}
|
resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -2198,41 +2234,49 @@ packages:
|
|||||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||||
@@ -2752,6 +2796,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
des.js@1.1.0:
|
||||||
|
resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==}
|
||||||
|
|
||||||
detect-libc@2.1.2:
|
detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3440,6 +3487,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
httpntlm@1.8.13:
|
||||||
|
resolution: {integrity: sha512-2F2FDPiWT4rewPzNMg3uPhNkP3NExENlUGADRUDPQvuftuUTGW98nLZtGemCIW3G40VhWZYgkIDcQFAwZ3mf2Q==}
|
||||||
|
engines: {node: '>=10.4.0'}
|
||||||
|
|
||||||
|
httpreq@1.1.1:
|
||||||
|
resolution: {integrity: sha512-uhSZLPPD2VXXOSN8Cni3kIsoFHaU2pT/nySEU/fHr/ePbqHYr0jeiQRmUKLEirC09SFPsdMoA7LU7UXMd/w0Kw==}
|
||||||
|
engines: {node: '>= 6.15.1'}
|
||||||
|
|
||||||
https-proxy-agent@7.0.6:
|
https-proxy-agent@7.0.6:
|
||||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
@@ -3705,6 +3760,9 @@ packages:
|
|||||||
jose@6.2.3:
|
jose@6.2.3:
|
||||||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||||
|
|
||||||
|
js-md4@0.3.2:
|
||||||
|
resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -3838,24 +3896,28 @@ packages:
|
|||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.32.0:
|
lightningcss-linux-arm64-musl@1.32.0:
|
||||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.32.0:
|
lightningcss-linux-x64-gnu@1.32.0:
|
||||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.32.0:
|
lightningcss-linux-x64-musl@1.32.0:
|
||||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.32.0:
|
lightningcss-win32-arm64-msvc@1.32.0:
|
||||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||||
@@ -4122,6 +4184,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
minimalistic-assert@1.0.1:
|
||||||
|
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||||
|
|
||||||
minimatch@10.2.5:
|
minimatch@10.2.5:
|
||||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@@ -5072,6 +5137,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
underscore@1.12.1:
|
||||||
|
resolution: {integrity: sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
@@ -5222,8 +5290,8 @@ packages:
|
|||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
ws@8.20.1:
|
ws@8.21.0:
|
||||||
resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==}
|
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
bufferutil: ^4.0.1
|
bufferutil: ^4.0.1
|
||||||
@@ -5898,7 +5966,7 @@ snapshots:
|
|||||||
google-auth-library: 10.6.2
|
google-auth-library: 10.6.2
|
||||||
p-retry: 4.6.2
|
p-retry: 4.6.2
|
||||||
protobufjs: 7.5.8
|
protobufjs: 7.5.8
|
||||||
ws: 8.20.1
|
ws: 8.21.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
|
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -7148,6 +7216,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/validate-npm-package-name@4.0.2': {}
|
'@types/validate-npm-package-name@4.0.2': {}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.19.41
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -7800,6 +7872,11 @@ snapshots:
|
|||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
|
des.js@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
inherits: 2.0.4
|
||||||
|
minimalistic-assert: 1.0.1
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
detect-node-es@1.1.0: {}
|
detect-node-es@1.1.0: {}
|
||||||
@@ -8699,6 +8776,15 @@ snapshots:
|
|||||||
statuses: 2.0.2
|
statuses: 2.0.2
|
||||||
toidentifier: 1.0.1
|
toidentifier: 1.0.1
|
||||||
|
|
||||||
|
httpntlm@1.8.13:
|
||||||
|
dependencies:
|
||||||
|
des.js: 1.1.0
|
||||||
|
httpreq: 1.1.1
|
||||||
|
js-md4: 0.3.2
|
||||||
|
underscore: 1.12.1
|
||||||
|
|
||||||
|
httpreq@1.1.1: {}
|
||||||
|
|
||||||
https-proxy-agent@7.0.6:
|
https-proxy-agent@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
@@ -8930,6 +9016,8 @@ snapshots:
|
|||||||
|
|
||||||
jose@6.2.3: {}
|
jose@6.2.3: {}
|
||||||
|
|
||||||
|
js-md4@0.3.2: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
@@ -9516,6 +9604,8 @@ snapshots:
|
|||||||
|
|
||||||
mimic-function@5.0.1: {}
|
mimic-function@5.0.1: {}
|
||||||
|
|
||||||
|
minimalistic-assert@1.0.1: {}
|
||||||
|
|
||||||
minimatch@10.2.5:
|
minimatch@10.2.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.6
|
brace-expansion: 5.0.6
|
||||||
@@ -10705,6 +10795,8 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
which-boxed-primitive: 1.1.1
|
which-boxed-primitive: 1.1.1
|
||||||
|
|
||||||
|
underscore@1.12.1: {}
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
unicorn-magic@0.3.0: {}
|
unicorn-magic@0.3.0: {}
|
||||||
@@ -10902,7 +10994,7 @@ snapshots:
|
|||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
ws@8.20.1: {}
|
ws@8.21.0: {}
|
||||||
|
|
||||||
wsl-utils@0.3.1:
|
wsl-utils@0.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ async function main() {
|
|||||||
`);
|
`);
|
||||||
console.log("[DB Init] Table commande_cadences is verified/created.");
|
console.log("[DB Init] Table commande_cadences is verified/created.");
|
||||||
|
|
||||||
|
await tempPool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "qlik_network_metrics" (
|
||||||
|
"code_centrale" varchar(20) PRIMARY KEY NOT NULL,
|
||||||
|
"ca_reseau" numeric(16, 2),
|
||||||
|
"qte_reseau" numeric(14, 2),
|
||||||
|
"nb_magasins_reseau" integer,
|
||||||
|
"periode" varchar(20),
|
||||||
|
"fetched_at" timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log("[DB Init] Table qlik_network_metrics is verified/created.");
|
||||||
|
|
||||||
await tempPool.end();
|
await tempPool.end();
|
||||||
console.log("[DB Init] Initialization successful. Exiting.");
|
console.log("[DB Init] Initialization successful. Exiting.");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Discovery Qlik Sense — autonome, à lancer à la main :
|
||||||
|
*
|
||||||
|
* QLIK_PWD='<mot de passe>' node scripts/qlik-discover.mjs # liste les apps
|
||||||
|
* QLIK_PWD='<mot de passe>' node scripts/qlik-discover.mjs <appGUID> # + champs & mesures de l'app
|
||||||
|
*
|
||||||
|
* Env (défauts adaptés à La Foir'Fouille) :
|
||||||
|
* QLIK_HOST=reporting-magasins.lafoirfouille.fr
|
||||||
|
* QLIK_USER=FFSCH
|
||||||
|
* QLIK_DOMAIN=FOIRFOUILLE
|
||||||
|
* QLIK_PWD=<requis>
|
||||||
|
*
|
||||||
|
* But : récupérer le GUID de l'app réseau (~270 magasins) + les noms exacts du champ
|
||||||
|
* "code article" et des mesures (CA / Qté / nb magasins) pour remplir .env.local :
|
||||||
|
* QLIK_APP_NETWORK / QLIK_DIM_CODE_ARTICLE / QLIK_MEAS_CA / QLIK_MEAS_QTE / QLIK_MEAS_NB_MAG
|
||||||
|
*/
|
||||||
|
|
||||||
|
import httpntlm from "httpntlm";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // certif Qlik interne
|
||||||
|
|
||||||
|
const HOST = process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr";
|
||||||
|
const USER = process.env.QLIK_USER || "FFSCH";
|
||||||
|
// Domaine vide par défaut : requests_ntlm("FFSCH", pwd) marche sans domaine explicite.
|
||||||
|
const DOMAIN = process.env.QLIK_DOMAIN || "";
|
||||||
|
const PWD = process.env.QLIK_PWD || "";
|
||||||
|
const APP = process.argv[2] || process.env.QLIK_APP_NETWORK || "";
|
||||||
|
|
||||||
|
if (!PWD) {
|
||||||
|
console.error("ERREUR: définis QLIK_PWD. Ex: QLIK_PWD='xxx' node scripts/qlik-discover.mjs");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xrfkey = randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
|
||||||
|
|
||||||
|
function ntlmGet(path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
httpntlm.get(
|
||||||
|
{
|
||||||
|
url: `https://${HOST}${path}`,
|
||||||
|
username: USER,
|
||||||
|
password: PWD,
|
||||||
|
domain: DOMAIN,
|
||||||
|
workstation: "",
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
headers: { "X-Qlik-Xrfkey": xrfkey, "User-Agent": "Mozilla/5.0 CollectFlow" },
|
||||||
|
},
|
||||||
|
(err, res) => (err ? reject(err) : resolve(res)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupère un targetId en initiant une requête non authentifiée vers /hub/.
|
||||||
|
async function getTargetId() {
|
||||||
|
const res = await fetch(`https://${HOST}/hub/`, { redirect: "manual" });
|
||||||
|
const loc = res.headers.get("location") || "";
|
||||||
|
const m = loc.match(/targetId=([0-9a-f-]+)/i);
|
||||||
|
return m ? m[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flux SSO ticket Qlik :
|
||||||
|
// 1. NTLM sur /internal_windows_authentication/?targetId=... -> 302 /hub/?qlikTicket=XXX
|
||||||
|
// 2. GET /hub/?qlikTicket=XXX -> Qlik valide le ticket et pose le cookie X-Qlik-Session
|
||||||
|
async function authCookie() {
|
||||||
|
const tid = await getTargetId();
|
||||||
|
const res = await ntlmGet(`/internal_windows_authentication/?targetId=${tid}&xrfkey=${xrfkey}`);
|
||||||
|
const loc = res.headers.location || "";
|
||||||
|
const ticket = (loc.match(/qlikTicket=([^&\s]+)/) || [])[1];
|
||||||
|
console.log(` [auth] NTLM HTTP ${res.statusCode}, ticket=${ticket ? "oui" : "non"}`);
|
||||||
|
if (!ticket) throw new Error(`pas de qlikTicket (HTTP ${res.statusCode})`);
|
||||||
|
|
||||||
|
// Échange ticket -> cookies de session (on garde TOUS les cookies posés)
|
||||||
|
const r2 = await fetch(loc, { redirect: "manual" });
|
||||||
|
const sc = typeof r2.headers.getSetCookie === "function"
|
||||||
|
? r2.headers.getSetCookie()
|
||||||
|
: (r2.headers.get("set-cookie") ? [r2.headers.get("set-cookie")] : []);
|
||||||
|
const pairs = sc.map((c) => c.split(";")[0]);
|
||||||
|
const hasSession = pairs.some((c) => /X-Qlik-Session/i.test(c));
|
||||||
|
console.log(` [auth] échange ticket HTTP ${r2.status}, cookies=${JSON.stringify(pairs.map(p=>p.split("=")[0]))}`);
|
||||||
|
if (!hasSession) throw new Error("ticket non échangé contre un cookie de session");
|
||||||
|
return pairs.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// QRS via cookie de session + Xrfkey.
|
||||||
|
async function qrsGet(path, cookie) {
|
||||||
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
|
const res = await fetch(`https://${HOST}${path}${sep}xrfkey=${xrfkey}`, {
|
||||||
|
headers: { Cookie: cookie, "X-Qlik-Xrfkey": xrfkey, Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (res.status !== 200) throw new Error(`QRS ${path} HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function qrsApps(cookie) {
|
||||||
|
const apps = await qrsGet("/qrs/app", cookie);
|
||||||
|
return apps.map((a) => ({ id: a.id, name: a.name, published: a.published, stream: a.stream?.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function engine(appGuid, cookie) {
|
||||||
|
const ident = randomBytes(8).toString("hex");
|
||||||
|
const url = `wss://${HOST}/app/${appGuid}/identity/${ident}?Xrfkey=${xrfkey}`;
|
||||||
|
const ws = new WebSocket(url, {
|
||||||
|
headers: { Cookie: cookie, "X-Qlik-Xrfkey": xrfkey, "User-Agent": "Mozilla/5.0" },
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
let id = 1;
|
||||||
|
const pending = new Map();
|
||||||
|
const ready = new Promise((resolve, reject) => {
|
||||||
|
ws.on("message", (raw) => {
|
||||||
|
const m = JSON.parse(raw.toString());
|
||||||
|
if (m.method === "OnConnected") return resolve();
|
||||||
|
if (m.id != null && pending.has(m.id)) {
|
||||||
|
const p = pending.get(m.id);
|
||||||
|
pending.delete(m.id);
|
||||||
|
m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.on("unexpected-response", (_req, res) => {
|
||||||
|
let body = "";
|
||||||
|
res.on("data", (d) => (body += d));
|
||||||
|
res.on("end", () => reject(new Error(`ws ${res.statusCode} server=${res.headers.server} wwwauth=${res.headers["www-authenticate"]} body=${body.slice(0, 120)}`)));
|
||||||
|
});
|
||||||
|
ws.on("error", reject);
|
||||||
|
});
|
||||||
|
const rpc = (method, params, handle = -1) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const i = id++;
|
||||||
|
pending.set(i, { resolve, reject });
|
||||||
|
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
|
||||||
|
});
|
||||||
|
return { ready, rpc, close: () => ws.close() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpFieldsMeasures(appGuid, cookie) {
|
||||||
|
const e = engine(appGuid, cookie);
|
||||||
|
await e.ready;
|
||||||
|
const open = await e.rpc("OpenDoc", { qDocName: appGuid });
|
||||||
|
const doc = open.qReturn.qHandle;
|
||||||
|
|
||||||
|
// Liste des champs
|
||||||
|
const fl = await e.rpc("CreateSessionObject", {
|
||||||
|
qProp: { qInfo: { qType: "FieldList" }, qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true } },
|
||||||
|
}, doc);
|
||||||
|
const flLayout = await e.rpc("GetLayout", {}, fl.qReturn.qHandle);
|
||||||
|
const fields = (flLayout.qLayout.qFieldList.qItems || []).map((f) => f.qName);
|
||||||
|
|
||||||
|
// Liste des mesures (master items)
|
||||||
|
const ml = await e.rpc("CreateSessionObject", {
|
||||||
|
qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } } },
|
||||||
|
}, doc);
|
||||||
|
const mlLayout = await e.rpc("GetLayout", {}, ml.qReturn.qHandle);
|
||||||
|
const measures = (mlLayout.qLayout.qMeasureList.qItems || []).map((m) => ({
|
||||||
|
title: m.qMeta?.title ?? m.qData?.title,
|
||||||
|
expr: m.qData?.expr,
|
||||||
|
}));
|
||||||
|
|
||||||
|
e.close();
|
||||||
|
return { fields, measures };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
console.log(`Auth NTLM ${DOMAIN}\\${USER} @ ${HOST} ...`);
|
||||||
|
const cookie = await authCookie();
|
||||||
|
console.log("✓ Authentifié.\n");
|
||||||
|
|
||||||
|
const apps = await qrsApps(cookie);
|
||||||
|
console.log(`=== ${apps.length} apps ===`);
|
||||||
|
for (const a of apps) console.log(` ${a.id} [${a.stream || "-"}] ${a.name}`);
|
||||||
|
|
||||||
|
if (APP) {
|
||||||
|
console.log(`\n=== Champs & mesures de l'app ${APP} ===`);
|
||||||
|
const { fields, measures } = await dumpFieldsMeasures(APP, cookie);
|
||||||
|
console.log(`\n-- Champs (${fields.length}) --`);
|
||||||
|
for (const f of fields) console.log(` ${f}`);
|
||||||
|
console.log(`\n-- Mesures master (${measures.length}) --`);
|
||||||
|
for (const m of measures) console.log(` ${m.title} = ${m.expr}`);
|
||||||
|
console.log(`\n→ Repère le champ "code article", et les expressions CA / Qté / nb magasins,`);
|
||||||
|
console.log(` puis remplis QLIK_APP_NETWORK / QLIK_DIM_CODE_ARTICLE / QLIK_MEAS_* dans .env.local.`);
|
||||||
|
} else {
|
||||||
|
console.log(`\n→ Relance avec le GUID de l'app réseau pour lister ses champs & mesures :`);
|
||||||
|
console.log(` QLIK_PWD='...' node scripts/qlik-discover.mjs <appGUID>`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("ERREUR:", e.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { getSavedDatabaseConfig } from "@/features/settings/actions";
|
|
||||||
import { OpenRouterClient } from "@/features/ai-copilot/data/open-router-client";
|
|
||||||
import { ProductAnalysisInput, SiteMonthlyData } from "@/features/ai-copilot/models/ai-analysis.types";
|
|
||||||
import { getMensuelByArticles, buildLast12MonthsRange } from "@/lib/api-ff-client";
|
|
||||||
|
|
||||||
// Limite max acceptable pour la route (Node.js self-hosted).
|
|
||||||
// Evite que le process tourne indéfiniment en cas de deadlock.
|
|
||||||
export const maxDuration = 55;
|
|
||||||
|
|
||||||
const SITE_LABELS: Record<string, string> = { "292": "Frouard", "579": "Houdemont" };
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
const config = await getSavedDatabaseConfig();
|
|
||||||
const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey;
|
|
||||||
const model = config?.openRouterModel || "google/gemini-2.0-flash-001";
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
console.error("[AI] OPENROUTER_API_KEY is missing.");
|
|
||||||
return NextResponse.json({ error: "OPENROUTER_API_KEY not configured. Please set it in Settings." }, { status: 503 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body: ProductAnalysisInput = await req.json();
|
|
||||||
|
|
||||||
// Si noid est fourni, enrichir avec les données mensuelles per-site depuis FF Nancy
|
|
||||||
let enrichedBody = body;
|
|
||||||
if (body.noid) {
|
|
||||||
try {
|
|
||||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
|
||||||
const mensuelMap = await getMensuelByArticles(
|
|
||||||
[{ codein: body.codein, noid: body.noid, libelle1: body.libelle1, codefou: body.codeFournisseur ?? "" }],
|
|
||||||
dateDebut, dateFin, 1
|
|
||||||
);
|
|
||||||
const entries = mensuelMap.get(body.codein) ?? [];
|
|
||||||
const siteMonthlyData: SiteMonthlyData[] = entries
|
|
||||||
.filter(e => e.site === "292" || e.site === "579")
|
|
||||||
.map(e => ({
|
|
||||||
site: SITE_LABELS[e.site] ?? e.site,
|
|
||||||
mois: e.mois,
|
|
||||||
ventes_qte: Math.abs(parseFloat(e.ventes?.qte_vendue ?? "0") || 0),
|
|
||||||
ventes_ca: Math.abs(parseFloat(e.ventes?.ca_ht ?? "0") || 0),
|
|
||||||
marge: parseFloat(e.ventes?.marge ?? "0") || 0,
|
|
||||||
stock_fin_mois: parseFloat(e.stock_fin_mois ?? "0") || 0,
|
|
||||||
receptions_qte: Math.abs(parseFloat(e.receptions?.qte_recue ?? "0") || 0),
|
|
||||||
}));
|
|
||||||
if (siteMonthlyData.length > 0) {
|
|
||||||
enrichedBody = { ...body, siteMonthlyData };
|
|
||||||
console.log(`[AI] Enriched ${body.codein} with ${siteMonthlyData.length} site-monthly entries`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[AI] Failed to fetch mensuel for ${body.codein}:`, err);
|
|
||||||
// Dégradation gracieuse — continuer sans données mensuelles
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const FALLBACK_MODEL = "google/gemini-2.0-flash-001";
|
|
||||||
let client = new OpenRouterClient({ apiKey, model });
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await client.analyzeProduct(enrichedBody);
|
|
||||||
} catch (modelErr) {
|
|
||||||
// If configured model fails (e.g., deprecated), retry with fallback
|
|
||||||
const msg = modelErr instanceof Error ? modelErr.message : "";
|
|
||||||
if (model !== FALLBACK_MODEL && (msg.includes("400") || msg.includes("404") || msg.includes("OpenRouter error"))) {
|
|
||||||
console.warn(`[AI] Model "${model}" failed (${msg}), retrying with fallback ${FALLBACK_MODEL}`);
|
|
||||||
client = new OpenRouterClient({ apiKey, model: FALLBACK_MODEL });
|
|
||||||
result = await client.analyzeProduct(enrichedBody);
|
|
||||||
} else {
|
|
||||||
throw modelErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Error && err.message === "rate_limited") {
|
|
||||||
return NextResponse.json({ error: "rate_limited", retryAfter: 30 }, { status: 429 });
|
|
||||||
}
|
|
||||||
// Timeout explicite déclenché par l'AbortController du client
|
|
||||||
if (err instanceof Error && err.message === "timeout") {
|
|
||||||
console.warn(`[AI] Timeout for product analysis — OpenRouter too slow.`);
|
|
||||||
return NextResponse.json({ error: "timeout", retryAfter: 5 }, { status: 504 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
||||||
|
|
||||||
// Propager l'état de surcharge Open Router (502 Gateway, 503 Unavailable, 529 Overloaded)
|
|
||||||
if (msg.includes("502") || msg.includes("503") || msg.includes("529") || msg.includes("Bad Gateway") || msg.includes("overloaded")) {
|
|
||||||
console.warn(`[AI] External API overload detected: ${msg}`);
|
|
||||||
return NextResponse.json({ error: "gateway_timeout", detail: msg }, { status: 502 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: msg }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { getSavedDatabaseConfig } from "@/features/settings/actions";
|
|
||||||
|
|
||||||
const BatchAnalyzeSchema = z.object({
|
|
||||||
rayon: z.string(),
|
|
||||||
supplierTotalCa: z.number().optional(),
|
|
||||||
supplierTotalMarge: z.number().optional(),
|
|
||||||
supplierStats: z.object({
|
|
||||||
totalProducts: z.number(),
|
|
||||||
medianScore: z.number(),
|
|
||||||
scoreDistribution: z.object({
|
|
||||||
above70: z.number(),
|
|
||||||
between30and70: z.number(),
|
|
||||||
below30: z.number(),
|
|
||||||
}),
|
|
||||||
maxStoreCount: z.number(),
|
|
||||||
nomenclature2Count: z.number(),
|
|
||||||
}).optional(),
|
|
||||||
products: z.array(z.object({
|
|
||||||
codein: z.string(),
|
|
||||||
nom: z.string().nullable().optional(),
|
|
||||||
ca: z.number().nullable().optional(),
|
|
||||||
adjustedCaWeight: z.number().optional().default(0),
|
|
||||||
weightInNomenclature2: z.number().optional().default(0),
|
|
||||||
nomenclature2Weight: z.number().optional().default(0),
|
|
||||||
ventes: z.number().nullable().optional(),
|
|
||||||
marge: z.number().nullable().optional(),
|
|
||||||
scorePercentile: z.number().optional().default(0),
|
|
||||||
moisActifs: z.number().optional().default(0),
|
|
||||||
storeCount: z.number().optional().default(1),
|
|
||||||
nomenclature: z.string().nullable().optional(),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
const config = await getSavedDatabaseConfig();
|
|
||||||
const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey;
|
|
||||||
const model = config?.openRouterModel || "google/gemini-2.0-flash-001";
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
console.error("[batch-analyze] API key manquante.");
|
|
||||||
return NextResponse.json({ error: "Clé API OpenRouter manquante. Configurez-la dans les Paramètres." }, { status: 503 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await req.json();
|
|
||||||
const parsed = BatchAnalyzeSchema.safeParse(body);
|
|
||||||
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: "Format de données invalide." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { rayon, products, supplierTotalCa, supplierTotalMarge, supplierStats } = parsed.data;
|
|
||||||
|
|
||||||
const statsContext = supplierStats
|
|
||||||
? `Fournisseur : ${supplierStats.totalProducts} produits, ${supplierStats.nomenclature2Count} categories N2, ${supplierStats.maxStoreCount} magasins.
|
|
||||||
CA total : ${supplierTotalCa?.toLocaleString('fr-FR') ?? '?'} EUR. Marge totale : ${supplierTotalMarge?.toLocaleString('fr-FR') ?? '?'} EUR.
|
|
||||||
Score median du fournisseur : ${supplierStats.medianScore}/100.`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const systemPrompt = `Tu es un expert en assortiment retail. Categorise chaque produit : A (garder), C (saisonnier), Z (sortir).
|
|
||||||
|
|
||||||
${statsContext}
|
|
||||||
|
|
||||||
DONNEES PAR PRODUIT :
|
|
||||||
- weightInNomenclature2 : % du CA du produit DANS sa categorie N2. C'est le critere le plus important.
|
|
||||||
- adjustedCaWeight : % du CA du produit dans le total fournisseur (extrapole au reseau).
|
|
||||||
- nomenclature2Weight : % du CA de toute la categorie N2 dans le fournisseur.
|
|
||||||
- scorePercentile : rang du produit parmi tous les produits du fournisseur (0-100, 50 = median).
|
|
||||||
- moisActifs : nombre de mois avec des ventes sur les 12 derniers mois.
|
|
||||||
- marge : taux de marge (%).
|
|
||||||
|
|
||||||
REGLES DE DECISION (applique dans l'ordre) :
|
|
||||||
|
|
||||||
REGLE 1 — PILIER DE CATEGORIE → A
|
|
||||||
Si weightInNomenclature2 >= 5% → le produit est un pilier de sa categorie → A.
|
|
||||||
|
|
||||||
REGLE 2 — ROTATION REGULIERE → A
|
|
||||||
Si moisActifs >= 8 → produit de fond de rayon avec rotation reguliere → A.
|
|
||||||
|
|
||||||
REGLE 3 — BON PERFORMEUR → A
|
|
||||||
Si scorePercentile >= 50 ET moisActifs >= 4 → au-dessus de la moyenne → A.
|
|
||||||
|
|
||||||
REGLE 4 — SAISONNIER → C
|
|
||||||
Si moisActifs entre 2 et 4 ET les ventes sont concentrees sur des mois specifiques → C.
|
|
||||||
|
|
||||||
REGLE 5 — SORTIE → Z
|
|
||||||
Si le produit ne remplit AUCUNE des regles 1-4, c'est un candidat Z.
|
|
||||||
Un produit ne doit etre Z que s'il cumule : weightInNomenclature2 faible + moisActifs < 5 + scorePercentile < 30.
|
|
||||||
|
|
||||||
REGLE DE COHERENCE OBLIGATOIRE :
|
|
||||||
Compare les produits ENTRE EUX dans ce lot. Si le produit X a un meilleur scorePercentile ET un meilleur weightInNomenclature2 que le produit Y, alors X doit avoir une recommandation >= Y. Ne mets JAMAIS en Z un produit meilleur qu'un autre en A.
|
|
||||||
|
|
||||||
REPONDS EN JSON VALIDE uniquement :
|
|
||||||
{
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"codein": "ID",
|
|
||||||
"recommandationGamme": "A|C|Z",
|
|
||||||
"isDuplicate": false,
|
|
||||||
"justificationCourte": "N2: X%, Perc: Y, Mois: Z -> Regle N"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const userPrompt = `${products.length} produits du rayon "${rayon}" (sur ${supplierStats?.totalProducts ?? products.length} au total).
|
|
||||||
${JSON.stringify(products, null, 2)}`;
|
|
||||||
|
|
||||||
const FALLBACK_MODEL = "google/gemini-2.0-flash-001";
|
|
||||||
const tryModel = async (modelToTry: string) => fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: modelToTry,
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: systemPrompt },
|
|
||||||
{ role: "user", content: userPrompt }
|
|
||||||
],
|
|
||||||
response_format: { type: "json_object" },
|
|
||||||
temperature: 0.1,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = await tryModel(model);
|
|
||||||
|
|
||||||
// Fallback if configured model is deprecated/not found
|
|
||||||
if (!response.ok && response.status === 400 && model !== FALLBACK_MODEL) {
|
|
||||||
console.warn(`[batch-analyze] Model "${model}" returned 400, retrying with fallback ${FALLBACK_MODEL}`);
|
|
||||||
response = await tryModel(FALLBACK_MODEL);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 429) {
|
|
||||||
const retryAfter = response.headers.get("Retry-After") || response.headers.get("x-ratelimit-reset-requests");
|
|
||||||
const waitSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
|
|
||||||
console.warn(`[batch-analyze] Rate limited. Retry after ${waitSeconds}s.`);
|
|
||||||
return NextResponse.json({ error: "rate_limited", retryAfter: waitSeconds }, { status: 429 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.text();
|
|
||||||
console.error("OpenRouter API Error:", response.status, err);
|
|
||||||
return NextResponse.json({ error: "Erreur lors de l'appel à OpenRouter.", status: response.status }, { status: response.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const content: string = data.choices?.[0]?.message?.content ?? "";
|
|
||||||
|
|
||||||
console.log("[batch-analyze] Raw LLM response:", content.slice(0, 500));
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
return NextResponse.json({ error: "Réponse vide du modèle." }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let jsonStr = content;
|
|
||||||
const jsonBlock = content.match(/```json\s*([\s\S]*?)\s*```/);
|
|
||||||
if (jsonBlock) {
|
|
||||||
jsonStr = jsonBlock[1];
|
|
||||||
} else {
|
|
||||||
const start = content.indexOf("{");
|
|
||||||
const end = content.lastIndexOf("}");
|
|
||||||
if (start !== -1 && end !== -1) {
|
|
||||||
jsonStr = content.slice(start, end + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultJson = JSON.parse(jsonStr);
|
|
||||||
console.log("[batch-analyze] Parsed results count:", resultJson?.results?.length ?? "N/A");
|
|
||||||
|
|
||||||
return NextResponse.json(resultJson);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Batch Analyze Error:", error);
|
|
||||||
return NextResponse.json({ error: "Erreur interne du serveur." }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { db } from "@/db";
|
|
||||||
import { aiSupplierContext } from "@/db/schema";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
// File system fallback for environments without PostgreSQL
|
|
||||||
const DATA_DIR = path.join(process.cwd(), "data");
|
|
||||||
const FALLBACK_FILE_PATH = path.join(DATA_DIR, "ai-context.json");
|
|
||||||
|
|
||||||
function ensureFallbackFileExists() {
|
|
||||||
if (!fs.existsSync(DATA_DIR)) {
|
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(FALLBACK_FILE_PATH)) {
|
|
||||||
fs.writeFileSync(FALLBACK_FILE_PATH, JSON.stringify({}), "utf-8");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFallbackContext(codeFournisseur: string): string {
|
|
||||||
ensureFallbackFileExists();
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(FALLBACK_FILE_PATH, "utf-8"));
|
|
||||||
return data[codeFournisseur] || "";
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveFallbackContext(codeFournisseur: string, contextText: string) {
|
|
||||||
ensureFallbackFileExists();
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(FALLBACK_FILE_PATH, "utf-8"));
|
|
||||||
data[codeFournisseur] = contextText;
|
|
||||||
fs.writeFileSync(FALLBACK_FILE_PATH, JSON.stringify(data, null, 2), "utf-8");
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error saving fallback context:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const codeFournisseur = searchParams.get("fournisseur");
|
|
||||||
|
|
||||||
if (!codeFournisseur) {
|
|
||||||
return NextResponse.json({ error: "Fournisseur manquant" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await db.select()
|
|
||||||
.from(aiSupplierContext)
|
|
||||||
.where(eq(aiSupplierContext.codeFournisseur, codeFournisseur))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return NextResponse.json({ context: result[0]?.context || "" });
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("DB fetch failed, using fallback JSON for AI context.");
|
|
||||||
return NextResponse.json({ context: getFallbackContext(codeFournisseur) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { codeFournisseur, context } = body;
|
|
||||||
|
|
||||||
if (!codeFournisseur) {
|
|
||||||
return NextResponse.json({ error: "Fournisseur manquant" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.insert(aiSupplierContext)
|
|
||||||
.values({
|
|
||||||
codeFournisseur,
|
|
||||||
context: context || "",
|
|
||||||
updatedAt: new Date()
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: aiSupplierContext.codeFournisseur,
|
|
||||||
set: {
|
|
||||||
context: context || "",
|
|
||||||
updatedAt: new Date()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (dbError) {
|
|
||||||
console.warn("DB insert failed, saving to fallback JSON.", dbError);
|
|
||||||
saveFallbackContext(codeFournisseur, context || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in AI context API:", error);
|
|
||||||
return NextResponse.json({ error: "Erreur lors de la sauvegarde" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { fetchNetworkMetrics } from "@/lib/qlik-client";
|
||||||
|
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||||
|
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
||||||
|
|
||||||
|
// Synchro potentiellement longue (extraction hypercube paginee).
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/qlik/sync?fournisseur=XXX
|
||||||
|
* Tire de Qlik les metriques reseau des articles du fournisseur (par code centrale)
|
||||||
|
* et met a jour le cache local. Admin uniquement.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session || (session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||||
|
if (!fournisseur) {
|
||||||
|
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Codes centraux des articles de ce fournisseur
|
||||||
|
const articles = await pgGetArticlesByFournisseur(fournisseur);
|
||||||
|
const codes = [
|
||||||
|
...new Set(
|
||||||
|
articles
|
||||||
|
.map((a) => (a.codeCentrale ? String(a.codeCentrale).trim() : ""))
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (codes.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
fournisseur,
|
||||||
|
fetched: 0,
|
||||||
|
upserted: 0,
|
||||||
|
message: "Aucun article avec code centrale pour ce fournisseur",
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = await fetchNetworkMetrics(codes);
|
||||||
|
const count = await upsertNetworkMetrics([...metrics.values()]);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
fournisseur,
|
||||||
|
requested: codes.length,
|
||||||
|
fetched: metrics.size,
|
||||||
|
upserted: count,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("[api/qlik/sync]", msg);
|
||||||
|
return NextResponse.json({ success: false, error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,6 +104,25 @@ export const commandeCadences = pgTable("commande_cadences", {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache des métriques réseau Qlik Sense (~270 magasins La Foir'Fouille).
|
||||||
|
* Clé = code centrale (format 10000XXXXXX). Rafraîchi par /api/qlik/sync.
|
||||||
|
*/
|
||||||
|
export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
|
||||||
|
/** Code centrale article (clé jointure Qlik ↔ FF) */
|
||||||
|
codeCentrale: varchar("code_centrale", { length: 20 }).primaryKey(),
|
||||||
|
/** CA réseau total du produit */
|
||||||
|
caReseau: numeric("ca_reseau", { precision: 16, scale: 2 }),
|
||||||
|
/** Quantité vendue réseau */
|
||||||
|
qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }),
|
||||||
|
/** Nombre de magasins travaillant le produit (sur ~270) */
|
||||||
|
nbMagasinsReseau: integer("nb_magasins_reseau"),
|
||||||
|
/** Période couverte (libre, ex "12m" ou "2025") */
|
||||||
|
periode: varchar("periode", { length: 20 }),
|
||||||
|
/** Dernière synchro depuis Qlik */
|
||||||
|
fetchedAt: timestamp("fetched_at").defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
/** AI Context rules per supplier (Epic: AI Context) */
|
/** AI Context rules per supplier (Epic: AI Context) */
|
||||||
export const aiSupplierContext = pgTable("ai_supplier_context", {
|
export const aiSupplierContext = pgTable("ai_supplier_context", {
|
||||||
/** Supplier code serving as the primary key */
|
/** Supplier code serving as the primary key */
|
||||||
|
|||||||
@@ -1,343 +0,0 @@
|
|||||||
/**
|
|
||||||
* CollectFlow — Analysis Engine v6
|
|
||||||
*
|
|
||||||
* TypeScript fait le calcul, l'IA fait le jugement.
|
|
||||||
* Les cas déterministes (stock mort, 0 ventes) sont traités AVANT l'IA.
|
|
||||||
* L'IA reçoit un verdict pré-calculé et des signaux pré-calculés.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ProductAnalysisInput, SiteMonthlyData } from "../models/ai-analysis.types";
|
|
||||||
import type { ProductContextProfile } from "./context-profiler";
|
|
||||||
|
|
||||||
export class AnalysisEngine {
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// SYSTEM PROMPT v6
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
static generateSystemPrompt(): string {
|
|
||||||
return `Tu es Mary, Senior Retail Strategist pour une enseigne discount d'équipement de la maison (type La Foir'Fouille).
|
|
||||||
Tu analyses des produits d'un même fournisseur pour recommander A (garder) ou Z (sortir).
|
|
||||||
|
|
||||||
⚠️ IMPORTANT : Les produits sans vente et ceux avec CA < 100€ ET quantité < 30 ont DÉJÀ été
|
|
||||||
classés Z automatiquement et ne te sont PAS soumis. N'applique PAS de règle de stock mort.
|
|
||||||
|
|
||||||
📦 STOCK NÉGATIF : Un stock négatif = commande validée après mise en vente (normal en retail).
|
|
||||||
Ne pas pénaliser.
|
|
||||||
|
|
||||||
--- RÈGLE 1 : RÈGLE MANAGER (PRIORITÉ ABSOLUE) ---
|
|
||||||
Si une section "RÈGLE MANAGER" est présente dans le message :
|
|
||||||
→ Détermine si CE produit est concerné par la règle
|
|
||||||
→ Si OUI : rule_applies = true, applique EXACTEMENT la consigne (A, B, C, D ou Z)
|
|
||||||
→ Si NON : rule_applies = false, passe à la Règle 2
|
|
||||||
→ IMPORTANT : Si la règle demande explicitement une Gamme B, C ou D, tu DOIS recommander B, C ou D (pas A ni Z).
|
|
||||||
|
|
||||||
Si AUCUNE règle manager n'est fournie → Passer directement à la Règle 2.
|
|
||||||
|
|
||||||
--- RÈGLE 2 : CONFIRMATION OU AJUSTEMENT DU VERDICT ---
|
|
||||||
Chaque produit arrive avec un SCORE (0-100) et un VERDICT pré-calculé (A ou Z).
|
|
||||||
Ce verdict est ta base de départ. Tu peux l'ajuster UNIQUEMENT dans ces cas :
|
|
||||||
|
|
||||||
PROMOUVOIR (Z → A) : Le verdict pré-calculé est Z, MAIS :
|
|
||||||
• Tendance en forte accélération (H2/H1 > 2.0)
|
|
||||||
• ET réapprovisionnement récent (réceptions 3 derniers mois > 0)
|
|
||||||
• ET score ≥ 35
|
|
||||||
→ Les 3 conditions doivent être remplies SIMULTANÉMENT.
|
|
||||||
|
|
||||||
DÉGRADER (A → Z) : Le verdict pré-calculé est A, MAIS :
|
|
||||||
• Tendance en effondrement (H2/H1 < 0.4)
|
|
||||||
• ET aucune réception récente
|
|
||||||
• ET score < 55 ⚠️ SI LE SCORE EST ≥ 55, TU NE PEUX JAMAIS DÉGRADER EN Z
|
|
||||||
→ Les 3 conditions doivent être remplies SIMULTANÉMENT.
|
|
||||||
→ Un score élevé (≥ 55) INTERDIT toute dégradation, quelle que soit la tendance.
|
|
||||||
|
|
||||||
Si AUCUNE condition d'ajustement n'est remplie → CONFIRME le verdict pré-calculé.
|
|
||||||
|
|
||||||
--- RÈGLE 3 : INFORMATIONS CONTEXTUELLES ---
|
|
||||||
Note dans la justification (sans changer la décision) :
|
|
||||||
• Si un magasin représente ≥ 80% des ventes → "porté par [magasin]"
|
|
||||||
• Si produit protégé (nouveauté, dernière ref fournisseur) → le mentionner
|
|
||||||
• Si un RANKING est fourni, mentionne la position et le percentile du produit (ex: "classé 45e / 5 000 produits, top 1%")
|
|
||||||
→ Le ranking est un signal contextuel qui RENFORCE le verdict :
|
|
||||||
- Top 5% du réseau → signal fort pour garder en A
|
|
||||||
- Top 20% du réseau → signal modéré pour garder en A
|
|
||||||
- Bottom 30% du réseau → signal défavorable, cohérent avec Z
|
|
||||||
→ Le ranking seul ne change PAS le verdict, mais combiné avec d'autres signaux il peut justifier un ajustement
|
|
||||||
|
|
||||||
--- FORMAT OBLIGATOIRE ---
|
|
||||||
JSON uniquement, sans markdown.
|
|
||||||
{
|
|
||||||
"rule_applies": boolean,
|
|
||||||
"recommendation": "A" | "B" | "C" | "D" | "Z",
|
|
||||||
"justification": "2-3 phrases max. Cite le score et le signal clé."
|
|
||||||
}
|
|
||||||
|
|
||||||
IMPORTANT — Gammes disponibles :
|
|
||||||
- A : Garder (performance correcte)
|
|
||||||
- B : Gamme secondaire (uniquement si RÈGLE MANAGER l'ordonne)
|
|
||||||
- C : Gamme saisonnière (uniquement si RÈGLE MANAGER l'ordonne)
|
|
||||||
- D : Gamme à surveiller (uniquement si RÈGLE MANAGER l'ordonne)
|
|
||||||
- Z : Sortir (sous-performance)
|
|
||||||
|
|
||||||
SI AUCUNE RÈGLE MANAGER n'est définie, tu recommandes UNIQUEMENT A ou Z (jamais B, C, D spontanément).`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// USER MESSAGE
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
static generateUserMessage(p: ProductAnalysisInput): string {
|
|
||||||
if (p.contextProfile) {
|
|
||||||
return AnalysisEngine.buildContextualMessage(p, p.contextProfile);
|
|
||||||
}
|
|
||||||
return AnalysisEngine.buildLegacyMessage(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Message contextuel enrichi (v6 — verdict pré-calculé)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static buildContextualMessage(
|
|
||||||
p: ProductAnalysisInput,
|
|
||||||
ctx: ProductContextProfile
|
|
||||||
): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
const storeLabel = ctx.storeCount > 1 ? `${ctx.storeCount} magasins` : `1 magasin`;
|
|
||||||
|
|
||||||
// En-tête produit
|
|
||||||
lines.push(`PRODUIT : ${ctx.libelle1} (${ctx.codein})`);
|
|
||||||
lines.push(`CATÉGORIE : ${ctx.libelleNiveau2}`);
|
|
||||||
lines.push(`DISTRIBUTION : ${storeLabel}`);
|
|
||||||
if (p.prixVente) {
|
|
||||||
lines.push(`PRIX VENTE : ${p.prixVente.toFixed(2)}€`);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
// ⚠️ RÈGLE MANAGER EN PRIORITÉ 1
|
|
||||||
if (p.supplierContext) {
|
|
||||||
lines.push(`🎯 ═══════════════════════════════════════════════════════════════`);
|
|
||||||
lines.push(`🎯 🎯 🎯 RÈGLE MANAGER (PRIORITÉ ABSOLUE) 🎯 🎯 🎯`);
|
|
||||||
lines.push(`═══════════════════════════════════════════════════════════════`);
|
|
||||||
lines.push(``);
|
|
||||||
lines.push(`RÈGLE DÉFINIE PAR LE MANAGER :`);
|
|
||||||
lines.push(`"${p.supplierContext}"`);
|
|
||||||
lines.push(``);
|
|
||||||
lines.push(`PRODUIT ANALYSÉ : "${ctx.libelle1}"`);
|
|
||||||
lines.push(`CATÉGORIE : ${ctx.libelleNiveau2}`);
|
|
||||||
lines.push(``);
|
|
||||||
lines.push(`⚠️ INSTRUCTION CRITIQUE :`);
|
|
||||||
lines.push(`1. Analyse si CE produit est concerné par la règle ci-dessus`);
|
|
||||||
lines.push(`2. Si OUI :`);
|
|
||||||
lines.push(` - rule_applies = true`);
|
|
||||||
lines.push(` - Applique EXACTEMENT la consigne (si la règle dit "Gamme B", tu DOIS mettre "B")`);
|
|
||||||
lines.push(` - IGNORE complètement la Règle 2`);
|
|
||||||
lines.push(`3. Si NON :`);
|
|
||||||
lines.push(` - rule_applies = false`);
|
|
||||||
lines.push(` - Applique la Règle 2 normalement`);
|
|
||||||
lines.push(`═══════════════════════════════════════════════════════════════`);
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verdict pré-calculé
|
|
||||||
const score = p.scoring?.score ?? p.score ?? 0;
|
|
||||||
const verdict = p.scoring?.verdict ?? (score >= 45 ? "A" : "Z");
|
|
||||||
const quadrant = ctx.quadrantLabel || p.scoring?.quadrant || "N/A";
|
|
||||||
|
|
||||||
lines.push(`--- VERDICT PRÉ-CALCULÉ ---`);
|
|
||||||
lines.push(`• Score : ${score}/100`);
|
|
||||||
lines.push(`• Verdict : ${verdict}`);
|
|
||||||
lines.push(`• Quadrant : ${ctx.quadrantEmoji} ${quadrant}`);
|
|
||||||
if (ctx.isProtected) {
|
|
||||||
lines.push(`• ⚠️ Protection : ${ctx.protectionReason}`);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
// Performance
|
|
||||||
const upsm = p.unitsPerStorePerMonth ?? (ctx.qtyPerStore / Math.max(ctx.regularityScore, 3));
|
|
||||||
const cpsm = (ctx.caPerStore / 12);
|
|
||||||
|
|
||||||
lines.push(`--- PERFORMANCE ---`);
|
|
||||||
lines.push(`• CA réseau : ${ctx.totalCaRaw.toLocaleString('fr-FR')}€ (${cpsm.toFixed(1)}€/mag/mois)`);
|
|
||||||
lines.push(`• Quantité : ${ctx.totalQtyRaw} unités (${upsm.toFixed(2)} uté/mag/mois)`);
|
|
||||||
lines.push(`• Marge : ${ctx.tauxMarge.toFixed(1)}%`);
|
|
||||||
lines.push(`• Régularité : ${ctx.regularityScore}/12 mois actifs`);
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
// Position dans le lot
|
|
||||||
lines.push(`--- POSITION DANS LE LOT (${ctx.lotSize} produits) ---`);
|
|
||||||
lines.push(`• Percentile CA : ${ctx.percentileCa}e`);
|
|
||||||
lines.push(`• Percentile Volume : ${ctx.percentileQty}e`);
|
|
||||||
lines.push(`• Poids CA fournisseur : ${ctx.weightCaFournisseur.toFixed(1)}%`);
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
// Ranking réseau & magasin (avec percentile contextuel)
|
|
||||||
if (p.rankingCa != null || p.rankingQte != null || p.rankingMagCa != null || p.rankingMagQte != null) {
|
|
||||||
const total = p.totalRankedProducts ?? 0;
|
|
||||||
const totalLabel = total > 0 ? ` / ${total.toLocaleString('fr-FR')} produits` : "";
|
|
||||||
lines.push(`--- RANKING RÉSEAU (${total > 0 ? total.toLocaleString('fr-FR') : "?"} produits vendus sur la période) ---`);
|
|
||||||
if (p.rankingCa != null) {
|
|
||||||
const pctCa = total > 0 ? ((p.rankingCa / total) * 100).toFixed(1) : "?";
|
|
||||||
lines.push(`• Classement CA réseau : ${p.rankingCa}e${totalLabel} (top ${pctCa}%)`);
|
|
||||||
}
|
|
||||||
if (p.rankingQte != null) {
|
|
||||||
const pctQte = total > 0 ? ((p.rankingQte / total) * 100).toFixed(1) : "?";
|
|
||||||
lines.push(`• Classement Qté réseau : ${p.rankingQte}e${totalLabel} (top ${pctQte}%)`);
|
|
||||||
}
|
|
||||||
if (p.rankingMagCa != null) lines.push(`• Classement CA magasin : ${p.rankingMagCa}e`);
|
|
||||||
if (p.rankingMagQte != null) lines.push(`• Classement Qté magasin : ${p.rankingMagQte}e`);
|
|
||||||
lines.push(`• Contexte : ce fournisseur a ${ctx.lotSize} produits dans le lot analysé`);
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signaux pré-calculés (si données mensuelles disponibles)
|
|
||||||
if (p.siteMonthlyData && p.siteMonthlyData.length > 0) {
|
|
||||||
const signals = AnalysisEngine.computeSignals(p.siteMonthlyData);
|
|
||||||
|
|
||||||
lines.push(`--- SIGNAUX ---`);
|
|
||||||
lines.push(`• Tendance : ${signals.tendanceLabel}`);
|
|
||||||
lines.push(`• Réapprovisionnement : ${signals.reappLabel}`);
|
|
||||||
if (signals.asymLabel) {
|
|
||||||
lines.push(`• Asymétrie magasins : ${signals.asymLabel}`);
|
|
||||||
}
|
|
||||||
lines.push(`• Inactivité : ${ctx.inactivityMonths} mois sans vente en fin de fenêtre`);
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stock & approvisionnement
|
|
||||||
const hasStockData = ctx.stockCoverage !== undefined || ctx.isLowStock || ctx.isDeadInventory || ctx.isCommandeEnCours;
|
|
||||||
if (hasStockData) {
|
|
||||||
lines.push(`--- STOCK & APPROVISIONNEMENT ---`);
|
|
||||||
if (ctx.stockCoverage > 0) {
|
|
||||||
lines.push(`• Couverture stock : ${ctx.stockCoverage.toFixed(1)} mois de ventes en stock`);
|
|
||||||
}
|
|
||||||
if (ctx.isLowStock) {
|
|
||||||
lines.push(`• ⚠️ Stock faible : moins d'un PCB disponible`);
|
|
||||||
}
|
|
||||||
if (ctx.isDeadInventory) {
|
|
||||||
lines.push(`• ⚠️ Stock dormant : pas de vente depuis > 90 jours`);
|
|
||||||
}
|
|
||||||
if (ctx.isCommandeEnCours) {
|
|
||||||
lines.push(`• Commande en cours : réapprovisionnement prévu`);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(`Génère UNIQUEMENT le JSON :`);
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Calcul des signaux à partir des données mensuelles
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static computeSignals(siteMonthlyData: SiteMonthlyData[]): {
|
|
||||||
tendanceLabel: string;
|
|
||||||
reappLabel: string;
|
|
||||||
asymLabel: string;
|
|
||||||
} {
|
|
||||||
const sortedMonths = [...new Set(siteMonthlyData.map(r => r.mois))].sort();
|
|
||||||
const midIdx = Math.floor(sortedMonths.length / 2);
|
|
||||||
const h1Months = new Set(sortedMonths.slice(0, midIdx));
|
|
||||||
const h2Months = new Set(sortedMonths.slice(midIdx));
|
|
||||||
const last3Months = new Set(sortedMonths.slice(-3));
|
|
||||||
|
|
||||||
let h1Qty = 0, h2Qty = 0;
|
|
||||||
const qteBySite: Record<string, number> = {};
|
|
||||||
let recentReceptions = 0;
|
|
||||||
let recentSales = 0;
|
|
||||||
|
|
||||||
for (const r of siteMonthlyData) {
|
|
||||||
if (h1Months.has(r.mois)) h1Qty += r.ventes_qte;
|
|
||||||
if (h2Months.has(r.mois)) h2Qty += r.ventes_qte;
|
|
||||||
qteBySite[r.site] = (qteBySite[r.site] ?? 0) + r.ventes_qte;
|
|
||||||
if (last3Months.has(r.mois)) {
|
|
||||||
recentReceptions += r.receptions_qte;
|
|
||||||
recentSales += r.ventes_qte;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tendanceRatio = h1Qty > 0 ? h2Qty / h1Qty : (h2Qty > 0 ? Infinity : 1);
|
|
||||||
|
|
||||||
// Signal tendance
|
|
||||||
let tendanceLabel: string;
|
|
||||||
if (h1Qty === 0 && h2Qty > 0) {
|
|
||||||
tendanceLabel = `Produit entrant (H1: 0 → H2: ${h2Qty} uté) — premières ventes récentes`;
|
|
||||||
} else if (tendanceRatio < 0.4) {
|
|
||||||
tendanceLabel = `Effondrement (H1: ${h1Qty} uté → H2: ${h2Qty} uté, ratio ${tendanceRatio.toFixed(2)})`;
|
|
||||||
} else if (tendanceRatio > 2.0) {
|
|
||||||
tendanceLabel = `Accélération forte (H1: ${h1Qty} uté → H2: ${h2Qty} uté, ratio ${tendanceRatio.toFixed(2)})`;
|
|
||||||
} else {
|
|
||||||
tendanceLabel = `Stable (H1: ${h1Qty} uté → H2: ${h2Qty} uté, ratio ${tendanceRatio.toFixed(2)})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal réapprovisionnement
|
|
||||||
const reappLabel = recentReceptions > 0
|
|
||||||
? `Réceptions récentes : ${recentReceptions} uté reçues sur les 3 derniers mois`
|
|
||||||
: (recentSales === 0
|
|
||||||
? "Aucune réception ni vente sur les 3 derniers mois"
|
|
||||||
: "Aucune réception récente (ventes encore actives)");
|
|
||||||
|
|
||||||
// Signal asymétrie
|
|
||||||
const totalQte = Object.values(qteBySite).reduce((a, b) => a + b, 0);
|
|
||||||
const sites = Object.entries(qteBySite);
|
|
||||||
let asymLabel = "";
|
|
||||||
if (sites.length >= 2 && totalQte > 0) {
|
|
||||||
const dominant = sites.sort((a, b) => b[1] - a[1])[0];
|
|
||||||
const pct = Math.round((dominant[1] / totalQte) * 100);
|
|
||||||
if (pct >= 80) {
|
|
||||||
asymLabel = `Porté par ${dominant[0]} (${pct}% des ventes)`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { tendanceLabel, reappLabel, asymLabel };
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Fallback legacy (sans contextProfile)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static buildLegacyMessage(p: ProductAnalysisInput): string {
|
|
||||||
const score = p.scoring?.score ?? p.score ?? 0;
|
|
||||||
const verdict = p.scoring?.verdict ?? (score >= 45 ? "A" : "Z");
|
|
||||||
const pmv = p.totalQuantite > 0 ? p.totalCa / p.totalQuantite : 0;
|
|
||||||
|
|
||||||
const contextRules = p.supplierContext
|
|
||||||
? `\n--- RÈGLE MANAGER ---\n"${p.supplierContext}"\n→ Évalue si le produit ("${p.libelle1}") est concerné. rule_applies = true/false.\n`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return `PRODUIT : ${p.libelle1} (${p.codein})
|
|
||||||
Famille / Rayon : ${p.libelleNiveau2 ?? "N/A"}
|
|
||||||
|
|
||||||
--- VERDICT PRÉ-CALCULÉ ---
|
|
||||||
• Score : ${score}/100
|
|
||||||
• Verdict : ${verdict}
|
|
||||||
|
|
||||||
--- PERFORMANCE ---
|
|
||||||
• CA réseau : ${p.totalCa.toFixed(2)}€
|
|
||||||
• Quantité : ${p.totalQuantite} unités
|
|
||||||
• Marge : ${p.tauxMarge.toFixed(1)}%
|
|
||||||
• PMV : ${pmv.toFixed(2)}€
|
|
||||||
${p.shareCa !== undefined ? `• Poids CA Fournisseur : ${p.shareCa.toFixed(1)}%` : ""}
|
|
||||||
${p.rankingCa != null || p.rankingQte != null ? `\n--- RANKING ---\n${p.rankingCa != null ? `• Classement CA réseau : ${p.rankingCa}e${p.totalRankedProducts ? ` / ${p.totalRankedProducts} produits (top ${((p.rankingCa / p.totalRankedProducts) * 100).toFixed(1)}%)` : ""}\n` : ""}${p.rankingQte != null ? `• Classement Qté réseau : ${p.rankingQte}e${p.totalRankedProducts ? ` / ${p.totalRankedProducts} produits (top ${((p.rankingQte / p.totalRankedProducts) * 100).toFixed(1)}%)` : ""}\n` : ""}${p.rankingMagCa != null ? `• Classement CA magasin : ${p.rankingMagCa}e\n` : ""}${p.rankingMagQte != null ? `• Classement Qté magasin : ${p.rankingMagQte}e` : ""}` : ""}${contextRules}
|
|
||||||
Génère UNIQUEMENT le JSON :`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Utilitaires de parsing
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
static extractRecommendation(content: string): "A" | "B" | "C" | "D" | "Z" | null {
|
|
||||||
const match = content.match(/\b([ABCDZ])\b/i);
|
|
||||||
if (match) return match[1].toUpperCase() as "A" | "B" | "C" | "D" | "Z";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static cleanInsight(content: string): string {
|
|
||||||
let cleaned = content;
|
|
||||||
cleaned = cleaned.replace(/^\[?[ABCDZ]\]?\s*[:\s-]+\s*/i, "");
|
|
||||||
cleaned = cleaned.replace(
|
|
||||||
/^(justification|explication|pourquoi|justification courte|raison|avis)\s*[:\s-]+\s*/i,
|
|
||||||
""
|
|
||||||
);
|
|
||||||
return cleaned.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
/**
|
|
||||||
* CollectFlow — Context Profiler (v3 — Normalisation multi-magasin)
|
|
||||||
*
|
|
||||||
* Génère une fiche de contexte normalisée et adaptative pour chaque produit
|
|
||||||
* AVANT de le soumettre à l'IA.
|
|
||||||
*
|
|
||||||
* v3 — Correctifs :
|
|
||||||
* - Normalisation par `storeCount` : tous les calculs de percentile, poids
|
|
||||||
* et quadrant utilisent les valeurs PAR MAGASIN (CA/store, QTÉ/store).
|
|
||||||
* Cela évite qu'un produit en 2 magasins soit mécaniquement favorisé
|
|
||||||
* dans les comparaisons par rapport à un produit en 1 seul magasin.
|
|
||||||
* - Le profil expose caPerStore et qtyPerStore pour que Mary voie les
|
|
||||||
* deux dimensions : réeau brut ET performance par magasin.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ProductAnalysisInput } from "../models/ai-analysis.types";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Constante : seuil minimal de produits dans un rayon pour activer les signaux
|
|
||||||
// Trafic/Marge. En dessous = statistiques non significatives.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const MIN_RAYON_SIZE = 6;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type Quadrant = "STAR" | "TRAFIC" | "MARGE" | "WATCH";
|
|
||||||
|
|
||||||
export interface ContextProfilerCache {
|
|
||||||
allCaPerStoreSorted: number[];
|
|
||||||
allQtyPerStoreSorted: number[];
|
|
||||||
allMargeValuesSorted: number[];
|
|
||||||
|
|
||||||
totalCaFournisseur: number;
|
|
||||||
totalQtyFournisseur: number;
|
|
||||||
|
|
||||||
top20CaThreshold: number;
|
|
||||||
top20QtyThreshold: number;
|
|
||||||
|
|
||||||
medianQtyPerStore: number;
|
|
||||||
medianMarge: number;
|
|
||||||
|
|
||||||
marge40: number;
|
|
||||||
qty60PerStore: number;
|
|
||||||
|
|
||||||
rayonStats: Map<string, { totalCa: number; totalQty: number; count: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductContextProfile {
|
|
||||||
// Identité
|
|
||||||
codein: string;
|
|
||||||
libelle1: string;
|
|
||||||
libelleNiveau2: string;
|
|
||||||
|
|
||||||
// Profil Quadrant (basé sur valeurs PAR MAGASIN pour comparaison équitable)
|
|
||||||
quadrant: Quadrant;
|
|
||||||
quadrantLabel: string;
|
|
||||||
quadrantEmoji: string;
|
|
||||||
|
|
||||||
// Nombre de magasins référençant le produit
|
|
||||||
storeCount: number;
|
|
||||||
|
|
||||||
// Valeurs brutes réseau
|
|
||||||
totalCaRaw: number;
|
|
||||||
totalQtyRaw: number;
|
|
||||||
|
|
||||||
// Valeurs normalisées PAR MAGASIN (pour comparaisons justes)
|
|
||||||
caPerStore: number;
|
|
||||||
qtyPerStore: number;
|
|
||||||
|
|
||||||
// Percentiles dans le lot fournisseur (0 = plus faible, 100 = meilleur)
|
|
||||||
// Calculés sur les valeurs normalisées par magasin
|
|
||||||
percentileCa: number;
|
|
||||||
percentileQty: number;
|
|
||||||
percentileMarge: number;
|
|
||||||
percentileComposite: number;
|
|
||||||
|
|
||||||
// Poids réels dans le lot fournisseur
|
|
||||||
// Calculés sur les valeurs brutes réseau (représentativité réelle du chiffre)
|
|
||||||
weightCaFournisseur: number; // % du CA total fournisseur
|
|
||||||
weightQtyFournisseur: number; // % des QTÉ totales fournisseur
|
|
||||||
|
|
||||||
// Poids réels dans le rayon (Niveau 2 de nomenclature)
|
|
||||||
weightCaRayon: number;
|
|
||||||
weightQtyRayon: number;
|
|
||||||
|
|
||||||
// Santé temporelle
|
|
||||||
tauxMarge: number;
|
|
||||||
inactivityMonths: number;
|
|
||||||
regularityScore: number;
|
|
||||||
|
|
||||||
// Contexte du lot
|
|
||||||
lotSize: number;
|
|
||||||
rayonSize: number;
|
|
||||||
|
|
||||||
// Signaux positifs (calculés sur la distribution réelle)
|
|
||||||
isAboveMedianComposite: boolean;
|
|
||||||
isTop20Ca: boolean; // Top 20% sur valeur PAR MAGASIN
|
|
||||||
isTop20Qty: boolean; // Top 20% sur valeur PAR MAGASIN
|
|
||||||
/**
|
|
||||||
* Fort volume ET marge < P40 du lot → rôle de "locomotive".
|
|
||||||
* Désactivé si rayonSize < MIN_RAYON_SIZE.
|
|
||||||
*/
|
|
||||||
isHighVolumeWithLowMargin: boolean;
|
|
||||||
/**
|
|
||||||
* Marge > P70 du lot même si volume faible → capital rentabilité.
|
|
||||||
* Désactivé si rayonSize < MIN_RAYON_SIZE.
|
|
||||||
*/
|
|
||||||
isMargePure: boolean;
|
|
||||||
|
|
||||||
// Signal négatif fort
|
|
||||||
/**
|
|
||||||
* Le produit appartient aux 30% les moins performants en CA et Quantité.
|
|
||||||
*/
|
|
||||||
isLowContribution: boolean;
|
|
||||||
/**
|
|
||||||
* Produit avec des statistiques absolues dérisoires (ex: < 100€ CA ou < 20 unités au réseau)
|
|
||||||
*/
|
|
||||||
isDeadStock: boolean;
|
|
||||||
|
|
||||||
// Signal positif fort — Locomotive de rayon
|
|
||||||
/**
|
|
||||||
* Le produit est une "locomotive" de sa nomenclature (poids CA rayon > 15% OU poids QTÉ rayon > 15%)
|
|
||||||
* → Pilier structurant de l'offre dans sa catégorie
|
|
||||||
*/
|
|
||||||
isLocomotiveRayon: boolean;
|
|
||||||
|
|
||||||
// Signaux stock & approvisionnement (API FF Nancy)
|
|
||||||
/** Stock actuel ≤ PCB → moins d'un conditionnement disponible */
|
|
||||||
isLowStock: boolean;
|
|
||||||
/** Commande fournisseur en cours */
|
|
||||||
isCommandeEnCours: boolean;
|
|
||||||
/** Stock > 0 mais pas de vente depuis > 90 jours */
|
|
||||||
isDeadInventory: boolean;
|
|
||||||
/** Couverture en mois : stockActuel / (totalQuantite / 12) */
|
|
||||||
stockCoverage: number;
|
|
||||||
|
|
||||||
// Gardes-fous (issus du ScoringEngine)
|
|
||||||
isProtected: boolean;
|
|
||||||
protectionReason: string;
|
|
||||||
|
|
||||||
// Règle absolue
|
|
||||||
scoreCritique: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers statistiques (purs)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function computePercentileFromSorted(value: number, sortedDistribution: number[]): number {
|
|
||||||
if (sortedDistribution.length <= 1) return 100;
|
|
||||||
|
|
||||||
let first = -1;
|
|
||||||
let last = -1;
|
|
||||||
|
|
||||||
for (let i = 0; i < sortedDistribution.length; i++) {
|
|
||||||
const current = sortedDistribution[i];
|
|
||||||
if (Math.abs(current - value) < 0.00001) { // Floating point protection
|
|
||||||
if (first === -1) first = i;
|
|
||||||
last = i;
|
|
||||||
} else if (current > value) {
|
|
||||||
if (first === -1) return Math.round((i / Math.max(1, sortedDistribution.length - 1)) * 100);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (first !== -1) {
|
|
||||||
const avgRank = (first + last) / 2;
|
|
||||||
return Math.round((avgRank / (sortedDistribution.length - 1)) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeMedianFromSorted(sorted: number[]): number {
|
|
||||||
if (sorted.length === 0) return 0;
|
|
||||||
const mid = Math.floor(sorted.length / 2);
|
|
||||||
return sorted.length % 2 !== 0
|
|
||||||
? sorted[mid]
|
|
||||||
: (sorted[mid - 1] + sorted[mid]) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function valueAtPercentileFromSorted(sorted: number[], p: number): number {
|
|
||||||
if (sorted.length === 0) return 0;
|
|
||||||
const idx = Math.max(0, Math.ceil((p / 100) * sorted.length) - 1);
|
|
||||||
return sorted[idx];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extrait la clé de groupement pour le rayon au niveau 2 de nomenclature.
|
|
||||||
* Priorité : `codeNomenclatureN2` (4 premiers chiffres) > extraction numérique
|
|
||||||
* depuis libelleNiveau2 > valeur brute de libelleNiveau2.
|
|
||||||
*/
|
|
||||||
function getRayonKey(p: ProductAnalysisInput): string {
|
|
||||||
if (p.codeNomenclatureN2) {
|
|
||||||
return p.codeNomenclatureN2;
|
|
||||||
}
|
|
||||||
// Fallback : extraire les 4 premiers chiffres si le libellé commence par un code numérique
|
|
||||||
const numericPrefix = p.libelleNiveau2?.match(/^(\d{4})/);
|
|
||||||
if (numericPrefix) {
|
|
||||||
return numericPrefix[1];
|
|
||||||
}
|
|
||||||
return p.libelleNiveau2 ?? "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
const getNormStoreCount = (p: ProductAnalysisInput) => Math.max(1, p.storeCount ?? 1);
|
|
||||||
const normCa = (p: ProductAnalysisInput) => (p.totalCa ?? 0) / getNormStoreCount(p);
|
|
||||||
const normQty = (p: ProductAnalysisInput) => (p.totalQuantite ?? 0) / getNormStoreCount(p);
|
|
||||||
const getWeightedCa = (p: ProductAnalysisInput) => p.weightedTotalCa ?? p.totalCa ?? 0;
|
|
||||||
const getWeightedQty = (p: ProductAnalysisInput) => p.weightedTotalQuantite ?? p.totalQuantite ?? 0;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Profiler principal
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export class ContextProfiler {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prépare un cache contextuel basé sur tous les produits pour éviter des tris
|
|
||||||
* répétés en mode "Bulk Analyze". Réduit la latence de manière dramatique (O(N) au lieu de O(N^2)).
|
|
||||||
*/
|
|
||||||
static prepareCache(allProds: ProductAnalysisInput[]): ContextProfilerCache {
|
|
||||||
const allCaPerStore = allProds.map(normCa);
|
|
||||||
const allQtyPerStore = allProds.map(normQty);
|
|
||||||
const allMargeValues = allProds.map(p => p.tauxMarge ?? 0);
|
|
||||||
|
|
||||||
// On trie une seule et unique fois
|
|
||||||
const allCaPerStoreSorted = [...allCaPerStore].sort((a, b) => a - b);
|
|
||||||
const allQtyPerStoreSorted = [...allQtyPerStore].sort((a, b) => a - b);
|
|
||||||
const allMargeValuesSorted = [...allMargeValues].sort((a, b) => a - b);
|
|
||||||
|
|
||||||
const totalCaFournisseur = allProds.reduce((s, p) => s + getWeightedCa(p), 0);
|
|
||||||
const totalQtyFournisseur = allProds.reduce((s, p) => s + getWeightedQty(p), 0);
|
|
||||||
|
|
||||||
const rayonStats = new Map<string, { totalCa: number; totalQty: number; count: number }>();
|
|
||||||
allProds.forEach(p => {
|
|
||||||
const key = getRayonKey(p);
|
|
||||||
if (!rayonStats.has(key)) rayonStats.set(key, { totalCa: 0, totalQty: 0, count: 0 });
|
|
||||||
const s = rayonStats.get(key)!;
|
|
||||||
s.totalCa += getWeightedCa(p);
|
|
||||||
s.totalQty += getWeightedQty(p);
|
|
||||||
s.count += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
allCaPerStoreSorted,
|
|
||||||
allQtyPerStoreSorted,
|
|
||||||
allMargeValuesSorted,
|
|
||||||
totalCaFournisseur,
|
|
||||||
totalQtyFournisseur,
|
|
||||||
top20CaThreshold: valueAtPercentileFromSorted(allCaPerStoreSorted, 80),
|
|
||||||
top20QtyThreshold: valueAtPercentileFromSorted(allQtyPerStoreSorted, 80),
|
|
||||||
medianQtyPerStore: computeMedianFromSorted(allQtyPerStoreSorted),
|
|
||||||
medianMarge: computeMedianFromSorted(allMargeValuesSorted),
|
|
||||||
marge40: valueAtPercentileFromSorted(allMargeValuesSorted, 40),
|
|
||||||
qty60PerStore: valueAtPercentileFromSorted(allQtyPerStoreSorted, 60),
|
|
||||||
rayonStats,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Génère le profil contextuel d'un produit au sein de son lot fournisseur.
|
|
||||||
*/
|
|
||||||
static buildProfile(
|
|
||||||
target: ProductAnalysisInput,
|
|
||||||
allProds: ProductAnalysisInput[],
|
|
||||||
score: number,
|
|
||||||
cache?: ContextProfilerCache
|
|
||||||
): ProductContextProfile {
|
|
||||||
if (allProds.length === 0) {
|
|
||||||
throw new Error("[ContextProfiler] Le lot de produits est vide.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utiliser le cache s'il est fourni (mode Bulk) pour éviter de recalculer,
|
|
||||||
// sinon le générer à la volée (mode single item)
|
|
||||||
const ctx: ContextProfilerCache = cache ?? ContextProfiler.prepareCache(allProds);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Normalisation par magasin — cœur de la v3
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const targetStoreCount = getNormStoreCount(target);
|
|
||||||
const targetCaPerStore = normCa(target);
|
|
||||||
const targetQtyPerStore = normQty(target);
|
|
||||||
|
|
||||||
// 1. Totaux fournisseur
|
|
||||||
const { totalCaFournisseur, totalQtyFournisseur } = ctx;
|
|
||||||
|
|
||||||
// 2. Totaux du rayon (Niveau 2 de nomenclature)
|
|
||||||
const targetRayonKey = getRayonKey(target);
|
|
||||||
const rayonStat = ctx.rayonStats.get(targetRayonKey) ?? { totalCa: 0, totalQty: 0, count: 0 };
|
|
||||||
const totalCaRayon = rayonStat.totalCa;
|
|
||||||
const totalQtyRayon = rayonStat.totalQty;
|
|
||||||
const rayonSizeForSignals = rayonStat.count;
|
|
||||||
|
|
||||||
// 3. Percentiles (0-100) — sur distribution PAR MAGASIN
|
|
||||||
const pCa = computePercentileFromSorted(targetCaPerStore, ctx.allCaPerStoreSorted);
|
|
||||||
const pQty = computePercentileFromSorted(targetQtyPerStore, ctx.allQtyPerStoreSorted);
|
|
||||||
const pMarge = computePercentileFromSorted(target.tauxMarge ?? 0, ctx.allMargeValuesSorted);
|
|
||||||
const pComposite = score;
|
|
||||||
|
|
||||||
// 4. Tops 20%
|
|
||||||
const isTop20Ca = targetCaPerStore >= ctx.top20CaThreshold;
|
|
||||||
const isTop20Qty = targetQtyPerStore >= ctx.top20QtyThreshold;
|
|
||||||
const isAboveMedianComposite = pComposite >= 50;
|
|
||||||
|
|
||||||
// 5. Poids pondérés
|
|
||||||
const weightCaFournisseur =
|
|
||||||
totalCaFournisseur > 0
|
|
||||||
? Math.round((getWeightedCa(target) / totalCaFournisseur) * 1000) / 10
|
|
||||||
: 0;
|
|
||||||
const weightQtyFournisseur =
|
|
||||||
totalQtyFournisseur > 0
|
|
||||||
? Math.round((getWeightedQty(target) / totalQtyFournisseur) * 1000) / 10
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const isLowContribution = pCa <= 30 && pQty <= 30 && pMarge <= 70;
|
|
||||||
const isDeadStock = (target.totalCa ?? 0) < 150 && (target.totalQuantite ?? 0) < 30;
|
|
||||||
|
|
||||||
// 5b. Locomotive de rayon — poids significatif dans la nomenclature
|
|
||||||
const weightCaRayonNum = totalCaRayon > 0
|
|
||||||
? (getWeightedCa(target) / totalCaRayon) * 100
|
|
||||||
: 0;
|
|
||||||
const weightQtyRayonNum = totalQtyRayon > 0
|
|
||||||
? (getWeightedQty(target) / totalQtyRayon) * 100
|
|
||||||
: 0;
|
|
||||||
const isLocomotiveRayon = weightCaRayonNum > 15 || weightQtyRayonNum > 15;
|
|
||||||
|
|
||||||
// 6. Signaux Trafic / Marge + Quadrant
|
|
||||||
const signalsActive = rayonSizeForSignals >= MIN_RAYON_SIZE;
|
|
||||||
|
|
||||||
let isHighVolumeWithLowMargin = false;
|
|
||||||
let isMargePure = false;
|
|
||||||
|
|
||||||
{
|
|
||||||
if (signalsActive) {
|
|
||||||
isHighVolumeWithLowMargin =
|
|
||||||
targetQtyPerStore >= ctx.qty60PerStore &&
|
|
||||||
(target.tauxMarge ?? 0) < ctx.marge40;
|
|
||||||
}
|
|
||||||
|
|
||||||
isMargePure =
|
|
||||||
(target.tauxMarge ?? 0) > ctx.medianMarge &&
|
|
||||||
targetQtyPerStore < ctx.medianQtyPerStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Quadrant
|
|
||||||
const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant(
|
|
||||||
targetQtyPerStore,
|
|
||||||
target.tauxMarge ?? 0,
|
|
||||||
ctx.medianQtyPerStore,
|
|
||||||
ctx.medianMarge
|
|
||||||
);
|
|
||||||
|
|
||||||
// 8. Gardes-fous (signaux, pas des verdicts — Mary décide au final)
|
|
||||||
// Les flags isRecent/isTop30Supplier/isLastProduct sont désormais
|
|
||||||
// injectés directement sur ProductRow par score-engine.ts.
|
|
||||||
// On les lit depuis le scoring payload transmis par le bulk analyzer.
|
|
||||||
const targetExt = target as ProductAnalysisInput & { isRecent?: boolean; isTop30Supplier?: boolean; isLastProduct?: boolean };
|
|
||||||
const isRecentFlag = targetExt.isRecent ?? false;
|
|
||||||
const isTop30Flag = targetExt.isTop30Supplier ?? false;
|
|
||||||
const isLastFlag = targetExt.isLastProduct ?? false;
|
|
||||||
|
|
||||||
const isProtected = isRecentFlag || isTop30Flag || isLastFlag;
|
|
||||||
|
|
||||||
let protectionReason = "";
|
|
||||||
if (isRecentFlag) protectionReason = "Nouveauté (< 3 mois de données)";
|
|
||||||
else if (isTop30Flag) protectionReason = "Top 30% CA Fournisseur";
|
|
||||||
else if (isLastFlag) protectionReason = "Dernière référence du fournisseur";
|
|
||||||
|
|
||||||
// 9. Règle absolue
|
|
||||||
const scoreCritique = score < 20;
|
|
||||||
|
|
||||||
return {
|
|
||||||
codein: target.codein,
|
|
||||||
libelle1: target.libelle1,
|
|
||||||
libelleNiveau2: target.libelleNiveau2 ?? "Général",
|
|
||||||
|
|
||||||
quadrant, quadrantLabel, quadrantEmoji,
|
|
||||||
|
|
||||||
storeCount: targetStoreCount,
|
|
||||||
totalCaRaw: target.totalCa ?? 0,
|
|
||||||
totalQtyRaw: target.totalQuantite ?? 0,
|
|
||||||
caPerStore: targetCaPerStore,
|
|
||||||
qtyPerStore: targetQtyPerStore,
|
|
||||||
|
|
||||||
percentileCa: pCa,
|
|
||||||
percentileQty: pQty,
|
|
||||||
percentileMarge: pMarge,
|
|
||||||
percentileComposite: pComposite,
|
|
||||||
|
|
||||||
weightCaFournisseur,
|
|
||||||
weightQtyFournisseur,
|
|
||||||
weightCaRayon:
|
|
||||||
totalCaRayon > 0
|
|
||||||
? Math.round((getWeightedCa(target) / totalCaRayon) * 1000) / 10
|
|
||||||
: 0,
|
|
||||||
weightQtyRayon:
|
|
||||||
totalQtyRayon > 0
|
|
||||||
? Math.round((getWeightedQty(target) / totalQtyRayon) * 1000) / 10
|
|
||||||
: 0,
|
|
||||||
|
|
||||||
tauxMarge: target.tauxMarge ?? 0,
|
|
||||||
inactivityMonths: target.inactivityMonths ?? 0,
|
|
||||||
regularityScore: target.regularityScore ?? 0,
|
|
||||||
|
|
||||||
lotSize: allProds.length,
|
|
||||||
rayonSize: rayonSizeForSignals,
|
|
||||||
|
|
||||||
isAboveMedianComposite,
|
|
||||||
isTop20Ca,
|
|
||||||
isTop20Qty,
|
|
||||||
isHighVolumeWithLowMargin,
|
|
||||||
isMargePure,
|
|
||||||
isLowContribution,
|
|
||||||
isDeadStock,
|
|
||||||
isLocomotiveRayon,
|
|
||||||
|
|
||||||
isProtected,
|
|
||||||
protectionReason,
|
|
||||||
scoreCritique,
|
|
||||||
|
|
||||||
// Stock & approvisionnement
|
|
||||||
isLowStock: (target.stockActuel !== undefined && target.pcb !== undefined && target.pcb > 0)
|
|
||||||
? target.stockActuel <= target.pcb
|
|
||||||
: false,
|
|
||||||
isCommandeEnCours: (target.commandesEnCours ?? 0) > 0,
|
|
||||||
isDeadInventory: (target.stockTotal ?? 0) > 0 && (target.nbJoursDerniereVente ?? 0) > 90,
|
|
||||||
stockCoverage: (() => {
|
|
||||||
const avgMonthlyQty = (target.totalQuantite ?? 0) / 12;
|
|
||||||
return avgMonthlyQty > 0 ? (target.stockActuel ?? 0) / avgMonthlyQty : 0;
|
|
||||||
})(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static resolveQuadrant(
|
|
||||||
qty: number,
|
|
||||||
marge: number,
|
|
||||||
medianQty: number,
|
|
||||||
medianMarge: number
|
|
||||||
): { quadrant: Quadrant; quadrantLabel: string; quadrantEmoji: string } {
|
|
||||||
if (qty > medianQty && marge > medianMarge) {
|
|
||||||
return { quadrant: "STAR", quadrantLabel: "Star (Vol élevé, Marge élevée)", quadrantEmoji: "⭐" };
|
|
||||||
}
|
|
||||||
if (qty > medianQty && marge <= medianMarge) {
|
|
||||||
return { quadrant: "TRAFIC", quadrantLabel: "Générateur de Trafic (Vol élevé, Marge faible)", quadrantEmoji: "🚶" };
|
|
||||||
}
|
|
||||||
if (qty <= medianQty && marge > medianMarge) {
|
|
||||||
return { quadrant: "MARGE", quadrantLabel: "Contributeur de Marge (Vol faible, Marge élevée)", quadrantEmoji: "💎" };
|
|
||||||
}
|
|
||||||
return { quadrant: "WATCH", quadrantLabel: "Sous-performant (Vol faible, Marge faible)", quadrantEmoji: "⚠️" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { X, Bot, Sparkles, TrendingUp, ShieldAlert, History } from "lucide-react";
|
|
||||||
|
|
||||||
interface AiExplanationModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
productName: string;
|
|
||||||
productCode: string;
|
|
||||||
explanation: string;
|
|
||||||
recommandation?: "A" | "C" | "Z" | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AiExplanationModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
productName,
|
|
||||||
productCode,
|
|
||||||
explanation,
|
|
||||||
recommandation
|
|
||||||
}: AiExplanationModalProps) {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
if (isOpen) {
|
|
||||||
setIsVisible(true);
|
|
||||||
} else {
|
|
||||||
setIsVisible(false);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setIsVisible(false);
|
|
||||||
setTimeout(onClose, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen && !isVisible) return null;
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
const getGammeStyles = (g?: string | null) => {
|
|
||||||
switch (g) {
|
|
||||||
case "A": return "text-[var(--accent-success)] border-[var(--accent-success-bg)] bg-[var(--accent-success-bg)]";
|
|
||||||
case "C": return "text-[var(--accent-warning)] border-[var(--accent-warning-bg)] bg-[var(--accent-warning-bg)]";
|
|
||||||
case "Z": return "text-[var(--accent-error)] border-[var(--accent-error-bg)] bg-[var(--accent-error-bg)]";
|
|
||||||
default: return "text-[var(--text-muted)] border-[var(--border)] bg-[var(--bg-elevated)]";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const modalContent = (
|
|
||||||
<div
|
|
||||||
className={`fixed inset-0 z-[9999] flex items-center justify-center p-6 transition-all duration-200 ${isVisible ? "opacity-100 backdrop-blur-sm" : "opacity-0 backdrop-blur-0"}`}
|
|
||||||
>
|
|
||||||
{/* Backdrop - consistent with Apple overlay */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/40"
|
|
||||||
onClick={handleClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Panel - Using var(--bg-surface) and strict spacing */}
|
|
||||||
<div
|
|
||||||
className={`relative w-full max-w-xl rounded-2xl overflow-hidden shadow-2xl border border-[var(--border-strong)] transition-all duration-200 ${isVisible ? "scale-100 translate-y-0" : "scale-[0.98] translate-y-4"}`}
|
|
||||||
style={{
|
|
||||||
background: "var(--bg-surface)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header - Clean & Monochromatic */}
|
|
||||||
<div className="px-6 py-5 border-b border-[var(--border)] flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border)] flex items-center justify-center shrink-0">
|
|
||||||
<Bot className="w-5 h-5 text-[var(--text-secondary)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-bold tracking-tight text-[var(--text-primary)]">
|
|
||||||
Analyse Décisionnelle IA
|
|
||||||
</h2>
|
|
||||||
<p className="text-[11px] font-medium text-[var(--text-muted)] truncate max-w-[300px]">
|
|
||||||
{productName} <span className="opacity-60 tabular-numbers">({productCode})</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="p-1.5 rounded-md hover:bg-[var(--bg-elevated)] text-[var(--text-muted)] transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="p-6 space-y-6">
|
|
||||||
{/* Recommendation Row */}
|
|
||||||
<div className="flex items-center justify-between px-1">
|
|
||||||
<span className="text-[11px] font-bold uppercase tracking-wider text-[var(--text-muted)]">Arbitrage suggéré</span>
|
|
||||||
<div className={`px-2.5 py-1 rounded-md border text-[11px] font-bold flex items-center gap-1.5 ${getGammeStyles(recommandation)}`}>
|
|
||||||
{recommandation === "A" && <Sparkles className="w-3 h-3" />}
|
|
||||||
{recommandation === "C" && <TrendingUp className="w-3 h-3" />}
|
|
||||||
{recommandation === "Z" && <ShieldAlert className="w-3 h-3" />}
|
|
||||||
{recommandation ? `GAMME ${recommandation}` : "NON DÉFINI"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Explanation Box - Monospace for figures */}
|
|
||||||
<div
|
|
||||||
className="p-5 rounded-xl border border-[var(--border)] bg-[var(--bg-base)]/50 relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<History className="w-4 h-4 mt-0.5 text-[var(--accent)] shrink-0 opacity-80" />
|
|
||||||
<div className="text-[13px] leading-relaxed text-[var(--text-secondary)] font-medium">
|
|
||||||
<span className="font-mono-nums leading-relaxed whitespace-pre-wrap">
|
|
||||||
{explanation}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Meta Info */}
|
|
||||||
<div className="flex items-center justify-center gap-4 py-2 border-t border-[var(--border)]">
|
|
||||||
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] font-medium italic">
|
|
||||||
<span>Basé sur CA, Marge & Volumes réels</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer Action */}
|
|
||||||
<div className="px-6 py-4 bg-[var(--bg-elevated)]/50 border-t border-[var(--border)] flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="apple-btn-secondary px-6"
|
|
||||||
>
|
|
||||||
Fermer l'Analyse
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
.font-mono-nums {
|
|
||||||
font-family: inherit;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return createPortal(modalContent, document.body);
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState } from "react";
|
|
||||||
|
|
||||||
import { Loader2, AlertCircle, Sparkles, Maximize2 } from "lucide-react";
|
|
||||||
import { AiExplanationModal } from "./ai-explanation-modal";
|
|
||||||
import { useAiCopilotStore } from "../store/use-ai-copilot-store";
|
|
||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import type { ProductRow } from "@/types/grid";
|
|
||||||
|
|
||||||
|
|
||||||
interface AiInsightBlockProps {
|
|
||||||
row: ProductRow;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AiInsightBlock({ row }: AiInsightBlockProps) {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const isAdmin = (session?.user as any)?.role === "admin";
|
|
||||||
const insight = useAiCopilotStore((s: any) => s.insights[row.codein]);
|
|
||||||
const analyzeProduct = useAiCopilotStore((s: any) => s.analyzeProduct);
|
|
||||||
const setLoading = useAiCopilotStore((s: any) => s.setLoading);
|
|
||||||
const setDraftGamme = useGridStore((s: any) => s.setDraftGamme);
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const status = insight?.status ?? "idle";
|
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
|
||||||
if (status === "loading") return;
|
|
||||||
|
|
||||||
// Produit sans aucune vente sur 12 mois → Z direct, pas d'appel IA
|
|
||||||
if (row.totalQuantite === 0) {
|
|
||||||
setDraftGamme(row.codein, "Z");
|
|
||||||
useAiCopilotStore.getState().setInsight(row.codein, "Aucune vente sur 12 mois — produit classé Z automatiquement.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stock mort absolu : CA < 100€ ET quantité < 30 → Z direct, aucune analyse IA
|
|
||||||
if ((row.totalCa ?? 0) < 100 && (row.totalQuantite ?? 0) < 30) {
|
|
||||||
setDraftGamme(row.codein, "Z");
|
|
||||||
useAiCopilotStore.getState().setInsight(row.codein, `CA < 100€ (${(row.totalCa ?? 0).toFixed(0)}€) et quantité < 30 (${row.totalQuantite} uté) — stock mort absolu, classé Z automatiquement.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset Visuel Immédiat
|
|
||||||
setLoading(row.codein);
|
|
||||||
setDraftGamme(row.codein, "Aucune");
|
|
||||||
|
|
||||||
// Fetch supplier context
|
|
||||||
let supplierContext = "";
|
|
||||||
if (row.codeFournisseur) {
|
|
||||||
try {
|
|
||||||
const ctxRes = await fetch(`/api/ai/context?fournisseur=${row.codeFournisseur}`);
|
|
||||||
if (ctxRes.ok) {
|
|
||||||
const data = await ctxRes.json();
|
|
||||||
supplierContext = data.context || "";
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load supplier context for AI", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calcul régularité et inactivité
|
|
||||||
const regScore = Object.values(row.sales12m || {}).filter((v: any) => v > 0).length;
|
|
||||||
const allMonths = Object.keys(row.sales12m || {});
|
|
||||||
const referenceMonth = allMonths.length > 0 ? Math.max(...allMonths.map(m => parseInt(m))).toString() : "";
|
|
||||||
const salesMonths = Object.entries(row.sales12m || {}).filter(([_, qty]: [string, any]) => qty > 0).map(([m]) => parseInt(m)).sort((a, b) => b - a);
|
|
||||||
const lastMonth = salesMonths.length > 0 ? salesMonths[0].toString() : "";
|
|
||||||
let inactivity = 0;
|
|
||||||
if (referenceMonth && lastMonth) {
|
|
||||||
const refY = parseInt(referenceMonth.substring(0, 4));
|
|
||||||
const refM = parseInt(referenceMonth.substring(4, 6));
|
|
||||||
const lastY = parseInt(lastMonth.substring(0, 4));
|
|
||||||
const lastM = parseInt(lastMonth.substring(4, 6));
|
|
||||||
inactivity = (refY - lastY) * 12 + (refM - lastM);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Score et verdict pré-calculés par score-engine.ts
|
|
||||||
const score = row.score ?? 0;
|
|
||||||
const verdict: "A" | "Z" = score >= 45 ? "A" : "Z";
|
|
||||||
|
|
||||||
const sc = row.workingStores?.length || 1;
|
|
||||||
const weight = sc === 1 ? 2 : 1;
|
|
||||||
|
|
||||||
analyzeProduct({
|
|
||||||
codein: row.codein,
|
|
||||||
noid: row.noid,
|
|
||||||
libelle1: row.libelle1,
|
|
||||||
libelleNiveau2: row.libelleNiveau2,
|
|
||||||
totalCa: row.totalCa,
|
|
||||||
tauxMarge: row.tauxMarge,
|
|
||||||
totalQuantite: row.totalQuantite,
|
|
||||||
storeCount: sc,
|
|
||||||
sales12m: row.sales12m,
|
|
||||||
codeGamme: row.codeGamme,
|
|
||||||
score: score,
|
|
||||||
regularityScore: regScore,
|
|
||||||
lastMonthWithSale: lastMonth,
|
|
||||||
inactivityMonths: inactivity,
|
|
||||||
weightedTotalQuantite: (row.totalQuantite || 0) * weight,
|
|
||||||
weightedTotalCa: (row.totalCa || 0) * weight,
|
|
||||||
avgQtyFournisseur: row.avgQtyFournisseur,
|
|
||||||
avgQtyRayon: row.avgQtyRayon,
|
|
||||||
shareCa: row.shareCa,
|
|
||||||
shareMarge: row.shareMarge,
|
|
||||||
shareQty: row.shareQty,
|
|
||||||
totalFournisseurCa: row.totalFournisseurCa,
|
|
||||||
codeFournisseur: row.codeFournisseur,
|
|
||||||
totalMagasins: 2,
|
|
||||||
prixVente: row.prixVente,
|
|
||||||
unitsPerStorePerMonth: row.unitsPerStorePerMonth,
|
|
||||||
caPerStorePerYear: row.caPerStorePerYear,
|
|
||||||
stockActuel: row.stockActuel,
|
|
||||||
stockTotal: row.stockTotal,
|
|
||||||
pcb: row.pcb,
|
|
||||||
commandesEnCours: row.commandesEnCours,
|
|
||||||
nbJoursDerniereVente: row.nbJoursDerniereVente,
|
|
||||||
derniereVente: row.derniereVente,
|
|
||||||
supplierContext: supplierContext,
|
|
||||||
scoring: {
|
|
||||||
score,
|
|
||||||
verdict,
|
|
||||||
isRecent: row.isRecent ?? false,
|
|
||||||
isLastProduct: row.isLastProduct ?? false,
|
|
||||||
isTop30Supplier: row.isTop30Supplier ?? false,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (status === "idle") {
|
|
||||||
if (!isAdmin) return null;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleAnalyze}
|
|
||||||
className="flex items-center gap-1.5 text-[11px] font-medium transition-colors group"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
<Sparkles className="w-3 h-3 text-indigo-500 dark:text-indigo-400 group-hover:text-indigo-600 dark:group-hover:text-indigo-300 transition-colors" />
|
|
||||||
<span className="group-hover:text-indigo-600 dark:group-hover:text-indigo-300 transition-colors">Analyser</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "loading") {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-muted)" }}>
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
|
||||||
<span className="italic">Analyse IA...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "error") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1.5 text-[11px] text-rose-600 dark:text-rose-500 cursor-help"
|
|
||||||
title={insight?.insight || "Une erreur inconnue est survenue"}
|
|
||||||
>
|
|
||||||
<AlertCircle className="w-3 h-3" />
|
|
||||||
<span>Erreur</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-1 group/insight" title="Cliquer pour re-analyser">
|
|
||||||
<div className="flex-1 min-w-0 pr-1 group">
|
|
||||||
<p
|
|
||||||
className="text-[11px] leading-snug font-medium text-[var(--text-secondary)] line-clamp-3 cursor-pointer hover:text-[var(--text-primary)] transition-colors relative"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{insight.insight}
|
|
||||||
<span className="inline-flex ml-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Maximize2 className="w-2.5 h-2.5 text-[var(--accent)]" />
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{insight?.isDuplicate && (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-amber-700 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 px-1.5 py-0.5 rounded-full w-fit">
|
|
||||||
Doublon probable
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AiExplanationModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={() => setIsModalOpen(false)}
|
|
||||||
productName={row.libelle1}
|
|
||||||
productCode={row.codein}
|
|
||||||
explanation={insight?.insight || ""}
|
|
||||||
recommandation={insight?.recommandation}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { AnalysisEngine } from "../business/analysis-engine";
|
|
||||||
import { ProductAnalysisInput, AnalysisResult } from "../models/ai-analysis.types";
|
|
||||||
|
|
||||||
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
|
||||||
|
|
||||||
export interface OpenRouterConfig {
|
|
||||||
apiKey: string;
|
|
||||||
model: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OpenRouterClient {
|
|
||||||
constructor(private config: OpenRouterConfig) { }
|
|
||||||
|
|
||||||
async analyzeProduct(p: ProductAnalysisInput): Promise<AnalysisResult> {
|
|
||||||
// Timeout de 50 secondes : évite le blocage indéfini si OpenRouter est lent,
|
|
||||||
// tout en laissant assez de temps aux modèles complexes pour répondre (maxDuration serveur = 55s).
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 50_000);
|
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetch(OPENROUTER_URL, {
|
|
||||||
method: "POST",
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.config.apiKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"HTTP-Referer": "https://collectflow.app",
|
|
||||||
"X-Title": "CollectFlow AI Copilot",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: this.config.model,
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: AnalysisEngine.generateSystemPrompt() },
|
|
||||||
{ role: "user", content: AnalysisEngine.generateUserMessage(p) },
|
|
||||||
],
|
|
||||||
response_format: { type: "json_object" },
|
|
||||||
max_tokens: 150,
|
|
||||||
temperature: 0.1,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (err instanceof Error && err.name === "AbortError") {
|
|
||||||
throw new Error("timeout"); // Géré proprement plus haut
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (response.status === 429) {
|
|
||||||
throw new Error("rate_limited");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.text();
|
|
||||||
throw new Error(`OpenRouter error: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const content = data.choices?.[0]?.message?.content ?? "";
|
|
||||||
|
|
||||||
let reco: "A" | "B" | "C" | "D" | "Z" | null = null;
|
|
||||||
let cleanInsight = "Erreur de génération.";
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Some models might wrap JSON in markdown blocks despite instructions
|
|
||||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
||||||
const jsonText = jsonMatch ? jsonMatch[0] : content;
|
|
||||||
const parsed = JSON.parse(jsonText);
|
|
||||||
|
|
||||||
reco = parsed.recommendation as "A" | "B" | "C" | "D" | "Z";
|
|
||||||
cleanInsight = parsed.justification || "";
|
|
||||||
|
|
||||||
const ruleApplies = parsed.rule_applies === true;
|
|
||||||
|
|
||||||
// Garde-fou de sécurité : On n'autorise B, C ou D QUE si une règle manager s'applique.
|
|
||||||
if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) {
|
|
||||||
reco = "A";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override recommendation if it doesn't match extracted reco for safety
|
|
||||||
if (!reco || !["A", "B", "C", "D", "Z"].includes(reco)) {
|
|
||||||
reco = AnalysisEngine.extractRecommendation(cleanInsight) || "A";
|
|
||||||
// Ré-appliquer le garde-fou pour la reco extraite du texte.
|
|
||||||
if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) reco = "A";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Garde-fou anti-dégradation : l'IA ne peut PAS dégrader A → Z
|
|
||||||
// si le score pré-calculé est ≥ 55 (sauf si règle manager).
|
|
||||||
// Corrige les hallucinations du LLM qui ignore la condition "score < 55".
|
|
||||||
const preScore = p.scoring?.score ?? p.score ?? 0;
|
|
||||||
const preVerdict = p.scoring?.verdict ?? (preScore >= 45 ? "A" : "Z");
|
|
||||||
if (!ruleApplies && preVerdict === "A" && reco === "Z" && preScore >= 55) {
|
|
||||||
console.warn(`[AI Guardrail] Blocked A→Z degradation for ${p.codein} (score ${preScore} ≥ 55)`);
|
|
||||||
reco = "A";
|
|
||||||
cleanInsight += " [Garde-fou : score élevé, dégradation bloquée]";
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to parse AI JSON response:", content, e);
|
|
||||||
// Fallback to text parsing
|
|
||||||
reco = AnalysisEngine.extractRecommendation(content);
|
|
||||||
cleanInsight = AnalysisEngine.cleanInsight(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
insight: reco ? `[${reco}] ${cleanInsight}` : cleanInsight,
|
|
||||||
codein: p.codein,
|
|
||||||
recommandation: reco
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import type { ProductContextProfile } from "../business/context-profiler";
|
|
||||||
|
|
||||||
export interface SiteMonthlyData {
|
|
||||||
site: string; // "Frouard" ou "Houdemont"
|
|
||||||
mois: string; // "YYYY-MM"
|
|
||||||
ventes_qte: number;
|
|
||||||
ventes_ca: number;
|
|
||||||
marge: number;
|
|
||||||
stock_fin_mois: number; // peut être négatif (validation tardive de commande)
|
|
||||||
receptions_qte: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductAnalysisInput {
|
|
||||||
codein: string;
|
|
||||||
libelle1: string;
|
|
||||||
libelleNiveau2?: string;
|
|
||||||
/**
|
|
||||||
* Code de nomenclature au niveau 2 (4 premiers chiffres du code à 6 chiffres).
|
|
||||||
* Utilisé pour calculer les poids rayon sur le bon périmètre (pas trop fin = niveau 3).
|
|
||||||
* Correspond à `code2` dans ProductRow.
|
|
||||||
*/
|
|
||||||
codeNomenclatureN2?: string;
|
|
||||||
totalCa: number;
|
|
||||||
tauxMarge: number;
|
|
||||||
totalQuantite: number;
|
|
||||||
weightedTotalQuantite?: number;
|
|
||||||
weightedTotalCa?: number;
|
|
||||||
avgTotalQuantite?: number;
|
|
||||||
avgQtyRayon?: number;
|
|
||||||
avgQtyFournisseur?: number;
|
|
||||||
/** Poids relatifs (%) */
|
|
||||||
shareCa?: number;
|
|
||||||
shareMarge?: number;
|
|
||||||
shareQty?: number;
|
|
||||||
/** Référentiels */
|
|
||||||
totalFournisseurCa?: number;
|
|
||||||
storeCount: number;
|
|
||||||
sales12m: Record<string, number>;
|
|
||||||
codeGamme: string | null;
|
|
||||||
score: number;
|
|
||||||
regularityScore: number;
|
|
||||||
/** Projection sur 12 mois si le produit est récent (Run Rate) */
|
|
||||||
projectedTotalQuantite?: number;
|
|
||||||
projectedTotalCa?: number;
|
|
||||||
/** Analyse de saisonnalité */
|
|
||||||
lastMonthWithSale?: string;
|
|
||||||
inactivityMonths?: number;
|
|
||||||
/** Contexte Fournisseur */
|
|
||||||
codeFournisseur?: string;
|
|
||||||
totalMagasins?: number;
|
|
||||||
isLastProductOfSupplier?: boolean;
|
|
||||||
/** Scoring metadata (unifié v4) */
|
|
||||||
scoring?: {
|
|
||||||
score: number; // 0-100, score hybride unifié
|
|
||||||
verdict: "A" | "Z"; // pré-calculé par TypeScript
|
|
||||||
quadrant?: string; // STAR/TRAFIC/MARGE/WATCH
|
|
||||||
isRecent: boolean;
|
|
||||||
isLastProduct: boolean;
|
|
||||||
isTop30Supplier: boolean;
|
|
||||||
};
|
|
||||||
/** Prix de vente unitaire */
|
|
||||||
prixVente?: number;
|
|
||||||
/** Rotation normalisée : unités / magasin / mois */
|
|
||||||
unitsPerStorePerMonth?: number;
|
|
||||||
/** CA normalisé : € / magasin / an */
|
|
||||||
caPerStorePerYear?: number;
|
|
||||||
/** Optional context rules for the supplier */
|
|
||||||
supplierContext?: string;
|
|
||||||
/** SQL Server internal ID — pour fetch mensuel per-site */
|
|
||||||
noid?: number;
|
|
||||||
/** Données mensuelles per-site (ventes, stock, réceptions) */
|
|
||||||
siteMonthlyData?: SiteMonthlyData[];
|
|
||||||
/** Données stock & approvisionnement (API FF Nancy) */
|
|
||||||
stockActuel?: number;
|
|
||||||
stockTotal?: number;
|
|
||||||
pcb?: number;
|
|
||||||
commandesEnCours?: number;
|
|
||||||
nbJoursDerniereVente?: number;
|
|
||||||
derniereVente?: string;
|
|
||||||
|
|
||||||
/** Ranking réseau (classement global tous magasins) */
|
|
||||||
rankingCa?: number;
|
|
||||||
rankingQte?: number;
|
|
||||||
/** Ranking magasin (classement local) */
|
|
||||||
rankingMagCa?: number;
|
|
||||||
rankingMagQte?: number;
|
|
||||||
/** Nombre total de produits classés (avec ventes sur la période) */
|
|
||||||
totalRankedProducts?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fiche de contexte enrichie générée par le ContextProfiler.
|
|
||||||
* Transmise au prompt de l'IA pour une analyse multi-dimensionnelle.
|
|
||||||
*/
|
|
||||||
contextProfile?: ProductContextProfile;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnalysisResult {
|
|
||||||
insight: string;
|
|
||||||
codein: string;
|
|
||||||
recommandation: "A" | "B" | "C" | "D" | "Z" | null;
|
|
||||||
scoring?: any; // On peut typer plus finement si nécessaire
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { create } from "zustand";
|
|
||||||
import { persist } from "zustand/middleware";
|
|
||||||
|
|
||||||
interface AiInsight {
|
|
||||||
codein: string;
|
|
||||||
insight: string;
|
|
||||||
status: "idle" | "loading" | "done" | "error";
|
|
||||||
isDuplicate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AiCopilotState {
|
|
||||||
insights: Record<string, AiInsight>;
|
|
||||||
setInsight: (codein: string, insight: string, isDuplicate?: boolean) => void;
|
|
||||||
batchSetInsights: (entries: { codein: string; insight: string }[]) => void;
|
|
||||||
setLoading: (codein: string) => void;
|
|
||||||
batchSetLoading: (codeins: string[]) => void;
|
|
||||||
setError: (codein: string, error: string) => void;
|
|
||||||
analyzeProduct: (payload: {
|
|
||||||
codein: string;
|
|
||||||
noid?: number;
|
|
||||||
libelle1: string;
|
|
||||||
libelleNiveau2?: string;
|
|
||||||
totalCa: number;
|
|
||||||
tauxMarge: number;
|
|
||||||
totalQuantite: number;
|
|
||||||
storeCount: number;
|
|
||||||
sales12m: Record<string, number>;
|
|
||||||
codeGamme: string | null;
|
|
||||||
score?: number | null;
|
|
||||||
regularityScore?: number;
|
|
||||||
lastMonthWithSale?: string;
|
|
||||||
inactivityMonths?: number;
|
|
||||||
weightedTotalQuantite?: number;
|
|
||||||
weightedTotalCa?: number;
|
|
||||||
avgQtyFournisseur?: number;
|
|
||||||
avgQtyRayon?: number;
|
|
||||||
shareCa?: number;
|
|
||||||
shareMarge?: number;
|
|
||||||
shareQty?: number;
|
|
||||||
totalFournisseurCa?: number;
|
|
||||||
codeFournisseur?: string;
|
|
||||||
totalMagasins?: number;
|
|
||||||
prixVente?: number;
|
|
||||||
unitsPerStorePerMonth?: number;
|
|
||||||
caPerStorePerYear?: number;
|
|
||||||
stockActuel?: number;
|
|
||||||
stockTotal?: number;
|
|
||||||
pcb?: number;
|
|
||||||
commandesEnCours?: number;
|
|
||||||
nbJoursDerniereVente?: number;
|
|
||||||
derniereVente?: string;
|
|
||||||
supplierContext?: string;
|
|
||||||
scoring?: {
|
|
||||||
score: number;
|
|
||||||
verdict: "A" | "Z";
|
|
||||||
isRecent: boolean;
|
|
||||||
isLastProduct: boolean;
|
|
||||||
isTop30Supplier: boolean;
|
|
||||||
};
|
|
||||||
}) => Promise<void>;
|
|
||||||
resetInsights: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAiCopilotStore = create<AiCopilotState>()(
|
|
||||||
persist(
|
|
||||||
(set, get) => ({
|
|
||||||
insights: {},
|
|
||||||
|
|
||||||
setInsight: (codein, insight, isDuplicate = false) => {
|
|
||||||
set((s) => ({
|
|
||||||
insights: {
|
|
||||||
...s.insights,
|
|
||||||
[codein]: { codein, insight, status: "done", isDuplicate },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
batchSetInsights: (entries) => {
|
|
||||||
set((s) => {
|
|
||||||
const nextInsights = { ...s.insights };
|
|
||||||
entries.forEach(({ codein, insight }) => {
|
|
||||||
nextInsights[codein] = { codein, insight, status: "done" };
|
|
||||||
});
|
|
||||||
return { insights: nextInsights };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
setLoading: (codein) => {
|
|
||||||
set((s) => ({
|
|
||||||
insights: {
|
|
||||||
...s.insights,
|
|
||||||
[codein]: { codein, insight: "", status: "loading" },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
batchSetLoading: (codeins) => {
|
|
||||||
set((s) => {
|
|
||||||
const nextInsights = { ...s.insights };
|
|
||||||
codeins.forEach(codein => {
|
|
||||||
nextInsights[codein] = { codein, insight: "", status: "loading" };
|
|
||||||
});
|
|
||||||
return { insights: nextInsights };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
setError: (codein, error) => {
|
|
||||||
set((s) => ({
|
|
||||||
insights: {
|
|
||||||
...s.insights,
|
|
||||||
[codein]: { codein, insight: error, status: "error" },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
resetInsights: () => {
|
|
||||||
set({ insights: {} });
|
|
||||||
},
|
|
||||||
|
|
||||||
analyzeProduct: async (payload) => {
|
|
||||||
const { codein } = payload;
|
|
||||||
|
|
||||||
// Mark as loading
|
|
||||||
set((s) => ({
|
|
||||||
insights: { ...s.insights, [codein]: { codein, insight: "", status: "loading" } },
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/ai/analyze", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
set((s) => ({
|
|
||||||
insights: {
|
|
||||||
...s.insights,
|
|
||||||
[codein]: { codein, insight: data.insight, status: "done" },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
set((s) => ({
|
|
||||||
insights: {
|
|
||||||
...s.insights,
|
|
||||||
[codein]: { codein, insight: "Erreur lors de l'analyse.", status: "error" },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: "collectflow-ai-storage",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import type { ProductRow, GammeCode, GridFilters } from "@/types/grid";
|
import type { ProductRow, GammeCode, GridFilters } from "@/types/grid";
|
||||||
import { computeProductScores } from "@/lib/score-engine";
|
|
||||||
import { buildLast12MonthsRange, getMensuelByArticles } from "@/lib/api-ff-client";
|
import { buildLast12MonthsRange, getMensuelByArticles } from "@/lib/api-ff-client";
|
||||||
import {
|
import {
|
||||||
pgGetArticlesByFournisseur,
|
pgGetArticlesByFournisseur,
|
||||||
@@ -9,13 +8,15 @@ import {
|
|||||||
pgGetGammesByFournisseur,
|
pgGetGammesByFournisseur,
|
||||||
pgGetNomenclatureByFournisseur,
|
pgGetNomenclatureByFournisseur,
|
||||||
pgGetStockByFournisseur,
|
pgGetStockByFournisseur,
|
||||||
pgGetRankingByFournisseur,
|
|
||||||
pgGetCommandesByFournisseur,
|
pgGetCommandesByFournisseur,
|
||||||
type PgStockRow,
|
type PgStockRow,
|
||||||
} from "@/lib/pg-ff-client";
|
} from "@/lib/pg-ff-client";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { sessionSnapshots } from "@/db/schema";
|
import { sessionSnapshots } from "@/db/schema";
|
||||||
import { eq, desc } from "drizzle-orm";
|
import { eq, desc } from "drizzle-orm";
|
||||||
|
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||||
|
|
||||||
|
const NB_MAGASINS_RESEAU = 270;
|
||||||
|
|
||||||
interface GetProductRowsInput {
|
interface GetProductRowsInput {
|
||||||
codeFournisseur: string;
|
codeFournisseur: string;
|
||||||
@@ -86,7 +87,6 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
|||||||
gammeMap,
|
gammeMap,
|
||||||
nomMap,
|
nomMap,
|
||||||
stockMap,
|
stockMap,
|
||||||
rankingResult,
|
|
||||||
commandesMap,
|
commandesMap,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }),
|
pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }),
|
||||||
@@ -94,12 +94,10 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
|||||||
pgGetGammesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetGammesByFournisseur ERROR:", e); return new Map<string, string>(); }),
|
pgGetGammesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetGammesByFournisseur ERROR:", e); return new Map<string, string>(); }),
|
||||||
pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }),
|
pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }),
|
||||||
pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map<string, PgStockRow[]>(); }),
|
pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map<string, PgStockRow[]>(); }),
|
||||||
pgGetRankingByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetRankingByFournisseur ERROR:", e); return { rankings: new Map(), totalRankedProducts: 0 }; }),
|
|
||||||
pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map<string, number>(); }),
|
pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map<string, number>(); }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { rankings: rankingMap, totalRankedProducts } = rankingResult;
|
console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`);
|
||||||
console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes, ${rankingMap.size} rankings`);
|
|
||||||
|
|
||||||
// ─── Phase 2 : Fenêtre temporelle 12 mois complets ───────────────────
|
// ─── Phase 2 : Fenêtre temporelle 12 mois complets ───────────────────
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -123,6 +121,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
|||||||
libelle1: art.libelle1 ?? "",
|
libelle1: art.libelle1 ?? "",
|
||||||
gtin: art.gtin ?? "",
|
gtin: art.gtin ?? "",
|
||||||
reference: art.reference ?? "",
|
reference: art.reference ?? "",
|
||||||
|
codeCentrale: art.codeCentrale ? String(art.codeCentrale).trim() : undefined,
|
||||||
code1: "", libelleNiveau1: "",
|
code1: "", libelleNiveau1: "",
|
||||||
code2: "", libelleNiveau2: "",
|
code2: "", libelleNiveau2: "",
|
||||||
code3: "", libelle3: "",
|
code3: "", libelle3: "",
|
||||||
@@ -136,9 +135,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
|||||||
totalCa: 0,
|
totalCa: 0,
|
||||||
totalMarge: 0,
|
totalMarge: 0,
|
||||||
tauxMarge: 0,
|
tauxMarge: 0,
|
||||||
score: 0,
|
|
||||||
workingStores: [],
|
workingStores: [],
|
||||||
aiRecommendation: null,
|
|
||||||
noid: art.no_id ? Number(art.no_id) : undefined,
|
noid: art.no_id ? Number(art.no_id) : undefined,
|
||||||
pcb: art.pcb ? Number(art.pcb) : undefined,
|
pcb: art.pcb ? Number(art.pcb) : undefined,
|
||||||
prixVente: art.pv_central ? Number(art.pv_central) : undefined,
|
prixVente: art.pv_central ? Number(art.pv_central) : undefined,
|
||||||
@@ -313,16 +310,8 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Phase 8 : Ranking réseau ────────────────────────────────────────
|
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
|
||||||
for (const [codein, ranking] of rankingMap.entries()) {
|
await enrichWithNetworkMetrics(productMap);
|
||||||
const product = productMap.get(codein);
|
|
||||||
if (!product) continue;
|
|
||||||
product.rankingCa = ranking.ranking_ca ? Number(ranking.ranking_ca) : undefined;
|
|
||||||
product.rankingQte = ranking.ranking_qte ? Number(ranking.ranking_qte) : undefined;
|
|
||||||
product.rankingMagCa = ranking.ranking_mag_ca ? Number(ranking.ranking_mag_ca) : undefined;
|
|
||||||
product.rankingMagQte = ranking.ranking_mag_qte ? Number(ranking.ranking_mag_qte) : undefined;
|
|
||||||
product.totalRankedProducts = totalRankedProducts;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
|
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
|
||||||
try {
|
try {
|
||||||
@@ -348,14 +337,14 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
|||||||
console.error("[getProductRows] Snapshot restore error:", snapErr);
|
console.error("[getProductRows] Snapshot restore error:", snapErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Phase 10 : Filtrer gamme Y sans ventes + compute scores ─────────
|
// ─── Phase 10 : Filtrer gamme Y sans ventes ──────────────────────────
|
||||||
const allRows = Array.from(productMap.values());
|
const allRows = Array.from(productMap.values());
|
||||||
const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0);
|
const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0);
|
||||||
await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, sortedPeriods);
|
await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, sortedPeriods);
|
||||||
const excludedY = allRows.length - rows.length;
|
const excludedY = allRows.length - rows.length;
|
||||||
console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`);
|
console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`);
|
||||||
|
|
||||||
return computeProductScores(rows);
|
return rows;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[getProductRows] Error for ${codeFournisseur}:`, error);
|
console.error(`[getProductRows] Error for ${codeFournisseur}:`, error);
|
||||||
@@ -467,3 +456,36 @@ async function reconcileSelectedStoreFromMensuelApi(
|
|||||||
console.error("[getProductRows] Mensuel API reconciliation error:", error);
|
console.error("[getProductRows] Mensuel API reconciliation error:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrichit les produits avec les metriques reseau Qlik (cache qlik_network_metrics),
|
||||||
|
* jointes par code centrale. Degradation propre si la sync Qlik n'a jamais tourne
|
||||||
|
* ou si le code centrale n'est pas encore disponible.
|
||||||
|
*/
|
||||||
|
async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Promise<void> {
|
||||||
|
try {
|
||||||
|
const byCodeCentrale = new Map<string, ProductRow[]>();
|
||||||
|
for (const product of productMap.values()) {
|
||||||
|
const cc = product.codeCentrale;
|
||||||
|
if (!cc) continue;
|
||||||
|
if (!byCodeCentrale.has(cc)) byCodeCentrale.set(cc, []);
|
||||||
|
byCodeCentrale.get(cc)!.push(product);
|
||||||
|
}
|
||||||
|
if (byCodeCentrale.size === 0) return;
|
||||||
|
|
||||||
|
const metrics = await getNetworkMetricsByCodeCentrale([...byCodeCentrale.keys()]);
|
||||||
|
for (const [cc, products] of byCodeCentrale.entries()) {
|
||||||
|
const m = metrics.get(cc);
|
||||||
|
if (!m) continue;
|
||||||
|
for (const product of products) {
|
||||||
|
product.caReseau = m.caReseau;
|
||||||
|
product.qteReseau = m.qteReseau;
|
||||||
|
product.nbMagasinsReseau = m.nbMagasinsReseau;
|
||||||
|
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
||||||
|
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[getProductRows] enrichWithNetworkMetrics error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
|
||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
|
||||||
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
|
|
||||||
import { ContextProfiler } from "@/features/ai-copilot/business/context-profiler";
|
|
||||||
import { Sparkles, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
|
||||||
import { GammeCode } from "@/types/grid";
|
|
||||||
import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types";
|
|
||||||
import { SupplierAiContextModal } from "./supplier-ai-context-modal";
|
|
||||||
|
|
||||||
export function BulkAiAnalyzer() {
|
|
||||||
const rows = useGridStore((s) => s.rows);
|
|
||||||
const supplierCode = rows.length > 0 ? rows[0].codeFournisseur : null;
|
|
||||||
const supplierName = rows.length > 0 ? rows[0].nomFournisseur : null;
|
|
||||||
|
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
|
||||||
const [progress, setProgress] = useState({ current: 0, total: 0, message: "", errors: 0 });
|
|
||||||
const isCancelledRef = useRef(false);
|
|
||||||
|
|
||||||
const batchSetDraftGamme = useGridStore((s) => s.batchSetDraftGamme);
|
|
||||||
const setDraftGamme = useGridStore((s) => s.setDraftGamme);
|
|
||||||
const { setInsight, batchSetInsights, batchSetLoading, setError } = useAiCopilotStore();
|
|
||||||
|
|
||||||
const prevSupplierRef = useRef(supplierCode);
|
|
||||||
useEffect(() => {
|
|
||||||
if (supplierCode !== prevSupplierRef.current) {
|
|
||||||
if (!isAnalyzing) {
|
|
||||||
setProgress({ current: 0, total: 0, message: "", errors: 0 });
|
|
||||||
}
|
|
||||||
prevSupplierRef.current = supplierCode;
|
|
||||||
}
|
|
||||||
}, [supplierCode, isAnalyzing]);
|
|
||||||
|
|
||||||
const handleStop = () => {
|
|
||||||
isCancelledRef.current = true;
|
|
||||||
setProgress((prev) => ({ ...prev, message: "Arrêt en cours..." }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
|
||||||
const { rows } = useGridStore.getState();
|
|
||||||
isCancelledRef.current = false;
|
|
||||||
|
|
||||||
// Show immediate visual feedback before any async work
|
|
||||||
setIsAnalyzing(true);
|
|
||||||
setProgress({ current: 0, total: 0, message: "Chargement du contexte...", errors: 0 });
|
|
||||||
|
|
||||||
// 1. Fetch AI Context for the Supplier
|
|
||||||
let supplierContext = "";
|
|
||||||
if (supplierCode) {
|
|
||||||
try {
|
|
||||||
const ctxRes = await fetch(`/api/ai/context?fournisseur=${supplierCode}`);
|
|
||||||
if (ctxRes.ok) {
|
|
||||||
const data = await ctxRes.json();
|
|
||||||
supplierContext = data.context || "";
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load supplier context for AI", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2a. Produits sans aucune vente → Z direct, sans appel IA (avant de construire les payloads)
|
|
||||||
const zeroSalesRows = rows.filter((r) => (r.totalQuantite || 0) === 0);
|
|
||||||
if (zeroSalesRows.length > 0) {
|
|
||||||
const zChanges: Record<string, GammeCode> = {};
|
|
||||||
zeroSalesRows.forEach(r => { zChanges[r.codein] = "Z"; });
|
|
||||||
batchSetDraftGamme(zChanges);
|
|
||||||
batchSetInsights(zeroSalesRows.map(r => ({
|
|
||||||
codein: r.codein,
|
|
||||||
insight: "Aucune vente sur 12 mois — produit classé Z automatiquement.",
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construire les payloads uniquement pour les produits avec ventes
|
|
||||||
const rowsWithSales = rows.filter((r) => (r.totalQuantite || 0) > 0);
|
|
||||||
const initialPayloads: ProductAnalysisInput[] = rowsWithSales.map((r) => {
|
|
||||||
const sc = r.workingStores?.length || 1;
|
|
||||||
const weight = sc === 1 ? 2 : 1;
|
|
||||||
|
|
||||||
const allMonths = Object.keys(r.sales12m || {});
|
|
||||||
const referenceMonth = allMonths.length > 0 ? Math.max(...allMonths.map((m) => parseInt(m))).toString() : "";
|
|
||||||
const salesMonths = Object.entries(r.sales12m || {})
|
|
||||||
.filter(([_, qty]) => qty > 0)
|
|
||||||
.map(([m]) => parseInt(m))
|
|
||||||
.sort((a, b) => b - a);
|
|
||||||
|
|
||||||
const lastMonth = salesMonths.length > 0 ? salesMonths[0].toString() : "";
|
|
||||||
let inactivity = 0;
|
|
||||||
if (referenceMonth && lastMonth) {
|
|
||||||
const refY = parseInt(referenceMonth.substring(0, 4));
|
|
||||||
const refM = parseInt(referenceMonth.substring(4, 6));
|
|
||||||
const lastY = parseInt(lastMonth.substring(0, 4));
|
|
||||||
const lastM = parseInt(lastMonth.substring(4, 6));
|
|
||||||
inactivity = (refY - lastY) * 12 + (refM - lastM);
|
|
||||||
}
|
|
||||||
|
|
||||||
const regScore = Object.values(r.sales12m || {}).filter((v) => v > 0).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
codein: r.codein,
|
|
||||||
noid: r.noid,
|
|
||||||
libelle1: r.libelle1 || "",
|
|
||||||
libelleNiveau2: r.libelleNiveau2 || "Général",
|
|
||||||
codeNomenclatureN2: r.code2 || undefined,
|
|
||||||
totalCa: r.totalCa || 0,
|
|
||||||
tauxMarge: r.tauxMarge || 0,
|
|
||||||
totalQuantite: r.totalQuantite || 0,
|
|
||||||
weightedTotalQuantite: (r.totalQuantite || 0) * weight,
|
|
||||||
weightedTotalCa: (r.totalCa || 0) * weight,
|
|
||||||
storeCount: sc,
|
|
||||||
sales12m: Object.fromEntries(Object.entries(r.sales12m || {}).map(([month, val]) => [month, (val as number) * weight])),
|
|
||||||
codeGamme: r.codeGamme || null,
|
|
||||||
score: r.score || 0,
|
|
||||||
regularityScore: regScore,
|
|
||||||
lastMonthWithSale: lastMonth,
|
|
||||||
inactivityMonths: inactivity,
|
|
||||||
codeFournisseur: r.codeFournisseur || undefined,
|
|
||||||
avgQtyFournisseur: r.avgQtyFournisseur,
|
|
||||||
avgQtyRayon: r.avgQtyRayon,
|
|
||||||
shareCa: r.shareCa,
|
|
||||||
shareMarge: r.shareMarge,
|
|
||||||
shareQty: r.shareQty,
|
|
||||||
totalFournisseurCa: r.totalFournisseurCa,
|
|
||||||
supplierContext: supplierContext,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2b. Stock mort absolu : CA < 100€ ET quantité < 30 → Z direct, sans appel IA
|
|
||||||
const deadStockRows = rows.filter(r =>
|
|
||||||
(r.totalQuantite || 0) > 0 &&
|
|
||||||
(r.totalCa ?? 0) < 100 && (r.totalQuantite ?? 0) < 30
|
|
||||||
);
|
|
||||||
if (deadStockRows.length > 0) {
|
|
||||||
const zChanges: Record<string, GammeCode> = {};
|
|
||||||
deadStockRows.forEach(r => { zChanges[r.codein] = "Z"; });
|
|
||||||
batchSetDraftGamme(zChanges);
|
|
||||||
batchSetInsights(deadStockRows.map(r => ({
|
|
||||||
codein: r.codein,
|
|
||||||
insight: `CA < 100€ (${(r.totalCa ?? 0).toFixed(0)}€) et quantité < 30 (${r.totalQuantite} uté) — stock mort absolu, classé Z automatiquement.`,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
const deadStockCodes = new Set(deadStockRows.map(r => r.codein));
|
|
||||||
|
|
||||||
// 2c. Top 20 réseau → A direct, sans appel IA
|
|
||||||
const top20Rows = rows.filter(r =>
|
|
||||||
(r.totalQuantite || 0) > 0 &&
|
|
||||||
!deadStockCodes.has(r.codein) &&
|
|
||||||
((r.rankingCa != null && r.rankingCa <= 20) || (r.rankingQte != null && r.rankingQte <= 20))
|
|
||||||
);
|
|
||||||
if (top20Rows.length > 0) {
|
|
||||||
const aChanges: Record<string, GammeCode> = {};
|
|
||||||
top20Rows.forEach(r => { aChanges[r.codein] = "A"; });
|
|
||||||
batchSetDraftGamme(aChanges);
|
|
||||||
batchSetInsights(top20Rows.map(r => {
|
|
||||||
const rkCa = r.rankingCa != null ? `${r.rankingCa}e CA` : "";
|
|
||||||
const rkQte = r.rankingQte != null ? `${r.rankingQte}e Qté` : "";
|
|
||||||
const total = r.totalRankedProducts ? ` / ${r.totalRankedProducts.toLocaleString("fr-FR")} produits` : "";
|
|
||||||
return {
|
|
||||||
codein: r.codein,
|
|
||||||
insight: `Top 20 réseau (${[rkCa, rkQte].filter(Boolean).join(", ")}${total}) — classé A automatiquement.`,
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
const top20Codes = new Set(top20Rows.map(r => r.codein));
|
|
||||||
|
|
||||||
// Filtrer : ne soumettre à l'IA que les produits avec au moins 1 vente et CA/quantité suffisants
|
|
||||||
const payloadsWithSales = initialPayloads.filter(p =>
|
|
||||||
(p.totalQuantite || 0) > 0 && !deadStockCodes.has(p.codein) && !top20Codes.has(p.codein)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2c. Context profiling en micro-batches asynchrones
|
|
||||||
// Le score est déjà calculé par score-engine.ts (champ row.score sur chaque ProductRow).
|
|
||||||
setProgress({ current: 0, total: payloadsWithSales.length, message: "Calcul du contexte...", errors: 0 });
|
|
||||||
|
|
||||||
const SCORING_BATCH_SIZE = 25;
|
|
||||||
const productPayloads: ProductAnalysisInput[] = [];
|
|
||||||
|
|
||||||
// Pre-compute context thresholds to prevent O(N^2) and O(N^2 log N) performance freezes
|
|
||||||
const contextCache = ContextProfiler.prepareCache(payloadsWithSales);
|
|
||||||
|
|
||||||
// Map codein → row pour récupérer le score et les flags depuis score-engine
|
|
||||||
const rowByCodein = new Map(rows.map(r => [r.codein, r]));
|
|
||||||
|
|
||||||
for (let i = 0; i < payloadsWithSales.length; i += SCORING_BATCH_SIZE) {
|
|
||||||
if (isCancelledRef.current) break;
|
|
||||||
|
|
||||||
const batch = payloadsWithSales.slice(i, i + SCORING_BATCH_SIZE);
|
|
||||||
|
|
||||||
for (const p of batch) {
|
|
||||||
const row = rowByCodein.get(p.codein);
|
|
||||||
const score = row?.score ?? p.score ?? 0;
|
|
||||||
const verdict: "A" | "Z" = score >= 45 ? "A" : "Z";
|
|
||||||
|
|
||||||
let contextProfile: ProductAnalysisInput["contextProfile"];
|
|
||||||
try {
|
|
||||||
contextProfile = ContextProfiler.buildProfile(p, payloadsWithSales, score, contextCache);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[BulkAnalyzer] ContextProfiler failed for ${p.codein}:`, err);
|
|
||||||
contextProfile = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
productPayloads.push({
|
|
||||||
...p,
|
|
||||||
prixVente: row?.prixVente,
|
|
||||||
unitsPerStorePerMonth: row?.unitsPerStorePerMonth,
|
|
||||||
caPerStorePerYear: row?.caPerStorePerYear,
|
|
||||||
stockActuel: row?.stockActuel,
|
|
||||||
stockTotal: row?.stockTotal,
|
|
||||||
pcb: row?.pcb,
|
|
||||||
commandesEnCours: row?.commandesEnCours,
|
|
||||||
nbJoursDerniereVente: row?.nbJoursDerniereVente,
|
|
||||||
derniereVente: row?.derniereVente,
|
|
||||||
rankingCa: row?.rankingCa,
|
|
||||||
rankingQte: row?.rankingQte,
|
|
||||||
rankingMagCa: row?.rankingMagCa,
|
|
||||||
rankingMagQte: row?.rankingMagQte,
|
|
||||||
totalRankedProducts: row?.totalRankedProducts,
|
|
||||||
contextProfile,
|
|
||||||
scoring: {
|
|
||||||
score,
|
|
||||||
verdict,
|
|
||||||
quadrant: contextProfile?.quadrant,
|
|
||||||
isRecent: row?.isRecent ?? false,
|
|
||||||
isLastProduct: row?.isLastProduct ?? false,
|
|
||||||
isTop30Supplier: row?.isTop30Supplier ?? false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yield au navigateur entre chaque batch pour éviter le freeze
|
|
||||||
setProgress(prev => ({
|
|
||||||
...prev,
|
|
||||||
current: Math.min(i + SCORING_BATCH_SIZE, payloadsWithSales.length),
|
|
||||||
message: `Contexte : ${Math.min(i + SCORING_BATCH_SIZE, payloadsWithSales.length)}/${payloadsWithSales.length}`,
|
|
||||||
}));
|
|
||||||
await new Promise(r => setTimeout(r, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCancelledRef.current) {
|
|
||||||
setIsAnalyzing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let completed = 0;
|
|
||||||
let errorsCount = 0;
|
|
||||||
|
|
||||||
const totalItems = productPayloads.length;
|
|
||||||
setProgress({ current: 0, total: totalItems, message: "Envoi aux modèles IA...", errors: 0 });
|
|
||||||
|
|
||||||
// Reset global ATOMIQUE et IMMÉDIAT
|
|
||||||
const codeins = productPayloads.map(p => p.codein);
|
|
||||||
batchSetLoading(codeins);
|
|
||||||
|
|
||||||
const gammeChanges: Record<string, GammeCode> = {};
|
|
||||||
codeins.forEach(c => gammeChanges[c] = "Aucune");
|
|
||||||
batchSetDraftGamme(gammeChanges);
|
|
||||||
|
|
||||||
const CONCURRENCY = 2;
|
|
||||||
const remaining = [...productPayloads];
|
|
||||||
|
|
||||||
const processBatch = async () => {
|
|
||||||
const workers = Array.from({ length: Math.min(CONCURRENCY, remaining.length) }, async () => {
|
|
||||||
while (remaining.length > 0 && !isCancelledRef.current) {
|
|
||||||
const payload = remaining.shift();
|
|
||||||
if (!payload) break;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let res: Response | null = null;
|
|
||||||
// Jusqu'à 3 tentatives par produit
|
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
|
||||||
if (isCancelledRef.current) break;
|
|
||||||
try {
|
|
||||||
res = await fetch("/api/ai/analyze", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (res.ok) break; // Succès → sortie de boucle
|
|
||||||
|
|
||||||
if (res.status === 429) {
|
|
||||||
// Rate-limit : on attend avant de réessayer
|
|
||||||
const retryData = await res.json().catch(() => ({}));
|
|
||||||
const wait = (retryData.retryAfter ?? 30) * 1000;
|
|
||||||
console.warn(`[BulkAI] Rate limited, retry in ${wait / 1000}s`);
|
|
||||||
await new Promise((r) => setTimeout(r, Math.min(wait, 15_000)));
|
|
||||||
} else if (res.status === 504 || res.status === 502 || res.status === 503 || res.status === 529) {
|
|
||||||
// Timeout ou réseau surchargé : on tente de réessayer de suite
|
|
||||||
console.warn(`[BulkAI] Network issue (${res.status}) for ${payload.codein}, retry #${attempt + 1}`);
|
|
||||||
await new Promise((r) => setTimeout(r, 2_000 * (attempt + 1))); // Exponential backoff (2s, 4s, 6s)
|
|
||||||
} else {
|
|
||||||
// Autre erreur (500 local, auth, etc.) : ne pas réessayer inutilement
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (fetchErr) {
|
|
||||||
console.error(`[BulkAI] Fetch error (attempt ${attempt + 1}) for ${payload.codein}`, fetchErr);
|
|
||||||
await new Promise((r) => setTimeout(r, 1_000)); // Petite pause avant retry réseau
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res || !res.ok) {
|
|
||||||
let errorMsg = res?.status === 504 ? "Timeout IA" : (res?.status === 502 ? "API Surchargée" : `Erreur HTTP ${res?.status}`);
|
|
||||||
|
|
||||||
// On tente d'extraire le message original s'il y en a un
|
|
||||||
if (res && res.headers.get("content-type")?.includes("application/json")) {
|
|
||||||
try {
|
|
||||||
const errData = await res.json();
|
|
||||||
if (errData.error) errorMsg += ` - ${errData.error}`;
|
|
||||||
if (errData.detail) errorMsg += ` (${errData.detail})`;
|
|
||||||
} catch (e) { /* ignore JSON parse error on error */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(payload.codein, errorMsg);
|
|
||||||
errorsCount++;
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setInsight(payload.codein, data.insight, data.isDuplicate);
|
|
||||||
if (data.recommandation) {
|
|
||||||
setDraftGamme(payload.codein, data.recommandation as GammeCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[BulkAI] Error processing ${payload.codein}`, err);
|
|
||||||
setError(payload.codein, "Erreur");
|
|
||||||
errorsCount++;
|
|
||||||
} finally {
|
|
||||||
completed++;
|
|
||||||
setProgress((prev) => ({
|
|
||||||
...prev,
|
|
||||||
current: completed,
|
|
||||||
message: `Analyse: ${completed}/${totalItems}`,
|
|
||||||
errors: errorsCount,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(workers);
|
|
||||||
};
|
|
||||||
|
|
||||||
await processBatch();
|
|
||||||
|
|
||||||
// Nettoyer les drafts "Aucune" restants (produits en erreur dont l'IA n'a pas retourné A/Z)
|
|
||||||
// Pour éviter que "Valider" ne sauvegarde "Aucune" comme gamme réelle
|
|
||||||
const currentDrafts = useGridStore.getState().draftChanges;
|
|
||||||
const aucuneCodeins = codeins.filter(c => currentDrafts[c] === "Aucune");
|
|
||||||
if (aucuneCodeins.length > 0) {
|
|
||||||
const { clearDrafts } = useGridStore.getState();
|
|
||||||
clearDrafts(aucuneCodeins);
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgress((prev) => ({
|
|
||||||
...prev,
|
|
||||||
message: isCancelledRef.current ? `Analyse interrompue (${completed}/${totalItems})` : `Analyse terminée ! (${errorsCount > 0 ? errorsCount + " erreurs" : "succès"})`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setTimeout(() => setIsAnalyzing(false), 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<SupplierAiContextModal codeFournisseur={supplierCode} nomFournisseur={supplierName} />
|
|
||||||
|
|
||||||
{isAnalyzing ? (
|
|
||||||
<div className="flex items-center gap-4 h-10 px-4 rounded-xl bg-[var(--accent-bg)] border border-[var(--accent-border)] shadow-sm">
|
|
||||||
<Loader2 className="w-4 h-4 text-[var(--accent)] animate-spin" />
|
|
||||||
<div className="flex flex-col min-w-[150px]">
|
|
||||||
<span className="text-[10px] font-bold text-[var(--accent)] leading-none">
|
|
||||||
{progress.message}
|
|
||||||
</span>
|
|
||||||
<div className="w-full bg-[var(--bg-elevated)] rounded-full h-1 mt-1 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="bg-[var(--accent)] h-1 rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${Math.max(5, (progress.current / progress.total) * 100)}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleStop}
|
|
||||||
className="p-1 hover:bg-[var(--bg-elevated)] rounded-lg transition-colors text-[var(--text-muted)] hover:text-[var(--accent-error)]"
|
|
||||||
title="Arrêter l'analyse"
|
|
||||||
>
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : progress.current > 0 ? (
|
|
||||||
<div className="apple-btn-primary opacity-80 cursor-default">
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
<span>
|
|
||||||
{progress.errors > 0 ? `Terminé (${progress.current - progress.errors}/${progress.total})` : `Analyse Terminée`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleAnalyze}
|
|
||||||
className="apple-btn-secondary"
|
|
||||||
>
|
|
||||||
<Sparkles className="w-4 h-4" style={{ color: "var(--accent)" }} />
|
|
||||||
Analyse IA
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -118,7 +118,7 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
|
|||||||
doc.text(`Export généré le : ${new Date().toLocaleDateString("fr-FR")}`, 14, 30);
|
doc.text(`Export généré le : ${new Date().toLocaleDateString("fr-FR")}`, 14, 30);
|
||||||
|
|
||||||
const head = [
|
const head = [
|
||||||
["Gencode", "Code In", "Réf.", "Libellé", "Score", "Vol.", "CA", "Marge", "Gamme"]
|
["Gencode", "Code In", "Réf.", "Libellé", "Mag. Rés.", "Vol.", "CA", "Marge", "Gamme"]
|
||||||
];
|
];
|
||||||
|
|
||||||
const body = rows.map(r => {
|
const body = rows.map(r => {
|
||||||
@@ -128,7 +128,7 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
|
|||||||
r.codein,
|
r.codein,
|
||||||
r.reference || "-",
|
r.reference || "-",
|
||||||
r.libelle1 ? r.libelle1.substring(0, 60) + (r.libelle1.length > 60 ? "..." : "") : "",
|
r.libelle1 ? r.libelle1.substring(0, 60) + (r.libelle1.length > 60 ? "..." : "") : "",
|
||||||
r.score.toString(),
|
r.nbMagasinsReseau != null ? r.nbMagasinsReseau.toString() : "-",
|
||||||
Math.round(r.totalQuantite).toLocaleString("fr-FR"),
|
Math.round(r.totalQuantite).toLocaleString("fr-FR"),
|
||||||
`${Math.round(r.totalCa).toLocaleString("fr-FR")} €`,
|
`${Math.round(r.totalCa).toLocaleString("fr-FR")} €`,
|
||||||
`${Math.round(r.totalMarge).toLocaleString("fr-FR")} €\n(${r.tauxMarge.toFixed(1)}%)`,
|
`${Math.round(r.totalMarge).toLocaleString("fr-FR")} €\n(${r.tauxMarge.toFixed(1)}%)`,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||||
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
|
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
import { BulkAiAnalyzer } from "./bulk-ai-analyzer";
|
import { SyncQlikButton } from "./sync-qlik-button";
|
||||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
||||||
import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera, ChevronDown } from "lucide-react";
|
import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera, ChevronDown } from "lucide-react";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
@@ -35,7 +34,6 @@ export function FloatingSummaryBar() {
|
|||||||
const rows = useGridStore((s) => s.rows);
|
const rows = useGridStore((s) => s.rows);
|
||||||
const filters = useGridStore((s) => s.filters);
|
const filters = useGridStore((s) => s.filters);
|
||||||
const draftChanges = useGridStore((s) => s.draftChanges);
|
const draftChanges = useGridStore((s) => s.draftChanges);
|
||||||
const { resetInsights } = useAiCopilotStore();
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||||
@@ -48,6 +46,15 @@ export function FloatingSummaryBar() {
|
|||||||
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
|
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
|
||||||
const { save, hasDrafts, count } = useSaveDrafts(filters.magasin || "TOTAL", visibleCodeins);
|
const { save, hasDrafts, count } = useSaveDrafts(filters.magasin || "TOTAL", visibleCodeins);
|
||||||
|
|
||||||
|
const supplierCode = filters.codeFournisseur || rows[0]?.codeFournisseur;
|
||||||
|
const lastQlikUpdate = useMemo(() => {
|
||||||
|
let max: string | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.networkFetchedAt && (!max || r.networkFetchedAt > max)) max = r.networkFetchedAt;
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await save();
|
const result = await save();
|
||||||
@@ -57,9 +64,8 @@ export function FloatingSummaryBar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
if (window.confirm("Es-tu sûr de vouloir annuler tous les changements non enregistrés (gammes et analyses IA) ?")) {
|
if (window.confirm("Es-tu sûr de vouloir annuler tous les changements non enregistrés (gammes) ?")) {
|
||||||
resetDrafts();
|
resetDrafts();
|
||||||
resetInsights();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,7 +188,7 @@ export function FloatingSummaryBar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex space-x-3 items-center">
|
<div className="flex space-x-3 items-center">
|
||||||
{isAdmin && <BulkAiAnalyzer />}
|
{isAdmin && <SyncQlikButton codeFournisseur={supplierCode} lastUpdate={lastQlikUpdate} />}
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { GammeSelect } from "@/features/grid/components/gamme-select";
|
|||||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||||
import type { ProductRow, GammeCode } from "@/types/grid";
|
import type { ProductRow, GammeCode } from "@/types/grid";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AiInsightBlock } from "@/features/ai-copilot/components/ai-insight-block";
|
|
||||||
|
|
||||||
function getLast12Months(): string[] {
|
function getLast12Months(): string[] {
|
||||||
const months: string[] = [];
|
const months: string[] = [];
|
||||||
@@ -178,8 +177,8 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisi
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell: Cell<ProductRow, unknown>) => {
|
{row.getVisibleCells().map((cell: Cell<ProductRow, unknown>) => {
|
||||||
const isFlexible = cell.column.id === "libelle1" || cell.column.id === "ai" || cell.column.id === "libelle3";
|
const isFlexible = cell.column.id === "libelle1" || cell.column.id === "libelle3";
|
||||||
const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || cell.column.id === "score" || cell.column.id === "gamme";
|
const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || cell.column.id === "caReseau" || cell.column.id === "qteReseau" || cell.column.id === "nbMagasinsReseau" || cell.column.id === "tauxPresenceReseau" || cell.column.id === "gamme";
|
||||||
const size = cell.column.getSize();
|
const size = cell.column.getSize();
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
@@ -507,67 +506,56 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "score",
|
accessorKey: "caReseau",
|
||||||
header: "Score",
|
header: () => <div className="text-center w-full">CA<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||||
size: 60,
|
size: 90,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const val = getValue<number>();
|
const val = getValue<number | undefined>();
|
||||||
const color = val >= 80 ? "text-emerald-500" : val >= 50 ? "text-amber-500" : "text-rose-500";
|
return (
|
||||||
|
<div className="text-center tabular-nums text-[12px] font-bold text-emerald-600 dark:text-emerald-400">
|
||||||
|
{val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "qteReseau",
|
||||||
|
header: () => <div className="text-center w-full">Qté<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||||
|
size: 80,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue<number | undefined>();
|
||||||
|
return (
|
||||||
|
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{val != null ? Math.round(val).toLocaleString("fr-FR") : "-"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "nbMagasinsReseau",
|
||||||
|
header: () => <div className="text-center w-full">Magasins<br/><span className="text-[9px] opacity-60">/ 270</span></div>,
|
||||||
|
size: 80,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue<number | undefined>();
|
||||||
|
return (
|
||||||
|
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{val != null ? val : "-"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "tauxPresenceReseau",
|
||||||
|
header: () => <div className="text-center w-full">% Prés.<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||||
|
size: 70,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue<number | undefined>();
|
||||||
|
if (val == null) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||||
|
const pct = Math.round(val * 100);
|
||||||
|
const color = pct >= 66 ? "text-emerald-500" : pct >= 33 ? "text-amber-500" : "text-rose-500";
|
||||||
return (
|
return (
|
||||||
<div className={cn("text-center font-black text-[13px] tabular-nums", color)}>
|
<div className={cn("text-center font-black text-[13px] tabular-nums", color)}>
|
||||||
{val}
|
{pct}%
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "rankingCa",
|
|
||||||
header: () => <div className="text-center w-full">Rk CA<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
|
||||||
size: 70,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue<number | undefined>();
|
|
||||||
return (
|
|
||||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{val != null ? val : "-"}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "rankingQte",
|
|
||||||
header: () => <div className="text-center w-full">Rk Qté<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
|
||||||
size: 70,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue<number | undefined>();
|
|
||||||
return (
|
|
||||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{val != null ? val : "-"}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "rankingMagCa",
|
|
||||||
header: () => <div className="text-center w-full">Rk CA<br/><span className="text-[9px] opacity-60">Mag.</span></div>,
|
|
||||||
size: 70,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue<number | undefined>();
|
|
||||||
return (
|
|
||||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{val != null ? val : "-"}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "rankingMagQte",
|
|
||||||
header: () => <div className="text-center w-full">Rk Qté<br/><span className="text-[9px] opacity-60">Mag.</span></div>,
|
|
||||||
size: 70,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue<number | undefined>();
|
|
||||||
return (
|
|
||||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{val != null ? val : "-"}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -679,17 +667,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
size: 110,
|
size: 110,
|
||||||
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
|
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "ai",
|
|
||||||
header: () => <div className="print:hidden">Recommandation IA</div>,
|
|
||||||
size: 230,
|
|
||||||
enableSorting: false,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="print:hidden w-full h-full">
|
|
||||||
<AiInsightBlock row={row.original} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
], [MONTHS_12, activeMagasin, isAdmin]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
|
], [MONTHS_12, activeMagasin, isAdmin]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -800,8 +777,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id} className="flex w-full">
|
<tr key={headerGroup.id} className="flex w-full">
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
const isFlexible = header.column.id === "libelle1" || header.column.id === "ai" || header.column.id === "libelle3";
|
const isFlexible = header.column.id === "libelle1" || header.column.id === "libelle3";
|
||||||
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "score" || header.column.id === "gamme";
|
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "caReseau" || header.column.id === "qteReseau" || header.column.id === "nbMagasinsReseau" || header.column.id === "tauxPresenceReseau" || header.column.id === "gamme";
|
||||||
const size = header.getSize();
|
const size = header.getSize();
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function NoSalesTab() {
|
|||||||
<div className="sticky top-0 z-10 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px]"
|
<div className="sticky top-0 z-10 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px]"
|
||||||
style={{ background: "var(--bg-elevated)", borderBottom: "1px solid var(--border)" }}
|
style={{ background: "var(--bg-elevated)", borderBottom: "1px solid var(--border)" }}
|
||||||
>
|
>
|
||||||
{["Code", "Libellé", "Réf.", "Gamme", "Dernière vente", "Jours", "Stock", "CA 12m", "Score"].map(col => (
|
{["Code", "Libellé", "Réf.", "Gamme", "Dernière vente", "Jours", "Stock", "CA 12m", "Mag."].map(col => (
|
||||||
<div key={col}
|
<div key={col}
|
||||||
className="px-3 py-2 text-left font-semibold whitespace-nowrap"
|
className="px-3 py-2 text-left font-semibold whitespace-nowrap"
|
||||||
style={{ color: "var(--text-muted)" }}
|
style={{ color: "var(--text-muted)" }}
|
||||||
@@ -159,8 +159,8 @@ export function NoSalesTab() {
|
|||||||
{row.totalCa > 0 ? formatCa(row.totalCa) : "—"}
|
{row.totalCa > 0 ? formatCa(row.totalCa) : "—"}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1.5 text-right tabular-nums font-semibold"
|
<div className="px-3 py-1.5 text-right tabular-nums font-semibold"
|
||||||
style={{ color: row.score >= 50 ? "var(--color-amber, #f59e0b)" : "var(--text-muted)" }}>
|
style={{ color: "var(--text-secondary)" }}>
|
||||||
{row.score > 0 ? row.score : "—"}
|
{row.nbMagasinsReseau != null ? row.nbMagasinsReseau : "—"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger } from "@/components/ui/dialog";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Brain } from "lucide-react";
|
|
||||||
|
|
||||||
interface SupplierAiContextModalProps {
|
|
||||||
codeFournisseur: string | null;
|
|
||||||
nomFournisseur: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SupplierAiContextModal({ codeFournisseur, nomFournisseur }: SupplierAiContextModalProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [context, setContext] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && codeFournisseur) {
|
|
||||||
fetchContext();
|
|
||||||
}
|
|
||||||
}, [open, codeFournisseur]);
|
|
||||||
|
|
||||||
const fetchContext = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/ai/context?fournisseur=${codeFournisseur}`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setContext(data.context || "");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load AI context", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!codeFournisseur) return;
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/ai/context", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ codeFournisseur, context }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setOpen(false);
|
|
||||||
} else {
|
|
||||||
console.error("Failed to save AI context");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save AI context", error);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!codeFournisseur) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<button className="apple-btn-rules">
|
|
||||||
<Brain className="w-4 h-4" />
|
|
||||||
Règles IA
|
|
||||||
</button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[500px] bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Contexte IA - {nomFournisseur || codeFournisseur}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Définissez ici les règles métier absolues pour ce fournisseur.
|
|
||||||
Mary (l'IA) respectera ces consignes en priorité lors de ses recommandations (ex: "Les calendriers vont en gamme C").
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="py-4">
|
|
||||||
<Textarea
|
|
||||||
placeholder="Ex: Les produits dont le nom contient 'Agenda' doivent toujours être classés en [A]..."
|
|
||||||
value={context}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setContext(e.target.value)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="min-h-[150px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<button className="apple-btn-secondary" onClick={() => setOpen(false)} disabled={isSaving}>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button onClick={handleSave} disabled={isSaving || isLoading} className="apple-btn-primary">
|
|
||||||
{isSaving ? "Enregistrement..." : "Enregistrer les règles"}
|
|
||||||
</button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||||
|
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
interface SyncQlikButtonProps {
|
||||||
|
/** Code fournisseur affiché (pour sync ciblée) */
|
||||||
|
codeFournisseur?: string;
|
||||||
|
/** Date ISO de dernière maj Qlik pour ce fournisseur (max des lignes) */
|
||||||
|
lastUpdate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso?: string | null): string {
|
||||||
|
if (!iso) return "jamais";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return "jamais";
|
||||||
|
return d.toLocaleString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché
|
||||||
|
* (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj.
|
||||||
|
*/
|
||||||
|
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
|
||||||
|
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||||
|
const [message, setMessage] = useState<string>("");
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
if (!codeFournisseur) {
|
||||||
|
setStatus("error");
|
||||||
|
setMessage("Aucun fournisseur sélectionné");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("loading");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.success) throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
setStatus("success");
|
||||||
|
setMessage(`${data.upserted} produits réseau synchronisés`);
|
||||||
|
// Recharge la grille pour afficher les nouvelles données réseau
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("_refresh", String(Date.now()));
|
||||||
|
router.replace(`${pathname}?${params.toString()}`);
|
||||||
|
setTimeout(() => setStatus("idle"), 4000);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("error");
|
||||||
|
setMessage(e instanceof Error ? e.message : String(e));
|
||||||
|
setTimeout(() => setStatus("idle"), 6000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<button
|
||||||
|
onClick={handleSync}
|
||||||
|
disabled={status === "loading"}
|
||||||
|
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
||||||
|
title={message || "Synchroniser les données réseau Qlik pour ce fournisseur"}
|
||||||
|
>
|
||||||
|
{status === "loading" ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : status === "success" ? (
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
) : status === "error" ? (
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 text-rose-500" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Sync Qlik
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] text-slate-500 mt-0.5">
|
||||||
|
{status === "error" ? message : `MAJ Qlik : ${formatDate(lastUpdate)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -496,102 +496,6 @@ export async function getCommandesByFournisseur(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Ranking — classement réseau et magasin
|
|
||||||
// GET /api/ranking?codein=<codein>
|
|
||||||
// Réponse : { count: number, ranking: RankingEntry[] }
|
|
||||||
// Les valeurs ranking sont des string|null, à parser en number.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface FfRanking {
|
|
||||||
ranking_ca?: number;
|
|
||||||
ranking_qte?: number;
|
|
||||||
ranking_mag_ca?: number;
|
|
||||||
ranking_mag_qte?: number;
|
|
||||||
ranking_mag_marge?: number;
|
|
||||||
pv_calcule?: number;
|
|
||||||
pv_mag?: number;
|
|
||||||
pv_cen?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse une valeur string|null en number|undefined */
|
|
||||||
function parseRankNum(val: string | null | undefined): number | undefined {
|
|
||||||
if (val == null || val === "") return undefined;
|
|
||||||
const n = Number(val);
|
|
||||||
return isNaN(n) ? undefined : n;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RankingResult {
|
|
||||||
rankings: Map<string, FfRanking>;
|
|
||||||
/** Nombre total de produits dans le classement (produits avec ventes sur la période) */
|
|
||||||
totalRankedProducts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère le ranking réseau pour les articles en 1 seul appel :
|
|
||||||
* GET /api/ranking?limit=<N> — récupère tout le classement réseau, puis filtre par codein.
|
|
||||||
*
|
|
||||||
* Fallback par article individuel si le bulk échoue.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Récupère le ranking par article individuel (GET /api/ranking?codein=<codein>).
|
|
||||||
* Appelé seulement pour les articles AVEC ventes — taille raisonnable même pour grands fournisseurs.
|
|
||||||
* Le bulk endpoint est capé à 500 résultats par l'API → inutilisable pour matching complet.
|
|
||||||
*/
|
|
||||||
export async function getRankingByArticles(
|
|
||||||
articles: { codein: string; gtin?: string }[],
|
|
||||||
_codeFournisseur?: string,
|
|
||||||
): Promise<RankingResult> {
|
|
||||||
const rankings = new Map<string, FfRanking>();
|
|
||||||
if (articles.length === 0) return { rankings, totalRankedProducts: 0 };
|
|
||||||
|
|
||||||
const batchSize = 50;
|
|
||||||
for (let i = 0; i < articles.length; i += batchSize) {
|
|
||||||
const batch = articles.slice(i, i + batchSize);
|
|
||||||
await Promise.all(
|
|
||||||
batch.map(async (art) => {
|
|
||||||
try {
|
|
||||||
const url = `${FF_API_BASE}/api/ranking?codein=${encodeURIComponent(art.codein)}`;
|
|
||||||
const res = await fetch(url, { cache: "no-store" });
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
const rankingList = data?.ranking;
|
|
||||||
if (!Array.isArray(rankingList) || rankingList.length === 0) return;
|
|
||||||
const entry = rankingList[0] as Record<string, string | null>;
|
|
||||||
rankings.set(art.codein, {
|
|
||||||
ranking_ca: parseRankNum(entry.ranking_ca),
|
|
||||||
ranking_qte: parseRankNum(entry.ranking_qte),
|
|
||||||
ranking_mag_ca: parseRankNum(entry.ranking_mag_ca),
|
|
||||||
ranking_mag_qte: parseRankNum(entry.ranking_mag_qte),
|
|
||||||
ranking_mag_marge: parseRankNum(entry.ranking_mag_marge),
|
|
||||||
pv_calcule: parseRankNum(entry.pv_calcule),
|
|
||||||
pv_mag: parseRankNum(entry.pv_mag),
|
|
||||||
pv_cen: parseRankNum(entry.pv_cen),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[api-ff] getRanking error for ${art.codein}:`, err);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// totalRankedProducts = nombre d'articles ayant un classement réseau
|
|
||||||
const totalRankedProducts = rankings.size > 0
|
|
||||||
? await fetchTotalRankedProducts()
|
|
||||||
: 0;
|
|
||||||
console.log(`[api-ff] Ranking: ${rankings.size}/${articles.length} articles classés (réseau: ${totalRankedProducts})`);
|
|
||||||
return { rankings, totalRankedProducts };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchTotalRankedProducts(): Promise<number> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${FF_API_BASE}/api/ranking?limit=500`, { cache: "no-store" });
|
|
||||||
if (!res.ok) return 0;
|
|
||||||
const data = await res.json();
|
|
||||||
return (data?.ranking as unknown[])?.length ?? 0;
|
|
||||||
} catch { return 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Statut de synchronisation
|
// Statut de synchronisation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+3
-82
@@ -39,6 +39,8 @@ async function pgNoParallel(query: SQL): Promise<{ rows: unknown[] }> {
|
|||||||
export interface PgArticle {
|
export interface PgArticle {
|
||||||
no_id: number;
|
no_id: number;
|
||||||
codein: string;
|
codein: string;
|
||||||
|
/** Code centrale = articles.artcentrale (format 10000XXXXXX) — clé jointure Qlik "Article Code". Vide pour les articles non référencés centralement. */
|
||||||
|
codeCentrale?: string;
|
||||||
codefou: string;
|
codefou: string;
|
||||||
nomfou?: string;
|
nomfou?: string;
|
||||||
libelle1?: string;
|
libelle1?: string;
|
||||||
@@ -74,15 +76,6 @@ export interface PgStockRow {
|
|||||||
dernierereception?: string;
|
dernierereception?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PgRankingRow {
|
|
||||||
codein: string;
|
|
||||||
site: string;
|
|
||||||
ranking_ca?: number;
|
|
||||||
ranking_qte?: number;
|
|
||||||
ranking_mag_ca?: number;
|
|
||||||
ranking_mag_qte?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 0. Liste des fournisseurs
|
// 0. Liste des fournisseurs
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -127,6 +120,7 @@ export async function pgGetArticlesByFournisseur(codefou: string): Promise<PgArt
|
|||||||
SELECT DISTINCT ON (a.no_id)
|
SELECT DISTINCT ON (a.no_id)
|
||||||
a.no_id,
|
a.no_id,
|
||||||
a.codein,
|
a.codein,
|
||||||
|
a.artcentrale AS "codeCentrale",
|
||||||
af.code AS codefou,
|
af.code AS codefou,
|
||||||
fi.nom AS nomfou,
|
fi.nom AS nomfou,
|
||||||
a.libelle1,
|
a.libelle1,
|
||||||
@@ -378,79 +372,6 @@ export async function pgGetStockByFournisseur(codefou: string): Promise<Map<stri
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 5. Ranking réseau
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retourne le classement réseau pour chaque article du fournisseur.
|
|
||||||
* 1 requête SQL — remplace getRankingByArticles() (N appels HTTP per-article,
|
|
||||||
* capés à 500 par l'API).
|
|
||||||
*/
|
|
||||||
export async function pgGetRankingByFournisseur(codefou: string): Promise<{
|
|
||||||
rankings: Map<string, PgRankingRow>;
|
|
||||||
totalRankedProducts: number;
|
|
||||||
}> {
|
|
||||||
// D'abord, découvrir quels sites existent dans la table ranking (réseau vs magasin)
|
|
||||||
const [rankResult, totalResult, sitesSample] = await Promise.all([
|
|
||||||
pgNoParallel(sql`
|
|
||||||
SELECT DISTINCT ON (a.codein)
|
|
||||||
a.codein,
|
|
||||||
r.site,
|
|
||||||
r.ranking_ca,
|
|
||||||
r.ranking_qte,
|
|
||||||
r.ranking_mag_ca,
|
|
||||||
r.ranking_mag_qte
|
|
||||||
FROM ranking r
|
|
||||||
JOIN art_gtin ag
|
|
||||||
ON ag.gtin = r.gencod
|
|
||||||
JOIN articles a
|
|
||||||
ON a.no_id = ag.idarticle
|
|
||||||
JOIN artfou1 af
|
|
||||||
ON af.art_no_id = a.no_id AND af.code = ${codefou}
|
|
||||||
ORDER BY a.codein,
|
|
||||||
CASE
|
|
||||||
WHEN r.site IN ('000', 'ALL', 'TOTAL', 'NET', 'RES') THEN 0
|
|
||||||
ELSE 1
|
|
||||||
END,
|
|
||||||
r.site
|
|
||||||
`),
|
|
||||||
pgNoParallel(sql`
|
|
||||||
SELECT COUNT(DISTINCT gencod)::int AS total FROM ranking
|
|
||||||
`),
|
|
||||||
pgNoParallel(sql`
|
|
||||||
SELECT DISTINCT site FROM ranking ORDER BY site LIMIT 10
|
|
||||||
`),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Log sites disponibles pour diagnostic ranking
|
|
||||||
const sitesDispos = (sitesSample.rows as { site: string }[]).map(r => r.site);
|
|
||||||
console.log(`[pg-ff] Ranking sites disponibles:`, sitesDispos.join(", "));
|
|
||||||
|
|
||||||
const rankings = new Map<string, PgRankingRow>();
|
|
||||||
for (const row of rankResult.rows as unknown as PgRankingRow[]) {
|
|
||||||
if (row.codein) rankings.set(row.codein, row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log doublons de rank pour diagnostic
|
|
||||||
const rankCount = new Map<number, number>();
|
|
||||||
for (const r of rankings.values()) {
|
|
||||||
if (r.ranking_ca) {
|
|
||||||
const v = Number(r.ranking_ca);
|
|
||||||
rankCount.set(v, (rankCount.get(v) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dupes = [...rankCount.entries()].filter(([, c]) => c > 1).slice(0, 5);
|
|
||||||
if (dupes.length > 0) {
|
|
||||||
console.log(`[pg-ff] Ranking doublons détectés (rank → nb articles):`, dupes.map(([r, c]) => `rank${r}×${c}`).join(", "));
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalRankedProducts = Number((totalResult.rows[0] as unknown as { total: number })?.total ?? 0);
|
|
||||||
console.log(`[pg-ff] Ranking: ${rankings.size} articles classés, réseau total: ${totalRankedProducts}`);
|
|
||||||
|
|
||||||
return { rankings, totalRankedProducts };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 6. Commandes en cours
|
// 6. Commandes en cours
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* CollectFlow — Client Qlik Sense (donnees reseau ~270 magasins)
|
||||||
|
*
|
||||||
|
* Recupere par produit (cle = code centrale via la dimension "Article Code") :
|
||||||
|
* - CA reseau, Qte vendue reseau, Nb magasins travaillant le produit.
|
||||||
|
*
|
||||||
|
* Auth = NTLM (Qlik Sense Enterprise on Windows) via flux ticket SSO :
|
||||||
|
* 1. GET /hub/ (non authentifie) -> 302 vers le proxy forms, on extrait le targetId
|
||||||
|
* 2. NTLM sur /internal_windows_authentication/?targetId=... -> 302 /hub/?qlikTicket=XXX
|
||||||
|
* 3. GET /hub/?qlikTicket=XXX -> Qlik pose le cookie X-Qlik-Session
|
||||||
|
* Le cookie + Xrfkey servent ensuite pour QRS (REST) et l'Engine API (websocket).
|
||||||
|
*
|
||||||
|
* Extraction = Engine API (QIX) sur websocket, hypercube generique reference par
|
||||||
|
* master items (qLibraryId) -> pas besoin des expressions brutes.
|
||||||
|
*
|
||||||
|
* Tout est parametrable par variables d'environnement (defauts = discovery 2026-06-20).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import httpntlm from "httpntlm";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config (env) — defauts issus de la discovery
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface QlikConfig {
|
||||||
|
host: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
domain: string; // vide par defaut (NTLM sans domaine, comme requests_ntlm)
|
||||||
|
workstation: string;
|
||||||
|
appNetwork: string; // GUID app "Magasins Vision Consolidee" (article x reseau)
|
||||||
|
dimCodeArticleId: string; // master dimension "Article Code"
|
||||||
|
measCaId: string; // master measure "CA N"
|
||||||
|
measQteId: string; // master measure "Quantite N"
|
||||||
|
measNbMagId: string; // master measure "Magasin Ventes Nb N"
|
||||||
|
tlsInsecure: boolean;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQlikConfig(): QlikConfig {
|
||||||
|
return {
|
||||||
|
host: process.env.QLIK_HOST ?? "reporting-magasins.lafoirfouille.fr",
|
||||||
|
user: process.env.QLIK_USER ?? "FFSCH",
|
||||||
|
password: process.env.QLIK_PWD ?? "",
|
||||||
|
domain: process.env.QLIK_DOMAIN ?? "",
|
||||||
|
workstation: process.env.QLIK_WORKSTATION ?? "",
|
||||||
|
appNetwork: process.env.QLIK_APP_NETWORK ?? "9872ee6e-d64a-4b43-984a-076bf1f7f647",
|
||||||
|
dimCodeArticleId: process.env.QLIK_DIM_CODE_ARTICLE_ID ?? "fcd239e5-288b-4830-a047-0e3d7665d971",
|
||||||
|
measCaId: process.env.QLIK_MEAS_CA_ID ?? "43a76088-86fa-402e-a80e-0efd7701b3e1",
|
||||||
|
measQteId: process.env.QLIK_MEAS_QTE_ID ?? "7b40caf1-be4b-4811-8d45-50acde33e715",
|
||||||
|
measNbMagId: process.env.QLIK_MEAS_NBMAG_ID ?? "8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b",
|
||||||
|
tlsInsecure: (process.env.QLIK_TLS_INSECURE ?? "true") === "true",
|
||||||
|
timeoutMs: Number(process.env.QLIK_TIMEOUT_MS ?? "60000"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkMetric {
|
||||||
|
codeCentrale: string;
|
||||||
|
caReseau: number;
|
||||||
|
qteReseau: number;
|
||||||
|
nbMagasinsReseau: number;
|
||||||
|
periode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeXrfkey(): string {
|
||||||
|
return randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Auth NTLM -> ticket SSO -> cookie de session
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface QlikSession {
|
||||||
|
cookie: string;
|
||||||
|
xrfkey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ntlmGet(cfg: QlikConfig, path: string): Promise<{ statusCode: number; headers: Record<string, unknown>; body: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
httpntlm.get(
|
||||||
|
{
|
||||||
|
url: `https://${cfg.host}${path}`,
|
||||||
|
username: cfg.user,
|
||||||
|
password: cfg.password,
|
||||||
|
domain: cfg.domain,
|
||||||
|
workstation: cfg.workstation,
|
||||||
|
rejectUnauthorized: !cfg.tlsInsecure,
|
||||||
|
headers: { "User-Agent": "Mozilla/5.0 CollectFlow", "X-Qlik-Xrfkey": "" },
|
||||||
|
} as unknown as Parameters<typeof httpntlm.get>[0],
|
||||||
|
(err: Error | null, res: { statusCode: number; headers: Record<string, unknown>; body: string }) =>
|
||||||
|
err ? reject(err) : resolve(res),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function qlikNtlmSession(cfg = getQlikConfig()): Promise<QlikSession> {
|
||||||
|
if (!cfg.user || !cfg.password) {
|
||||||
|
throw new Error("[qlik] QLIK_USER / QLIK_PWD manquants dans l'environnement");
|
||||||
|
}
|
||||||
|
const xrfkey = makeXrfkey();
|
||||||
|
const tlsRestore = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
||||||
|
if (cfg.tlsInsecure) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
||||||
|
try {
|
||||||
|
// 1. targetId
|
||||||
|
const hub = await fetch(`https://${cfg.host}/hub/`, { redirect: "manual" });
|
||||||
|
const loc1 = hub.headers.get("location") ?? "";
|
||||||
|
const targetId = (loc1.match(/targetId=([0-9a-f-]+)/i) ?? [])[1] ?? "";
|
||||||
|
|
||||||
|
// 2. NTLM -> ticket. Interne (reseau corp), /hub/ peut deja renvoyer 401 NTLM
|
||||||
|
// et poser le cookie directement ; on gere les 2 cas.
|
||||||
|
const winPath = targetId
|
||||||
|
? `/internal_windows_authentication/?targetId=${targetId}&xrfkey=${xrfkey}`
|
||||||
|
: `/hub/?xrfkey=${xrfkey}`;
|
||||||
|
const ntlm = await ntlmGet(cfg, winPath);
|
||||||
|
|
||||||
|
// Cookie posé directement (cas interne) ?
|
||||||
|
const directCookie = extractSessionCookie(ntlm.headers["set-cookie"]);
|
||||||
|
if (directCookie) return { cookie: directCookie, xrfkey };
|
||||||
|
|
||||||
|
// 3. Échange ticket -> cookie
|
||||||
|
const ticketLoc = String(ntlm.headers["location"] ?? "");
|
||||||
|
if (!/qlikTicket=/.test(ticketLoc)) {
|
||||||
|
throw new Error(`[qlik] Auth NTLM sans ticket (HTTP ${ntlm.statusCode}) — verifier mot de passe`);
|
||||||
|
}
|
||||||
|
const exch = await fetch(ticketLoc, { redirect: "manual" });
|
||||||
|
const sc = typeof exch.headers.getSetCookie === "function" ? exch.headers.getSetCookie() : [];
|
||||||
|
const cookie = sc.map((c) => c.split(";")[0]).filter((c) => /X-Qlik-Session/i.test(c)).join("; ");
|
||||||
|
if (!cookie) throw new Error("[qlik] ticket non echange contre un cookie de session");
|
||||||
|
return { cookie, xrfkey };
|
||||||
|
} finally {
|
||||||
|
if (cfg.tlsInsecure) process.env.NODE_TLS_REJECT_UNAUTHORIZED = tlsRestore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSessionCookie(setCookie: unknown): string | null {
|
||||||
|
const arr = Array.isArray(setCookie) ? setCookie : setCookie ? [String(setCookie)] : [];
|
||||||
|
const c = arr.map((x) => String(x).split(";")[0]).find((x) => /X-Qlik-Session/i.test(x));
|
||||||
|
return c ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. Websocket Engine API (QIX) + JSON-RPC minimal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RpcConn {
|
||||||
|
rpc: (method: string, params: unknown, handle?: number) => Promise<Record<string, unknown>>;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEngine(appGuid: string, sess: QlikSession, cfg: QlikConfig): Promise<RpcConn> {
|
||||||
|
const url = `wss://${cfg.host}/app/${encodeURIComponent(appGuid)}?Xrfkey=${sess.xrfkey}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(url, {
|
||||||
|
headers: { Cookie: sess.cookie, "X-Qlik-Xrfkey": sess.xrfkey },
|
||||||
|
rejectUnauthorized: !cfg.tlsInsecure,
|
||||||
|
handshakeTimeout: cfg.timeoutMs,
|
||||||
|
});
|
||||||
|
let nextId = 1;
|
||||||
|
const pending = new Map<number, { resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }>();
|
||||||
|
const timer = setTimeout(() => { ws.terminate(); reject(new Error("[qlik] timeout engine")); }, cfg.timeoutMs);
|
||||||
|
|
||||||
|
ws.on("message", (raw: Buffer) => {
|
||||||
|
let msg: Record<string, unknown>;
|
||||||
|
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
||||||
|
if (msg.method === "OnConnected") { clearTimeout(timer); resolve(conn); return; }
|
||||||
|
const id = msg.id as number | undefined;
|
||||||
|
if (id != null && pending.has(id)) {
|
||||||
|
const p = pending.get(id)!;
|
||||||
|
pending.delete(id);
|
||||||
|
if (msg.error) p.reject(new Error(`[qlik] RPC ${JSON.stringify(msg.error)}`));
|
||||||
|
else p.resolve(msg.result as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.on("unexpected-response", (_req, res) => { clearTimeout(timer); reject(new Error(`[qlik] ws HTTP ${res.statusCode}`)); });
|
||||||
|
ws.on("error", (e: Error) => { clearTimeout(timer); reject(e); });
|
||||||
|
ws.on("close", () => { for (const p of pending.values()) p.reject(new Error("[qlik] ws ferme")); pending.clear(); });
|
||||||
|
|
||||||
|
const rpc = (method: string, params: unknown, handle = -1) =>
|
||||||
|
new Promise<Record<string, unknown>>((res, rej) => {
|
||||||
|
const id = nextId++;
|
||||||
|
pending.set(id, { resolve: res, reject: rej });
|
||||||
|
ws.send(JSON.stringify({ jsonrpc: "2.0", id, handle, method, params }));
|
||||||
|
});
|
||||||
|
const conn: RpcConn = { rpc, close: () => ws.close() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. Extraction hypercube (master items) -> NetworkMetric[]
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function hyperCubeDef(cfg: QlikConfig, height: number) {
|
||||||
|
return {
|
||||||
|
qInfo: { qType: "collectflow-network" },
|
||||||
|
qHyperCubeDef: {
|
||||||
|
qDimensions: [{ qLibraryId: cfg.dimCodeArticleId, qNullSuppression: true }],
|
||||||
|
qMeasures: [
|
||||||
|
{ qLibraryId: cfg.measCaId },
|
||||||
|
{ qLibraryId: cfg.measQteId },
|
||||||
|
{ qLibraryId: cfg.measNbMagId },
|
||||||
|
],
|
||||||
|
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 4, qHeight: height }],
|
||||||
|
qSuppressZero: false,
|
||||||
|
qSuppressMissing: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Matrix = Array<Array<{ qText?: string; qNum?: number }>>;
|
||||||
|
|
||||||
|
export async function fetchNetworkMetrics(
|
||||||
|
codeCentraux?: string[],
|
||||||
|
cfg = getQlikConfig(),
|
||||||
|
): Promise<Map<string, NetworkMetric>> {
|
||||||
|
if (!cfg.appNetwork) throw new Error("[qlik] QLIK_APP_NETWORK manquant");
|
||||||
|
const wanted = codeCentraux ? new Set(codeCentraux) : null;
|
||||||
|
const out = new Map<string, NetworkMetric>();
|
||||||
|
|
||||||
|
const sess = await qlikNtlmSession(cfg);
|
||||||
|
const conn = await openEngine(cfg.appNetwork, sess, cfg);
|
||||||
|
try {
|
||||||
|
const openRes = await conn.rpc("OpenDoc", { qDocName: cfg.appNetwork });
|
||||||
|
const docHandle = (((openRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number) ?? 1;
|
||||||
|
|
||||||
|
const pageHeight = 2500; // 4 colonnes * 2500 = 10000 cellules max
|
||||||
|
const createRes = await conn.rpc("CreateSessionObject", { qProp: hyperCubeDef(cfg, pageHeight) }, docHandle);
|
||||||
|
const objHandle = (((createRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number);
|
||||||
|
|
||||||
|
let top = 0;
|
||||||
|
for (;;) {
|
||||||
|
const dataRes = await conn.rpc(
|
||||||
|
"GetHyperCubeData",
|
||||||
|
{ qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 4, qHeight: pageHeight }] },
|
||||||
|
objHandle,
|
||||||
|
);
|
||||||
|
const pages = (dataRes.qDataPages as Array<{ qMatrix?: Matrix }>) ?? [];
|
||||||
|
const matrix: Matrix = pages[0]?.qMatrix ?? [];
|
||||||
|
if (matrix.length === 0) break;
|
||||||
|
|
||||||
|
for (const row of matrix) {
|
||||||
|
const codeCentrale = (row[0]?.qText ?? "").trim();
|
||||||
|
if (!codeCentrale) continue;
|
||||||
|
if (wanted && !wanted.has(codeCentrale)) continue;
|
||||||
|
out.set(codeCentrale, {
|
||||||
|
codeCentrale,
|
||||||
|
caReseau: Number(row[1]?.qNum ?? 0) || 0,
|
||||||
|
qteReseau: Number(row[2]?.qNum ?? 0) || 0,
|
||||||
|
nbMagasinsReseau: Number(row[3]?.qNum ?? 0) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (matrix.length < pageHeight) break;
|
||||||
|
top += matrix.length;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[qlik] fetchNetworkMetrics: ${out.size} produits reseau`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* CollectFlow — Cache des metriques reseau Qlik (table qlik_network_metrics).
|
||||||
|
*
|
||||||
|
* Lecture par lot (jointure grille) + upsert masse (route de sync).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { qlikNetworkMetrics } from "@/db/schema";
|
||||||
|
import { inArray, sql } from "drizzle-orm";
|
||||||
|
import type { NetworkMetric } from "@/lib/qlik-client";
|
||||||
|
|
||||||
|
export interface NetworkMetricCached {
|
||||||
|
caReseau: number;
|
||||||
|
qteReseau: number;
|
||||||
|
nbMagasinsReseau: number;
|
||||||
|
fetchedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lit les metriques reseau pour une liste de codes centraux. */
|
||||||
|
export async function getNetworkMetricsByCodeCentrale(
|
||||||
|
codes: string[],
|
||||||
|
): Promise<Map<string, NetworkMetricCached>> {
|
||||||
|
const out = new Map<string, NetworkMetricCached>();
|
||||||
|
const unique = [...new Set(codes.filter(Boolean))];
|
||||||
|
if (unique.length === 0) return out;
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(qlikNetworkMetrics)
|
||||||
|
.where(inArray(qlikNetworkMetrics.codeCentrale, unique));
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
out.set(r.codeCentrale, {
|
||||||
|
caReseau: Number(r.caReseau ?? 0) || 0,
|
||||||
|
qteReseau: Number(r.qteReseau ?? 0) || 0,
|
||||||
|
nbMagasinsReseau: Number(r.nbMagasinsReseau ?? 0) || 0,
|
||||||
|
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert en masse des metriques reseau (depuis Qlik). */
|
||||||
|
export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<number> {
|
||||||
|
if (metrics.length === 0) return 0;
|
||||||
|
const now = new Date();
|
||||||
|
const values = metrics.map((m) => ({
|
||||||
|
codeCentrale: m.codeCentrale,
|
||||||
|
caReseau: String(m.caReseau),
|
||||||
|
qteReseau: String(m.qteReseau),
|
||||||
|
nbMagasinsReseau: m.nbMagasinsReseau,
|
||||||
|
periode: m.periode ?? null,
|
||||||
|
fetchedAt: now,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Upsert par paquets pour eviter les requetes trop volumineuses.
|
||||||
|
const chunk = 500;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < values.length; i += chunk) {
|
||||||
|
const batch = values.slice(i, i + chunk);
|
||||||
|
await db
|
||||||
|
.insert(qlikNetworkMetrics)
|
||||||
|
.values(batch)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: qlikNetworkMetrics.codeCentrale,
|
||||||
|
set: {
|
||||||
|
caReseau: sql`excluded.ca_reseau`,
|
||||||
|
qteReseau: sql`excluded.qte_reseau`,
|
||||||
|
nbMagasinsReseau: sql`excluded.nb_magasins_reseau`,
|
||||||
|
periode: sql`excluded.periode`,
|
||||||
|
fetchedAt: sql`excluded.fetched_at`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
count += batch.length;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
/**
|
|
||||||
* CollectFlow — Score Engine v4 (Hybride Absolu + Relatif)
|
|
||||||
*
|
|
||||||
* Calcule le score de performance d'un produit en combinant :
|
|
||||||
* - Performance ABSOLUE : Volume (50pts), CA (30pts), Marge (20pts)
|
|
||||||
* - Ajustement RELATIF : Position dans le lot fournisseur (±10pts)
|
|
||||||
* - Pénalité de régularité : multiplicateur si inactivité prolongée
|
|
||||||
*
|
|
||||||
* Score final : 0-100, sensible au prix unitaire.
|
|
||||||
*
|
|
||||||
* Exemples :
|
|
||||||
* - 0.80€ × 100 unités (80€ CA) → ~53 (bon trafic malgré petit CA)
|
|
||||||
* - 40€ × 2 unités (80€ CA) → ~19 (faible rotation)
|
|
||||||
* - 500€ CA, 200u, 45% marge → ~90 (star)
|
|
||||||
* - 8€ CA, 2 unités → ~13 (stock mort)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ProductRow } from "@/types/grid";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Interpolation linéaire entre deux paliers */
|
|
||||||
function lerp(value: number, minVal: number, maxVal: number, minPts: number, maxPts: number): number {
|
|
||||||
if (value >= maxVal) return maxPts;
|
|
||||||
if (value <= minVal) return minPts;
|
|
||||||
return minPts + ((value - minVal) / (maxVal - minVal)) * (maxPts - minPts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Score Volume (0-50) basé sur unités / magasin / mois */
|
|
||||||
function computeVolumeScore(unitsPerStorePerMonth: number): number {
|
|
||||||
if (unitsPerStorePerMonth >= 10) return 50;
|
|
||||||
if (unitsPerStorePerMonth >= 5) return lerp(unitsPerStorePerMonth, 5, 10, 40, 50);
|
|
||||||
if (unitsPerStorePerMonth >= 2) return lerp(unitsPerStorePerMonth, 2, 5, 30, 40);
|
|
||||||
if (unitsPerStorePerMonth >= 1) return lerp(unitsPerStorePerMonth, 1, 2, 20, 30);
|
|
||||||
if (unitsPerStorePerMonth >= 0.5) return lerp(unitsPerStorePerMonth, 0.5, 1, 10, 20);
|
|
||||||
return lerp(unitsPerStorePerMonth, 0, 0.5, 0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Score CA (0-30) basé sur CA € / magasin / an */
|
|
||||||
function computeCaScore(caPerStorePerYear: number): number {
|
|
||||||
if (caPerStorePerYear >= 500) return 30;
|
|
||||||
if (caPerStorePerYear >= 200) return lerp(caPerStorePerYear, 200, 500, 22, 30);
|
|
||||||
if (caPerStorePerYear >= 100) return lerp(caPerStorePerYear, 100, 200, 15, 22);
|
|
||||||
if (caPerStorePerYear >= 50) return lerp(caPerStorePerYear, 50, 100, 8, 15);
|
|
||||||
return lerp(caPerStorePerYear, 0, 50, 0, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Score Marge (0-20) basé sur taux de marge % */
|
|
||||||
function computeMargeScore(tauxMarge: number): number {
|
|
||||||
if (tauxMarge >= 50) return 20;
|
|
||||||
if (tauxMarge >= 40) return lerp(tauxMarge, 40, 50, 16, 20);
|
|
||||||
if (tauxMarge >= 30) return lerp(tauxMarge, 30, 40, 12, 16);
|
|
||||||
if (tauxMarge >= 20) return lerp(tauxMarge, 20, 30, 8, 12);
|
|
||||||
return lerp(tauxMarge, 0, 20, 0, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rangs percentiles (0-100) pour un tableau de valeurs numériques. */
|
|
||||||
function computePercentileRanks(values: number[]): number[] {
|
|
||||||
const n = values.length;
|
|
||||||
if (n === 0) return [];
|
|
||||||
if (n === 1) return [50];
|
|
||||||
|
|
||||||
const indexed = values.map((v, i) => ({ v, i }));
|
|
||||||
indexed.sort((a, b) => a.v - b.v);
|
|
||||||
|
|
||||||
const ranks = new Array<number>(n);
|
|
||||||
let i = 0;
|
|
||||||
while (i < n) {
|
|
||||||
let j = i;
|
|
||||||
while (j < n && indexed[j].v === indexed[i].v) j++;
|
|
||||||
const avgRank = ((i + j - 1) / 2) / (n - 1) * 100;
|
|
||||||
for (let k = i; k < j; k++) {
|
|
||||||
ranks[indexed[k].i] = Math.round(avgRank);
|
|
||||||
}
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ranks;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// API publique
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calcule les scores pour un ensemble de produits d'un même fournisseur.
|
|
||||||
* Les rows sont modifiées en place et retournées.
|
|
||||||
*/
|
|
||||||
export function computeProductScores(rows: ProductRow[]): ProductRow[] {
|
|
||||||
if (rows.length === 0) return rows;
|
|
||||||
|
|
||||||
// 1. Données pondérées pour normalisation multi-magasins
|
|
||||||
const weightedData = rows.map((r) => {
|
|
||||||
const storeCount = Math.max(1, r.workingStores?.length || 1);
|
|
||||||
const weight = storeCount === 1 ? 2 : 1;
|
|
||||||
|
|
||||||
// Régularité : nombre de mois avec au moins 1 vente
|
|
||||||
const regScore = Object.values(r.sales12m || {}).filter((v) => v > 0).length;
|
|
||||||
|
|
||||||
// Inactivité : mois sans vente en fin de fenêtre
|
|
||||||
const allMonths = Object.keys(r.sales12m || {});
|
|
||||||
const refMonth = allMonths.length > 0 ? Math.max(...allMonths.map(m => parseInt(m))).toString() : "";
|
|
||||||
const salesMonths = Object.entries(r.sales12m || {})
|
|
||||||
.filter(([, qty]) => qty > 0)
|
|
||||||
.map(([m]) => parseInt(m))
|
|
||||||
.sort((a, b) => b - a);
|
|
||||||
const lastSaleMonth = salesMonths.length > 0 ? salesMonths[0].toString() : "";
|
|
||||||
|
|
||||||
let inactivity = 0;
|
|
||||||
if (refMonth && lastSaleMonth) {
|
|
||||||
const refY = parseInt(refMonth.substring(0, 4));
|
|
||||||
const refM = parseInt(refMonth.substring(4, 6));
|
|
||||||
const lastY = parseInt(lastSaleMonth.substring(0, 4));
|
|
||||||
const lastM = parseInt(lastSaleMonth.substring(4, 6));
|
|
||||||
inactivity = (refY - lastY) * 12 + (refM - lastM);
|
|
||||||
} else if (refMonth && !lastSaleMonth) {
|
|
||||||
// Jamais vendu sur la fenêtre → inactivité maximale
|
|
||||||
inactivity = 24;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
row: r,
|
|
||||||
storeCount,
|
|
||||||
weight,
|
|
||||||
wQty: (r.totalQuantite ?? 0) * weight,
|
|
||||||
wCa: (r.totalCa ?? 0) * weight,
|
|
||||||
tauxMarge: Number.isFinite(r.tauxMarge) ? r.tauxMarge : 0,
|
|
||||||
regScore,
|
|
||||||
inactivity,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Totaux fournisseur
|
|
||||||
const totalQty = weightedData.reduce((acc, d) => acc + d.wQty, 0);
|
|
||||||
const totalCa = weightedData.reduce((acc, d) => acc + d.wCa, 0);
|
|
||||||
const totalMarge = weightedData.reduce((sum, d) => sum + d.tauxMarge, 0);
|
|
||||||
const avgQty = rows.length > 0 ? totalQty / rows.length : 0;
|
|
||||||
|
|
||||||
// 3. Totaux par rayon (code2)
|
|
||||||
const rayonTotals = new Map<string, { total: number; count: number }>();
|
|
||||||
weightedData.forEach((d) => {
|
|
||||||
const r2 = d.row.code2 || "default";
|
|
||||||
const current = rayonTotals.get(r2) || { total: 0, count: 0 };
|
|
||||||
rayonTotals.set(r2, { total: current.total + d.wQty, count: current.count + 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Percentiles dans le lot (pour le bonus relatif)
|
|
||||||
const caPctRanks = computePercentileRanks(weightedData.map(d => d.wCa));
|
|
||||||
const qtyPctRanks = computePercentileRanks(weightedData.map(d => d.wQty));
|
|
||||||
const margePctRanks = computePercentileRanks(weightedData.map(d => d.tauxMarge));
|
|
||||||
|
|
||||||
// 5. Seuil top 30% CA pour le flag isTop30Supplier
|
|
||||||
const sortedCa = [...weightedData.map(d => d.wCa)].sort((a, b) => b - a);
|
|
||||||
const top30Idx = Math.max(0, Math.ceil(sortedCa.length * 0.3) - 1);
|
|
||||||
const top30CaThreshold = sortedCa[top30Idx] ?? 0;
|
|
||||||
|
|
||||||
// 6. Score de chaque produit
|
|
||||||
for (let idx = 0; idx < weightedData.length; idx++) {
|
|
||||||
const d = weightedData[idx];
|
|
||||||
const r = d.row;
|
|
||||||
|
|
||||||
// --- Guard absolu : aucune vente → score 0, quelle que soit la marge ou le ranking ---
|
|
||||||
if (d.wQty === 0 && d.wCa === 0) {
|
|
||||||
r.avgQtyFournisseur = avgQty;
|
|
||||||
r.totalFournisseurCa = totalCa;
|
|
||||||
r.shareQty = 0;
|
|
||||||
r.shareCa = 0;
|
|
||||||
r.shareMarge = 0;
|
|
||||||
const r2z = r.code2 || "default";
|
|
||||||
const rStat = rayonTotals.get(r2z);
|
|
||||||
r.avgQtyRayon = rStat ? rStat.total / rStat.count : 0;
|
|
||||||
r.unitsPerStorePerMonth = 0;
|
|
||||||
r.caPerStorePerYear = 0;
|
|
||||||
r.score = 0;
|
|
||||||
r.isRecent = true;
|
|
||||||
r.isLastProduct = rows.length === 1;
|
|
||||||
r.isTop30Supplier = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Métriques de contexte (inchangées, pour l'IA et l'affichage) ---
|
|
||||||
r.avgQtyFournisseur = avgQty;
|
|
||||||
r.totalFournisseurCa = totalCa;
|
|
||||||
r.shareQty = totalQty > 0 ? (d.wQty / totalQty) * 100 : 0;
|
|
||||||
r.shareCa = totalCa > 0 ? (d.wCa / totalCa) * 100 : 0;
|
|
||||||
r.shareMarge = totalMarge > 0 ? (d.tauxMarge / totalMarge) * 100 : 0;
|
|
||||||
|
|
||||||
const r2 = r.code2 || "default";
|
|
||||||
const rayonStat = rayonTotals.get(r2);
|
|
||||||
r.avgQtyRayon = rayonStat ? rayonStat.total / rayonStat.count : 0;
|
|
||||||
|
|
||||||
// --- KPIs normalisés (sensibles au prix) ---
|
|
||||||
const activeMonths = Math.max(d.regScore, 3); // plancher 3 mois
|
|
||||||
const unitsPerStorePerMonth = d.wQty / (d.storeCount * activeMonths);
|
|
||||||
const caPerStorePerYear = d.wCa / d.storeCount;
|
|
||||||
|
|
||||||
r.unitsPerStorePerMonth = Math.round(unitsPerStorePerMonth * 100) / 100;
|
|
||||||
r.caPerStorePerYear = Math.round(caPerStorePerYear * 100) / 100;
|
|
||||||
|
|
||||||
// --- Score absolu (Volume + CA + Marge = 0-100 pts) ---
|
|
||||||
const volumeScore = computeVolumeScore(unitsPerStorePerMonth);
|
|
||||||
const caScore = computeCaScore(caPerStorePerYear);
|
|
||||||
const margeScore = computeMargeScore(d.tauxMarge);
|
|
||||||
|
|
||||||
// --- Bonus relatif (±10 pts) ---
|
|
||||||
const pctComposite = (caPctRanks[idx] * 0.40 + qtyPctRanks[idx] * 0.35 + margePctRanks[idx] * 0.25);
|
|
||||||
const relativeBonus = ((pctComposite - 50) / 50) * 10;
|
|
||||||
|
|
||||||
// --- Pénalité de régularité ---
|
|
||||||
let regularityMultiplier = 1;
|
|
||||||
if (d.regScore <= 2 && d.inactivity >= 6) {
|
|
||||||
regularityMultiplier = 0.3;
|
|
||||||
} else if (d.regScore <= 2 && d.inactivity >= 3) {
|
|
||||||
regularityMultiplier = 0.5;
|
|
||||||
} else if (d.inactivity >= 6) {
|
|
||||||
regularityMultiplier = 0.5;
|
|
||||||
} else if (d.inactivity >= 3) {
|
|
||||||
regularityMultiplier = 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Score final ---
|
|
||||||
const rawScore = (volumeScore + caScore + margeScore + relativeBonus) * regularityMultiplier;
|
|
||||||
r.score = Math.round(Math.max(0, Math.min(100, rawScore)) * 10) / 10;
|
|
||||||
|
|
||||||
// --- Flags de protection ---
|
|
||||||
r.isRecent = d.regScore <= 3;
|
|
||||||
r.isLastProduct = rows.length === 1;
|
|
||||||
r.isTop30Supplier = d.wCa >= top30CaThreshold && top30CaThreshold > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
+10
-11
@@ -73,8 +73,6 @@ export interface ProductRow {
|
|||||||
totalCa: number;
|
totalCa: number;
|
||||||
totalMarge: number;
|
totalMarge: number;
|
||||||
tauxMarge: number;
|
tauxMarge: number;
|
||||||
/** Score de performance hybride (0-100) */
|
|
||||||
score: number;
|
|
||||||
workingStores: string[];
|
workingStores: string[];
|
||||||
/** Rotation normalisée : unités vendues / magasin / mois */
|
/** Rotation normalisée : unités vendues / magasin / mois */
|
||||||
unitsPerStorePerMonth?: number;
|
unitsPerStorePerMonth?: number;
|
||||||
@@ -86,7 +84,6 @@ export interface ProductRow {
|
|||||||
isLastProduct?: boolean;
|
isLastProduct?: boolean;
|
||||||
/** Top 30% CA du lot fournisseur */
|
/** Top 30% CA du lot fournisseur */
|
||||||
isTop30Supplier?: boolean;
|
isTop30Supplier?: boolean;
|
||||||
aiRecommendation?: string | null;
|
|
||||||
/** SQL Server internal ID — nécessaire pour /api/articles/:noid/mensuel */
|
/** SQL Server internal ID — nécessaire pour /api/articles/:noid/mensuel */
|
||||||
noid?: number;
|
noid?: number;
|
||||||
/** Données stock & approvisionnement (API FF Nancy) */
|
/** Données stock & approvisionnement (API FF Nancy) */
|
||||||
@@ -110,14 +107,16 @@ export interface ProductRow {
|
|||||||
shareQty?: number;
|
shareQty?: number;
|
||||||
/** Référentiels globaux */
|
/** Référentiels globaux */
|
||||||
totalFournisseurCa?: number;
|
totalFournisseurCa?: number;
|
||||||
/** Ranking réseau (classement global) */
|
/** Code centrale (clé jointure Qlik, format 10000XXXXXX) */
|
||||||
rankingCa?: number;
|
codeCentrale?: string;
|
||||||
rankingQte?: number;
|
/** Données réseau Qlik (~270 magasins) */
|
||||||
/** Ranking magasin */
|
caReseau?: number;
|
||||||
rankingMagCa?: number;
|
qteReseau?: number;
|
||||||
rankingMagQte?: number;
|
nbMagasinsReseau?: number;
|
||||||
/** Nombre total de produits classés dans le réseau (produits avec ventes sur la période) */
|
/** Taux de présence réseau = nbMagasinsReseau / 270 */
|
||||||
totalRankedProducts?: number;
|
tauxPresenceReseau?: number;
|
||||||
|
/** Fraîcheur des données réseau (ISO) */
|
||||||
|
networkFetchedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Summary bar totals for the currently visible/filtered rows */
|
/** Summary bar totals for the currently visible/filtered rows */
|
||||||
|
|||||||
Vendored
+23
@@ -0,0 +1,23 @@
|
|||||||
|
declare module "httpntlm" {
|
||||||
|
interface NtlmOptions {
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
domain?: string;
|
||||||
|
workstation?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
rejectUnauthorized?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
interface NtlmResponse {
|
||||||
|
statusCode: number;
|
||||||
|
headers: Record<string, unknown>;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
type Cb = (err: Error | null, res: NtlmResponse) => void;
|
||||||
|
const httpntlm: {
|
||||||
|
get(opts: NtlmOptions, cb: Cb): void;
|
||||||
|
post(opts: NtlmOptions, cb: Cb): void;
|
||||||
|
};
|
||||||
|
export default httpntlm;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user