diff --git a/.gitignore b/.gitignore index 5ef6a52..287b20d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +.playwright-mcp/ diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md new file mode 100644 index 0000000..aafdb8e --- /dev/null +++ b/docs/qlik-integration.md @@ -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= +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. diff --git a/next.config.ts b/next.config.ts index 703bf8b..63ddf4e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", - serverExternalPackages: ["pg"], + serverExternalPackages: ["pg", "ws", "httpntlm"], experimental: { serverActions: { bodySizeLimit: "50mb", diff --git a/package.json b/package.json index 7effe5c..7698253 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "clsx": "^2.1.1", "drizzle-orm": "^0.45.1", "exceljs": "^4.4.0", + "httpntlm": "^1.8.13", "jspdf": "^4.2.0", "jspdf-autotable": "^5.0.7", "lucide-react": "^0.575.0", @@ -35,6 +36,7 @@ "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.5.0", + "ws": "^8.21.0", "xlsx": "^0.18.5", "zod": "^4.3.6", "zustand": "^5.0.11" @@ -45,6 +47,7 @@ "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", "drizzle-kit": "^0.31.9", "eslint": "^9", "eslint-config-next": "16.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83e6a38..dbcfc38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: exceljs: specifier: ^4.4.0 version: 4.4.0 + httpntlm: + specifier: ^1.8.13 + version: 1.8.13 jspdf: specifier: ^4.2.0 version: 4.2.1 @@ -77,6 +80,9 @@ importers: tailwind-merge: specifier: ^3.5.0 version: 3.6.0 + ws: + specifier: ^8.21.0 + version: 8.21.0 xlsx: specifier: ^0.18.5 version: 0.18.5 @@ -102,6 +108,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.14) + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 drizzle-kit: specifier: ^0.31.9 version: 0.31.10 @@ -895,89 +904,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1093,24 +1118,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.6': resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} @@ -1945,24 +1974,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -2097,6 +2130,9 @@ packages: '@types/validate-npm-package-name@4.0.2': 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': resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2198,41 +2234,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2752,6 +2796,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + des.js@1.1.0: + resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -3440,6 +3487,14 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} 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: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -3705,6 +3760,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-md4@0.3.2: + resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3838,24 +3896,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -4122,6 +4184,9 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -5072,6 +5137,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + underscore@1.12.1: + resolution: {integrity: sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -5222,8 +5290,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5898,7 +5966,7 @@ snapshots: google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.5.8 - ws: 8.20.1 + ws: 8.21.0 optionalDependencies: '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: @@ -7148,6 +7216,10 @@ snapshots: '@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)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -7800,6 +7872,11 @@ snapshots: 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-node-es@1.1.0: {} @@ -8699,6 +8776,15 @@ snapshots: statuses: 2.0.2 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: dependencies: agent-base: 7.1.4 @@ -8930,6 +9016,8 @@ snapshots: jose@6.2.3: {} + js-md4@0.3.2: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -9516,6 +9604,8 @@ snapshots: mimic-function@5.0.1: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -10705,6 +10795,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + underscore@1.12.1: {} + undici-types@6.21.0: {} unicorn-magic@0.3.0: {} @@ -10902,7 +10994,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.20.1: {} + ws@8.21.0: {} wsl-utils@0.3.1: dependencies: diff --git a/scripts/db-init.js b/scripts/db-init.js index ee62f72..0b8b28a 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -96,6 +96,18 @@ async function main() { `); 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(); console.log("[DB Init] Initialization successful. Exiting."); process.exit(0); diff --git a/scripts/qlik-discover.mjs b/scripts/qlik-discover.mjs new file mode 100644 index 0000000..8dcfca9 --- /dev/null +++ b/scripts/qlik-discover.mjs @@ -0,0 +1,190 @@ +/** + * Discovery Qlik Sense — autonome, à lancer à la main : + * + * QLIK_PWD='' node scripts/qlik-discover.mjs # liste les apps + * QLIK_PWD='' node scripts/qlik-discover.mjs # + 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= + * + * 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 `); + } + } catch (e) { + console.error("ERREUR:", e.message); + process.exit(1); + } +})(); diff --git a/src/app/api/ai/analyze/route.ts b/src/app/api/ai/analyze/route.ts deleted file mode 100644 index 8c9e842..0000000 --- a/src/app/api/ai/analyze/route.ts +++ /dev/null @@ -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 = { "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 }); - } -} diff --git a/src/app/api/ai/batch-analyze/route.ts b/src/app/api/ai/batch-analyze/route.ts deleted file mode 100644 index 15bcf45..0000000 --- a/src/app/api/ai/batch-analyze/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/ai/context/route.ts b/src/app/api/ai/context/route.ts deleted file mode 100644 index e9df5fb..0000000 --- a/src/app/api/ai/context/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts new file mode 100644 index 0000000..a8f131f --- /dev/null +++ b/src/app/api/qlik/sync/route.ts @@ -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 }); + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index eb10177..3afe040 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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) */ export const aiSupplierContext = pgTable("ai_supplier_context", { /** Supplier code serving as the primary key */ diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts deleted file mode 100644 index 765f586..0000000 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ /dev/null @@ -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 = {}; - 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(); - } -} diff --git a/src/features/ai-copilot/business/context-profiler.ts b/src/features/ai-copilot/business/context-profiler.ts deleted file mode 100644 index 3ea2231..0000000 --- a/src/features/ai-copilot/business/context-profiler.ts +++ /dev/null @@ -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; -} - -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(); - 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: "⚠️" }; - } -} diff --git a/src/features/ai-copilot/components/ai-explanation-modal.tsx b/src/features/ai-copilot/components/ai-explanation-modal.tsx deleted file mode 100644 index 63366c4..0000000 --- a/src/features/ai-copilot/components/ai-explanation-modal.tsx +++ /dev/null @@ -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 = ( -
- {/* Backdrop - consistent with Apple overlay */} -
- - {/* Panel - Using var(--bg-surface) and strict spacing */} -
- {/* Header - Clean & Monochromatic */} -
-
-
- -
-
-

- Analyse Décisionnelle IA -

-

- {productName} ({productCode}) -

-
-
- -
- - {/* Body */} -
- {/* Recommendation Row */} -
- Arbitrage suggéré -
- {recommandation === "A" && } - {recommandation === "C" && } - {recommandation === "Z" && } - {recommandation ? `GAMME ${recommandation}` : "NON DÉFINI"} -
-
- - {/* Explanation Box - Monospace for figures */} -
-
- -
- - {explanation} - -
-
-
- - {/* Meta Info */} -
-
- Basé sur CA, Marge & Volumes réels -
-
-
- - {/* Footer Action */} -
- -
-
- - -
- ); - - return createPortal(modalContent, document.body); -} diff --git a/src/features/ai-copilot/components/ai-insight-block.tsx b/src/features/ai-copilot/components/ai-insight-block.tsx deleted file mode 100644 index 58a42a2..0000000 --- a/src/features/ai-copilot/components/ai-insight-block.tsx +++ /dev/null @@ -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 ( - - ); - } - - if (status === "loading") { - return ( -
- - Analyse IA... -
- ); - } - - if (status === "error") { - return ( -
- - Erreur -
- ); - } - - return ( - <> -
-
-

{ - e.stopPropagation(); - setIsModalOpen(true); - }} - > - {insight.insight} - - - -

-
- {insight?.isDuplicate && ( - - Doublon probable - - )} -
- - setIsModalOpen(false)} - productName={row.libelle1} - productCode={row.codein} - explanation={insight?.insight || ""} - recommandation={insight?.recommandation} - /> - - ); -} diff --git a/src/features/ai-copilot/data/open-router-client.ts b/src/features/ai-copilot/data/open-router-client.ts deleted file mode 100644 index 3a94403..0000000 --- a/src/features/ai-copilot/data/open-router-client.ts +++ /dev/null @@ -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 { - // 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 - }; - } -} diff --git a/src/features/ai-copilot/models/ai-analysis.types.ts b/src/features/ai-copilot/models/ai-analysis.types.ts deleted file mode 100644 index 8dc39e6..0000000 --- a/src/features/ai-copilot/models/ai-analysis.types.ts +++ /dev/null @@ -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; - 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 -} diff --git a/src/features/ai-copilot/store/use-ai-copilot-store.ts b/src/features/ai-copilot/store/use-ai-copilot-store.ts deleted file mode 100644 index 2074854..0000000 --- a/src/features/ai-copilot/store/use-ai-copilot-store.ts +++ /dev/null @@ -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; - 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; - 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; - resetInsights: () => void; -} - -export const useAiCopilotStore = create()( - 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", - } - ) -); diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index a46478a..370b515 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -1,7 +1,6 @@ import "server-only"; import type { ProductRow, GammeCode, GridFilters } from "@/types/grid"; -import { computeProductScores } from "@/lib/score-engine"; import { buildLast12MonthsRange, getMensuelByArticles } from "@/lib/api-ff-client"; import { pgGetArticlesByFournisseur, @@ -9,13 +8,15 @@ import { pgGetGammesByFournisseur, pgGetNomenclatureByFournisseur, pgGetStockByFournisseur, - pgGetRankingByFournisseur, pgGetCommandesByFournisseur, type PgStockRow, } from "@/lib/pg-ff-client"; import { db } from "@/db"; import { sessionSnapshots } from "@/db/schema"; import { eq, desc } from "drizzle-orm"; +import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache"; + +const NB_MAGASINS_RESEAU = 270; interface GetProductRowsInput { codeFournisseur: string; @@ -86,7 +87,6 @@ async function buildProductRows(input: GetProductRowsInput): Promise { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }), @@ -94,12 +94,10 @@ async function buildProductRows(input: GetProductRowsInput): Promise { console.error("[getProductRows] pgGetGammesByFournisseur 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(); }), - 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(); }), ]); - const { rankings: rankingMap, totalRankedProducts } = rankingResult; - console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes, ${rankingMap.size} rankings`); + console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`); // ─── Phase 2 : Fenêtre temporelle 12 mois complets ─────────────────── const now = new Date(); @@ -123,6 +121,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise p.codeGamme !== "Y" || p.totalQuantite > 0); await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, sortedPeriods); const excludedY = allRows.length - rows.length; console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`); - return computeProductScores(rows); + return rows; } catch (error) { console.error(`[getProductRows] Error for ${codeFournisseur}:`, error); @@ -467,3 +456,36 @@ async function reconcileSelectedStoreFromMensuelApi( 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): Promise { + try { + const byCodeCentrale = new Map(); + 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); + } +} diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx deleted file mode 100644 index f1b8908..0000000 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ /dev/null @@ -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 = {}; - 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 = {}; - 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 = {}; - 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 = {}; - 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 ( -
- - - {isAnalyzing ? ( -
- -
- - {progress.message} - -
-
-
-
- -
- ) : progress.current > 0 ? ( -
- - - {progress.errors > 0 ? `Terminé (${progress.current - progress.errors}/${progress.total})` : `Analyse Terminée`} - -
- ) : ( - - )} -
- ); -} diff --git a/src/features/grid/components/export-dropdown.tsx b/src/features/grid/components/export-dropdown.tsx index 4cff9ec..7dc9323 100644 --- a/src/features/grid/components/export-dropdown.tsx +++ b/src/features/grid/components/export-dropdown.tsx @@ -118,7 +118,7 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string }) doc.text(`Export généré le : ${new Date().toLocaleDateString("fr-FR")}`, 14, 30); 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 => { @@ -128,7 +128,7 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string }) r.codein, r.reference || "-", 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.totalCa).toLocaleString("fr-FR")} €`, `${Math.round(r.totalMarge).toLocaleString("fr-FR")} €\n(${r.tauxMarge.toFixed(1)}%)`, diff --git a/src/features/grid/components/floating-summary-bar.tsx b/src/features/grid/components/floating-summary-bar.tsx index 944fe09..07df5a8 100644 --- a/src/features/grid/components/floating-summary-bar.tsx +++ b/src/features/grid/components/floating-summary-bar.tsx @@ -1,8 +1,7 @@ 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 { BulkAiAnalyzer } from "./bulk-ai-analyzer"; +import { SyncQlikButton } from "./sync-qlik-button"; import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts"; import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera, ChevronDown } from "lucide-react"; import { useMemo, useState, useTransition } from "react"; @@ -35,7 +34,6 @@ export function FloatingSummaryBar() { const rows = useGridStore((s) => s.rows); const filters = useGridStore((s) => s.filters); const draftChanges = useGridStore((s) => s.draftChanges); - const { resetInsights } = useAiCopilotStore(); const [isPending, startTransition] = useTransition(); const [isSavingSnapshot, setIsSavingSnapshot] = useState(false); 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 { 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 = () => { startTransition(async () => { const result = await save(); @@ -57,9 +64,8 @@ export function FloatingSummaryBar() { }; 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(); - resetInsights(); } }; @@ -182,7 +188,7 @@ export function FloatingSummaryBar() {
- {isAdmin && } + {isAdmin && } diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 0db0306..cf18c1a 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -26,7 +26,6 @@ import { GammeSelect } from "@/features/grid/components/gamme-select"; import { HeatmapCell } from "@/features/grid/components/heatmap-cell"; import type { ProductRow, GammeCode } from "@/types/grid"; import { cn } from "@/lib/utils"; -import { AiInsightBlock } from "@/features/ai-copilot/components/ai-insight-block"; function getLast12Months(): string[] { const months: string[] = []; @@ -178,8 +177,8 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisi }} > {row.getVisibleCells().map((cell: Cell) => { - const isFlexible = cell.column.id === "libelle1" || cell.column.id === "ai" || 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 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 === "caReseau" || cell.column.id === "qteReseau" || cell.column.id === "nbMagasinsReseau" || cell.column.id === "tauxPresenceReseau" || cell.column.id === "gamme"; const size = cell.column.getSize(); return (
CA
Réseau
, + size: 90, cell: ({ getValue }) => { - const val = getValue(); - const color = val >= 80 ? "text-emerald-500" : val >= 50 ? "text-amber-500" : "text-rose-500"; + const val = getValue(); + return ( +
+ {val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"} +
+ ); + }, + }, + { + accessorKey: "qteReseau", + header: () =>
Qté
Réseau
, + size: 80, + cell: ({ getValue }) => { + const val = getValue(); + return ( +
+ {val != null ? Math.round(val).toLocaleString("fr-FR") : "-"} +
+ ); + }, + }, + { + accessorKey: "nbMagasinsReseau", + header: () =>
Magasins
/ 270
, + size: 80, + cell: ({ getValue }) => { + const val = getValue(); + return ( +
+ {val != null ? val : "-"} +
+ ); + }, + }, + { + accessorKey: "tauxPresenceReseau", + header: () =>
% Prés.
Réseau
, + size: 70, + cell: ({ getValue }) => { + const val = getValue(); + if (val == null) return
-
; + const pct = Math.round(val * 100); + const color = pct >= 66 ? "text-emerald-500" : pct >= 33 ? "text-amber-500" : "text-rose-500"; return (
- {val} -
- ); - }, - }, - { - accessorKey: "rankingCa", - header: () =>
Rk CA
Réseau
, - size: 70, - cell: ({ getValue }) => { - const val = getValue(); - return ( -
- {val != null ? val : "-"} -
- ); - }, - }, - { - accessorKey: "rankingQte", - header: () =>
Rk Qté
Réseau
, - size: 70, - cell: ({ getValue }) => { - const val = getValue(); - return ( -
- {val != null ? val : "-"} -
- ); - }, - }, - { - accessorKey: "rankingMagCa", - header: () =>
Rk CA
Mag.
, - size: 70, - cell: ({ getValue }) => { - const val = getValue(); - return ( -
- {val != null ? val : "-"} -
- ); - }, - }, - { - accessorKey: "rankingMagQte", - header: () =>
Rk Qté
Mag.
, - size: 70, - cell: ({ getValue }) => { - const val = getValue(); - return ( -
- {val != null ? val : "-"} + {pct}%
); }, @@ -679,17 +667,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { size: 110, cell: ({ row }) => , }, - { - id: "ai", - header: () =>
Recommandation IA
, - size: 230, - enableSorting: false, - cell: ({ row }) => ( -
- -
- ), - }, ], [MONTHS_12, activeMagasin, isAdmin]); // activeMagasin déclenche re-render des cellules mensuelles et totaux const table = useReactTable({ @@ -800,8 +777,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { - const isFlexible = header.column.id === "libelle1" || header.column.id === "ai" || 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 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 === "caReseau" || header.column.id === "qteReseau" || header.column.id === "nbMagasinsReseau" || header.column.id === "tauxPresenceReseau" || header.column.id === "gamme"; const size = header.getSize(); return ( - {["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 => (
0 ? formatCa(row.totalCa) : "—"}
= 50 ? "var(--color-amber, #f59e0b)" : "var(--text-muted)" }}> - {row.score > 0 ? row.score : "—"} + style={{ color: "var(--text-secondary)" }}> + {row.nbMagasinsReseau != null ? row.nbMagasinsReseau : "—"}
); diff --git a/src/features/grid/components/supplier-ai-context-modal.tsx b/src/features/grid/components/supplier-ai-context-modal.tsx deleted file mode 100644 index 90f061d..0000000 --- a/src/features/grid/components/supplier-ai-context-modal.tsx +++ /dev/null @@ -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 ( - - - - - - - Contexte IA - {nomFournisseur || codeFournisseur} - - 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"). - - - -
-