mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-20 13:38:08 +02:00
Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeaae09282 | ||
|
|
86cdc86963 | ||
|
|
03b6ddb8c9 | ||
|
|
a6cfb75caa | ||
|
|
59efd70886 | ||
|
|
b10a5a027e | ||
|
|
99097b6eca | ||
|
|
164bda1fe9 | ||
|
|
1633d5e407 | ||
|
|
b5144ed0cf | ||
|
|
2af7c42b2b | ||
|
|
147a05c090 | ||
|
|
ac1c404ce5 | ||
|
|
24ed93f833 | ||
|
|
5b5e69eed1 | ||
|
|
dec99ce3c1 | ||
|
|
6d349e33b4 | ||
|
|
76e908e8b2 | ||
|
|
8a3f18f23b | ||
|
|
e8507490f7 | ||
|
|
adb8455353 | ||
|
|
f0a553eb92 | ||
|
|
4cb2c7d1c5 | ||
|
|
0511a3a7ea | ||
|
|
332b21c1ae | ||
|
|
21758573c2 | ||
|
|
473ed1e467 | ||
|
|
b9a5bd5e22 | ||
|
|
74b183abb3 | ||
|
|
5aeb1ca496 | ||
|
|
5b74133ecd | ||
|
|
5b874f3581 | ||
|
|
71563500a0 | ||
|
|
ff264dcdff | ||
|
|
6c5e28b05e | ||
|
|
6adcb0d4c8 | ||
|
|
2e25bbc5b3 | ||
|
|
d0f92e46a3 | ||
|
|
f0fdab465c | ||
|
|
e9ac5bc0a5 | ||
|
|
eba4cb0774 | ||
|
|
cda149ccaa | ||
|
|
ea19c5771e | ||
|
|
159508132c | ||
|
|
b7de347a99 | ||
|
|
d733755659 | ||
|
|
32f9a27563 | ||
|
|
abac5c5ee5 | ||
|
|
1cb6c2496d | ||
|
|
f005c08ad6 | ||
|
|
850852912f | ||
|
|
e7bcba169c | ||
|
|
c4eeab11d2 | ||
|
|
649a1df267 | ||
|
|
6036ea5600 | ||
|
|
6d27c7935d | ||
|
|
de8a435c14 | ||
|
|
8cc95cb387 | ||
|
|
5f364f7d52 | ||
|
|
524faf4a7a | ||
|
|
af93e362e0 | ||
|
|
ae1deedd19 | ||
|
|
da73693ea0 | ||
|
|
8bd45518f2 | ||
|
|
bd9bc78f4c | ||
|
|
0b9846f55d | ||
|
|
d54c3ae520 | ||
|
|
6cdb845a1f | ||
|
|
b755a69258 | ||
|
|
d319107076 | ||
|
|
c64d32a219 | ||
|
|
694b780160 | ||
|
|
1281b0e21f | ||
|
|
abed369bfb | ||
|
|
2ffa7dd1c0 | ||
|
|
5a4ce70695 |
No files matched your search
@@ -32,6 +32,10 @@ ENV NODE_ENV=production
|
||||
# Next.js telemetry is disabled
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Chromium pour l'extraction Qlik via Playwright (ws in-page) + dépendances headless
|
||||
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
|
||||
ENV PLAYWRIGHT_CHROMIUM_PATH=/usr/bin/chromium-browser
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
@@ -46,6 +50,9 @@ RUN chown nextjs:nodejs .next data
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# playwright-core n'est pas toujours tracé par le standalone — on le copie explicitement
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/playwright-core ./node_modules/playwright-core
|
||||
|
||||
# Grant write access to /app for settings persistence
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Qlik — Performance & tuning de la sync réseau
|
||||
|
||||
Ce document rassemble les variables d'environnement et leviers qui agissent
|
||||
sur la **vitesse** et la **robustesse** de l'extraction Qlik réseau
|
||||
(`POST /api/qlik/sync`, routeur `src/app/api/qlik/sync/route.ts`,
|
||||
extracteur `src/lib/qlik-playwright.ts`, helper de fenêtre
|
||||
`src/lib/qlik-date-range.ts`).
|
||||
|
||||
Toutes ces variables sont **optionnelles** : valeur par défaut sûre.
|
||||
|
||||
> ⚠️ Aucune concurrence / parallélisme Qlik n'a été ajouté — chaque sync
|
||||
> reste **mono-flux** (un seul websocket Engine, une seule fenêtre
|
||||
> temporelle). Les optimisations ci-dessous n'augmentent pas la charge
|
||||
> sur le moteur Qlik ; elles **réduisent** la quantité de travail qu'on
|
||||
> lui demande.
|
||||
|
||||
---
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Nom | Type | Défaut | Bornes | Effet |
|
||||
| --------------------------- | ---- | ------ | ------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `QLIK_SYNC_MONTHS_BACK` | int | `12` | `1`..`12` | Profondeur de la fenêtre temporelle (mois glissants, mois courant exclu). Plus petit = moins de mois scannés = extraction plus rapide. |
|
||||
| `QLIK_SETTLE_MS` | int | `150` | `0`..`2000` | Latence (ms) après chaque mutation de sélection (Date / Article Code) avant l'appel RPC suivant. Protège l'Engine contre les collisions qui provoquent `code:15 Request aborted`. |
|
||||
| `QLIK_CODE15_MAX_ATTEMPTS` | int | `4` | `1`..`8` | Nombre max de retries sur Engine `code:15` par appel RPC, avec backoff `QLIK_SETTLE_MS * attempt`. |
|
||||
| `QLIK_ARTICLE_BATCH_SIZE` | int | `150` | `1`..`500` | Taille du premier lot d'`Article Code` sélectionné par appel `SelectValues`. |
|
||||
| `QLIK_ARTICLE_BATCH_FALLBACKS` | CSV | `75,50` | chaque valeur `1`..`500` | Tailles de repli quand un lot déclenche un `code:15`. Valeurs ordonnées croissant appliquées successivement. |
|
||||
|
||||
> **Format CSV pour `QLIK_ARTICLE_BATCH_FALLBACKS`** : valeurs séparées par
|
||||
> des virgules, ex. `QLIK_ARTICLE_BATCH_FALLBACKS=100,75,50,25`. Les doublons
|
||||
> et valeurs hors bornes sont dédupliqués / clampés automatiquement, puis appliqués dans l'ordre fourni.
|
||||
|
||||
---
|
||||
|
||||
## Optimisations déjà en place (pattern durable)
|
||||
|
||||
Le pipeline reste **mono-connexion** pour ne jamais surcharger le moteur Qlik :
|
||||
|
||||
1. **Fenêtre temporelle bornée** : on ne demande jamais à Qlik de scanner
|
||||
l'historique complet ; uniquement les `QLIK_SYNC_MONTHS_BACK` derniers
|
||||
mois.
|
||||
2. **Découpe mensuelle** : `getMonthRanges()` produit 1 sous-fenêtre par mois
|
||||
calendaire. Chaque cube reste ainsi borné à ~28–31 jours × N codes.
|
||||
3. **Hypercube session object JETABLE** : `fetchCubeForSelection()` crée un
|
||||
`CreateSessionObject` neuf par lot, lit ses pages, puis appelle
|
||||
`DestroySessionObject`. C'est ce qui a éliminé les `code:15 Request aborted`
|
||||
à grande échelle (la réutilisation du même objet sur des centaines de
|
||||
recalculs forçait l'Engine à recalculer en boucle).
|
||||
4. **Fallbacks code 15** : si un lot trop large déclenche un `code:15`, on
|
||||
retente automatiquement avec une taille plus petite (cf.
|
||||
`QLIK_ARTICLE_BATCH_FALLBACKS`) sans tout annuler.
|
||||
|
||||
## Nouveautés (cette itération)
|
||||
|
||||
1. **Fenêtre paramétrable `QLIK_SYNC_MONTHS_BACK`** (1..12, défaut 12). Permet
|
||||
de scoper un sync test à 6 mois au lieu de 12, ou de n'extraire que les 3
|
||||
derniers mois pour un check rapide. Le helper `envMonthsBack()` (dans
|
||||
`src/lib/qlik-date-range.ts`) clamp la valeur et applique le défaut si
|
||||
l'env est mal configurée.
|
||||
2. **Filtre strict des codes centraux** dans `route.ts` (regex `[A-Z0-9][A-Z0-9_-]{1,29}`,
|
||||
longueur 2..30, exclusion de `""`, `"-"`, `"_"`). Réduit les erreurs Engine
|
||||
sur des valeurs parasites et le gaspillage réseau. Le log expose le **nombre**
|
||||
de codes rejetés mais pas les valeurs (hygiène logs).
|
||||
3. **Résumé timing final** : ligne unique `[qlik-pw][summary] rows=… months=…
|
||||
batches=… code15Retries=… fallbackCount=… totalMs=…` émise en fin de sync
|
||||
pour audit/debug sans avoir à parser les logs détaillés par lot.
|
||||
|
||||
## Exemple de `.env.local`
|
||||
|
||||
```env
|
||||
# Sync Qlik : fenêtre par défaut 12 mois ; on peut descendre à 6 pour un
|
||||
# test rapide ou un check hebdo sans recharger tout l'historique.
|
||||
QLIK_SYNC_MONTHS_BACK=12
|
||||
|
||||
# Batch sizes / fallbacks (laisser par défaut sauf contre-indication).
|
||||
QLIK_ARTICLE_BATCH_SIZE=150
|
||||
QLIK_ARTICLE_BATCH_FALLBACKS=75,50
|
||||
QLIK_SETTLE_MS=150
|
||||
QLIK_CODE15_MAX_ATTEMPTS=4
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# Tests purs Node (aucune dépendance Next.js).
|
||||
npx tsx scripts/test-qlik-date-range.ts
|
||||
```
|
||||
|
||||
Couvre :
|
||||
- repères Qlik (sérial ↔ ISO),
|
||||
- fenêtre 12 mois (référence),
|
||||
- `getMonthRanges()` : 12 mois / fenêtre partielle / année bissextile,
|
||||
- option `monthsBack` : 6 mois + clamp 0 / -5 / 24 / NaN / undefined,
|
||||
- helper `envMonthsBack()` : env absent / vide / valide / hors bornes /
|
||||
non numérique / décimal / espaces.
|
||||
|
||||
## Référence
|
||||
|
||||
- `src/lib/qlik-date-range.ts` — fenêtre, `envMonthsBack`, `getMonthRanges`.
|
||||
- `src/lib/qlik-playwright.ts` — extracteur Playwright, hypercube jetable,
|
||||
fallbacks code 15, `[qlik-pw][summary]` final.
|
||||
- `src/app/api/qlik/sync/route.ts` — route admin, `filterCentralCodes`,
|
||||
applique `QLIK_SYNC_MONTHS_BACK` via `envMonthsBack`.
|
||||
- `scripts/test-qlik-date-range.ts` — tests CLI.
|
||||
- `docs/qlik-integration.md` — doc d'intégration générale (auth NTLM,
|
||||
discovery, hypercube, etc.).
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["pg", "ws", "httpntlm"],
|
||||
serverExternalPackages: ["pg", "ws", "httpntlm", "playwright-core"],
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "50mb",
|
||||
|
||||
Generated
+13
@@ -26,6 +26,7 @@
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.18.0",
|
||||
"playwright-core": "^1.61.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
@@ -13285,6 +13286,18 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.18.0",
|
||||
"playwright-core": "^1.61.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
|
||||
Generated
+10
@@ -62,6 +62,9 @@ importers:
|
||||
pg:
|
||||
specifier: ^8.18.0
|
||||
version: 8.20.0
|
||||
playwright-core:
|
||||
specifier: ^1.61.0
|
||||
version: 1.61.0
|
||||
radix-ui:
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -4503,6 +4506,11 @@ packages:
|
||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
|
||||
playwright-core@1.61.0:
|
||||
resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
possible-typed-array-names@1.1.0:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -9931,6 +9939,8 @@ snapshots:
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
playwright-core@1.61.0: {}
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
|
||||
@@ -102,10 +102,22 @@ async function main() {
|
||||
"ca_reseau" numeric(16, 2),
|
||||
"qte_reseau" numeric(14, 2),
|
||||
"nb_magasins_reseau" integer,
|
||||
"ca_par_magasin_reseau" numeric(16, 2),
|
||||
"marge_pct_reseau" numeric(8, 4),
|
||||
"periode" varchar(20),
|
||||
"fetched_at" timestamp DEFAULT now()
|
||||
);
|
||||
`);
|
||||
// Colonnes ajoutées après coup (DB existante) — CREATE IF NOT EXISTS ne les pose pas.
|
||||
await tempPool.query(`
|
||||
ALTER TABLE "qlik_network_metrics"
|
||||
ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2),
|
||||
ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4),
|
||||
ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb,
|
||||
ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb;
|
||||
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau";
|
||||
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
|
||||
`);
|
||||
console.log("[DB Init] Table qlik_network_metrics is verified/created.");
|
||||
|
||||
await tempPool.end();
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Test CLI pour `src/lib/qlik-date-range.ts` (aucune dépendance Next.js).
|
||||
* Usage : npx tsx scripts/test-qlik-date-range.ts
|
||||
*
|
||||
* Vérifie :
|
||||
* - les repères Qlik (2025-01-01→45658, etc.)
|
||||
* - la fenêtre 12 mois complets pour `now=2026-06-21` (doit donner
|
||||
* dateDebut=2025-06-01, dateFin=2026-05-31, qStart=45809, qEnd=46173,
|
||||
* label=2025-06_2026-05, 365 serials)
|
||||
* - exemples supplémentaires (1er du mois, fin d'année, année bissextile)
|
||||
* - erreurs attendues sur entrée mal formée
|
||||
* - option `monthsBack` : 6 mois, bornes (clamp 1..12)
|
||||
* - helper `envMonthsBack` : valeur absente, valide, hors bornes, non numérique
|
||||
*
|
||||
* Aucun framework : assertions manuelles, exit code 0/1.
|
||||
*/
|
||||
|
||||
import {
|
||||
isoDateToQlikSerial,
|
||||
qlikSerialToIsoDate,
|
||||
buildGridNetworkQlikDateFilter,
|
||||
chunkSerials,
|
||||
getMonthRanges,
|
||||
envMonthsBack,
|
||||
QLIK_DATE_ANCHORS,
|
||||
QLIK_MONTHS_BACK_MIN,
|
||||
QLIK_MONTHS_BACK_MAX,
|
||||
QLIK_MONTHS_BACK_DEFAULT,
|
||||
} from "../src/lib/qlik-date-range";
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
|
||||
function eq(label: string, got: unknown, want: unknown): void {
|
||||
const ok = JSON.stringify(got) === JSON.stringify(want);
|
||||
if (ok) {
|
||||
pass++;
|
||||
console.log(` \u2713 ${label}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(` \u2717 ${label}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function truthy(label: string, got: unknown): void {
|
||||
if (got) {
|
||||
pass++;
|
||||
console.log(` \u2713 ${label}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(` \u2717 ${label} \u2014 falsy: ${JSON.stringify(got)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function throws(label: string, fn: () => unknown, re?: RegExp): void {
|
||||
try {
|
||||
fn();
|
||||
fail++;
|
||||
console.log(` \u2717 ${label} \u2014 n'a pas lanc\u00e9 d'exception`);
|
||||
} catch (e) {
|
||||
const msg = String((e as Error)?.message ?? e);
|
||||
if (!re || re.test(msg)) {
|
||||
pass++;
|
||||
console.log(` \u2713 ${label}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(` \u2717 ${label} \u2014 exception inattendue: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n[1] Rep\u00e8res Qlik (skill qlik)");
|
||||
for (const a of QLIK_DATE_ANCHORS) {
|
||||
eq(`isoDateToQlikSerial(${a.iso}) = ${a.serial}`, isoDateToQlikSerial(a.iso), a.serial);
|
||||
}
|
||||
|
||||
console.log("\n[2] Round-trip s\u00e9rial \u2194 ISO");
|
||||
for (const a of QLIK_DATE_ANCHORS) {
|
||||
eq(`qlikSerialToIsoDate(${a.serial}) = ${a.iso}`, qlikSerialToIsoDate(a.serial), a.iso);
|
||||
}
|
||||
|
||||
console.log("\n[3] Fen\u00eatre grille (now=2026-06-21) \u2192 2025-06-01 .. 2026-05-31");
|
||||
const f = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"));
|
||||
eq("dateDebut", f.dateDebut, "2025-06-01");
|
||||
eq("dateFin", f.dateFin, "2026-05-31");
|
||||
eq("qStart", f.qStart, 45809);
|
||||
eq("qEnd", f.qEnd, 46173);
|
||||
eq("label", f.label, "2025-06_2026-05");
|
||||
eq("setAnalysis", f.setAnalysis, `Date={">=45809<=46173"}`);
|
||||
eq("dailySerials.length = 365", f.dailySerials.length, 365);
|
||||
eq("dailySerials[0]", f.dailySerials[0], 45809);
|
||||
eq("dailySerials[364]", f.dailySerials[364], 46173);
|
||||
|
||||
console.log("\n[4] Fen\u00eatre grille (now=2026-03-15) \u2192 2025-03-01 .. 2026-02-28");
|
||||
const f2 = buildGridNetworkQlikDateFilter(new Date("2026-03-15T08:00:00Z"));
|
||||
eq("dateDebut", f2.dateDebut, "2025-03-01");
|
||||
eq("dateFin", f2.dateFin, "2026-02-28");
|
||||
eq("qStart", f2.qStart, 45717);
|
||||
eq("qEnd", f2.qEnd, 46081);
|
||||
|
||||
console.log("\n[5] Fen\u00eatre grille (now=2025-01-05) \u2192 2024-01-01 .. 2024-12-31 (ann\u00e9e bissextile, 366 j)");
|
||||
const f3 = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z"));
|
||||
eq("dateDebut", f3.dateDebut, "2024-01-01");
|
||||
eq("dateFin", f3.dateFin, "2024-12-31");
|
||||
eq("dailySerials.length = 366", f3.dailySerials.length, 366);
|
||||
|
||||
console.log("\n[6] chunkSerials");
|
||||
const chunks = chunkSerials(f.dailySerials, 100);
|
||||
eq("chunks.length pour 365/100", chunks.length, 4);
|
||||
eq("chunks[3].length = 65", chunks[3].length, 65);
|
||||
eq("chunks[0][0]", chunks[0][0], 45809);
|
||||
eq("chunks[3][64]", chunks[3][64], 46173);
|
||||
|
||||
console.log("\n[7] Robustesse");
|
||||
throws("format invalide '2025/01/01'", () => isoDateToQlikSerial("2025/01/01"), /YYYY-MM-DD/);
|
||||
throws("format vide", () => isoDateToQlikSerial(""), /YYYY-MM-DD/);
|
||||
truthy("label non vide pour fenêtre valide", f.label && f.label.length > 0);
|
||||
truthy("setAnalysis commence par 'Date='", f.setAnalysis.startsWith("Date="));
|
||||
|
||||
console.log("\n[8] getMonthRanges — fenêtre 12 mois (now=2026-06-21)");
|
||||
const months = getMonthRanges(f);
|
||||
eq("12 mois exactement", months.length, 12);
|
||||
eq("premier mois label", months[0].label, "2025-06");
|
||||
eq("premier mois dateDebut", months[0].dateDebut, "2025-06-01");
|
||||
eq("premier mois dateFin", months[0].dateFin, "2025-06-30");
|
||||
eq("premier mois serials.length = 30", months[0].dailySerials.length, 30);
|
||||
eq("premier mois qStart", months[0].qStart, 45809);
|
||||
eq("premier mois qEnd", months[0].qEnd, 45838);
|
||||
eq("dernier mois label", months[11].label, "2026-05");
|
||||
eq("dernier mois dateDebut", months[11].dateDebut, "2026-05-01");
|
||||
eq("dernier mois dateFin", months[11].dateFin, "2026-05-31");
|
||||
eq("dernier mois serials.length = 31", months[11].dailySerials.length, 31);
|
||||
// Couvre février d'une année non bissextile (2025) — window now=2025-06-15 → 2024-06..2025-05
|
||||
const fFeb25 = buildGridNetworkQlikDateFilter(new Date("2025-06-15T08:00:00Z"));
|
||||
truthy("contient février 2025 (28j)", getMonthRanges(fFeb25).some((m) => m.label === "2025-02" && m.dailySerials.length === 28));
|
||||
// Couvre février d'une année bissextile (2024)
|
||||
const fBiss = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z"));
|
||||
const monthsBiss = getMonthRanges(fBiss);
|
||||
truthy("contient février 2024 (29j)", monthsBiss.some((m) => m.label === "2024-02" && m.dailySerials.length === 29));
|
||||
// Total jours = 365 (somme des serials)
|
||||
const totalDays = months.reduce((acc, m) => acc + m.dailySerials.length, 0);
|
||||
eq("somme des jours = 365", totalDays, 365);
|
||||
|
||||
console.log("\n[9] getMonthRanges — fenêtre courte partielle (troncature dernier mois)");
|
||||
const fShort = { ...f, dateDebut: "2025-06-01", dateFin: "2025-08-15", qStart: 45809, qEnd: 45884, dailySerials: [], setAnalysis: "" };
|
||||
const monthsShort = getMonthRanges(fShort);
|
||||
eq("3 mois (juin, juillet, août)", monthsShort.length, 3);
|
||||
eq("août tronqué à 15j", monthsShort[2].dailySerials.length, 15);
|
||||
eq("août dateFin = 2025-08-15", monthsShort[2].dateFin, "2025-08-15");
|
||||
|
||||
console.log("\n[10] monthsBack=6 (now=2026-06-21) → 2025-12-01 .. 2026-05-31");
|
||||
const f6 = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 6);
|
||||
eq("dateDebut", f6.dateDebut, "2025-12-01");
|
||||
eq("dateFin", f6.dateFin, "2026-05-31");
|
||||
eq("qStart", f6.qStart, 45992);
|
||||
eq("qEnd", f6.qEnd, 46173);
|
||||
eq("label", f6.label, "2025-12_2026-05");
|
||||
eq("dailySerials.length = 182 (déc 2025:31 + jan:31 + fév:28 + mar:31 + avr:30 + mai:31)", f6.dailySerials.length, 182);
|
||||
const months6 = getMonthRanges(f6);
|
||||
eq("getMonthRanges: 6 mois exactement", months6.length, 6);
|
||||
eq("premier mois label", months6[0].label, "2025-12");
|
||||
eq("dernier mois label", months6[5].label, "2026-05");
|
||||
const totalDays6 = months6.reduce((acc, m) => acc + m.dailySerials.length, 0);
|
||||
eq("somme des jours = 182", totalDays6, 182);
|
||||
|
||||
console.log("\n[11] monthsBack — bornes et clamp");
|
||||
eq("monthsBack=1 (now=2026-06-21) → 2026-05-01 .. 2026-05-31 (1 mois complet)",
|
||||
(() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 1); return x.dateDebut === "2026-05-01" && x.dateFin === "2026-05-31"; })(),
|
||||
true);
|
||||
eq("monthsBack=0 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 0); return x.dateDebut === "2026-05-01"; })(), true);
|
||||
eq("monthsBack=24 clampé à 12 (max)", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 24); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true);
|
||||
eq("monthsBack=-5 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), -5); return x.dateDebut === "2026-05-01"; })(), true);
|
||||
eq("monthsBack=NaN ramené au défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), Number.NaN); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true);
|
||||
eq("monthsBack=undefined (par défaut) = défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z")); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true);
|
||||
eq("bornes exportées MIN=1", QLIK_MONTHS_BACK_MIN, 1);
|
||||
eq("bornes exportées MAX=12", QLIK_MONTHS_BACK_MAX, 12);
|
||||
eq("défaut exporté = 12", QLIK_MONTHS_BACK_DEFAULT, 12);
|
||||
|
||||
console.log("\n[12] envMonthsBack — lecture env");
|
||||
const PREV = process.env.QLIK_TEST_MONTHS_BACK;
|
||||
delete process.env.QLIK_TEST_MONTHS_BACK;
|
||||
eq("env absent → défaut", envMonthsBack("QLIK_TEST_MONTHS_BACK"), QLIK_MONTHS_BACK_DEFAULT);
|
||||
eq("env vide → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = ""; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT);
|
||||
eq("env '6' → 6", (() => { process.env.QLIK_TEST_MONTHS_BACK = "6"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 6);
|
||||
eq("env 'abc' → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = "abc"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT);
|
||||
eq("env '24' clampé à MAX=12", (() => { process.env.QLIK_TEST_MONTHS_BACK = "24"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MAX);
|
||||
eq("env '-3' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "-3"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN);
|
||||
eq("env '0' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "0"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN);
|
||||
eq("env '3.7' troncé à 3", (() => { process.env.QLIK_TEST_MONTHS_BACK = "3.7"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 3);
|
||||
eq("env ' 8 ' trimé puis parsé → 8", (() => { process.env.QLIK_TEST_MONTHS_BACK = " 8 "; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 8);
|
||||
if (PREV === undefined) delete process.env.QLIK_TEST_MONTHS_BACK; else process.env.QLIK_TEST_MONTHS_BACK = PREV;
|
||||
|
||||
console.log(`\n=== ${pass} passés, ${fail} échoués ===`);
|
||||
if (fail > 0) process.exit(1);
|
||||
@@ -28,6 +28,43 @@ interface Props {
|
||||
pivotted: HitParadePivotRow[];
|
||||
}
|
||||
|
||||
function ColHeader({
|
||||
label,
|
||||
sortable,
|
||||
group,
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
}: {
|
||||
label: string;
|
||||
sortable?: SortKey;
|
||||
group?: "292" | "579" | "total";
|
||||
sortKey: SortKey;
|
||||
sortDir: SortDir;
|
||||
onSort: (key: SortKey) => void;
|
||||
}) {
|
||||
const isActive = sortable && sortKey === sortable;
|
||||
const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : "";
|
||||
const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700";
|
||||
return (
|
||||
<th
|
||||
onClick={sortable ? () => onSort(sortable) : undefined}
|
||||
className={[
|
||||
"px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none",
|
||||
sortable ? "cursor-pointer" : "",
|
||||
isActive ? activeBg : `${groupBg} text-gray-600`,
|
||||
].join(" ")}
|
||||
>
|
||||
{label}
|
||||
{sortable && (
|
||||
<span className="ml-1 inline-block w-3 text-center">
|
||||
{isActive ? (sortDir === "desc" ? "↓" : "↑") : <span className="text-gray-300">↕</span>}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
const router = useRouter();
|
||||
const [sortKey, setSortKey] = useState<SortKey>(DEFAULT_SORT);
|
||||
@@ -73,6 +110,32 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
|
||||
const isSorted = sortKey !== DEFAULT_SORT || sortDir !== DEFAULT_DIR;
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
pivotted.filter(r =>
|
||||
(filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) &&
|
||||
(filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature)
|
||||
),
|
||||
[pivotted, filterFournisseur, filterNomenclature]
|
||||
);
|
||||
|
||||
const sorted = useMemo(() =>
|
||||
[...filtered].sort((a, b) => {
|
||||
const diff = a[sortKey] - b[sortKey];
|
||||
return sortDir === "desc" ? -diff : diff;
|
||||
}),
|
||||
[filtered, sortKey, sortDir]
|
||||
);
|
||||
|
||||
const totals = useMemo(() => sorted.reduce(
|
||||
(acc, r) => ({
|
||||
qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292,
|
||||
qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579,
|
||||
qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal,
|
||||
stock292: acc.stock292 + r.stock292, stock579: acc.stock579 + r.stock579, stockTotal: acc.stockTotal + r.stockTotal,
|
||||
}),
|
||||
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
|
||||
), [sorted]);
|
||||
|
||||
function exportToExcel() {
|
||||
const headers = [
|
||||
"Code", "Désignation", "Fournisseur", "Nomenclature",
|
||||
@@ -115,56 +178,6 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
XLSX.writeFile(wb, `hit-parade_${dateDebut}_${dateFin}.xlsx`);
|
||||
}
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
pivotted.filter(r =>
|
||||
(filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) &&
|
||||
(filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature)
|
||||
),
|
||||
[pivotted, filterFournisseur, filterNomenclature]
|
||||
);
|
||||
|
||||
const sorted = useMemo(() =>
|
||||
[...filtered].sort((a, b) => {
|
||||
const diff = a[sortKey] - b[sortKey];
|
||||
return sortDir === "desc" ? -diff : diff;
|
||||
}),
|
||||
[filtered, sortKey, sortDir]
|
||||
);
|
||||
|
||||
const totals = useMemo(() => sorted.reduce(
|
||||
(acc, r) => ({
|
||||
qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292,
|
||||
qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579,
|
||||
qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal,
|
||||
stock292: acc.stock292 + r.stock292, stock579: acc.stock579 + r.stock579, stockTotal: acc.stockTotal + r.stockTotal,
|
||||
}),
|
||||
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
|
||||
), [sorted]);
|
||||
|
||||
function ColHeader({ label, sortable, k, group }: { label: string; sortable?: SortKey; k: string; group?: "292" | "579" | "total" }) {
|
||||
const isActive = sortable && sortKey === sortable;
|
||||
const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : "";
|
||||
const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700";
|
||||
return (
|
||||
<th
|
||||
key={k}
|
||||
onClick={sortable ? () => handleSort(sortable) : undefined}
|
||||
className={[
|
||||
"px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none",
|
||||
sortable ? "cursor-pointer" : "",
|
||||
isActive ? activeBg : `${groupBg} text-gray-600`,
|
||||
].join(" ")}
|
||||
>
|
||||
{label}
|
||||
{sortable && (
|
||||
<span className="ml-1 inline-block w-3 text-center">
|
||||
{isActive ? (sortDir === "desc" ? "↓" : "↑") : <span className="text-gray-300">↕</span>}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Barre de contrôles */}
|
||||
@@ -277,22 +290,22 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="border-b-2 border-gray-200">
|
||||
<ColHeader label="Qté" sortable="qte292" k="qte292" group="292" />
|
||||
<ColHeader label="CA TTC" sortable="ca292" k="ca292" group="292" />
|
||||
<ColHeader label="Qté" sortable="qte292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="CA TTC" sortable="ca292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<th className="bg-blue-50 px-2 py-2 text-center text-xs font-semibold text-blue-500">% Marge</th>
|
||||
<ColHeader label="Stock" sortable="stock292" k="stock292" group="292" />
|
||||
<ColHeader label="Qté" sortable="qte579" k="qte579" group="579" />
|
||||
<ColHeader label="CA TTC" sortable="ca579" k="ca579" group="579" />
|
||||
<ColHeader label="Stock" sortable="stock292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="Qté" sortable="qte579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="CA TTC" sortable="ca579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<th className="bg-violet-50 px-2 py-2 text-center text-xs font-semibold text-violet-500">% Marge</th>
|
||||
<ColHeader label="Stock" sortable="stock579" k="stock579" group="579" />
|
||||
<ColHeader label="Qté" sortable="qteTotal" k="qteTotal" group="total" />
|
||||
<ColHeader label="CA TTC" sortable="caTotal" k="caTotal" group="total" />
|
||||
<ColHeader label="Stock" sortable="stock579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="Qté" sortable="qteTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="CA TTC" sortable="caTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<th className="bg-emerald-50 px-2 py-2 text-center text-xs font-semibold text-emerald-600">% Marge</th>
|
||||
<ColHeader label="Stock" sortable="stockTotal" k="stockTotal" group="total" />
|
||||
<ColHeader label="Stock" sortable="stockTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row, idx) => (
|
||||
{sorted.map((row) => (
|
||||
<tr key={row.codein} className={`border-b border-gray-100 hover:brightness-95 transition-colors`}>
|
||||
<td className="bg-white px-2 py-2.5 font-mono text-xs text-gray-400 text-center">{row.codein}</td>
|
||||
<td className="bg-white px-2 py-2.5 text-gray-900 font-medium min-w-[200px]">{row.libelle.trim()}</td>
|
||||
|
||||
@@ -42,14 +42,17 @@ function pivotHitParade(rows: HitParadeRow[]): HitParadePivotRow[] {
|
||||
});
|
||||
}
|
||||
const entry = map.get(row.codein)!;
|
||||
if (row.site === "292") {
|
||||
entry.qte292 = row.qte_vendue;
|
||||
entry.ca292 = row.ca_ttc;
|
||||
entry.marge292 = row.marge;
|
||||
} else if (row.site === "579") {
|
||||
entry.qte579 = row.qte_vendue;
|
||||
entry.ca579 = row.ca_ttc;
|
||||
entry.marge579 = row.marge;
|
||||
// Accumulation (+=) et non affectation : si la requête renvoyait plusieurs
|
||||
// lignes pour un même (codein, site), aucune valeur ne serait écrasée.
|
||||
const site = String(row.site).trim();
|
||||
if (site === "292") {
|
||||
entry.qte292 += row.qte_vendue;
|
||||
entry.ca292 += row.ca_ttc;
|
||||
entry.marge292 += row.marge;
|
||||
} else if (site === "579") {
|
||||
entry.qte579 += row.qte_vendue;
|
||||
entry.ca579 += row.ca_ttc;
|
||||
entry.marge579 += row.marge;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers,
|
||||
RefreshCw, Loader2, CheckCircle, AlertCircle, WifiOff, LayoutGrid,
|
||||
} from "lucide-react";
|
||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||
import { NetworkLineChart, DualLineChart, TrendSparkline } from "@/features/grid/components/network-charts";
|
||||
import { computeNetworkTrend, trendLabel, TREND_COLOR, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
import { formatMonthLabel, formatDate, SITE_LABELS, ffMonthToQlik } from "@/features/grid/lib/months";
|
||||
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
|
||||
import { computeComparatif, indiceVerdict } from "@/features/produits/lib/compare-reseau";
|
||||
import type { ProduitFiche } from "@/features/produits/types";
|
||||
import { OpportunitesFamille } from "./opportunites";
|
||||
|
||||
// ─── Formatteurs partagés ────────────────────────────────────────────────────
|
||||
|
||||
const fmtEur = (v: number) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
|
||||
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
|
||||
const fmtDec = (v: number, d = 1) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);
|
||||
|
||||
// ─── Briques de présentation ─────────────────────────────────────────────────
|
||||
|
||||
function Card({ title, icon: Icon, children, action }: {
|
||||
title: string;
|
||||
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
children: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="apple-card">
|
||||
<div className="apple-card-header flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
<Icon className="w-4 h-4" style={{ color: "var(--accent)" }} />
|
||||
{title}
|
||||
</h2>
|
||||
{action}
|
||||
</div>
|
||||
<div className="apple-card-content">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-[13px] truncate ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
|
||||
{value ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, hint, color }: { label: string; value: string; hint?: string; color?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[19px] font-black tabular-nums leading-tight mt-0.5" style={{ color: color ?? "var(--text-primary)" }}>
|
||||
{value}
|
||||
</div>
|
||||
{hint && <div className="text-[10px] mt-0.5" style={{ color: "var(--text-muted)" }}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Bouton d'extraction Qlik pour un seul code centrale ─────────────────────
|
||||
|
||||
function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) {
|
||||
const router = useRouter();
|
||||
const { status, message, start } = useQlikSyncJob({
|
||||
target: { mode: "produit", codeCentrale },
|
||||
onSuccess: () => router.refresh(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{message && (
|
||||
<span
|
||||
className="text-[10px] leading-tight text-right max-w-[280px]"
|
||||
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
|
||||
title={message}
|
||||
>
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={status === "running"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
||||
title="Extraire les données réseau Qlik pour ce seul produit"
|
||||
>
|
||||
{status === "running" ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: status === "success" ? <CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
||||
: status === "error" ? <AlertCircle className="w-3.5 h-3.5 text-rose-500" />
|
||||
: <RefreshCw className="w-3.5 h-3.5" />}
|
||||
{status === "running" ? "Extraction…" : "Charger les données réseau"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fiche ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; backQuery: string }) {
|
||||
const { detail, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche;
|
||||
const [magasin, setMagasin] = useState<string>("TOTAL");
|
||||
|
||||
const sitesDisponibles = useMemo(() => {
|
||||
const sites = new Set<string>([...Object.keys(fiche.mensuelParSite), ...stock.map((s) => s.site)]);
|
||||
return [...sites].filter(Boolean).sort();
|
||||
}, [fiche.mensuelParSite, stock]);
|
||||
|
||||
// Série affichée selon le magasin sélectionné.
|
||||
const serie = magasin === "TOTAL" ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {});
|
||||
const totauxAffiches = magasin === "TOTAL" ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 });
|
||||
|
||||
const trend = useMemo(() => computeNetworkTrend(reseau?.qteByMonth ?? null), [reseau]);
|
||||
const comparatif = useMemo(() => computeComparatif(fiche), [fiche]);
|
||||
const verdict = indiceVerdict(comparatif.indiceQte);
|
||||
|
||||
const fournisseurPrincipal = fournisseurs.find((f) => f.principal) ?? fournisseurs[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* En-tête */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={backQuery ? `/produits?q=${encodeURIComponent(backQuery)}` : "/produits"}
|
||||
className="inline-flex items-center gap-1.5 text-[12px] mb-1.5 hover:underline"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
Retour aux résultats
|
||||
</Link>
|
||||
<h2 className="text-[20px] font-semibold tracking-[-0.3px]" style={{ color: "var(--text-primary)" }}>
|
||||
{detail.libelle1 || "Sans libellé"}
|
||||
</h2>
|
||||
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
<span className="font-mono">{detail.codein}</span>
|
||||
{detail.code_centrale && <> · centrale <span className="font-mono">{detail.code_centrale}</span></>}
|
||||
{fournisseurPrincipal && <> · {fournisseurPrincipal.nomfou}</>}
|
||||
</p>
|
||||
</div>
|
||||
{fournisseurPrincipal?.codefou && (
|
||||
<Link
|
||||
href={`/grid?fournisseur=${encodeURIComponent(fournisseurPrincipal.codefou)}&magasin=TOTAL`}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5"
|
||||
title="Ouvrir la grille d'arbitrage de ce fournisseur"
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
Voir dans la Grille
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Identité */}
|
||||
<Card title="Identité" icon={Package}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<Field label="Code article" value={detail.codein} mono />
|
||||
<Field label="Code centrale" value={detail.code_centrale || "—"} mono />
|
||||
<Field label="GTIN" value={detail.gtin || "—"} mono />
|
||||
<Field label="Référence fou." value={detail.reference || "—"} mono />
|
||||
<Field label="PCB" value={detail.pcb != null ? fmtQte(detail.pcb) : "—"} />
|
||||
<Field label="Gamme actuelle" value={gammes[0]?.gamme_code ?? "—"} />
|
||||
<Field label="Secteur" value={detail.libelle1nom ?? "—"} />
|
||||
<Field label="Famille" value={detail.libelle2 ?? "—"} />
|
||||
<Field label="Sous-famille" value={detail.libelle3 ?? "—"} />
|
||||
<Field label="Prix d'achat" value={detail.pa != null ? fmtEur(detail.pa) : "—"} />
|
||||
<Field label="PV central" value={detail.pv_central != null ? fmtEur(detail.pv_central) : "—"} />
|
||||
<Field label="En commande" value={commandesEnCours > 0 ? fmtQte(commandesEnCours) : "—"} />
|
||||
</div>
|
||||
|
||||
{fournisseurs.length > 0 && (
|
||||
<div className="mt-4 pt-3" style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
Fournisseurs référencés
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{fournisseurs.map((f) => (
|
||||
<span
|
||||
key={f.codefou}
|
||||
className="rounded-lg px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
background: f.principal ? "var(--accent-bg)" : "var(--bg-elevated)",
|
||||
border: `1px solid ${f.principal ? "var(--accent-border)" : "var(--border)"}`,
|
||||
color: f.principal ? "var(--accent)" : "var(--text-secondary)",
|
||||
}}
|
||||
title={f.reference ? `Réf. ${f.reference}` : undefined}
|
||||
>
|
||||
{f.nomfou}
|
||||
{f.principal && <span className="ml-1 font-semibold">· principal</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gammes.length > 1 && (
|
||||
<div className="mt-3 pt-3" style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
Historique de gamme
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{gammes.slice(0, 10).map((g, i) => (
|
||||
<span
|
||||
key={`${g.saison_no_id}-${i}`}
|
||||
className="rounded-lg px-2 py-1 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
{g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`}
|
||||
<span className="ml-1.5 font-bold" style={{ color: "var(--text-primary)" }}>{g.gamme_code}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Stock */}
|
||||
<Card title="Stock temps réel" icon={Warehouse}>
|
||||
{stock.length === 0 ? (
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>Aucune ligne de stock pour ce produit.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{stock.map((s) => (
|
||||
<div key={s.site} className="rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
{SITE_LABELS[s.site]?.nom ?? s.site}
|
||||
</span>
|
||||
<span
|
||||
className="text-[16px] font-black tabular-nums"
|
||||
style={{ color: s.qte < 0 ? "var(--accent-error)" : "var(--text-primary)" }}
|
||||
>
|
||||
{fmtQte(s.qte)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<div>Dispo : <span className="tabular-nums">{fmtQte(s.stockdispo)}</span></div>
|
||||
<div>Valeur : <span className="tabular-nums">{fmtEur(s.valstock)}</span></div>
|
||||
<div>PRMP : <span className="tabular-nums">{s.prmp > 0 ? fmtEur(s.prmp) : "—"}</span></div>
|
||||
<div>Dern. vente : {formatDate(s.dernierevente)}</div>
|
||||
<div className="col-span-2">Dern. réception : {formatDate(s.dernierereception)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Ventes 12 mois glissants */}
|
||||
<Card
|
||||
title="Ventes · 12 mois glissants"
|
||||
icon={ShoppingCart}
|
||||
action={
|
||||
<div className="segment-control">
|
||||
{["TOTAL", ...sitesDisponibles].map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => setMagasin(code)}
|
||||
className={`segment-btn ${magasin === code ? "active" : ""}`}
|
||||
>
|
||||
{code === "TOTAL" ? "Total" : (SITE_LABELS[code]?.label ?? code)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
<Stat label="Quantité vendue" value={fmtQte(totauxAffiches.qte)} />
|
||||
<Stat label="CA (TTC)" value={fmtEur(totauxAffiches.ca)} />
|
||||
<Stat label="Marge" value={fmtEur(totauxAffiches.marge)} />
|
||||
<Stat
|
||||
label="Taux de marge"
|
||||
value={`${fmtDec(totauxAffiches.tauxMarge)} %`}
|
||||
color={totauxAffiches.tauxMarge >= 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[12px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left text-[10px] font-semibold uppercase" style={{ color: "var(--text-muted)" }}>
|
||||
Mois
|
||||
</th>
|
||||
{months.map((m) => (
|
||||
<th key={m} className="px-1 py-1.5 text-center text-[10px] font-semibold whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
||||
{formatMonthLabel(m)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
Ventes
|
||||
</td>
|
||||
{months.map((m) => (
|
||||
<td key={m} className="px-0.5 py-1 min-w-[46px]">
|
||||
<HeatmapCell
|
||||
value={serie[m]?.qte ?? 0}
|
||||
tooltipStock={serie[m]?.stockFinMois ?? null}
|
||||
tooltipReceptions={serie[m]?.qteRecue ?? null}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
CA (TTC)
|
||||
</td>
|
||||
{months.map((m) => (
|
||||
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
{serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
Stock fin de mois
|
||||
</td>
|
||||
{months.map((m) => (
|
||||
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
{serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<NetworkLineChart
|
||||
labels={months.map(ffMonthToQlik)}
|
||||
values={months.map((m) => serie[m]?.qte ?? 0)}
|
||||
color="#0ea5e9"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Réseau Qlik */}
|
||||
<Card
|
||||
title="Réseau Qlik"
|
||||
icon={Globe}
|
||||
action={detail.code_centrale ? <SyncProduitButton codeCentrale={detail.code_centrale} /> : undefined}
|
||||
>
|
||||
{!detail.code_centrale ? (
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Ce produit n'a pas de code centrale : il n'est pas référencé centralement et n'existe donc pas dans Qlik.
|
||||
</p>
|
||||
) : !reseau ? (
|
||||
<div className="flex items-start gap-3 rounded-xl p-4" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<WifiOff className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--text-muted)" }} />
|
||||
<div className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
|
||||
Aucune donnée réseau en cache pour ce produit.
|
||||
<div className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
Lancez l'extraction ci-dessus : elle ne porte que sur ce code centrale et prend quelques secondes.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<Stat label="CA réseau" value={fmtEur(reseau.caReseau)} />
|
||||
<Stat label="Qté réseau" value={fmtQte(reseau.qteReseau)} />
|
||||
<Stat
|
||||
label="Magasins"
|
||||
value={`${reseau.nbMagasinsReseau}`}
|
||||
hint={`sur ${NB_MAGASINS_RESEAU} · ${fmtDec((reseau.nbMagasinsReseau / NB_MAGASINS_RESEAU) * 100, 0)} %`}
|
||||
/>
|
||||
<Stat label="CA / magasin" value={fmtEur(reseau.caParMagasinReseau)} />
|
||||
<Stat
|
||||
label="Marge % réseau"
|
||||
value={comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"}
|
||||
/>
|
||||
<Stat
|
||||
label="Tendance 12 m"
|
||||
value={trend.pct != null ? `${trend.pct >= 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"}
|
||||
hint={trendLabel(trend.pct)}
|
||||
color={TREND_COLOR[trend.direction]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
|
||||
Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)}
|
||||
</p>
|
||||
|
||||
{trend.hasData && (
|
||||
<div className="mt-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
Quantités vendues réseau, mois par mois
|
||||
</div>
|
||||
<NetworkLineChart labels={trend.labels} values={trend.values} color={TREND_COLOR[trend.direction]} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Détail mensuel complet (CA, marge, magasins) si disponible */}
|
||||
{reseau.metricsByMonth && Object.keys(reseau.metricsByMonth).length > 0 && (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
Détail mensuel réseau
|
||||
</div>
|
||||
<table className="w-full border-collapse text-[11px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-semibold" style={{ color: "var(--text-muted)" }}>Mesure</th>
|
||||
{months.map((m) => (
|
||||
<th key={m} className="px-1 py-1.5 text-center font-semibold whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
||||
{formatMonthLabel(m)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{([
|
||||
{ key: "qte", label: "Quantité", fmt: (v: number) => fmtQte(v) },
|
||||
{ key: "ca", label: "CA", fmt: (v: number) => Math.round(v).toLocaleString("fr-FR") },
|
||||
{ key: "nbMag", label: "Magasins", fmt: (v: number) => fmtQte(v) },
|
||||
{ key: "margePct", label: "Marge %", fmt: (v: number) => `${fmtDec(Math.abs(v) <= 1 ? v * 100 : v)} %` },
|
||||
] as const).map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td className="px-2 py-1 font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
{row.label}
|
||||
</td>
|
||||
{months.map((m) => {
|
||||
const v = reseau.metricsByMonth?.[ffMonthToQlik(m)]?.[row.key];
|
||||
return (
|
||||
<td key={m} className="px-1 py-1 text-center tabular-nums" style={{ color: "var(--text-muted)" }}>
|
||||
{v != null ? row.fmt(v) : "—"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-[10px] mt-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
Les mois sans CA ni marge proviennent de la mesure « Quantité COMP », qui ne porte que la quantité.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Comparatif local vs réseau */}
|
||||
{comparatif.hasReseau && (
|
||||
<Card title="Nous vs. un magasin moyen du réseau" icon={Globe}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Stat
|
||||
label="Indice de performance"
|
||||
value={comparatif.indiceQte != null ? `${Math.round(comparatif.indiceQte)} %` : "—"}
|
||||
hint={verdict.label}
|
||||
color={verdict.color}
|
||||
/>
|
||||
<Stat
|
||||
label="Nos ventes / magasin"
|
||||
value={fmtQte(comparatif.qteLocaleParMagasin)}
|
||||
hint={`${comparatif.nbMagasinsLocaux} magasin${comparatif.nbMagasinsLocaux > 1 ? "s" : ""} · 12 mois`}
|
||||
/>
|
||||
<Stat
|
||||
label="Réseau / magasin"
|
||||
value={fmtQte(comparatif.qteReseauParMagasin)}
|
||||
hint={`${comparatif.nbMagasinsReseau} magasins · 12 mois`}
|
||||
/>
|
||||
<Stat
|
||||
label="Présence réseau"
|
||||
value={comparatif.tauxPresence != null ? `${fmtDec(comparatif.tauxPresence * 100, 0)} %` : "—"}
|
||||
hint={`${comparatif.nbMagasinsReseau} / ${NB_MAGASINS_RESEAU} magasins`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
<div className="rounded-xl p-3 text-[12px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
CA par magasin · indicatif
|
||||
</div>
|
||||
<div className="flex items-center justify-between" style={{ color: "var(--text-secondary)" }}>
|
||||
<span>Nous : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{fmtEur(comparatif.caLocalParMagasin)}</span></span>
|
||||
<span>Réseau : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{comparatif.caReseauParMagasin != null ? fmtEur(comparatif.caReseauParMagasin) : "—"}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl p-3 text-[12px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
Taux de marge · indicatif
|
||||
</div>
|
||||
<div className="flex items-center justify-between" style={{ color: "var(--text-secondary)" }}>
|
||||
<span>Nous : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{fmtDec(comparatif.tauxMargeLocal)} %</span></span>
|
||||
<span>Réseau : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] mt-2" style={{ color: "var(--text-muted)" }}>
|
||||
L'indice compare les <strong>quantités</strong> par magasin, seule base commune fiable.
|
||||
Notre CA est TTC (issu des mouvements de caisse) ; la base du CA Qlik n'est pas
|
||||
documentée — montants et marges sont donc donnés à titre indicatif.
|
||||
</p>
|
||||
|
||||
{comparatif.hasMonthly && (
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
Quantité par magasin, mois par mois
|
||||
</div>
|
||||
<DualLineChart
|
||||
labels={comparatif.monthly.map((m) => ffMonthToQlik(m.mois))}
|
||||
series={[
|
||||
{ name: "Nous / magasin", values: comparatif.monthly.map((m) => m.local), color: "#0ea5e9" },
|
||||
{ name: "Réseau / magasin", values: comparatif.monthly.map((m) => m.reseau), color: "#f59e0b" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Opportunités de la même famille */}
|
||||
{detail.nom_no_id != null && (
|
||||
<Card title="Opportunités · même famille" icon={Layers}>
|
||||
<OpportunitesFamille
|
||||
nomNoId={detail.nom_no_id}
|
||||
currentCodein={detail.codein}
|
||||
familleLabel={detail.libelle3 ?? detail.libelle2 ?? ""}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sparkline discrète en pied de fiche, cohérente avec la Grille */}
|
||||
{trend.hasData && (
|
||||
<div className="flex items-center justify-end gap-2 text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
<span>Tendance réseau :</span>
|
||||
<TrendSparkline trend={trend} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
export default function ProduitsLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Recherche du produit..." />;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Loader2, TrendingUp } from "lucide-react";
|
||||
import type { PgOpportuniteRow } from "@/lib/pg-ff-client";
|
||||
|
||||
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
|
||||
const fmtDec = (v: number) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v);
|
||||
|
||||
/**
|
||||
* Produits de la même famille que le produit affiché, classés par écart de
|
||||
* performance réseau vs locale (quantité **par magasin**, pour neutraliser
|
||||
* l'écart d'échelle entre ~270 magasins et nos 2 sites).
|
||||
*
|
||||
* Chargement à la demande : la requête agrège 12 mois de mouvements pour toute
|
||||
* la famille, elle ne doit pas ralentir l'ouverture de la fiche.
|
||||
*/
|
||||
export function OpportunitesFamille({
|
||||
nomNoId,
|
||||
currentCodein,
|
||||
familleLabel,
|
||||
}: {
|
||||
nomNoId: number;
|
||||
currentCodein: string;
|
||||
familleLabel: string;
|
||||
}) {
|
||||
const [rows, setRows] = useState<PgOpportuniteRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/produits/opportunites?nomNoId=${nomNoId}`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
setRows((data.rows ?? []) as PgOpportuniteRow[]);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (rows === null) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
|
||||
Comparer ce produit aux autres références{familleLabel ? ` de « ${familleLabel} »` : " de la même famille"} :
|
||||
lesquelles le réseau vend bien et que nous vendons peu ?
|
||||
</p>
|
||||
<button onClick={load} disabled={loading} className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <TrendingUp className="w-3.5 h-3.5" />}
|
||||
{loading ? "Analyse…" : "Analyser la famille"}
|
||||
</button>
|
||||
{error && <p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On retire le produit courant : il est déjà détaillé au-dessus.
|
||||
const others = rows.filter((r) => r.codein !== currentCodein);
|
||||
|
||||
if (others.length === 0) {
|
||||
return (
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Aucune autre référence de cette famille n'a de données réseau en cache.
|
||||
Synchronisez d'autres fournisseurs pour enrichir la comparaison.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full border-collapse text-[12px]">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Produit</th>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Fournisseur</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Réseau / mag.</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Nous / mag.</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Écart</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasins</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{others.map((r) => {
|
||||
const opportunite = r.ecart_par_magasin > 0;
|
||||
return (
|
||||
<tr key={r.codein} className="transition-colors hover:bg-[var(--bg-elevated)]" style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td className="px-3 py-2">
|
||||
<Link
|
||||
href={`/produits?codein=${encodeURIComponent(r.codein)}`}
|
||||
className="font-medium hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{r.libelle1 || r.codein}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{r.fournisseur}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtDec(r.qte_reseau_par_magasin)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtDec(r.qte_locale_par_magasin)}
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-2 text-right tabular-nums font-semibold"
|
||||
style={{ color: opportunite ? "#f59e0b" : "#22c55e" }}
|
||||
title={opportunite
|
||||
? "Le réseau vend plus que nous sur ce produit"
|
||||
: "Nous vendons autant ou plus que le réseau"}
|
||||
>
|
||||
{r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
{fmtQte(r.nb_magasins_reseau)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Écart = quantité réseau par magasin − notre quantité par magasin, sur 12 mois.
|
||||
Un écart positif signale une référence sous-exploitée chez nous.
|
||||
Périmètre : les articles <strong>de notre catalogue</strong> ayant déjà des données réseau —
|
||||
Qlik est interrogé par code article, un produit que Nancy ne référence pas ne peut pas apparaître ici.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { pgSearchProduits } from "@/lib/pg-ff-client";
|
||||
import { getProduitFiche } from "@/features/produits/api/get-produit-fiche";
|
||||
import { ProduitSearchBar } from "./search-bar";
|
||||
import { ProduitResults } from "./results";
|
||||
import { ProduitFicheView } from "./fiche";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Recherche produit : par libellé ou par code centrale.
|
||||
*
|
||||
* - `?q=…` → liste de résultats
|
||||
* - `?codein=…` → fiche complète (URL partageable)
|
||||
*/
|
||||
export default async function ProduitsPage(props: {
|
||||
searchParams: Promise<{ q?: string; codein?: string }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const q = (searchParams.q ?? "").trim();
|
||||
const codein = (searchParams.codein ?? "").trim();
|
||||
|
||||
const fiche = codein ? await getProduitFiche(codein) : null;
|
||||
// On ne relance pas la recherche quand une fiche est ouverte : la liste
|
||||
// reste accessible via le lien retour, qui conserve `q`.
|
||||
const results = !codein && q.length >= 3 ? await pgSearchProduits(q) : [];
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-screen-2xl mx-auto space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
|
||||
Recherche produit
|
||||
</h1>
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Par libellé ou par code centrale — ventes sur 12 mois glissants et données réseau Qlik.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}
|
||||
<ProduitSearchBar key={q} initialQuery={q} />
|
||||
|
||||
{codein && !fiche && (
|
||||
<div
|
||||
className="rounded-xl p-6 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
|
||||
>
|
||||
Aucun produit trouvé pour le code article <span className="font-mono">{codein}</span>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fiche && <ProduitFicheView fiche={fiche} backQuery={q} />}
|
||||
|
||||
{!codein && (
|
||||
<ProduitResults rows={results} query={q} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import Link from "next/link";
|
||||
import { PackageSearch, Wifi, WifiOff } from "lucide-react";
|
||||
import type { PgProduitSearchRow } from "@/lib/pg-ff-client";
|
||||
|
||||
function fmtQte(v: number): string {
|
||||
return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
|
||||
}
|
||||
|
||||
/** Liste des résultats de recherche. */
|
||||
export function ProduitResults({ rows, query }: { rows: PgProduitSearchRow[]; query: string }) {
|
||||
if (!query) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-10 text-center"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<PackageSearch className="w-8 h-8 mx-auto mb-3" style={{ color: "var(--text-muted)" }} strokeWidth={1.5} />
|
||||
<p className="text-[14px] font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||
Recherchez un produit
|
||||
</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-8 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
|
||||
>
|
||||
Aucun produit ne correspond à « {query} ».
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
{rows.length} produit{rows.length > 1 ? "s" : ""} trouvé{rows.length > 1 ? "s" : ""}
|
||||
{rows.length >= 50 && " (50 premiers — affinez la recherche)"}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[13px] border-collapse">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<Th>Libellé</Th>
|
||||
<Th>Fournisseur</Th>
|
||||
<Th>Code article</Th>
|
||||
<Th>Code centrale</Th>
|
||||
<Th>Famille</Th>
|
||||
<Th align="right">Stock</Th>
|
||||
<Th align="right">Qté réseau</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr
|
||||
key={r.codein}
|
||||
className="transition-colors hover:bg-[var(--bg-elevated)]"
|
||||
style={{ borderTop: "1px solid var(--border)" }}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<Link
|
||||
href={`/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}`}
|
||||
className="font-medium hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{r.libelle1 || "—"}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.fournisseur || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
{r.codein}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
{r.code_centrale || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.nomenclature || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtQte(r.stock_total)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{r.has_reseau ? (
|
||||
<span className="inline-flex items-center gap-1.5" style={{ color: "var(--text-secondary)" }}>
|
||||
<Wifi className="w-3 h-3" style={{ color: "var(--accent-success)" }} />
|
||||
{fmtQte(r.qte_reseau)}
|
||||
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
/ {r.nb_magasins_reseau} mag.
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[11px]"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
title="Données réseau Qlik non synchronisées pour ce produit"
|
||||
>
|
||||
<WifiOff className="w-3 h-3" />
|
||||
non synchro
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" }) {
|
||||
return (
|
||||
<th
|
||||
// Classes littérales : Tailwind ne génère pas les classes construites dynamiquement.
|
||||
className={`px-3 py-2.5 ${align === "right" ? "text-right" : "text-left"} text-[11px] font-semibold uppercase tracking-wide whitespace-nowrap`}
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Champ de recherche produit.
|
||||
*
|
||||
* Soumission **explicite** (Entrée ou bouton) et non à la frappe : la requête
|
||||
* fait un ILIKE sans index sur `articles.libelle1`, on ne veut pas la déclencher
|
||||
* à chaque caractère. Minimum 3 caractères, aligné sur `pgSearchProduits`.
|
||||
*
|
||||
* L'appelant passe `key={q}` pour que le champ se resynchronise sur l'URL
|
||||
* (retour arrière, lien partagé) par remontage, plutôt que par un effet qui
|
||||
* appellerait setState en cascade.
|
||||
*/
|
||||
export function ProduitSearchBar({ initialQuery }: { initialQuery: string }) {
|
||||
const router = useRouter();
|
||||
const [term, setTerm] = useState(initialQuery);
|
||||
|
||||
const tooShort = term.trim().length > 0 && term.trim().length < 3;
|
||||
|
||||
function submit() {
|
||||
const cleaned = term.trim();
|
||||
if (cleaned.length < 3) return;
|
||||
router.push(`/produits?q=${encodeURIComponent(cleaned)}`);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setTerm("");
|
||||
router.push("/produits");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xl">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
|
||||
placeholder="Libellé produit ou code centrale…"
|
||||
aria-label="Rechercher un produit par libellé ou code centrale"
|
||||
className="apple-input h-10 text-[14px]"
|
||||
style={{ paddingLeft: "38px", paddingRight: term ? "36px" : undefined }}
|
||||
/>
|
||||
{term && (
|
||||
<button
|
||||
onClick={clear}
|
||||
aria-label="Effacer la recherche"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors hover:bg-[var(--bg-elevated)]"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={term.trim().length < 3}
|
||||
className="btn-action btn-action-primary disabled:opacity-50"
|
||||
>
|
||||
Rechercher
|
||||
</button>
|
||||
</div>
|
||||
{tooShort && (
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Saisissez au moins 3 caractères.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, M
|
||||
import { useTheme } from "next-themes";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
|
||||
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig } from "@/features/settings/actions";
|
||||
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection } from "@/features/settings/actions";
|
||||
import { useEffect } from "react";
|
||||
import { UserManagement } from "@/features/admin/components/user-management";
|
||||
|
||||
@@ -106,6 +106,82 @@ function FfApiStatusSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function QlikSettingsSection() {
|
||||
const [host, setHost] = useState("");
|
||||
const [user, setUser] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSavedDatabaseConfig().then((c) => {
|
||||
if (!c) return;
|
||||
setHost(c.qlikHost ?? "");
|
||||
setUser(c.qlikUser ?? "");
|
||||
setPassword(c.qlikPassword ?? "");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setStatus(null);
|
||||
const res = await saveQlikSettings(host.trim(), user.trim(), password);
|
||||
setSaving(false);
|
||||
setStatus(res.success ? { type: "ok", msg: "Réglages Qlik enregistrés" } : { type: "err", msg: res.error || "Erreur" });
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
setStatus(null);
|
||||
const res = await testQlikConnection(host.trim(), user.trim(), password);
|
||||
setTesting(false);
|
||||
setStatus(res.success ? { type: "ok", msg: "Connexion Qlik OK" } : { type: "err", msg: res.error || "Échec connexion" });
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Qlik Sense — Données réseau" subtitle="Identifiants pour la synchro des données réseau (~270 magasins). Auth NTLM ; laisser l'hôte vide pour le défaut.">
|
||||
<Field label="Hôte" hint="Défaut : reporting-magasins.lafoirfouille.fr (laisser vide pour ce défaut)">
|
||||
<input type="text" placeholder="reporting-magasins.lafoirfouille.fr" value={host}
|
||||
onChange={(e) => setHost(e.target.value)} className="apple-input font-mono" />
|
||||
</Field>
|
||||
<Field label="Utilisateur" hint="Ex : FFSCH (sans domaine)">
|
||||
<input type="text" placeholder="FFSCH" value={user}
|
||||
onChange={(e) => setUser(e.target.value)} className="apple-input font-mono" />
|
||||
</Field>
|
||||
<Field label="Mot de passe">
|
||||
<div className="relative">
|
||||
<input type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} className="apple-input font-mono pr-10" />
|
||||
<button type="button" onClick={() => setShowPwd((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-muted)]">
|
||||
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
{status && (
|
||||
<div className={`flex items-center gap-1.5 text-[12px] ${status.type === "ok" ? "text-emerald-600" : "text-rose-600"}`}>
|
||||
{status.type === "ok" ? <CheckCircle className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
||||
{status.msg}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
className="btn-action btn-action-primary flex items-center gap-1.5 disabled:opacity-60">
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
Enregistrer
|
||||
</button>
|
||||
<button onClick={handleTest} disabled={testing || !user || !password}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
|
||||
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Tester
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
@@ -336,6 +412,8 @@ export default function SettingsPage() {
|
||||
{/* API FF Nancy */}
|
||||
<FfApiStatusSection />
|
||||
|
||||
<QlikSettingsSection />
|
||||
|
||||
{/* AI Provider selector */}
|
||||
<Section title="Admin AI Chat — Fournisseur" subtitle="Choisissez le fournisseur IA utilisé dans l'onglet Admin AI Chat">
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client";
|
||||
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/produits/opportunites?nomNoId=123
|
||||
*
|
||||
* Produits de la même nomenclature, classés par écart entre performance réseau
|
||||
* et performance locale (quantités par magasin). Chargé à la demande depuis la
|
||||
* fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille,
|
||||
* on ne veut pas la payer au chargement de la page.
|
||||
*
|
||||
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const raw = req.nextUrl.searchParams.get("nomNoId");
|
||||
const nomNoId = Number(raw);
|
||||
if (!raw || !Number.isInteger(nomNoId) || nomNoId <= 0) {
|
||||
return NextResponse.json({ error: "Param 'nomNoId' invalide" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
const rows = await pgGetOpportunitesFamille(nomNoId, dateDebut, dateFin);
|
||||
return NextResponse.json({ success: true, rows });
|
||||
} catch (e) {
|
||||
console.error("[api/produits/opportunites]", (e as Error).message?.slice(0, 250));
|
||||
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+429
-42
@@ -1,62 +1,449 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { fetchNetworkMetrics } from "@/lib/qlik-client";
|
||||
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
||||
import {
|
||||
buildGridNetworkQlikDateFilter,
|
||||
envMonthsBack,
|
||||
QLIK_MONTHS_BACK_DEFAULT,
|
||||
QLIK_MONTHS_BACK_MAX,
|
||||
QLIK_MONTHS_BACK_MIN,
|
||||
} from "@/lib/qlik-date-range";
|
||||
|
||||
// Synchro potentiellement longue (extraction hypercube paginee).
|
||||
// Tâche d'extraction Qlik potentiellement très longue (hypercube paginé).
|
||||
// On accepte quand même 5 min côté plateforme Next.js, mais on rend la main au
|
||||
// client tout de suite via le state job ci-dessous.
|
||||
export const maxDuration = 300;
|
||||
// Forcer le runtime Node.js : Playwright/Chromium ne tourne pas en edge et on a
|
||||
// besoin d'un process persistant pour conserver l'état de job en mémoire.
|
||||
export const runtime = "nodejs";
|
||||
// Toujours exécuter dynamiquement (sinon Next peut essayer de cacher des POST).
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** État d'un job de sync Qlik pour un fournisseur donné. */
|
||||
type QlikSyncStatus = "idle" | "running" | "success" | "error";
|
||||
|
||||
interface QlikSyncJob {
|
||||
jobId: string;
|
||||
/** Code fournisseur (mode fournisseur) — chaîne vide en mode produit. */
|
||||
fournisseur: string;
|
||||
/** Code centrale (mode produit) — absent en mode fournisseur. */
|
||||
codeCentrale?: string;
|
||||
status: QlikSyncStatus;
|
||||
requested: number;
|
||||
fetched: number;
|
||||
upserted: number;
|
||||
periode?: string;
|
||||
dateDebut?: string;
|
||||
dateFin?: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store en mémoire au niveau module. Une Map par fournisseur car on ne lance
|
||||
* qu'une extraction à la fois par fournisseur. C'est volontairement simple :
|
||||
* si le process redémarre, les jobs en cours sont perdus → GET renverra idle
|
||||
* et l'utilisateur pourra relancer. Suffisant pour corriger le bug UX.
|
||||
*/
|
||||
const jobs = new Map<string, QlikSyncJob>();
|
||||
|
||||
/**
|
||||
* Clé de job. Le mode fournisseur garde sa clé historique (le code fournisseur
|
||||
* brut) ; le mode produit est préfixé pour ne jamais entrer en collision.
|
||||
*/
|
||||
function jobKeyForProduct(codeCentrale: string): string {
|
||||
return `cc:${codeCentrale.toUpperCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre maximum d'extractions produit simultanées.
|
||||
*
|
||||
* Le mode produit est ouvert à tous les utilisateurs connectés (contrairement au
|
||||
* mode fournisseur, réservé aux admins). Une extraction sur 1 seul code est très
|
||||
* légère, mais le serveur Qlik est sujet aux saturations mémoire : on plafonne
|
||||
* la concurrence plutôt que de lui laisser prendre N requêtes en parallèle.
|
||||
*/
|
||||
const MAX_CONCURRENT_PRODUCT_JOBS = 3;
|
||||
|
||||
/** Compte les extractions produit actuellement en cours. */
|
||||
function runningProductJobs(): number {
|
||||
let n = 0;
|
||||
for (const [key, job] of jobs) {
|
||||
if (key.startsWith("cc:") && job.status === "running") n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Construit un jobId court et lisible. */
|
||||
function newJobId(): string {
|
||||
return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/** Sérialise un job pour la réponse HTTP. */
|
||||
function publicJob(job: QlikSyncJob) {
|
||||
return {
|
||||
jobId: job.jobId,
|
||||
fournisseur: job.fournisseur,
|
||||
codeCentrale: job.codeCentrale,
|
||||
status: job.status,
|
||||
requested: job.requested,
|
||||
fetched: job.fetched,
|
||||
upserted: job.upserted,
|
||||
periode: job.periode,
|
||||
dateDebut: job.dateDebut,
|
||||
dateFin: job.dateFin,
|
||||
startedAt: job.startedAt,
|
||||
finishedAt: job.finishedAt,
|
||||
error: job.error,
|
||||
message: job.message,
|
||||
};
|
||||
}
|
||||
|
||||
/** État "idle" sérialisé (pas de job connu pour cette cible). */
|
||||
function idleJob(fournisseur: string, codeCentrale?: string) {
|
||||
return {
|
||||
jobId: null,
|
||||
fournisseur,
|
||||
codeCentrale,
|
||||
status: "idle" as const,
|
||||
requested: 0,
|
||||
fetched: 0,
|
||||
upserted: 0,
|
||||
periode: undefined,
|
||||
dateDebut: undefined,
|
||||
dateFin: undefined,
|
||||
startedAt: undefined,
|
||||
finishedAt: undefined,
|
||||
error: undefined,
|
||||
message: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie l'auth admin. Renvoie une réponse 403/401 si refusée, sinon null.
|
||||
*/
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie qu'une session existe (sans exiger le rôle admin).
|
||||
*
|
||||
* Utilisé par le mode produit : le middleware Next ne couvre PAS les routes
|
||||
* `/api/*`, le contrôle doit donc être fait ici explicitement.
|
||||
*/
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex de validation d'un code centrale "raisonnable".
|
||||
*
|
||||
* Format attendu d'après l'API FF : préfixe `10000` + 6 chiffres (ex `10000167303`),
|
||||
* donc 11 caractères alphanumériques. Pour rester large et ne pas casser
|
||||
* d'éventuels codes alphanumériques fournisseur (refs internes), on autorise
|
||||
* - longueur 2..30
|
||||
* - jeu `[A-Z0-9_-]`
|
||||
* - premier caractère alphanumérique (pas `_` ni `-`)
|
||||
*
|
||||
* But : éviter d'envoyer à Qlik des chaînes vides, des `-`, des espaces
|
||||
* parasites, ou des entrées manifestement invalides qui déclenchent des
|
||||
* codes d'erreur Engine ou du gaspillage réseau. **Volontairement simple** :
|
||||
* on ne touche pas au mapping ; on filtre seulement ce qui ne ressemble à
|
||||
* aucun code plausible.
|
||||
*/
|
||||
const CENTRAL_CODE_REGEX = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i;
|
||||
|
||||
/**
|
||||
* Filtre strict des codes centraux avant d'envoyer à Qlik.
|
||||
* - trim + drop vides
|
||||
* - drop "-" (placeholder déjà exclu côté agrégation mais on double-check)
|
||||
* - drop doublons (préserve l'ordre, comparaison insensible à la casse)
|
||||
* - drop codes ne matchant pas CENTRAL_CODE_REGEX (longueur / charset)
|
||||
*
|
||||
* Renvoie `{ accepted, rejected, deduped }`. Les logs n'exposent que les
|
||||
* compteurs, jamais les valeurs des codes.
|
||||
*/
|
||||
function filterCentralCodes(rawCodes: Array<string | null | undefined>): {
|
||||
accepted: string[];
|
||||
rejected: number;
|
||||
deduped: number;
|
||||
} {
|
||||
const seen = new Set<string>();
|
||||
const accepted: string[] = [];
|
||||
let rejected = 0;
|
||||
let deduped = 0;
|
||||
for (const raw of rawCodes) {
|
||||
if (raw == null) {
|
||||
rejected++;
|
||||
continue;
|
||||
}
|
||||
const code = String(raw).trim();
|
||||
const key = code.toUpperCase();
|
||||
if (!code || code === "-" || code === "_") {
|
||||
rejected++;
|
||||
continue;
|
||||
}
|
||||
if (seen.has(key)) {
|
||||
deduped++;
|
||||
continue;
|
||||
}
|
||||
if (!CENTRAL_CODE_REGEX.test(code)) {
|
||||
rejected++;
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
accepted.push(code);
|
||||
}
|
||||
return { accepted, rejected, deduped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute réellement l'extraction + l'upsert, en mettant à jour le job
|
||||
* au fur et à mesure (compteur fetched, puis upserted, puis finishedAt).
|
||||
*/
|
||||
async function runJob(job: QlikSyncJob): Promise<void> {
|
||||
try {
|
||||
const articles = await pgGetArticlesByFournisseur(job.fournisseur);
|
||||
// Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects).
|
||||
const { accepted: codes, rejected: codesRejected, deduped: codesDeduped } = filterCentralCodes(
|
||||
articles.map((a) => a.codeCentrale),
|
||||
);
|
||||
job.requested = codes.length;
|
||||
console.log(
|
||||
`[api/qlik/sync] job=${job.jobId} fournisseur=${job.fournisseur} — ${articles.length} articles, ${codes.length} codes centraux retenus, ${codesRejected} rejetés (vide/-/anormaux), ${codesDeduped} doublons supprimés`,
|
||||
);
|
||||
if (codes.length === 0) {
|
||||
job.status = "success";
|
||||
job.message = "Aucun code centrale valide pour ce fournisseur";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fenêtre temporelle alignée sur la grille, éventuellement raccourcie via
|
||||
// QLIK_SYNC_MONTHS_BACK (1..12, défaut 12). On logue la valeur effective
|
||||
// pour audit.
|
||||
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
|
||||
job.periode = dateFilter.label;
|
||||
job.dateDebut = dateFilter.dateDebut;
|
||||
job.dateFin = dateFilter.dateFin;
|
||||
console.log(
|
||||
`[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, QLIK_SYNC_MONTHS_BACK=${monthsBack}, bornes ${QLIK_MONTHS_BACK_MIN}..${QLIK_MONTHS_BACK_MAX})…`,
|
||||
);
|
||||
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
|
||||
job.fetched = metrics.size;
|
||||
console.log(`[api/qlik/sync] job=${job.jobId} ← Qlik a renvoyé ${metrics.size} produits réseau`);
|
||||
const count = await upsertNetworkMetrics([...metrics.values()]);
|
||||
job.upserted = count;
|
||||
console.log(`[api/qlik/sync] job=${job.jobId} ${count} lignes upsert dans le cache`);
|
||||
job.status = "success";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
} catch (e) {
|
||||
applyJobError(job, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscrit une erreur dans le job, en traduisant la saturation mémoire du
|
||||
* serveur Qlik en message actionnable (ce n'est PAS un bug CollectFlow :
|
||||
* l'app Qlik ne se charge même pas).
|
||||
*/
|
||||
function applyJobError(job: QlikSyncJob, e: unknown): void {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/qlik/sync] job=${job.jobId}`, msg);
|
||||
job.status = "error";
|
||||
const lower = msg.toLowerCase();
|
||||
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes("file corrupted") || lower.includes('"code":6') || lower.includes('"code":3002')) {
|
||||
job.error = "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).";
|
||||
} else {
|
||||
job.error = msg;
|
||||
}
|
||||
job.finishedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extraction Qlik pour UN seul code centrale (fiche produit).
|
||||
*
|
||||
* Beaucoup plus légère que la sync fournisseur : un seul code au lieu de
|
||||
* plusieurs centaines, sur la même fenêtre 12 mois glissants. Réutilise
|
||||
* exactement le même extracteur, donc le même détail mensuel.
|
||||
*/
|
||||
async function runProductJob(job: QlikSyncJob): Promise<void> {
|
||||
try {
|
||||
const code = job.codeCentrale!;
|
||||
job.requested = 1;
|
||||
|
||||
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
|
||||
job.periode = dateFilter.label;
|
||||
job.dateDebut = dateFilter.dateDebut;
|
||||
job.dateFin = dateFilter.dateFin;
|
||||
console.log(
|
||||
`[api/qlik/sync] job=${job.jobId} → extraction Qlik produit (1 code) — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
|
||||
);
|
||||
|
||||
const metrics = await fetchNetworkMetricsPlaywright([code], undefined, dateFilter);
|
||||
job.fetched = metrics.size;
|
||||
if (metrics.size === 0) {
|
||||
job.status = "success";
|
||||
job.message = "Ce code centrale est inconnu de Qlik (aucune vente réseau sur la période)";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
return;
|
||||
}
|
||||
const count = await upsertNetworkMetrics([...metrics.values()]);
|
||||
job.upserted = count;
|
||||
job.status = "success";
|
||||
job.message = "Données réseau récupérées";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
console.log(`[api/qlik/sync] job=${job.jobId} produit ${code} — ${count} ligne(s) upsert`);
|
||||
} catch (e) {
|
||||
applyJobError(job, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY
|
||||
* Retourne l'état courant du job pour cette cible. Si aucun job n'est
|
||||
* connu, renvoie un état "idle" (avec un message explicite). Permet au
|
||||
* frontend de reprendre l'état après un reload et de poller en arrière-plan.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const codeCentrale = req.nextUrl.searchParams.get("codeCentrale");
|
||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||
|
||||
// Mode produit : ouvert à tout utilisateur connecté.
|
||||
if (codeCentrale) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const job = jobs.get(jobKeyForProduct(codeCentrale));
|
||||
if (!job) {
|
||||
return NextResponse.json({ success: true, ...idleJob("", codeCentrale) });
|
||||
}
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
// Mode fournisseur : admin uniquement (inchangé).
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
if (!fournisseur) {
|
||||
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
|
||||
}
|
||||
const job = jobs.get(fournisseur);
|
||||
if (!job) {
|
||||
return NextResponse.json({ success: true, ...idleJob(fournisseur) });
|
||||
}
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Démarre (ou réutilise si déjà running) un job d'extraction en arrière-plan
|
||||
* et retourne *immédiatement* { success, status, jobId, ... }. Le client doit
|
||||
* ensuite poller GET pour suivre l'avancement et être notifié de la fin.
|
||||
*/
|
||||
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 codeCentraleParam = req.nextUrl.searchParams.get("codeCentrale");
|
||||
|
||||
// ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ───
|
||||
if (codeCentraleParam) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
// Même validation que la sync fournisseur (trim, rejet des vides / "-" /
|
||||
// codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik.
|
||||
const { accepted } = filterCentralCodes([codeCentraleParam]);
|
||||
if (accepted.length === 0) {
|
||||
return NextResponse.json({ error: "Code centrale invalide" }, { status: 400 });
|
||||
}
|
||||
const code = accepted[0];
|
||||
const key = jobKeyForProduct(code);
|
||||
|
||||
const existingProduct = jobs.get(key);
|
||||
if (existingProduct && existingProduct.status === "running") {
|
||||
return NextResponse.json({ success: true, ...publicJob(existingProduct) });
|
||||
}
|
||||
|
||||
if (runningProductJobs() >= MAX_CONCURRENT_PRODUCT_JOBS) {
|
||||
return NextResponse.json(
|
||||
{ error: "Trop d'extractions Qlik en cours. Réessayez dans quelques instants." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const productJob: QlikSyncJob = {
|
||||
jobId: newJobId(),
|
||||
fournisseur: "",
|
||||
codeCentrale: code,
|
||||
status: "running",
|
||||
requested: 0,
|
||||
fetched: 0,
|
||||
upserted: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
jobs.set(key, productJob);
|
||||
console.log(`[api/qlik/sync] job=${productJob.jobId} démarré pour codeCentrale=${code}`);
|
||||
|
||||
void runProductJob(productJob).catch((e) => applyJobError(productJob, e));
|
||||
|
||||
return NextResponse.json({ success: true, ...publicJob(productJob) });
|
||||
}
|
||||
|
||||
// ─── Mode fournisseur : admin uniquement (inchangé) ──────────────────────
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||
if (!fournisseur) {
|
||||
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
|
||||
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' 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 existing = jobs.get(fournisseur);
|
||||
if (existing && existing.status === "running") {
|
||||
// Un job tourne déjà pour ce fournisseur : on le réutilise.
|
||||
return NextResponse.json({ success: true, ...publicJob(existing) });
|
||||
}
|
||||
|
||||
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 job: QlikSyncJob = {
|
||||
jobId: newJobId(),
|
||||
fournisseur,
|
||||
status: "running",
|
||||
requested: 0,
|
||||
fetched: 0,
|
||||
upserted: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
jobs.set(fournisseur, job);
|
||||
console.log(`[api/qlik/sync] job=${job.jobId} démarré pour fournisseur=${fournisseur}`);
|
||||
|
||||
// Lancement fire-and-forget. On ne `await` PAS l'extraction ici : on
|
||||
// renvoie tout de suite au client et il polle GET. Les erreurs sont
|
||||
// capturées dans runJob et inscrites dans le job.
|
||||
void runJob(job).catch((e) => {
|
||||
// Filet de sécurité (runJob ne devrait pas throw, mais on ne veut
|
||||
// jamais qu'une exception non capturée crashe le process).
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("[api/qlik/sync]", msg);
|
||||
return NextResponse.json({ success: false, error: msg }, { status: 500 });
|
||||
}
|
||||
console.error(`[api/qlik/sync] job=${job.jobId} crash non géré`, msg);
|
||||
job.status = "error";
|
||||
job.error = msg;
|
||||
job.finishedAt = new Date().toISOString();
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, MessageSquareCode } from "lucide-react";
|
||||
import { LayoutGrid, Camera, FileDown, Settings, Package, PackageSearch, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, MessageSquareCode } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -11,6 +11,7 @@ import { useSession, signOut } from "next-auth/react";
|
||||
const NAV_ITEMS = [
|
||||
{ icon: BarChart2, label: "Dashboard", href: "/dashboard" },
|
||||
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
|
||||
{ icon: PackageSearch, label: "Produits", href: "/produits" },
|
||||
{ icon: TrendingUp, label: "Analytics", href: "/analytics" },
|
||||
{ icon: Trophy, label: "Hit Parade", href: "/hit-parade" },
|
||||
{ icon: Megaphone, label: "Publicités", href: "/publicites" },
|
||||
|
||||
@@ -117,8 +117,20 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
|
||||
qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }),
|
||||
/** Nombre de magasins travaillant le produit (sur ~270) */
|
||||
nbMagasinsReseau: integer("nb_magasins_reseau"),
|
||||
/** CA moyen par magasin (productivité normalisée présence) */
|
||||
caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }),
|
||||
/** Taux de marge réseau (ratio brut Qlik) */
|
||||
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
|
||||
/** Période couverte (libre, ex "12m" ou "2025") */
|
||||
periode: varchar("periode", { length: 20 }),
|
||||
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */
|
||||
qteByMonth: jsonb("qte_by_month"),
|
||||
/**
|
||||
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
|
||||
* Superset de `qteByMonth`, qui reste maintenu tel quel pour la colonne
|
||||
* « Tendance / Réseau » de la Grille.
|
||||
*/
|
||||
metricsByMonth: jsonb("metrics_by_month"),
|
||||
/** Dernière synchro depuis Qlik */
|
||||
fetchedAt: timestamp("fetched_at").defaultNow(),
|
||||
});
|
||||
|
||||
@@ -15,8 +15,7 @@ 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;
|
||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
@@ -47,10 +46,33 @@ export function invalidateGridRowsCache(codeFournisseur?: string) {
|
||||
gridRowsPending.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante)
|
||||
* sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas
|
||||
* touchée : elle conserve la valeur issue du snapshot. Ainsi l'état serveur (INIT)
|
||||
* est toujours à jour à chaque chargement, même sur un hit du cache 10 min — et le
|
||||
* marqueur "modifié" (Gamme ≠ INIT) se résorbe dès que le serveur rattrape la modif.
|
||||
*/
|
||||
async function refreshGammeInit(rows: ProductRow[], codeFournisseur: string): Promise<void> {
|
||||
try {
|
||||
const gammeMap = await pgGetGammesByFournisseur(codeFournisseur);
|
||||
if (gammeMap.size === 0) return;
|
||||
for (const row of rows) {
|
||||
const live = gammeMap.get(row.codein);
|
||||
if (live !== undefined) row.codeGammeInit = live as GammeCode;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[getProductRows] refreshGammeInit error:", (e as Error).message?.slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
|
||||
const cacheKey = gridRowsCacheKey(input);
|
||||
const cached = gridRowsCache.get(cacheKey);
|
||||
if (!input.forceRefresh && cached && Date.now() - cached.createdAt < GRID_ROWS_CACHE_TTL_MS) {
|
||||
// La colonne INIT doit refléter l'état serveur à CHAQUE chargement,
|
||||
// y compris sur un hit de cache (les données lourdes restent, elles, cachées).
|
||||
await refreshGammeInit(cached.rows, input.codeFournisseur);
|
||||
return cached.rows;
|
||||
}
|
||||
|
||||
@@ -314,6 +336,9 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
await enrichWithNetworkMetrics(productMap);
|
||||
|
||||
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
|
||||
// La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle.
|
||||
// La colonne INIT (codeGammeInit) reste, elle, l'état LIVE du serveur
|
||||
// rechargé en Phase 6 — jamais écrasée ici.
|
||||
try {
|
||||
const snaps = await db
|
||||
.select()
|
||||
@@ -327,7 +352,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
for (const [codein, change] of Object.entries(changes)) {
|
||||
const product = productMap.get(codein);
|
||||
if (product && change.after) {
|
||||
// codeGammeInit reste figé — seulement codeGamme est overridé
|
||||
// codeGammeInit reste figé (état serveur) — seul codeGamme est overridé
|
||||
product.codeGamme = change.after as GammeCode;
|
||||
}
|
||||
}
|
||||
@@ -391,8 +416,10 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
for (const entry of entries) {
|
||||
const period = entry.mois.replace("-", "");
|
||||
if (!sortedPeriods.includes(period)) continue;
|
||||
const qty = Math.abs(Number(entry.ventes?.qte_vendue ?? 0) || 0);
|
||||
const ca = Math.abs(Number(entry.ventes?.ca_ht ?? 0) || 0);
|
||||
// qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
|
||||
// les nie au lieu de Math.abs pour que les retours restent déduits.
|
||||
const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0);
|
||||
const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0);
|
||||
const marge = Number(entry.ventes?.marge ?? 0) || 0;
|
||||
const stock = Number(entry.stock_fin_mois ?? 0) || 0;
|
||||
const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0;
|
||||
@@ -474,6 +501,8 @@ async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Pr
|
||||
if (byCodeCentrale.size === 0) return;
|
||||
|
||||
const metrics = await getNetworkMetricsByCodeCentrale([...byCodeCentrale.keys()]);
|
||||
const sampleKey = [...byCodeCentrale.keys()].find((cc) => (metrics.get(cc)?.caReseau ?? 0) > 0) ?? [...byCodeCentrale.keys()].find((cc) => metrics.has(cc));
|
||||
console.log(`[getProductRows] réseau: ${byCodeCentrale.size} codes centraux → ${metrics.size} matchés en cache. Sample:`, sampleKey ? JSON.stringify(metrics.get(sampleKey)) : "aucun match");
|
||||
for (const [cc, products] of byCodeCentrale.entries()) {
|
||||
const m = metrics.get(cc);
|
||||
if (!m) continue;
|
||||
@@ -481,7 +510,10 @@ async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Pr
|
||||
product.caReseau = m.caReseau;
|
||||
product.qteReseau = m.qteReseau;
|
||||
product.nbMagasinsReseau = m.nbMagasinsReseau;
|
||||
product.caParMagasinReseau = m.caParMagasinReseau;
|
||||
product.margePctReseau = m.margePctReseau;
|
||||
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
||||
product.qteReseauByMonth = m.qteByMonth ?? null;
|
||||
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,13 @@ import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-
|
||||
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
||||
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
|
||||
import { ExportDropdown } from "@/features/grid/components/export-dropdown";
|
||||
import { NoSalesTab, countNoSalesIn6Months } from "@/features/grid/components/no-sales-tab";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import { CheckCircle, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { CheckCircle, AlertCircle, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
||||
import type { GridFilters } from "@/types/grid";
|
||||
|
||||
interface GridClientProps {
|
||||
@@ -31,6 +30,27 @@ type GridRowsStreamMessage =
|
||||
| { type: "done"; loaded: number; total: number }
|
||||
| { type: "error"; error: string };
|
||||
|
||||
/** Petite pastille métrique (libellé + valeur) — densité pro, thème Slate/teal. */
|
||||
function MetricPill({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-start px-2.5 py-1 rounded-lg shrink-0"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<span className="text-[8.5px] font-semibold uppercase tracking-wider leading-none mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[12px] font-bold leading-none tabular-nums max-w-[160px] truncate"
|
||||
style={{ color: accent ? "var(--accent)" : "var(--text-primary)" }}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin, filters }: GridClientProps) {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
|
||||
@@ -40,19 +60,18 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const setFilter = useGridStore((s) => s.setFilter);
|
||||
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
const [activeTab, setActiveTab] = useState<"grid" | "no-sales">("grid");
|
||||
const [isLoadingRows, setIsLoadingRows] = useState(false);
|
||||
const [rowsLoaded, setRowsLoaded] = useState(0);
|
||||
const [totalRows, setTotalRows] = useState<number | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [showStartOverlay, setShowStartOverlay] = useState(false);
|
||||
|
||||
const noSalesCount = useMemo(() => countNoSalesIn6Months(rows), [rows]);
|
||||
|
||||
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
|
||||
const { save, hasDrafts, count } = useSaveDrafts(magasin, visibleCodeins);
|
||||
|
||||
@@ -170,6 +189,16 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
});
|
||||
};
|
||||
|
||||
// Force le rechargement des données depuis le serveur en ignorant le cache
|
||||
// 10 min : on change le paramètre URL `_refresh`, ce qui relance le fetch
|
||||
// avec `refresh=1` (→ forceRefresh) et remonte l'état serveur à jour (INIT).
|
||||
const handleForceRefresh = () => {
|
||||
if (isLoadingRows) return;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", String(Date.now()));
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
if (!isMounted) {
|
||||
return <div className="p-8 text-center animate-pulse text-muted italic">Initialisation de la grille...</div>;
|
||||
}
|
||||
@@ -178,37 +207,65 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3 min-h-0 pb-12">
|
||||
{/* Header */}
|
||||
<div className="flex items-end justify-between shrink-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Révision d'Assortiment</h1>
|
||||
<p className="text-[13px] mt-0.5" style={{ color: "var(--text-secondary)" }}>
|
||||
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>{nomFournisseur}</span>
|
||||
{" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span>
|
||||
{" "}• {totalRows?.toLocaleString("fr-FR") ?? rows.length.toLocaleString("fr-FR")} références
|
||||
</p>
|
||||
{/* Header — chrome raffiné (eyebrow teal + fournisseur héro + pastilles métriques) */}
|
||||
<header className="shrink-0 flex items-center justify-between gap-4 pb-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
<div
|
||||
className="h-10 w-[3px] rounded-full shrink-0"
|
||||
style={{ background: "linear-gradient(to bottom, var(--accent), var(--accent-success))" }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.18em] leading-none mb-1.5" style={{ color: "var(--accent)" }}>
|
||||
Révision d'assortiment
|
||||
</div>
|
||||
<h1 className="text-[19px] font-bold tracking-tight truncate leading-none" style={{ color: "var(--text-primary)" }}>
|
||||
{nomFournisseur}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-2 ml-1 pl-3 shrink-0" style={{ borderLeft: "1px solid var(--border)" }}>
|
||||
<MetricPill label="Magasin" value={activeStoreNom} />
|
||||
<MetricPill label="Références" value={(totalRows ?? rows.length).toLocaleString("fr-FR")} accent />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{(isLoadingRows || loadError) && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-semibold"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: loadError ? "var(--accent-error)" : "var(--text-secondary)" }}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold tabular-nums"
|
||||
style={{
|
||||
background: loadError ? "var(--accent-error-bg)" : "var(--accent-bg)",
|
||||
border: `1px solid ${loadError ? "var(--accent-error)" : "var(--accent-border)"}`,
|
||||
color: loadError ? "var(--accent-error)" : "var(--accent)",
|
||||
}}
|
||||
>
|
||||
{isLoadingRows && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{loadError
|
||||
? loadError
|
||||
: totalRows
|
||||
? `${rowsLoaded.toLocaleString("fr-FR")} / ${totalRows.toLocaleString("fr-FR")}`
|
||||
: "Chargement produits..."}
|
||||
: "Chargement…"}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleForceRefresh}
|
||||
disabled={isLoadingRows}
|
||||
title="Forcer le rafraîchissement — recharge les données depuis le serveur en ignorant le cache (met à jour la colonne INIT)"
|
||||
className="flex items-center gap-2 px-3 h-9 text-[13px] font-semibold rounded-xl disabled:opacity-50 transition-all hover:brightness-110 active:scale-[0.97]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoadingRows ? "animate-spin" : ""}`} />
|
||||
<span className="hidden sm:inline">Rafraîchir</span>
|
||||
</button>
|
||||
<div id="grid-toolbar-actions"></div>
|
||||
{isAdmin && <ExportDropdown nomFournisseur={nomFournisseur} />}
|
||||
{isAdmin && hasDrafts && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg disabled:opacity-60 transition-colors"
|
||||
className="flex items-center gap-2 px-4 h-9 text-white text-[13px] font-semibold rounded-xl disabled:opacity-60 transition-all hover:brightness-110 active:scale-[0.97]"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, var(--brand-solid), var(--brand-hover))",
|
||||
boxShadow: "0 4px 12px rgba(16,185,129,0.25), inset 0 1px 1px rgba(255,255,255,0.2)",
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
@@ -218,7 +275,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
) : null}
|
||||
{isPending
|
||||
? "Sauvegarde..."
|
||||
? "Sauvegarde…"
|
||||
: saveStatus === "success"
|
||||
? "Sauvegardé !"
|
||||
: saveStatus === "error"
|
||||
@@ -227,75 +284,33 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="shrink-0 print:hidden">
|
||||
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="shrink-0 flex gap-0 print:hidden" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
{(["grid", "no-sales"] as const).map(tab => {
|
||||
const isActive = activeTab === tab;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-[13px] font-medium transition-colors"
|
||||
style={{
|
||||
color: isActive ? "var(--text-primary)" : "var(--text-muted)",
|
||||
borderBottom: isActive ? "2px solid var(--accent)" : "2px solid transparent",
|
||||
marginBottom: "-1px",
|
||||
}}
|
||||
>
|
||||
{tab === "grid" ? "Assortiment" : "Sans vente 6 mois"}
|
||||
{tab === "no-sales" && noSalesCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-bold"
|
||||
style={{
|
||||
background: isActive ? "var(--accent)" : "var(--bg-elevated)",
|
||||
color: isActive ? "white" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{noSalesCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Bulk toolbar (contextual) */}
|
||||
<div className="shrink-0 print:hidden">
|
||||
<BulkActionToolbar
|
||||
selectedCodeins={selectedCodeins}
|
||||
onClearSelection={() => setSelectedCodeins([])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bulk toolbar (contextual) — uniquement sur l'onglet grille */}
|
||||
{activeTab === "grid" && (
|
||||
<div className="shrink-0 print:hidden">
|
||||
<BulkActionToolbar
|
||||
selectedCodeins={selectedCodeins}
|
||||
onClearSelection={() => setSelectedCodeins([])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
{activeTab === "grid" ? (
|
||||
<div className="flex-1 min-h-0 min-w-0">
|
||||
<HeatmapGrid
|
||||
onSelectionChange={setSelectedCodeins}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 min-w-0 flex flex-col">
|
||||
<NoSalesTab />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 min-w-0">
|
||||
<HeatmapGrid
|
||||
onSelectionChange={setSelectedCodeins}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary bar — uniquement sur l'onglet grille */}
|
||||
{activeTab === "grid" && (
|
||||
<div className="print:hidden">
|
||||
<FloatingSummaryBar />
|
||||
</div>
|
||||
)}
|
||||
{/* Summary bar */}
|
||||
<div className="print:hidden">
|
||||
<FloatingSummaryBar />
|
||||
</div>
|
||||
|
||||
{/* Overlay de démarrage premium (Glassmorphism semi-bloquant) */}
|
||||
{showStartOverlay && isLoadingRows && (
|
||||
|
||||
@@ -26,64 +26,19 @@ 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";
|
||||
|
||||
function getLast12Months(): string[] {
|
||||
const months: string[] = [];
|
||||
const now = new Date();
|
||||
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
|
||||
// pour exclure le mois en cours (i=0)
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
// La constante est supprimée d'ici pour éviter le mismatch d'hydratation (new Date() au runtime module)
|
||||
|
||||
function formatMonthLabel(key: string): string {
|
||||
const m = parseInt(key.slice(4, 6), 10);
|
||||
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
|
||||
return `${names[m - 1]} ${key.slice(2, 4)}`;
|
||||
}
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns premium styling and color for stores based on the store name's hash.
|
||||
* This guarantees a consistent color for a given store, regardless of its position in the list.
|
||||
*/
|
||||
const SITE_LABELS: Record<string, { label: string; nom: string }> = {
|
||||
"292": { label: "F", nom: "Frouard (Nancy)" },
|
||||
"579": { label: "H", nom: "Houdemont" },
|
||||
};
|
||||
|
||||
function getStoreConfig(name: string) {
|
||||
const known = SITE_LABELS[name];
|
||||
const words = (known?.nom ?? name).trim().split(/\s+/);
|
||||
let label = known?.label ?? words[0].charAt(0).toUpperCase();
|
||||
if (!known && words.length > 1) {
|
||||
label += words[1].charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const colorInt = Math.abs(hash) % 5;
|
||||
|
||||
switch (colorInt) {
|
||||
case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo
|
||||
case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber
|
||||
case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald
|
||||
case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink
|
||||
case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky
|
||||
default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label };
|
||||
}
|
||||
}
|
||||
import {
|
||||
getLast12Months,
|
||||
formatMonthLabel,
|
||||
formatDate,
|
||||
SITE_LABELS,
|
||||
getStoreConfig,
|
||||
} from "@/features/grid/lib/months";
|
||||
import {
|
||||
computeNetworkTrend,
|
||||
trendLabel,
|
||||
TREND_COLOR,
|
||||
} from "@/features/grid/lib/network-trend";
|
||||
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||
|
||||
interface HeatmapGridProps {
|
||||
onSelectionChange?: (codeins: string[]) => void;
|
||||
@@ -92,6 +47,27 @@ interface HeatmapGridProps {
|
||||
|
||||
const EMPTY_DRAFT_CHANGES: Record<string, GammeCode> = {};
|
||||
|
||||
const QLIK_NETWORK_COLUMN_IDS = new Set<string>([
|
||||
"caReseau",
|
||||
"qteReseau",
|
||||
"nbMagasinsReseau",
|
||||
"tendanceReseau",
|
||||
"caParMagasinReseau",
|
||||
"margePctReseau",
|
||||
]);
|
||||
|
||||
function getQlikNetworkSortValue(value: number | null | undefined, columnId: string, sorting: SortingState): number | undefined {
|
||||
if (value != null) return value;
|
||||
|
||||
const activeSort = sorting.find((sort) => sort.id === columnId);
|
||||
if (!activeSort) return undefined;
|
||||
|
||||
// TanStack inverse le résultat en tri descendant. On utilise donc une
|
||||
// sentinelle dépendante du sens de tri pour garder les valeurs absentes
|
||||
// toujours en bas, en ascendant comme en descendant.
|
||||
return activeSort.desc ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires)
|
||||
// =========================================================================
|
||||
@@ -178,7 +154,7 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisi
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<ProductRow, unknown>) => {
|
||||
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 isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(cell.column.id) || cell.column.id === "gamme";
|
||||
const size = cell.column.getSize();
|
||||
return (
|
||||
<td
|
||||
@@ -217,6 +193,38 @@ interface CellDetailData {
|
||||
receptions: number | null;
|
||||
}
|
||||
|
||||
/** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */
|
||||
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
|
||||
const trend = computeNetworkTrend(row.qteReseauByMonth);
|
||||
const { values, labels, direction, pct } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
return (
|
||||
<DialogContent className="max-w-md" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle1}
|
||||
</DialogTitle>
|
||||
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"}
|
||||
{row.nbMagasinsReseau != null && <> · <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{row.nbMagasinsReseau} magasins</span></>}
|
||||
{pct != null && (
|
||||
<> · <span className="font-bold" style={{ color }}>
|
||||
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)}
|
||||
</span></>
|
||||
)}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
{!trend.hasData ? (
|
||||
<div className="mt-3 rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
|
||||
Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir.
|
||||
</div>
|
||||
) : (
|
||||
<NetworkLineChart labels={labels} values={values} color={color} />
|
||||
)}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; activeMagasin: string; onClose: () => void }) {
|
||||
const hasReceptions = (d.receptions ?? 0) > 0;
|
||||
const storeLabel = activeMagasin === "TOTAL" ? "Total réseau" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin);
|
||||
@@ -307,6 +315,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
|
||||
// État pour le détail d'une cellule mensuelle (modal)
|
||||
const [cellDetail, setCellDetail] = useState<CellDetailData | null>(null);
|
||||
const [networkModal, setNetworkModal] = useState<ProductRow | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client
|
||||
@@ -506,11 +515,12 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "caReseau",
|
||||
id: "caReseau",
|
||||
accessorFn: (row) => getQlikNetworkSortValue(row.caReseau, "caReseau", sorting),
|
||||
header: () => <div className="text-center w-full">CA<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||
size: 90,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<number | undefined>();
|
||||
cell: ({ row }) => {
|
||||
const val = row.original.caReseau;
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"}
|
||||
@@ -519,11 +529,12 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "qteReseau",
|
||||
id: "qteReseau",
|
||||
accessorFn: (row) => getQlikNetworkSortValue(row.qteReseau, "qteReseau", sorting),
|
||||
header: () => <div className="text-center w-full">Qté<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||
size: 80,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<number | undefined>();
|
||||
cell: ({ row }) => {
|
||||
const val = row.original.qteReseau;
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||
{val != null ? Math.round(val).toLocaleString("fr-FR") : "-"}
|
||||
@@ -532,11 +543,12 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "nbMagasinsReseau",
|
||||
id: "nbMagasinsReseau",
|
||||
accessorFn: (row) => getQlikNetworkSortValue(row.nbMagasinsReseau, "nbMagasinsReseau", sorting),
|
||||
header: () => <div className="text-center w-full">Magasins<br/><span className="text-[9px] opacity-60">/ 270</span></div>,
|
||||
size: 80,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<number | undefined>();
|
||||
cell: ({ row }) => {
|
||||
const val = row.original.nbMagasinsReseau;
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||
{val != null ? val : "-"}
|
||||
@@ -545,17 +557,56 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "tauxPresenceReseau",
|
||||
header: () => <div className="text-center w-full">% Prés.<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||
size: 70,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<number | undefined>();
|
||||
if (val == null) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
const pct = Math.round(val * 100);
|
||||
const color = pct >= 66 ? "text-emerald-500" : pct >= 33 ? "text-amber-500" : "text-rose-500";
|
||||
id: "tendanceReseau",
|
||||
accessorFn: (row) => {
|
||||
const t = computeNetworkTrend(row.qteReseauByMonth);
|
||||
return t.hasData && t.pct != null ? t.pct : Number.NEGATIVE_INFINITY;
|
||||
},
|
||||
header: () => <div className="text-center w-full">Tendance<br/><span className="text-[9px] opacity-60">Réseau · 12 m</span></div>,
|
||||
size: 78,
|
||||
cell: ({ row }) => {
|
||||
const trend = computeNetworkTrend(row.original.qteReseauByMonth);
|
||||
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
// Cliquable → modal des ventes réseau mois par mois (12 derniers mois).
|
||||
return (
|
||||
<div className={cn("text-center font-black text-[13px] tabular-nums", color)}>
|
||||
{pct}%
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNetworkModal(row.original)}
|
||||
title="Voir les ventes réseau mois par mois (12 derniers mois)"
|
||||
className="w-full cursor-pointer rounded hover:bg-[var(--bg-elevated)] transition-colors py-0.5"
|
||||
>
|
||||
<TrendSparkline trend={trend} />
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "caParMagasinReseau",
|
||||
accessorFn: (row) => getQlikNetworkSortValue(row.caParMagasinReseau, "caParMagasinReseau", sorting),
|
||||
header: () => <div className="text-center w-full">CA / Mag<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||
size: 85,
|
||||
cell: ({ row }) => {
|
||||
const val = row.original.caParMagasinReseau;
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||
{val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "margePctReseau",
|
||||
accessorFn: (row) => getQlikNetworkSortValue(row.margePctReseau, "margePctReseau", sorting),
|
||||
header: () => <div className="text-center w-full">Marge %<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||
size: 75,
|
||||
cell: ({ row }) => {
|
||||
const val = row.original.margePctReseau;
|
||||
if (val == null) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
const pct = Math.abs(val) <= 1 ? val * 100 : val;
|
||||
const color = pct >= 30 ? "text-emerald-500" : pct >= 15 ? "text-amber-500" : "text-rose-500";
|
||||
return (
|
||||
<div className={cn("text-center font-bold text-[12px] tabular-nums", color)}>
|
||||
{pct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}%
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -667,7 +718,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
size: 110,
|
||||
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
|
||||
},
|
||||
], [MONTHS_12, activeMagasin, isAdmin]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
|
||||
], [MONTHS_12, activeMagasin, isAdmin, sorting]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
@@ -760,11 +811,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className="h-full w-full overflow-auto rounded-[12px] relative"
|
||||
className="h-full w-full overflow-auto rounded-[14px] relative"
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
boxShadow: "var(--shadow-sm)"
|
||||
boxShadow: "var(--shadow-md)"
|
||||
}}
|
||||
>
|
||||
<table className="text-sm block" style={{ width: "100%", minWidth: totalWidth }}>
|
||||
@@ -778,7 +829,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
<tr key={headerGroup.id} className="flex w-full">
|
||||
{headerGroup.headers.map((header) => {
|
||||
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 isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(header.column.id) || header.column.id === "gamme";
|
||||
const size = header.getSize();
|
||||
return (
|
||||
<th
|
||||
@@ -847,6 +898,9 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
<Dialog open={cellDetail !== null} onOpenChange={(open) => { if (!open) setCellDetail(null); }}>
|
||||
{cellDetail !== null && <CellDetailModal d={cellDetail!} activeMagasin={activeMagasin} onClose={() => setCellDetail(null)} />}
|
||||
</Dialog>
|
||||
<Dialog open={networkModal !== null} onOpenChange={(open) => { if (!open) setNetworkModal(null); }}>
|
||||
{networkModal !== null && <NetworkMonthlyModal row={networkModal} onClose={() => setNetworkModal(null)} />}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Graphiques réseau (SVG fait main, aucune librairie de charts).
|
||||
*
|
||||
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la fiche
|
||||
* produit (`/produits`). Aucun changement de comportement.
|
||||
*/
|
||||
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import { fmtMonthShort } from "@/features/grid/lib/months";
|
||||
import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend";
|
||||
|
||||
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
|
||||
export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
|
||||
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
const { values, direction, pct } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
const W = 46, H = 18, n = values.length;
|
||||
const max = Math.max(...values, 1), min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
|
||||
const y = H - 1 - ((v - min) / range) * (H - 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(" ");
|
||||
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null ? `Tendance ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
|
||||
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{n === 1 && <circle cx={W / 2} cy={H / 2} r={2} fill={color} />}
|
||||
</svg>
|
||||
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
|
||||
</div>
|
||||
{pct != null && (
|
||||
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
|
||||
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Courbe unique des ventes réseau mensuelles (SVG). */
|
||||
export function NetworkLineChart({ labels, values, color }: { labels: string[]; values: number[]; color: string }) {
|
||||
const W = 460, H = 200;
|
||||
const padL = 10, padR = 10, padT = 22, padB = 30;
|
||||
const plotW = W - padL - padR, plotH = H - padT - padB;
|
||||
const n = values.length;
|
||||
const maxV = Math.max(...values, 1);
|
||||
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
|
||||
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
|
||||
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`;
|
||||
return (
|
||||
<div className="mt-3 w-full overflow-x-auto">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 380 }} role="img" aria-label="Ventes réseau mensuelles">
|
||||
{/* ligne de base */}
|
||||
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
|
||||
{/* aire douce sous la courbe */}
|
||||
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
|
||||
{/* courbe */}
|
||||
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{values.map((v, i) => (
|
||||
<g key={labels[i]}>
|
||||
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} />
|
||||
{/* quantité au-dessus du point */}
|
||||
<text x={x(i)} y={y(v) - 6} textAnchor="middle" fontSize={9} fontWeight={700} fill="var(--text-primary)">
|
||||
{Math.round(v).toLocaleString("fr-FR")}
|
||||
</text>
|
||||
{/* mois sous l'axe */}
|
||||
<text x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
|
||||
{fmtMonthShort(labels[i])}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux courbes superposées sur le même axe (comparatif local vs réseau).
|
||||
* Les valeurs sont normalisées ensemble pour rester comparables visuellement.
|
||||
*/
|
||||
export function DualLineChart({
|
||||
labels,
|
||||
series,
|
||||
}: {
|
||||
labels: string[];
|
||||
series: Array<{ name: string; values: number[]; color: string }>;
|
||||
}) {
|
||||
const W = 620, H = 220;
|
||||
const padL = 10, padR = 10, padT = 26, padB = 30;
|
||||
const plotW = W - padL - padR, plotH = H - padT - padB;
|
||||
const n = labels.length;
|
||||
const maxV = Math.max(1, ...series.flatMap((s) => s.values));
|
||||
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
|
||||
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className="flex items-center gap-4 mb-1">
|
||||
{series.map((s) => (
|
||||
<span key={s.name} className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: s.color }} />
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 460 }} role="img" aria-label="Comparatif mensuel local vs réseau">
|
||||
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
|
||||
{series.map((s) => {
|
||||
const pts = s.values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
return (
|
||||
<g key={s.name}>
|
||||
{n > 1 && (
|
||||
<polyline points={pts} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
)}
|
||||
{s.values.map((v, i) => (
|
||||
<circle key={`${s.name}-${labels[i]}`} cx={x(i)} cy={y(v)} r={2.4} fill={s.color} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{labels.map((lab, i) => (
|
||||
<text key={lab} x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
|
||||
{fmtMonthShort(lab)}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import { PackageX } from "lucide-react";
|
||||
|
||||
function getNoSalesIn6Months(rows: ProductRow[]): ProductRow[] {
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||
|
||||
return rows.filter(row => {
|
||||
if (row.derniereVente) {
|
||||
return new Date(row.derniereVente) < sixMonthsAgo;
|
||||
}
|
||||
// Fallback : calcul depuis sales12m
|
||||
const salesMonths = Object.entries(row.sales12m || {})
|
||||
.filter(([, qty]) => qty > 0)
|
||||
.map(([m]) => m)
|
||||
.sort();
|
||||
if (salesMonths.length === 0) return true;
|
||||
const last = salesMonths[salesMonths.length - 1];
|
||||
const lastDate = new Date(parseInt(last.slice(0, 4)), parseInt(last.slice(4, 6)) - 1, 1);
|
||||
return lastDate < sixMonthsAgo;
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
function daysSince(iso?: string): number | null {
|
||||
if (!iso) return null;
|
||||
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function formatCa(ca: number): string {
|
||||
return ca.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
const GAMME_COLORS: Record<string, string> = {
|
||||
A: "bg-emerald-500/15 text-emerald-700",
|
||||
B: "bg-blue-500/15 text-blue-700",
|
||||
C: "bg-amber-500/15 text-amber-700",
|
||||
Y: "bg-purple-500/15 text-purple-700",
|
||||
Z: "bg-red-500/15 text-red-700",
|
||||
};
|
||||
|
||||
export function NoSalesTab() {
|
||||
const rows = useGridStore(s => s.rows);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const result = getNoSalesIn6Months(rows);
|
||||
// Tri : produits les plus anciens en premier, puis par stock décroissant
|
||||
return result.sort((a, b) => {
|
||||
const da = a.derniereVente ? new Date(a.derniereVente).getTime() : 0;
|
||||
const db = b.derniereVente ? new Date(b.derniereVente).getTime() : 0;
|
||||
if (da !== db) return da - db;
|
||||
return (b.stockActuel ?? 0) - (a.stockActuel ?? 0);
|
||||
});
|
||||
}, [rows]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filtered.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 36,
|
||||
overscan: 12,
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-sm italic" style={{ color: "var(--text-muted)" }}>
|
||||
Chargement des données en cours…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3" style={{ color: "var(--text-muted)" }}>
|
||||
<PackageX className="w-10 h-10 opacity-30" />
|
||||
<p className="text-sm italic">Aucun produit sans vente depuis 6 mois.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
||||
{/* Résumé */}
|
||||
<div className="shrink-0 px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
<PackageX className="w-4 h-4 shrink-0" style={{ color: "var(--accent)" }} />
|
||||
<span>
|
||||
<span className="font-bold" style={{ color: "var(--text-primary)" }}>
|
||||
{filtered.length.toLocaleString("fr-FR")}
|
||||
</span>
|
||||
{" "}produit{filtered.length > 1 ? "s" : ""} sans vente depuis plus de 6 mois
|
||||
{" "}({rows.length.toLocaleString("fr-FR")} références au total)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<div className="min-w-[900px] text-[12px]">
|
||||
<div className="sticky top-0 z-10 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px]"
|
||||
style={{ background: "var(--bg-elevated)", borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
{["Code", "Libellé", "Réf.", "Gamme", "Dernière vente", "Jours", "Stock", "CA 12m", "Mag."].map(col => (
|
||||
<div key={col}
|
||||
className="px-3 py-2 text-left font-semibold whitespace-nowrap"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{col}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ height: rowVirtualizer.getTotalSize(), position: "relative" }}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = filtered[virtualRow.index];
|
||||
const i = virtualRow.index;
|
||||
const days = daysSince(row.derniereVente);
|
||||
const gamme = row.codeGamme?.trim().toUpperCase() ?? "—";
|
||||
const gammeClass = GAMME_COLORS[gamme] ?? "bg-zinc-500/15 text-zinc-600";
|
||||
return (
|
||||
<div
|
||||
key={row.codein}
|
||||
className="absolute left-0 right-0 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px] items-center transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
borderBottom: i < filtered.length - 1 ? "1px solid var(--border)" : undefined,
|
||||
background: i % 2 === 0 ? "transparent" : "var(--bg-subtle, rgba(0,0,0,0.02))",
|
||||
}}
|
||||
>
|
||||
<div className="px-3 py-1.5 font-mono" style={{ color: "var(--text-muted)" }}>{row.codein}</div>
|
||||
<div className="px-3 py-1.5 truncate" style={{ color: "var(--text-primary)" }} title={row.libelle1}>{row.libelle1}</div>
|
||||
<div className="px-3 py-1.5 font-mono text-[11px]" style={{ color: "var(--text-muted)" }}>{row.reference || "—"}</div>
|
||||
<div className="px-3 py-1.5">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-bold ${gammeClass}`}>
|
||||
{gamme}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 whitespace-nowrap" style={{ color: row.derniereVente ? "var(--text-secondary)" : "var(--text-muted)" }}>
|
||||
{formatDate(row.derniereVente)}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-right font-medium tabular-nums"
|
||||
style={{ color: days && days > 365 ? "var(--color-red, #ef4444)" : "var(--text-secondary)" }}>
|
||||
{days != null ? days.toLocaleString("fr-FR") : "—"}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{row.stockActuel != null ? row.stockActuel.toLocaleString("fr-FR") : "—"}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{row.totalCa > 0 ? formatCa(row.totalCa) : "—"}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-right tabular-nums font-semibold"
|
||||
style={{ color: "var(--text-secondary)" }}>
|
||||
{row.nbMagasinsReseau != null ? row.nbMagasinsReseau : "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compte les produits sans vente depuis 6 mois — utilisé pour le badge dans le switcher */
|
||||
export function countNoSalesIn6Months(rows: ProductRow[]): number {
|
||||
return getNoSalesIn6Months(rows).length;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
|
||||
|
||||
interface SyncQlikButtonProps {
|
||||
/** Code fournisseur affiché (pour sync ciblée) */
|
||||
@@ -11,7 +12,8 @@ interface SyncQlikButtonProps {
|
||||
lastUpdate?: string | null;
|
||||
}
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
/** Date + heure de dernière synchro, ou "jamais". */
|
||||
function formatLastUpdate(iso?: string | null): string {
|
||||
if (!iso) return "jamais";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "jamais";
|
||||
@@ -21,49 +23,48 @@ function formatDate(iso?: string | null): string {
|
||||
/**
|
||||
* Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché
|
||||
* (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj.
|
||||
*
|
||||
* Toute la mécanique de job (POST puis polling GET, reprise après reload) vit
|
||||
* dans `useQlikSyncJob`, partagée avec la fiche produit.
|
||||
*/
|
||||
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!codeFournisseur) {
|
||||
setStatus("error");
|
||||
setMessage("Aucun fournisseur sélectionné");
|
||||
return;
|
||||
}
|
||||
setStatus("loading");
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
setStatus("success");
|
||||
setMessage(`${data.upserted} produits réseau synchronisés`);
|
||||
// Recharge la grille pour afficher les nouvelles données réseau
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", String(Date.now()));
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
setTimeout(() => setStatus("idle"), 4000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setMessage(e instanceof Error ? e.message : String(e));
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
}
|
||||
};
|
||||
/** Force le refresh de la grille (avec cache-buster) et de la barre de résumé. */
|
||||
const refreshGrid = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", String(Date.now()));
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
router.refresh();
|
||||
}, [pathname, router, searchParams]);
|
||||
|
||||
const { status, message, start } = useQlikSyncJob({
|
||||
target: { mode: "fournisseur", fournisseur: codeFournisseur },
|
||||
onSuccess: refreshGrid,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={status === "loading"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
||||
title={message || "Synchroniser les données réseau Qlik pour ce fournisseur"}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-[10px] leading-tight text-right whitespace-nowrap hidden lg:block"
|
||||
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
|
||||
title={message || undefined}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
{status === "error"
|
||||
? message
|
||||
: status === "running"
|
||||
? (message || `Extraction Qlik…`)
|
||||
: `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={status === "running"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
||||
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatLastUpdate(lastUpdate)}`}
|
||||
>
|
||||
{status === "running" ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : status === "success" ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
||||
@@ -72,11 +73,8 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Sync Qlik
|
||||
{status === "running" ? "Sync en cours…" : "Sync Qlik"}
|
||||
</button>
|
||||
<span className="text-[10px] text-slate-500 mt-0.5">
|
||||
{status === "error" ? message : `MAJ Qlik : ${formatDate(lastUpdate)}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* CollectFlow — Helpers de fenêtre mensuelle et de libellés magasin.
|
||||
*
|
||||
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la
|
||||
* fiche produit (`/produits`). Aucun changement de comportement.
|
||||
*
|
||||
* ⚠️ Deux conventions de clé de mois cohabitent dans l'application :
|
||||
* - séries FF Nancy (ventes / stock / réceptions) : `"YYYYMM"` (sans tiret)
|
||||
* - séries réseau Qlik (`qteByMonth`, `metricsByMonth`) : `"YYYY-MM"` (avec tiret)
|
||||
* Utiliser `ffMonthToQlik` / `qlikMonthToFf` pour passer de l'une à l'autre.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Les 12 mois complets glissants, du plus ancien au plus récent, au format
|
||||
* `"YYYYMM"`. Le mois en cours est **exclu** (on part de i=12 jusqu'à i=1).
|
||||
*
|
||||
* ⚠️ À n'appeler qu'à l'intérieur d'un `useMemo` / d'un rendu, jamais au niveau
|
||||
* module : `new Date()` évalué à l'import provoque un mismatch d'hydratation
|
||||
* entre le rendu serveur et le rendu client.
|
||||
*/
|
||||
export function getLast12Months(): string[] {
|
||||
const months: string[] = [];
|
||||
const now = new Date();
|
||||
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
|
||||
// pour exclure le mois en cours (i=0)
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
/** `"202601"` → `"Jan 26"`. */
|
||||
export function formatMonthLabel(key: string): string {
|
||||
const m = parseInt(key.slice(4, 6), 10);
|
||||
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
|
||||
return `${names[m - 1]} ${key.slice(2, 4)}`;
|
||||
}
|
||||
|
||||
/** `"2026-01"` → `"01/26"` — axe compact des graphiques. */
|
||||
export function fmtMonthShort(lab: string): string {
|
||||
const [y, m] = lab.split("-");
|
||||
return `${m ?? lab}/${(y ?? "").slice(2)}`;
|
||||
}
|
||||
|
||||
/** Date ISO → `jj/mm/aaaa`, `"—"` si absente ou invalide. */
|
||||
export function formatDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
/** Clé de mois FF `"202601"` → clé Qlik `"2026-01"`. */
|
||||
export function ffMonthToQlik(key: string): string {
|
||||
return `${key.slice(0, 4)}-${key.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
/** Clé de mois Qlik `"2026-01"` → clé FF `"202601"`. */
|
||||
export function qlikMonthToFf(key: string): string {
|
||||
return key.replace("-", "");
|
||||
}
|
||||
|
||||
/** Les deux magasins FF Nancy suivis par l'application. */
|
||||
export const SITE_LABELS: Record<string, { label: string; nom: string }> = {
|
||||
"292": { label: "F", nom: "Frouard (Nancy)" },
|
||||
"579": { label: "H", nom: "Houdemont" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Style de badge stable pour un magasin : la couleur est dérivée d'un hash du
|
||||
* nom, donc identique quel que soit l'ordre d'affichage.
|
||||
*/
|
||||
export function getStoreConfig(name: string) {
|
||||
const known = SITE_LABELS[name];
|
||||
const words = (known?.nom ?? name).trim().split(/\s+/);
|
||||
let label = known?.label ?? words[0].charAt(0).toUpperCase();
|
||||
if (!known && words.length > 1) {
|
||||
label += words[1].charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const colorInt = Math.abs(hash) % 5;
|
||||
|
||||
switch (colorInt) {
|
||||
case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo
|
||||
case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber
|
||||
case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald
|
||||
case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink
|
||||
case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky
|
||||
default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* CollectFlow — Tendance des ventes réseau Qlik sur 12 mois.
|
||||
*
|
||||
* Extrait de `heatmap-grid.tsx` pour être partagé entre la Grille (sparkline de
|
||||
* la colonne « Tendance / Réseau ») et la fiche produit (`/produits`).
|
||||
* Aucun changement de comportement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Nombre de magasins du réseau La Foir'Fouille servant de dénominateur au taux
|
||||
* de présence. Constante métier : source unique pour la Grille et la fiche produit.
|
||||
*/
|
||||
export const NB_MAGASINS_RESEAU = 270;
|
||||
|
||||
export type NetworkTrend = {
|
||||
values: number[]; // quantités mensuelles, ordre chronologique
|
||||
labels: string[]; // "YYYY-MM"
|
||||
direction: "up" | "down" | "flat";
|
||||
pct: number | null; // variation modélisée (régression linéaire) sur 12 mois
|
||||
hasData: boolean;
|
||||
};
|
||||
|
||||
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */
|
||||
export const TREND_STRONG = 0.25;
|
||||
|
||||
/** Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%). */
|
||||
export const TREND_FLAT = 0.08;
|
||||
|
||||
export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||
up: "#22c55e",
|
||||
down: "#ef4444",
|
||||
flat: "#94a3b8",
|
||||
};
|
||||
|
||||
/**
|
||||
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois.
|
||||
* Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la
|
||||
* variation modélisée sur la période (pente × durée) rapportée à la moyenne.
|
||||
* Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
|
||||
*/
|
||||
export function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
|
||||
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
|
||||
if (!qteByMonth) return empty;
|
||||
let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
|
||||
if (labels.length === 0) return empty;
|
||||
labels = labels.slice(-12); // 12 derniers mois
|
||||
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
|
||||
const n = values.length;
|
||||
let pct: number | null = null;
|
||||
let direction: NetworkTrend["direction"] = "flat";
|
||||
if (n >= 3) {
|
||||
const mx = (n - 1) / 2;
|
||||
const my = values.reduce((s, v) => s + v, 0) / n;
|
||||
let num = 0, den = 0;
|
||||
for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); }
|
||||
const slope = den ? num / den : 0;
|
||||
if (my > 0) {
|
||||
pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne
|
||||
direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat";
|
||||
} else if (slope > 0) {
|
||||
direction = "up";
|
||||
}
|
||||
} else if (n === 2 && values[0] > 0) {
|
||||
pct = (values[1] - values[0]) / values[0];
|
||||
direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat";
|
||||
}
|
||||
return { values, labels, direction, pct, hasData: true };
|
||||
}
|
||||
|
||||
/** Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… */
|
||||
export function trendLabel(pct: number | null): string {
|
||||
if (pct == null) return "Stable";
|
||||
if (pct > TREND_STRONG) return "Forte hausse";
|
||||
if (pct > TREND_FLAT) return "Hausse";
|
||||
if (pct < -TREND_STRONG) return "Forte baisse";
|
||||
if (pct < -TREND_FLAT) return "Baisse";
|
||||
return "Stable";
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
pgGetProduitDetail,
|
||||
pgGetFournisseursByCodein,
|
||||
pgGetStockByCodein,
|
||||
pgGetCommandesByCodein,
|
||||
pgGetGammeHistoryByCodein,
|
||||
pgGetMensuelByCodein,
|
||||
} from "@/lib/pg-ff-client";
|
||||
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
import type { ProduitFiche, ProduitMois, ProduitTotaux } from "@/features/produits/types";
|
||||
|
||||
/** Les 12 clés de mois "YYYYMM" glissantes, mois courant exclu. */
|
||||
function buildMonthKeys(): string[] {
|
||||
const months: string[] = [];
|
||||
const now = new Date();
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
function emptyMois(mois: string): ProduitMois {
|
||||
return { mois, qte: 0, ca: 0, marge: 0, stockFinMois: 0, qteRecue: 0 };
|
||||
}
|
||||
|
||||
function totauxFrom(rows: ProduitMois[]): ProduitTotaux {
|
||||
const qte = rows.reduce((s, r) => s + r.qte, 0);
|
||||
const ca = rows.reduce((s, r) => s + r.ca, 0);
|
||||
const marge = rows.reduce((s, r) => s + r.marge, 0);
|
||||
return { qte, ca, marge, tauxMarge: ca > 0 ? (marge / ca) * 100 : 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble la fiche complète d'un produit : identité, fournisseurs, stock,
|
||||
* gammes, 12 mois glissants de ventes (total et par magasin) et métriques
|
||||
* réseau Qlik.
|
||||
*
|
||||
* Renvoie `null` si le codein est inconnu du catalogue FF.
|
||||
*/
|
||||
export async function getProduitFiche(codein: string): Promise<ProduitFiche | null> {
|
||||
const cleaned = codein.trim();
|
||||
if (!cleaned) return null;
|
||||
|
||||
const detail = await pgGetProduitDetail(cleaned);
|
||||
if (!detail) return null;
|
||||
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
|
||||
const [fournisseurs, stock, commandesEnCours, gammes, mensuelRows] = await Promise.all([
|
||||
pgGetFournisseursByCodein(cleaned),
|
||||
pgGetStockByCodein(cleaned),
|
||||
pgGetCommandesByCodein(cleaned),
|
||||
pgGetGammeHistoryByCodein(cleaned),
|
||||
pgGetMensuelByCodein(cleaned, dateDebut, dateFin),
|
||||
]);
|
||||
|
||||
const months = buildMonthKeys();
|
||||
const allowed = new Set(months);
|
||||
|
||||
const mensuelTotal: Record<string, ProduitMois> = {};
|
||||
const mensuelParSite: Record<string, Record<string, ProduitMois>> = {};
|
||||
for (const m of months) mensuelTotal[m] = emptyMois(m);
|
||||
|
||||
// Mois réellement présents dans les mouvements. Indispensable pour distinguer
|
||||
// « pas de mouvement ce mois-là » (→ report du stock précédent) de « stock
|
||||
// effectivement nul » — c'est la convention de la Grille.
|
||||
const moisAvecDonnees = new Set<string>();
|
||||
const moisAvecDonneesParSite = new Map<string, Set<string>>();
|
||||
|
||||
for (const row of mensuelRows) {
|
||||
// SQL renvoie "YYYY-MM", la timeline de l'app utilise "YYYYMM".
|
||||
const mois = row.mois.replace("-", "");
|
||||
if (!allowed.has(mois)) continue;
|
||||
const site = String(row.site).trim();
|
||||
|
||||
const qte = Number(row.qte_vendue) || 0;
|
||||
const ca = Number(row.ca_ht) || 0;
|
||||
const marge = Number(row.marge) || 0;
|
||||
const stockFinMois = Number(row.stock_fin_mois) || 0;
|
||||
const qteRecue = Number(row.qte_recue) || 0;
|
||||
|
||||
moisAvecDonnees.add(mois);
|
||||
const tot = mensuelTotal[mois];
|
||||
tot.qte += qte;
|
||||
tot.ca += ca;
|
||||
tot.marge += marge;
|
||||
tot.stockFinMois += stockFinMois;
|
||||
tot.qteRecue += qteRecue;
|
||||
|
||||
mensuelParSite[site] ??= Object.fromEntries(months.map((m) => [m, emptyMois(m)]));
|
||||
if (!moisAvecDonneesParSite.has(site)) moisAvecDonneesParSite.set(site, new Set());
|
||||
moisAvecDonneesParSite.get(site)!.add(mois);
|
||||
const cell = mensuelParSite[site][mois];
|
||||
cell.qte += qte;
|
||||
cell.ca += ca;
|
||||
cell.marge += marge;
|
||||
// Le SQL donne déjà le stock du dernier mouvement du mois pour ce site.
|
||||
cell.stockFinMois = stockFinMois;
|
||||
cell.qteRecue += qteRecue;
|
||||
}
|
||||
|
||||
// Report du stock de fin de mois sur les mois SANS mouvement (carry-forward),
|
||||
// même convention que la Grille : un mois sans mouvement conserve le stock du
|
||||
// mois précédent, alors qu'un mois avec mouvements et un stock nul reste à 0.
|
||||
const carryForward = (serie: Record<string, ProduitMois>, avecDonnees: Set<string>) => {
|
||||
let last = 0;
|
||||
for (const m of months) {
|
||||
if (avecDonnees.has(m)) last = serie[m].stockFinMois;
|
||||
else serie[m].stockFinMois = last;
|
||||
}
|
||||
};
|
||||
carryForward(mensuelTotal, moisAvecDonnees);
|
||||
for (const [site, bySite] of Object.entries(mensuelParSite)) {
|
||||
carryForward(bySite, moisAvecDonneesParSite.get(site) ?? new Set());
|
||||
}
|
||||
|
||||
const totaux = totauxFrom(months.map((m) => mensuelTotal[m]));
|
||||
const totauxParSite: Record<string, ProduitTotaux> = {};
|
||||
for (const [site, bySite] of Object.entries(mensuelParSite)) {
|
||||
totauxParSite[site] = totauxFrom(months.map((m) => bySite[m]));
|
||||
}
|
||||
|
||||
const sitesActifs = Object.entries(totauxParSite)
|
||||
.filter(([, t]) => t.qte > 0)
|
||||
.map(([site]) => site)
|
||||
.sort();
|
||||
|
||||
// Métriques réseau Qlik (jointure par code centrale).
|
||||
let reseau = null;
|
||||
if (detail.code_centrale) {
|
||||
const map = await getNetworkMetricsByCodeCentrale([detail.code_centrale]);
|
||||
reseau = map.get(detail.code_centrale) ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
detail,
|
||||
fournisseurs,
|
||||
stock,
|
||||
commandesEnCours,
|
||||
gammes,
|
||||
months,
|
||||
mensuelTotal,
|
||||
mensuelParSite,
|
||||
totaux,
|
||||
totauxParSite,
|
||||
sitesActifs,
|
||||
reseau,
|
||||
periode: { dateDebut, dateFin },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* CollectFlow — Comparatif local vs réseau pour un produit.
|
||||
*
|
||||
* Principe : rapporter les deux périmètres à un **magasin moyen**, sinon on
|
||||
* compare 2 magasins Nancy à ~270 magasins réseau et le chiffre ne veut rien dire.
|
||||
*
|
||||
* ⚠️ La comparaison de référence porte sur les **quantités**. Le CA local vient
|
||||
* de `mvtart.mntmvtttc` (TTC) alors que la base de la mesure Qlik « CA N » n'est
|
||||
* pas documentée : les montants sont donc affichés côte à côte à titre indicatif,
|
||||
* sans indice calculé.
|
||||
*/
|
||||
|
||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
import { ffMonthToQlik } from "@/features/grid/lib/months";
|
||||
import type { ProduitFiche } from "@/features/produits/types";
|
||||
|
||||
export interface ComparatifMois {
|
||||
/** Clé de mois FF "YYYYMM". */
|
||||
mois: string;
|
||||
/** Quantité vendue par magasin, chez nous. */
|
||||
local: number;
|
||||
/** Quantité vendue par magasin, au réseau. */
|
||||
reseau: number;
|
||||
}
|
||||
|
||||
export interface ComparatifReseau {
|
||||
/** false si le produit n'a pas encore de données réseau en cache. */
|
||||
hasReseau: boolean;
|
||||
/** Nombre de nos magasins ayant vendu le produit (min. 1 pour éviter /0). */
|
||||
nbMagasinsLocaux: number;
|
||||
nbMagasinsReseau: number;
|
||||
/** Taux de présence réseau (0..1), ou null sans données. */
|
||||
tauxPresence: number | null;
|
||||
|
||||
/** Quantité 12 mois, par magasin. */
|
||||
qteLocaleParMagasin: number;
|
||||
qteReseauParMagasin: number;
|
||||
/**
|
||||
* Indice de performance en % : 100 = on vend autant qu'un magasin moyen du
|
||||
* réseau, 150 = 50 % de mieux. `null` si le réseau ne vend rien.
|
||||
*/
|
||||
indiceQte: number | null;
|
||||
|
||||
/** CA 12 mois par magasin — indicatif (bases de calcul non alignées). */
|
||||
caLocalParMagasin: number;
|
||||
caReseauParMagasin: number | null;
|
||||
|
||||
/** Taux de marge en %, local et réseau (indicatif). */
|
||||
tauxMargeLocal: number;
|
||||
tauxMargeReseau: number | null;
|
||||
|
||||
/** Série mensuelle par magasin, alignée sur les 12 mois de la fiche. */
|
||||
monthly: ComparatifMois[];
|
||||
/** true si au moins un mois réseau est disponible. */
|
||||
hasMonthly: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le taux de marge Qlik est un ratio brut (0.32 = 32 %) mais certaines
|
||||
* configurations renvoient déjà des points de pourcentage. Même normalisation
|
||||
* que la colonne « Marge % / Réseau » de la Grille.
|
||||
*/
|
||||
export function normalizeMargePct(raw: number | null | undefined): number | null {
|
||||
if (raw == null) return null;
|
||||
const v = Number(raw);
|
||||
if (!Number.isFinite(v) || v === 0) return null;
|
||||
return Math.abs(v) <= 1 ? v * 100 : v;
|
||||
}
|
||||
|
||||
/** Calcule le comparatif local vs réseau à partir d'une fiche produit. */
|
||||
export function computeComparatif(fiche: ProduitFiche): ComparatifReseau {
|
||||
const { reseau, totaux, sitesActifs, months, mensuelTotal } = fiche;
|
||||
|
||||
// Nos magasins actifs sur la période. Si aucun n'a vendu, on garde 1 pour
|
||||
// que la division reste définie — l'indice vaudra alors 0, ce qui est le
|
||||
// message voulu : « le réseau vend, nous non ».
|
||||
const nbMagasinsLocaux = Math.max(sitesActifs.length, 1);
|
||||
const qteLocaleParMagasin = totaux.qte / nbMagasinsLocaux;
|
||||
const caLocalParMagasin = totaux.ca / nbMagasinsLocaux;
|
||||
|
||||
if (!reseau || !reseau.nbMagasinsReseau) {
|
||||
return {
|
||||
hasReseau: false,
|
||||
nbMagasinsLocaux,
|
||||
nbMagasinsReseau: 0,
|
||||
tauxPresence: null,
|
||||
qteLocaleParMagasin,
|
||||
qteReseauParMagasin: 0,
|
||||
indiceQte: null,
|
||||
caLocalParMagasin,
|
||||
caReseauParMagasin: null,
|
||||
tauxMargeLocal: totaux.tauxMarge,
|
||||
tauxMargeReseau: null,
|
||||
monthly: [],
|
||||
hasMonthly: false,
|
||||
};
|
||||
}
|
||||
|
||||
const nbMagasinsReseau = reseau.nbMagasinsReseau;
|
||||
const qteReseauParMagasin = reseau.qteReseau / nbMagasinsReseau;
|
||||
const caReseauParMagasin = reseau.caReseau / nbMagasinsReseau;
|
||||
|
||||
// Série mensuelle : quantité par magasin de part et d'autre.
|
||||
// Le nb de magasins réseau du mois est utilisé quand il est disponible
|
||||
// (`metricsByMonth`), sinon on retombe sur le total de la période.
|
||||
const monthly: ComparatifMois[] = [];
|
||||
let hasMonthly = false;
|
||||
for (const mois of months) {
|
||||
const qlikKey = ffMonthToQlik(mois);
|
||||
const mensuel = reseau.metricsByMonth?.[qlikKey];
|
||||
const qteReseauMois = mensuel?.qte ?? reseau.qteByMonth?.[qlikKey];
|
||||
const nbMagMois = mensuel?.nbMag && mensuel.nbMag > 0 ? mensuel.nbMag : nbMagasinsReseau;
|
||||
|
||||
if (qteReseauMois != null) hasMonthly = true;
|
||||
monthly.push({
|
||||
mois,
|
||||
local: (mensuelTotal[mois]?.qte ?? 0) / nbMagasinsLocaux,
|
||||
reseau: qteReseauMois != null ? qteReseauMois / nbMagMois : 0,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hasReseau: true,
|
||||
nbMagasinsLocaux,
|
||||
nbMagasinsReseau,
|
||||
tauxPresence: nbMagasinsReseau / NB_MAGASINS_RESEAU,
|
||||
qteLocaleParMagasin,
|
||||
qteReseauParMagasin,
|
||||
indiceQte: qteReseauParMagasin > 0 ? (qteLocaleParMagasin / qteReseauParMagasin) * 100 : null,
|
||||
caLocalParMagasin,
|
||||
caReseauParMagasin,
|
||||
tauxMargeLocal: totaux.tauxMarge,
|
||||
tauxMargeReseau: normalizeMargePct(reseau.margePctReseau),
|
||||
monthly,
|
||||
hasMonthly,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lecture métier de l'indice de performance. */
|
||||
export function indiceVerdict(indice: number | null): { label: string; color: string } {
|
||||
if (indice == null) return { label: "Non comparable", color: "var(--text-muted)" };
|
||||
if (indice >= 150) return { label: "Forte surperformance", color: "#22c55e" };
|
||||
if (indice >= 110) return { label: "Surperformance", color: "#22c55e" };
|
||||
if (indice >= 90) return { label: "Dans la moyenne réseau", color: "var(--text-secondary)" };
|
||||
if (indice >= 50) return { label: "Sous-performance", color: "#f59e0b" };
|
||||
return { label: "Forte sous-performance", color: "#ef4444" };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* CollectFlow — Types de la fiche produit (page /produits).
|
||||
*/
|
||||
|
||||
import type {
|
||||
PgProduitDetailRow,
|
||||
PgProduitFournisseurRow,
|
||||
PgProduitStockRow,
|
||||
PgGammeHistoryRow,
|
||||
} from "@/lib/pg-ff-client";
|
||||
import type { NetworkMetricCached } from "@/lib/qlik-network-cache";
|
||||
|
||||
/** Les métriques d'un mois, pour un périmètre donné (total ou un site). */
|
||||
export interface ProduitMois {
|
||||
/** Clé de mois FF, format "YYYYMM". */
|
||||
mois: string;
|
||||
qte: number;
|
||||
/** Issu de mntmvtttc — TTC malgré le nom historique `ca_ht`. */
|
||||
ca: number;
|
||||
marge: number;
|
||||
stockFinMois: number;
|
||||
qteRecue: number;
|
||||
}
|
||||
|
||||
export interface ProduitTotaux {
|
||||
qte: number;
|
||||
ca: number;
|
||||
marge: number;
|
||||
/** Taux de marge en % (marge / CA × 100). */
|
||||
tauxMarge: number;
|
||||
}
|
||||
|
||||
export interface ProduitFiche {
|
||||
detail: PgProduitDetailRow;
|
||||
fournisseurs: PgProduitFournisseurRow[];
|
||||
stock: PgProduitStockRow[];
|
||||
commandesEnCours: number;
|
||||
gammes: PgGammeHistoryRow[];
|
||||
/** Les 12 clés de mois "YYYYMM", du plus ancien au plus récent. */
|
||||
months: string[];
|
||||
/** Cumul tous sites, par mois. */
|
||||
mensuelTotal: Record<string, ProduitMois>;
|
||||
/** site → mois → métriques. */
|
||||
mensuelParSite: Record<string, Record<string, ProduitMois>>;
|
||||
totaux: ProduitTotaux;
|
||||
totauxParSite: Record<string, ProduitTotaux>;
|
||||
/** Sites ayant vendu au moins une unité sur les 12 mois. */
|
||||
sitesActifs: string[];
|
||||
/** Métriques réseau Qlik, `null` si le produit n'est pas encore en cache. */
|
||||
reseau: NetworkMetricCached | null;
|
||||
/** Fenêtre analysée, pour l'affichage. */
|
||||
periode: { dateDebut: string; dateFin: string };
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/** État serveur renvoyé par GET/POST /api/qlik/sync. */
|
||||
export interface QlikSyncJobState {
|
||||
jobId: string | null;
|
||||
fournisseur: string;
|
||||
codeCentrale?: string;
|
||||
status: "idle" | "running" | "success" | "error";
|
||||
requested: number;
|
||||
fetched: number;
|
||||
upserted: number;
|
||||
periode?: string;
|
||||
dateDebut?: string;
|
||||
dateFin?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type QlikSyncStatus = QlikSyncJobState["status"];
|
||||
|
||||
/**
|
||||
* Cible de la synchro : un fournisseur entier (réservé aux admins) ou un seul
|
||||
* code centrale (ouvert à tout utilisateur connecté).
|
||||
*/
|
||||
export type QlikSyncTarget =
|
||||
| { mode: "fournisseur"; fournisseur: string | undefined }
|
||||
| { mode: "produit"; codeCentrale: string | undefined };
|
||||
|
||||
interface UseQlikSyncJobOptions {
|
||||
target: QlikSyncTarget;
|
||||
/** Appelé une fois quand un job se termine avec succès. */
|
||||
onSuccess?: () => void;
|
||||
/** Intervalle de polling en ms (défaut 3 s — une extraction peut durer plusieurs minutes). */
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
/** Construit la query string de la route selon la cible. */
|
||||
function targetQuery(target: QlikSyncTarget): string | null {
|
||||
if (target.mode === "fournisseur") {
|
||||
return target.fournisseur ? `fournisseur=${encodeURIComponent(target.fournisseur)}` : null;
|
||||
}
|
||||
return target.codeCentrale ? `codeCentrale=${encodeURIComponent(target.codeCentrale)}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pilote un job de synchronisation Qlik : démarrage (POST), suivi (polling GET),
|
||||
* reprise d'un job déjà en cours au montage, et nettoyage des timers.
|
||||
*
|
||||
* Extrait de `SyncQlikButton` pour être partagé avec la fiche produit. Le
|
||||
* protocole est inchangé : le POST démarre un job serveur en arrière-plan et
|
||||
* revient immédiatement, le client poll ensuite GET jusqu'à l'état terminal.
|
||||
*/
|
||||
export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: UseQlikSyncJobOptions) {
|
||||
const [status, setStatus] = useState<QlikSyncStatus>("idle");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
|
||||
const query = targetQuery(target);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
/** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */
|
||||
const trackingRef = useRef<boolean>(false);
|
||||
// Gardé dans une ref : le callback peut changer d'identité à chaque rendu
|
||||
// sans qu'on veuille relancer le polling pour autant.
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Met à jour le libellé visible selon l'état courant. */
|
||||
const applyJobToUi = useCallback((j: QlikSyncJobState | null) => {
|
||||
if (!j) return;
|
||||
if (j.status === "running") {
|
||||
setStatus("running");
|
||||
const requestedTxt = j.requested > 0 ? ` (${j.requested} code${j.requested > 1 ? "s" : ""})` : "";
|
||||
setMessage(`Extraction Qlik en cours${requestedTxt}…`);
|
||||
} else if (j.status === "success") {
|
||||
setStatus("success");
|
||||
if (j.message) setMessage(j.message);
|
||||
else setMessage(`${j.upserted} produits réseau synchronisés`);
|
||||
} else if (j.status === "error") {
|
||||
setStatus("error");
|
||||
setMessage(j.error || "Erreur sync Qlik");
|
||||
} else {
|
||||
setStatus("idle");
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Un tour de polling. Renvoie true s'il faut continuer.
|
||||
*/
|
||||
const pollOnce = useCallback(async (): Promise<boolean> => {
|
||||
if (!query || !trackingRef.current) return false;
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/sync?${query}`, { method: "GET", cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
// Erreur HTTP transitoire : on retente au tour suivant.
|
||||
return true;
|
||||
}
|
||||
const data: QlikSyncJobState = await res.json();
|
||||
applyJobToUi(data);
|
||||
|
||||
if (data.status === "running") return true;
|
||||
|
||||
if (data.status === "success" || data.status === "error") {
|
||||
trackingRef.current = false;
|
||||
if (data.status === "success") onSuccessRef.current?.();
|
||||
// Petit délai pour laisser l'utilisateur lire le message vert/rouge.
|
||||
setTimeout(() => {
|
||||
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
|
||||
}, 4000);
|
||||
return false;
|
||||
}
|
||||
trackingRef.current = false;
|
||||
return false;
|
||||
} catch {
|
||||
// Erreur réseau transitoire : on laisse le polling réessayer.
|
||||
return true;
|
||||
}
|
||||
}, [applyJobToUi, query]);
|
||||
|
||||
/** Boucle de polling auto-réarmée tant que pollOnce le demande. */
|
||||
const startPolling = useCallback(() => {
|
||||
clearPollTimer();
|
||||
const tick = async () => {
|
||||
const keepGoing = await pollOnce();
|
||||
if (keepGoing && trackingRef.current) {
|
||||
pollTimerRef.current = setTimeout(tick, pollIntervalMs);
|
||||
} else {
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
// Premier appel quasi immédiat.
|
||||
pollTimerRef.current = setTimeout(tick, 200);
|
||||
}, [clearPollTimer, pollOnce, pollIntervalMs]);
|
||||
|
||||
/**
|
||||
* Au montage (et quand la cible change) : reprend l'état d'un éventuel job
|
||||
* déjà en cours, par exemple après un reload pendant l'extraction.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!query) return;
|
||||
let cancelled = false;
|
||||
trackingRef.current = false;
|
||||
clearPollTimer();
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/sync?${query}`, { method: "GET", cache: "no-store" });
|
||||
if (cancelled || !res.ok) return;
|
||||
const data: QlikSyncJobState = await res.json();
|
||||
if (cancelled) return;
|
||||
if (data.status === "running" && data.jobId) {
|
||||
trackingRef.current = true;
|
||||
applyJobToUi(data);
|
||||
startPolling();
|
||||
} else if (data.status === "success" || data.status === "error") {
|
||||
// Job terminé avant le montage : on l'affiche brièvement.
|
||||
applyJobToUi(data);
|
||||
setTimeout(() => {
|
||||
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
|
||||
}, 4000);
|
||||
}
|
||||
} catch {
|
||||
/* ignore : on n'empêche pas l'UI de s'afficher */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
trackingRef.current = false;
|
||||
clearPollTimer();
|
||||
};
|
||||
// On veut interroger une seule fois au montage (et quand la cible change).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, clearPollTimer]);
|
||||
|
||||
/** Cleanup : annule un timer de polling éventuel au démontage. */
|
||||
useEffect(() => clearPollTimer, [clearPollTimer]);
|
||||
|
||||
/** Démarre l'extraction. */
|
||||
const start = useCallback(async () => {
|
||||
if (!query) {
|
||||
setStatus("error");
|
||||
setMessage(target.mode === "fournisseur" ? "Aucun fournisseur sélectionné" : "Aucun code centrale");
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
return;
|
||||
}
|
||||
// Empêche un double-clic pendant qu'un job tourne déjà.
|
||||
if (status === "running") return;
|
||||
|
||||
setStatus("running");
|
||||
setMessage("Démarrage de l'extraction Qlik…");
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/sync?${query}`, { method: "POST" });
|
||||
const data: QlikSyncJobState & { success?: boolean } = await res.json();
|
||||
if (!res.ok || data.success === false) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
if (data.status === "running" && data.jobId) {
|
||||
trackingRef.current = true;
|
||||
applyJobToUi(data);
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
// Statut terminal direct (cas rare : 0 code → success immédiat).
|
||||
applyJobToUi(data);
|
||||
if (data.status === "success") onSuccessRef.current?.();
|
||||
setTimeout(() => setStatus("idle"), 4000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setMessage(e instanceof Error ? e.message : String(e));
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
}
|
||||
}, [applyJobToUi, query, startPolling, status, target.mode]);
|
||||
|
||||
return { status, message, start };
|
||||
}
|
||||
@@ -14,6 +14,20 @@ export interface DbConfig {
|
||||
aiProvider?: "openrouter" | "google";
|
||||
googleAiKey?: string;
|
||||
googleAiModel?: string;
|
||||
/** Qlik Sense — données réseau */
|
||||
qlikHost?: string;
|
||||
qlikUser?: string;
|
||||
qlikPassword?: string;
|
||||
}
|
||||
|
||||
/** Lit la config existante (ou {} si absente). */
|
||||
async function readConfig(): Promise<Partial<DbConfig>> {
|
||||
try {
|
||||
const data = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function testDatabaseConnection(url: string) {
|
||||
@@ -46,7 +60,9 @@ export async function saveDatabaseSettings(
|
||||
googleAiModel?: string,
|
||||
) {
|
||||
try {
|
||||
const config: DbConfig = { url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
|
||||
// Merge avec l'existant pour préserver les autres réglages (ex: Qlik)
|
||||
const existing = await readConfig();
|
||||
const config: DbConfig = { ...existing, url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
|
||||
|
||||
// S'assurer que le dossier data existe
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
@@ -67,6 +83,34 @@ export async function saveDatabaseSettings(
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword: string) {
|
||||
try {
|
||||
const existing = await readConfig();
|
||||
const config = { ...existing, qlikHost, qlikUser, qlikPassword } as DbConfig;
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
console.log("[Settings] Qlik configuration saved.");
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error("[Settings] Failed to save Qlik config:", msg);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword: string) {
|
||||
try {
|
||||
const { getQlikConfig, qlikNtlmSession } = await import("@/lib/qlik-client");
|
||||
const cfg = { ...getQlikConfig(), user: qlikUser, password: qlikPassword };
|
||||
if (qlikHost) cfg.host = qlikHost;
|
||||
await qlikNtlmSession(cfg);
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSavedDatabaseConfig(): Promise<DbConfig | null> {
|
||||
try {
|
||||
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
|
||||
|
||||
+602
-47
@@ -273,26 +273,37 @@ export interface PgNomRow {
|
||||
* Colonnes confirmées : nomenclature.code, nomenclature.libelle
|
||||
* Colonne parent : découverte dynamiquement via information_schema
|
||||
*/
|
||||
/**
|
||||
* Découvre (une seule fois par process) le nom de la colonne "parent" de la
|
||||
* table `nomenclature`, qui porte la FK vers le `no_id` du niveau supérieur.
|
||||
*
|
||||
* On ne peut pas la coder en dur : le nom varie selon les installations
|
||||
* ("chemin_pere" existe mais c'est un chemin *texte* matérialisé, pas la FK),
|
||||
* d'où le filtre explicite sur `data_type` entier.
|
||||
*/
|
||||
async function getNomenclatureParentCol(): Promise<string | null> {
|
||||
if (_nomenclatureParentCol !== undefined) return _nomenclatureParentCol;
|
||||
|
||||
const metaResult = await db.execute(sql`
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name = 'nomenclature' ORDER BY ordinal_position
|
||||
`);
|
||||
const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]);
|
||||
console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", "));
|
||||
// Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent)
|
||||
// "chemin_pere" = chemin vers le père en français
|
||||
// On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé)
|
||||
const parentRow = nomCols.find(r =>
|
||||
(/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name)))
|
||||
&& /int|serial/i.test(r.data_type)
|
||||
);
|
||||
_nomenclatureParentCol = parentRow?.column_name ?? null;
|
||||
console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol);
|
||||
return _nomenclatureParentCol;
|
||||
}
|
||||
|
||||
export async function pgGetNomenclatureByFournisseur(codefou: string): Promise<Map<string, PgNomRow>> {
|
||||
// Découverte de la colonne parent : mise en cache module-level (1 seule fois par process)
|
||||
if (_nomenclatureParentCol === undefined) {
|
||||
const metaResult = await db.execute(sql`
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name = 'nomenclature' ORDER BY ordinal_position
|
||||
`);
|
||||
const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]);
|
||||
console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", "));
|
||||
// Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent)
|
||||
// "chemin_pere" = chemin vers le père en français
|
||||
// On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé)
|
||||
const parentRow = nomCols.find(r =>
|
||||
(/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name)))
|
||||
&& /int|serial/i.test(r.data_type)
|
||||
);
|
||||
_nomenclatureParentCol = parentRow?.column_name ?? null;
|
||||
console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol);
|
||||
}
|
||||
const parentCol = _nomenclatureParentCol;
|
||||
const parentCol = await getNomenclatureParentCol();
|
||||
|
||||
let result;
|
||||
if (parentCol) {
|
||||
@@ -674,6 +685,10 @@ export interface CaByNomenclatureRow {
|
||||
/**
|
||||
* Retourne le CA TTC par fournisseur et site pour deux mois donnés (mois et mois N-1).
|
||||
* Attribution via artfou1.preference = true (fournisseur principal de l'article).
|
||||
* CA NET : SUM(-mntmvtttc) — les retours clients sont déduits (même convention
|
||||
* que pgGetMensuelByFournisseur / pgGetHitParade). Fournisseur préféré résolu
|
||||
* via une sous-requête artfou1 dédupliquée (DISTINCT ON) : un seul code par
|
||||
* article, aucune duplication possible des mouvements dans la somme.
|
||||
*/
|
||||
export async function pgGetCaByFournisseur(
|
||||
mois: string,
|
||||
@@ -685,10 +700,15 @@ export async function pgGetCaByFournisseur(
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS nom,
|
||||
m.site,
|
||||
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
|
||||
ABS(SUM(m.mntmvtttc))::float AS ca_ttc
|
||||
SUM(-m.mntmvtttc)::float AS ca_ttc
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (art_no_id) art_no_id, code
|
||||
FROM artfou1
|
||||
WHERE preference = 1
|
||||
ORDER BY art_no_id, code
|
||||
) af ON af.art_no_id = a.no_id
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
WHERE (TO_CHAR(m.datmvt, 'YYYY-MM') = ${mois} OR TO_CHAR(m.datmvt, 'YYYY-MM') = ${moisN1})
|
||||
AND m.site IN ('292', '579')
|
||||
@@ -703,6 +723,7 @@ export async function pgGetCaByFournisseur(
|
||||
|
||||
/**
|
||||
* Retourne le CA TTC par nomenclature et site pour deux mois donnés.
|
||||
* CA NET : SUM(-mntmvtttc) — retours clients déduits (convention commune).
|
||||
*/
|
||||
export async function pgGetCaByNomenclature(
|
||||
mois: string,
|
||||
@@ -714,7 +735,7 @@ export async function pgGetCaByNomenclature(
|
||||
COALESCE(n.libelle, 'Sans nomenclature')::text AS libelle,
|
||||
m.site,
|
||||
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
|
||||
ABS(SUM(m.mntmvtttc))::float AS ca_ttc
|
||||
SUM(-m.mntmvtttc)::float AS ca_ttc
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
|
||||
@@ -750,48 +771,74 @@ export interface HitParadeRow {
|
||||
|
||||
/**
|
||||
* Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC, marge et stock par site.
|
||||
* Le stock est intégré via un LEFT JOIN — pas de requête séparée, pas de tableau de paramètres.
|
||||
*
|
||||
* L'agrégation des ventes se fait UNIQUEMENT sur mvtart × articles (jointure 1:1),
|
||||
* exactement comme pgGetMensuelByFournisseur (le calcul canonique de la Grille) :
|
||||
* qte = SUM(-qtemvt), ca = SUM(-mntmvtttc), marge = SUM(margemvt) pour genremvt = 3
|
||||
* (ventes stockées en négatif — vérifié sur les mouvements réels de l'API).
|
||||
* Les attributs (libellé, fournisseur préféré, nomenclature) et le stock sont
|
||||
* joints APRÈS l'agrégation, par codein : aucune jointure ne peut dupliquer les
|
||||
* mouvements et fausser les sommes, et le LATERAL fournisseur ne s'exécute que
|
||||
* par article vendu (et non par ligne de mouvement).
|
||||
*
|
||||
* Cas vérifié (codein 487673, site 579, jan→juil 2026) : 31 ventes brutes,
|
||||
* 7 retours clients → 24 net. L'ancien SUM(ABS(qtemvt)) affichait 38 (retours
|
||||
* additionnés) ; SUM(-qtemvt) donne 24, identique à l'API mensuel officielle.
|
||||
*/
|
||||
export async function pgGetHitParade(dateDebut: string, dateFin: string): Promise<HitParadeRow[]> {
|
||||
const result = await pgNoParallel(sql`
|
||||
WITH ventes AS (
|
||||
SELECT
|
||||
a.no_id AS art_no_id,
|
||||
a.codein::text AS codein,
|
||||
a.libelle1::text AS libelle,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||||
COALESCE(n.code, '')::text AS nomenclature_code,
|
||||
COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature,
|
||||
TRIM(a.codein::text) AS codein,
|
||||
m.site,
|
||||
SUM(ABS(m.qtemvt))::float AS qte_vendue,
|
||||
ABS(SUM(m.mntmvtttc))::float AS ca_ttc,
|
||||
SUM(-m.qtemvt)::float AS qte_vendue,
|
||||
SUM(-m.mntmvtttc)::float AS ca_ttc,
|
||||
SUM(m.margemvt)::float AS marge
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
||||
AND m.site IN ('292', '579')
|
||||
AND m.genremvt = 3
|
||||
GROUP BY a.no_id, a.codein, a.libelle1, fi.nom, af.code, n.code, n.libelle, m.site
|
||||
AND a.codein IS NOT NULL
|
||||
GROUP BY TRIM(a.codein::text), m.site
|
||||
),
|
||||
attrs AS (
|
||||
SELECT DISTINCT ON (TRIM(a.codein::text))
|
||||
TRIM(a.codein::text) AS codein,
|
||||
a.libelle1::text AS libelle,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||||
COALESCE(n.code, '')::text AS nomenclature_code,
|
||||
COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature
|
||||
FROM articles a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT af1.code
|
||||
FROM artfou1 af1
|
||||
WHERE af1.art_no_id = a.no_id AND af1.preference = 1
|
||||
ORDER BY af1.code
|
||||
LIMIT 1
|
||||
) af ON TRUE
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
|
||||
WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes)
|
||||
ORDER BY TRIM(a.codein::text), a.no_id DESC
|
||||
),
|
||||
stock_agg AS (
|
||||
SELECT
|
||||
cs.artnoid,
|
||||
TRIM(a2.codein::text) AS codein,
|
||||
SUM(CASE WHEN cs.site = '292' THEN cs.qte ELSE 0 END)::float AS stock292,
|
||||
SUM(CASE WHEN cs.site = '579' THEN cs.qte ELSE 0 END)::float AS stock579,
|
||||
SUM(cs.qte)::float AS stockTotal
|
||||
FROM cube_stock cs
|
||||
WHERE cs.artnoid IN (SELECT DISTINCT art_no_id FROM ventes)
|
||||
GROUP BY cs.artnoid
|
||||
JOIN articles a2 ON a2.no_id = cs.artnoid
|
||||
WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes)
|
||||
GROUP BY TRIM(a2.codein::text)
|
||||
)
|
||||
SELECT
|
||||
v.codein,
|
||||
v.libelle,
|
||||
v.fournisseur,
|
||||
v.nomenclature_code,
|
||||
v.nomenclature,
|
||||
COALESCE(ar.libelle, '') AS libelle,
|
||||
COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur,
|
||||
COALESCE(ar.nomenclature_code, '') AS nomenclature_code,
|
||||
COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature,
|
||||
v.site,
|
||||
v.qte_vendue,
|
||||
v.ca_ttc,
|
||||
@@ -800,7 +847,8 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis
|
||||
COALESCE(s.stock579, 0) AS stock579,
|
||||
COALESCE(s.stockTotal, 0) AS "stockTotal"
|
||||
FROM ventes v
|
||||
LEFT JOIN stock_agg s ON s.artnoid = v.art_no_id
|
||||
LEFT JOIN attrs ar ON ar.codein = v.codein
|
||||
LEFT JOIN stock_agg s ON s.codein = v.codein
|
||||
ORDER BY v.ca_ttc DESC
|
||||
`);
|
||||
|
||||
@@ -929,12 +977,17 @@ export async function pgGetStockNegatif(site?: string): Promise<PgStockNegatifRo
|
||||
) AS derniereentree
|
||||
FROM cube_stock cs
|
||||
JOIN articles a ON a.no_id = cs.artnoid
|
||||
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (art_no_id) art_no_id, code
|
||||
FROM artfou1
|
||||
WHERE preference = 1
|
||||
ORDER BY art_no_id, code
|
||||
) af ON af.art_no_id = a.no_id
|
||||
JOIN fouident f ON f.code = af.code
|
||||
WHERE cs.qte < 0
|
||||
AND cs.site IN ('292', '579')
|
||||
${siteFilter}
|
||||
ORDER BY cs.stockdispo ASC
|
||||
ORDER BY cs.qte ASC
|
||||
`);
|
||||
return result.rows as PgStockNegatifRow[];
|
||||
}
|
||||
@@ -982,7 +1035,12 @@ export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6Mo
|
||||
) AS derniere_entree
|
||||
FROM cube_stock cs
|
||||
JOIN articles a ON a.no_id = cs.artnoid
|
||||
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (art_no_id) art_no_id, code
|
||||
FROM artfou1
|
||||
WHERE preference = 1
|
||||
ORDER BY art_no_id, code
|
||||
) af ON af.art_no_id = a.no_id
|
||||
JOIN fouident f ON f.code = af.code
|
||||
WHERE cs.site IN ('292', '579')
|
||||
AND cs.qte > 0
|
||||
@@ -1179,3 +1237,500 @@ export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 13. Recherche & fiche produit (page /produits)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Une ligne de résultat de recherche produit. Volontairement **légère** :
|
||||
* aucune agrégation `mvtart` ici (le seq-scan du ILIKE domine déjà le coût).
|
||||
* Le détail lourd n'est calculé qu'à l'ouverture de la fiche.
|
||||
*/
|
||||
export interface PgProduitSearchRow {
|
||||
no_id: number;
|
||||
codein: string;
|
||||
code_centrale: string;
|
||||
libelle1: string;
|
||||
/** Nom du fournisseur principal (artfou1.preference = 1), sinon son code. */
|
||||
fournisseur: string;
|
||||
codefou: string;
|
||||
nomenclature_code: string;
|
||||
nomenclature: string;
|
||||
stock_total: number;
|
||||
/** true si le produit a déjà des métriques réseau Qlik en cache. */
|
||||
has_reseau: boolean;
|
||||
qte_reseau: number;
|
||||
nb_magasins_reseau: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche un produit par **libellé** (sous-chaîne) ou par **code centrale**
|
||||
* (préfixe). Si le terme est entièrement numérique, on tente aussi une
|
||||
* correspondance exacte sur le `codein`.
|
||||
*
|
||||
* ⚠️ Il n'existe aucun index texte sur `articles.libelle1` (base FF resynchronisée
|
||||
* chaque nuit depuis SQL Server — on n'y crée pas d'index). La requête fait donc
|
||||
* un seq-scan : l'appelant impose un minimum de 3 caractères et une soumission
|
||||
* explicite, jamais une recherche à la frappe.
|
||||
*/
|
||||
export async function pgSearchProduits(term: string, limit = 50): Promise<PgProduitSearchRow[]> {
|
||||
const cleaned = term.trim();
|
||||
if (cleaned.length < 3) return [];
|
||||
|
||||
const like = `%${cleaned}%`;
|
||||
const prefix = `${cleaned}%`;
|
||||
// Un terme purement numérique peut être un codein : on l'ajoute au filtre
|
||||
// (correspondance exacte, la colonne est space-padded d'où le TRIM).
|
||||
const codeinClause = /^\d+$/.test(cleaned)
|
||||
? sql`OR TRIM(a.codein::text) = ${cleaned}`
|
||||
: sql``;
|
||||
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
WITH raw AS (
|
||||
SELECT DISTINCT ON (TRIM(a.codein::text))
|
||||
a.no_id,
|
||||
TRIM(a.codein::text) AS codein,
|
||||
COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale,
|
||||
COALESCE(a.libelle1, '') AS libelle1,
|
||||
a.nom_no_id
|
||||
FROM articles a
|
||||
WHERE a.codein IS NOT NULL
|
||||
AND (
|
||||
a.libelle1 ILIKE ${like}
|
||||
OR TRIM(a.artcentrale::text) ILIKE ${prefix}
|
||||
${codeinClause}
|
||||
)
|
||||
ORDER BY TRIM(a.codein::text), a.no_id DESC
|
||||
),
|
||||
matched AS (
|
||||
SELECT * FROM raw ORDER BY libelle1 LIMIT ${limit}
|
||||
)
|
||||
SELECT
|
||||
m.no_id,
|
||||
m.codein,
|
||||
m.code_centrale,
|
||||
m.libelle1,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||||
COALESCE(af.code, '')::text AS codefou,
|
||||
COALESCE(n.code, '')::text AS nomenclature_code,
|
||||
COALESCE(n.libelle, '')::text AS nomenclature,
|
||||
COALESCE(st.stock_total, 0)::float AS stock_total,
|
||||
(q.code_centrale IS NOT NULL) AS has_reseau,
|
||||
COALESCE(q.qte_reseau, 0)::float AS qte_reseau,
|
||||
COALESCE(q.nb_magasins_reseau, 0) AS nb_magasins_reseau
|
||||
FROM matched m
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT af1.code
|
||||
FROM artfou1 af1
|
||||
WHERE af1.art_no_id = m.no_id AND af1.preference = 1
|
||||
ORDER BY af1.code
|
||||
LIMIT 1
|
||||
) af ON TRUE
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT SUM(cs.qte)::float AS stock_total
|
||||
FROM cube_stock cs
|
||||
WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579')
|
||||
) st ON TRUE
|
||||
LEFT JOIN qlik_network_metrics q ON q.code_centrale = m.code_centrale
|
||||
ORDER BY m.libelle1
|
||||
`);
|
||||
|
||||
console.log(`[pg-ff] pgSearchProduits("${cleaned}"): ${result.rows.length} résultats`);
|
||||
return result.rows as unknown as PgProduitSearchRow[];
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgSearchProduits error:", (e as Error).message?.slice(0, 250));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Identité, prix et nomenclature d'un produit (une seule ligne). */
|
||||
export interface PgProduitDetailRow {
|
||||
no_id: number;
|
||||
codein: string;
|
||||
code_centrale: string;
|
||||
libelle1: string;
|
||||
nom_no_id: number | null;
|
||||
gtin: string;
|
||||
reference: string;
|
||||
pcb: number | null;
|
||||
/** article_infosup.prix_vente_mini */
|
||||
pv_central: number | null;
|
||||
/** cube_pa.pa */
|
||||
pa: number | null;
|
||||
code3: string | null;
|
||||
libelle3: string | null;
|
||||
code2: string | null;
|
||||
libelle2: string | null;
|
||||
code1: string | null;
|
||||
libelle1nom: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiche d'identité complète d'un produit à partir de son `codein`.
|
||||
* Renvoie `null` si le codein est inconnu.
|
||||
*/
|
||||
export async function pgGetProduitDetail(codein: string): Promise<PgProduitDetailRow | null> {
|
||||
const parentCol = await getNomenclatureParentCol();
|
||||
// Hiérarchie nomenclature : seulement si la colonne parent a été trouvée,
|
||||
// sinon on se limite au niveau feuille (même dégradation que pgGetNomenclatureByFournisseur).
|
||||
const hierarchy = parentCol
|
||||
? sql`
|
||||
LEFT JOIN nomenclature n2 ON n2.no_id = n3.${sql.raw(parentCol)}
|
||||
LEFT JOIN nomenclature n1 ON n1.no_id = n2.${sql.raw(parentCol)}
|
||||
`
|
||||
: sql``;
|
||||
const hierarchyCols = parentCol
|
||||
? sql`,
|
||||
n2.code AS code2,
|
||||
n2.libelle AS libelle2,
|
||||
n1.code AS code1,
|
||||
n1.libelle AS libelle1nom`
|
||||
: sql`,
|
||||
NULL::text AS code2,
|
||||
NULL::text AS libelle2,
|
||||
NULL::text AS code1,
|
||||
NULL::text AS libelle1nom`;
|
||||
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT
|
||||
a.no_id,
|
||||
TRIM(a.codein::text) AS codein,
|
||||
COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale,
|
||||
COALESCE(a.libelle1, '') AS libelle1,
|
||||
a.nom_no_id,
|
||||
COALESCE(ag.gtin, af.ean13, '')::text AS gtin,
|
||||
COALESCE(af.reference, '')::text AS reference,
|
||||
af.pcb::float AS pcb,
|
||||
ai.prix_vente_mini::float AS pv_central,
|
||||
pa.pa::float AS pa,
|
||||
n3.code AS code3,
|
||||
n3.libelle AS libelle3
|
||||
${hierarchyCols}
|
||||
FROM articles a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT af1.code, af1.pcb, af1.reference, af1.ean13
|
||||
FROM artfou1 af1
|
||||
WHERE af1.art_no_id = a.no_id
|
||||
ORDER BY af1.preference DESC NULLS LAST, af1.no_id DESC
|
||||
LIMIT 1
|
||||
) af ON TRUE
|
||||
LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1
|
||||
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
|
||||
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
|
||||
LEFT JOIN nomenclature n3 ON n3.no_id = a.nom_no_id
|
||||
${hierarchy}
|
||||
WHERE TRIM(a.codein::text) = ${codein}
|
||||
ORDER BY a.no_id DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const row = (result.rows as unknown as PgProduitDetailRow[])[0] ?? null;
|
||||
console.log(`[pg-ff] pgGetProduitDetail(${codein}): ${row ? "trouvé" : "introuvable"}`);
|
||||
return row;
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetProduitDetail error:", (e as Error).message?.slice(0, 250));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Un fournisseur référencé pour l'article. */
|
||||
export interface PgProduitFournisseurRow {
|
||||
codefou: string;
|
||||
nomfou: string;
|
||||
reference: string;
|
||||
pcb: number | null;
|
||||
/** true si artfou1.preference = 1 */
|
||||
principal: boolean;
|
||||
}
|
||||
|
||||
/** Tous les fournisseurs référencés pour un article (le principal en premier). */
|
||||
export async function pgGetFournisseursByCodein(codein: string): Promise<PgProduitFournisseurRow[]> {
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT DISTINCT ON (af.code)
|
||||
af.code::text AS codefou,
|
||||
COALESCE(fi.nom, af.code)::text AS nomfou,
|
||||
COALESCE(af.reference, '')::text AS reference,
|
||||
af.pcb::float AS pcb,
|
||||
(af.preference = 1) AS principal
|
||||
FROM articles a
|
||||
JOIN artfou1 af ON af.art_no_id = a.no_id
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
WHERE TRIM(a.codein::text) = ${codein}
|
||||
ORDER BY af.code, af.preference DESC NULLS LAST, af.no_id DESC
|
||||
`);
|
||||
const rows = result.rows as unknown as PgProduitFournisseurRow[];
|
||||
// Le principal remonte en tête (le DISTINCT ON impose un tri par code).
|
||||
rows.sort((x, y) => Number(y.principal) - Number(x.principal) || x.nomfou.localeCompare(y.nomfou, "fr"));
|
||||
return rows;
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetFournisseursByCodein error:", (e as Error).message?.slice(0, 250));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Stock temps réel par site pour un article. */
|
||||
export interface PgProduitStockRow {
|
||||
site: string;
|
||||
stockdispo: number;
|
||||
qte: number;
|
||||
valstock: number;
|
||||
prmp: number;
|
||||
dernierevente: string | null;
|
||||
dernierereception: string | null;
|
||||
}
|
||||
|
||||
export async function pgGetStockByCodein(codein: string): Promise<PgProduitStockRow[]> {
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT
|
||||
cs.site,
|
||||
COALESCE(cs.stockdispo, 0)::float AS stockdispo,
|
||||
COALESCE(cs.qte, 0)::float AS qte,
|
||||
COALESCE(cs.valstock, 0)::float AS valstock,
|
||||
COALESCE(cs.prmp, 0)::float AS prmp,
|
||||
cs.dernierevente::text AS dernierevente,
|
||||
cs.dernierereception::text AS dernierereception
|
||||
FROM cube_stock cs
|
||||
JOIN articles a ON a.no_id = cs.artnoid
|
||||
WHERE TRIM(a.codein::text) = ${codein}
|
||||
AND cs.site IN ('292', '579')
|
||||
ORDER BY cs.site
|
||||
`);
|
||||
return result.rows as unknown as PgProduitStockRow[];
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetStockByCodein error:", (e as Error).message?.slice(0, 250));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Quantité totale en commande fournisseur pour un article. */
|
||||
export async function pgGetCommandesByCodein(codein: string): Promise<number> {
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT COALESCE(SUM(cv.cdefou_ligne_qtecde), 0)::float AS qtecde
|
||||
FROM cdefou_vivant cv
|
||||
WHERE TRIM(cv.articles_codein::text) = ${codein}
|
||||
`);
|
||||
const row = (result.rows as unknown as { qtecde: number }[])[0];
|
||||
return Number(row?.qtecde) || 0;
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetCommandesByCodein error:", (e as Error).message?.slice(0, 250));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Une gamme affectée à l'article pour une saison donnée. */
|
||||
export interface PgGammeHistoryRow {
|
||||
saison_no_id: number;
|
||||
saison_code: string | null;
|
||||
saison_libelle: string | null;
|
||||
gamme_code: string;
|
||||
gamme_libelle: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Historique complet des gammes de l'article, saison par saison (la plus
|
||||
* récente en premier). Le reste de l'application ne lit que la saison active
|
||||
* (`pgGetGammesByFournisseur`) ; ici on veut la trajectoire d'arbitrage.
|
||||
*/
|
||||
export async function pgGetGammeHistoryByCodein(codein: string): Promise<PgGammeHistoryRow[]> {
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT
|
||||
s.no_id AS saison_no_id,
|
||||
s.code::text AS saison_code,
|
||||
s.libelle::text AS saison_libelle,
|
||||
g.code::text AS gamme_code,
|
||||
g.libelle::text AS gamme_libelle
|
||||
FROM art_gamme_saison ags
|
||||
JOIN articles a ON a.no_id = ags.artnoid
|
||||
JOIN gammes g ON g.no_id = ags.idgamme
|
||||
JOIN saisons s ON s.no_id = ags.idsaison
|
||||
WHERE TRIM(a.codein::text) = ${codein}
|
||||
ORDER BY s.no_id DESC
|
||||
`);
|
||||
return result.rows as unknown as PgGammeHistoryRow[];
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetGammeHistoryByCodein error:", (e as Error).message?.slice(0, 250));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Données mensuelles (ventes / CA / marge / stock fin de mois / réceptions)
|
||||
* d'UN article, par site et par mois.
|
||||
*
|
||||
* Reprend **exactement** le calcul canonique de `pgGetMensuelByFournisseur`
|
||||
* (la source de vérité de la Grille) : seule la sélection change — filtre sur
|
||||
* le codein au lieu d'une jointure `artfou1`. Les chiffres affichés sur la
|
||||
* fiche produit sont donc identiques à ceux de la Grille, par construction.
|
||||
*/
|
||||
export async function pgGetMensuelByCodein(
|
||||
codein: string,
|
||||
dateDebut: string,
|
||||
dateFin: string
|
||||
): Promise<PgMensuelRow[]> {
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
TRIM(a.codein::text) AS codein,
|
||||
m.site,
|
||||
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
|
||||
m.qtemvt,
|
||||
m.mntmvtttc,
|
||||
m.margemvt,
|
||||
m.genremvt,
|
||||
m.qtestock,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
|
||||
ORDER BY m.datmvt DESC
|
||||
) AS rn_last
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
WHERE TRIM(a.codein::text) = ${codein}
|
||||
AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
||||
AND m.site IN ('292', '579')
|
||||
)
|
||||
SELECT
|
||||
codein,
|
||||
site,
|
||||
mois,
|
||||
SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue,
|
||||
SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht,
|
||||
SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge,
|
||||
MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois,
|
||||
SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue
|
||||
FROM base
|
||||
GROUP BY codein, site, mois
|
||||
ORDER BY site, mois
|
||||
`);
|
||||
|
||||
console.log(`[pg-ff] pgGetMensuelByCodein(${codein}): ${result.rows.length} lignes`);
|
||||
return result.rows as unknown as PgMensuelRow[];
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetMensuelByCodein error:", (e as Error).message?.slice(0, 250));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Un produit de la même famille, comparé local vs réseau. */
|
||||
export interface PgOpportuniteRow {
|
||||
codein: string;
|
||||
libelle1: string;
|
||||
code_centrale: string;
|
||||
fournisseur: string;
|
||||
qte_reseau: number;
|
||||
nb_magasins_reseau: number;
|
||||
ca_reseau: number;
|
||||
ca_par_magasin_reseau: number;
|
||||
qte_locale: number;
|
||||
nb_sites_locaux: number;
|
||||
qte_reseau_par_magasin: number;
|
||||
qte_locale_par_magasin: number;
|
||||
/** Écart réseau − local, par magasin, sur 12 mois. Positif = opportunité. */
|
||||
ecart_par_magasin: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produits de la même nomenclature, classés par écart entre la performance
|
||||
* réseau et la performance locale (quantités **par magasin**, pour neutraliser
|
||||
* la différence d'échelle entre ~270 magasins et nos 2 sites).
|
||||
*
|
||||
* Périmètre volontairement borné aux articles **de notre catalogue** ayant déjà
|
||||
* des métriques réseau en cache : la dimension Qlik interrogée est
|
||||
* `Article Code`, on ne peut pas découvrir un produit que Nancy ne référence pas.
|
||||
*/
|
||||
export async function pgGetOpportunitesFamille(
|
||||
nomNoId: number,
|
||||
dateDebut: string,
|
||||
dateFin: string,
|
||||
limit = 30
|
||||
): Promise<PgOpportuniteRow[]> {
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
WITH famille AS (
|
||||
SELECT DISTINCT ON (TRIM(a.codein::text))
|
||||
a.no_id,
|
||||
TRIM(a.codein::text) AS codein,
|
||||
TRIM(a.artcentrale::text) AS code_centrale,
|
||||
COALESCE(a.libelle1, '') AS libelle1
|
||||
FROM articles a
|
||||
WHERE a.nom_no_id = ${nomNoId}
|
||||
AND a.codein IS NOT NULL
|
||||
AND a.artcentrale IS NOT NULL
|
||||
AND TRIM(a.artcentrale::text) <> ''
|
||||
ORDER BY TRIM(a.codein::text), a.no_id DESC
|
||||
),
|
||||
avec_reseau AS (
|
||||
SELECT
|
||||
f.no_id,
|
||||
f.codein,
|
||||
f.code_centrale,
|
||||
f.libelle1,
|
||||
q.qte_reseau::float AS qte_reseau,
|
||||
q.nb_magasins_reseau AS nb_magasins_reseau,
|
||||
COALESCE(q.ca_reseau, 0)::float AS ca_reseau,
|
||||
COALESCE(q.ca_par_magasin_reseau, 0)::float AS ca_par_magasin_reseau
|
||||
FROM famille f
|
||||
JOIN qlik_network_metrics q ON q.code_centrale = f.code_centrale
|
||||
WHERE q.nb_magasins_reseau > 0
|
||||
AND q.qte_reseau > 0
|
||||
),
|
||||
ventes AS (
|
||||
SELECT
|
||||
TRIM(a.codein::text) AS codein,
|
||||
SUM(-m.qtemvt)::float AS qte_locale,
|
||||
COUNT(DISTINCT m.site) AS nb_sites_locaux
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
WHERE m.genremvt = 3
|
||||
AND m.site IN ('292', '579')
|
||||
AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
||||
AND TRIM(a.codein::text) IN (SELECT codein FROM avec_reseau)
|
||||
GROUP BY TRIM(a.codein::text)
|
||||
)
|
||||
SELECT
|
||||
r.codein,
|
||||
r.libelle1,
|
||||
r.code_centrale,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||||
r.qte_reseau,
|
||||
r.nb_magasins_reseau,
|
||||
r.ca_reseau,
|
||||
r.ca_par_magasin_reseau,
|
||||
COALESCE(v.qte_locale, 0)::float AS qte_locale,
|
||||
COALESCE(v.nb_sites_locaux, 0) AS nb_sites_locaux,
|
||||
(r.qte_reseau / r.nb_magasins_reseau)::float AS qte_reseau_par_magasin,
|
||||
(COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))::float AS qte_locale_par_magasin,
|
||||
(
|
||||
(r.qte_reseau / r.nb_magasins_reseau)
|
||||
- (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))
|
||||
)::float AS ecart_par_magasin
|
||||
FROM avec_reseau r
|
||||
LEFT JOIN ventes v ON v.codein = r.codein
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT af1.code
|
||||
FROM artfou1 af1
|
||||
WHERE af1.art_no_id = r.no_id AND af1.preference = 1
|
||||
ORDER BY af1.code
|
||||
LIMIT 1
|
||||
) af ON TRUE
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
ORDER BY ecart_par_magasin DESC
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
|
||||
console.log(`[pg-ff] pgGetOpportunitesFamille(nom=${nomNoId}): ${result.rows.length} lignes`);
|
||||
return result.rows as unknown as PgOpportuniteRow[];
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetOpportunitesFamille error:", (e as Error).message?.slice(0, 250));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+116
-12
@@ -19,6 +19,20 @@
|
||||
import httpntlm from "httpntlm";
|
||||
import { WebSocket } from "ws";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { buildGridNetworkQlikDateFilter, chunkSerials, type QlikDateFilter } from "@/lib/qlik-date-range";
|
||||
|
||||
/** Lit les réglages Qlik depuis data/.db-config.json (configurés dans Paramètres). */
|
||||
function readQlikFileConfig(): { qlikHost?: string; qlikUser?: string; qlikPassword?: string } {
|
||||
try {
|
||||
const raw = readFileSync(join(process.cwd(), "data", ".db-config.json"), "utf-8");
|
||||
const c = JSON.parse(raw);
|
||||
return { qlikHost: c.qlikHost, qlikUser: c.qlikUser, qlikPassword: c.qlikPassword };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config (env) — defauts issus de la discovery
|
||||
@@ -35,22 +49,31 @@ export interface QlikConfig {
|
||||
measCaId: string; // master measure "CA N"
|
||||
measQteId: string; // master measure "Quantite N"
|
||||
measNbMagId: string; // master measure "Magasin Ventes Nb N"
|
||||
measCaMagId: string; // master measure "CA par Magasin N"
|
||||
measMargePctId: string; // master measure "Marge % N"
|
||||
tlsInsecure: boolean;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export function getQlikConfig(): QlikConfig {
|
||||
// Priorité : réglages UI (data/.db-config.json) > env > défaut
|
||||
const f = readQlikFileConfig();
|
||||
return {
|
||||
host: process.env.QLIK_HOST ?? "reporting-magasins.lafoirfouille.fr",
|
||||
user: process.env.QLIK_USER ?? "FFSCH",
|
||||
password: process.env.QLIK_PWD ?? "",
|
||||
host: f.qlikHost || process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr",
|
||||
user: f.qlikUser || process.env.QLIK_USER || "FFSCH",
|
||||
password: f.qlikPassword || process.env.QLIK_PWD || "",
|
||||
domain: process.env.QLIK_DOMAIN ?? "",
|
||||
workstation: process.env.QLIK_WORKSTATION ?? "",
|
||||
appNetwork: process.env.QLIK_APP_NETWORK ?? "9872ee6e-d64a-4b43-984a-076bf1f7f647",
|
||||
dimCodeArticleId: process.env.QLIK_DIM_CODE_ARTICLE_ID ?? "fcd239e5-288b-4830-a047-0e3d7665d971",
|
||||
measCaId: process.env.QLIK_MEAS_CA_ID ?? "43a76088-86fa-402e-a80e-0efd7701b3e1",
|
||||
measQteId: process.env.QLIK_MEAS_QTE_ID ?? "7b40caf1-be4b-4811-8d45-50acde33e715",
|
||||
measNbMagId: process.env.QLIK_MEAS_NBMAG_ID ?? "8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b",
|
||||
// qIds engine (master items de l'app "Magasins Vision Consolidée")
|
||||
dimCodeArticleId: process.env.QLIK_DIM_CODE_ARTICLE_ID ?? "yesCP",
|
||||
measCaId: process.env.QLIK_MEAS_CA_ID ?? "JhqJ",
|
||||
measQteId: process.env.QLIK_MEAS_QTE_ID ?? "41516861-5997-4635-8187-3643a2bde422",
|
||||
measNbMagId: process.env.QLIK_MEAS_NBMAG_ID ?? "yNBLjc",
|
||||
// Master GUIDs de l'app "Magasins Vision Consolidée" (9872ee6e). Si l'engine
|
||||
// les refuse (qId court attendu), override via env après discovery.
|
||||
measCaMagId: process.env.QLIK_MEAS_CAMAG_ID ?? "16778c00-a4e0-41c7-ab1c-179810d78172",
|
||||
measMargePctId: process.env.QLIK_MEAS_MARGEPCT_ID ?? "ea1e8feb-49ba-4eb3-8db6-72dcfb4c1222",
|
||||
tlsInsecure: (process.env.QLIK_TLS_INSECURE ?? "true") === "true",
|
||||
timeoutMs: Number(process.env.QLIK_TIMEOUT_MS ?? "60000"),
|
||||
};
|
||||
@@ -61,7 +84,37 @@ export interface NetworkMetric {
|
||||
caReseau: number;
|
||||
qteReseau: number;
|
||||
nbMagasinsReseau: number;
|
||||
/** CA moyen par magasin (productivité normalisée présence) */
|
||||
caParMagasinReseau: number;
|
||||
/** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */
|
||||
margePctReseau: number;
|
||||
periode?: string;
|
||||
/** Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. */
|
||||
qteByMonth?: Record<string, number>;
|
||||
/**
|
||||
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
|
||||
*
|
||||
* Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois) —
|
||||
* les conserver ne coûte aucune requête supplémentaire. Seule `qte` est
|
||||
* garantie : les mois N-1 dérivés de « Quantité COMP » n'ont que la quantité
|
||||
* tant que la passe complémentaire N-1 ne les a pas complétés (elle est
|
||||
* sautée si le champ Qlik `Année` est absent).
|
||||
*/
|
||||
metricsByMonth?: Record<string, QlikMonthMetrics>;
|
||||
}
|
||||
|
||||
/** Les mesures réseau Qlik pour un produit sur un mois donné. */
|
||||
export interface QlikMonthMetrics {
|
||||
/** Quantité vendue réseau — toujours présente. */
|
||||
qte: number;
|
||||
/** CA réseau du mois. */
|
||||
ca?: number;
|
||||
/** Nombre de magasins ayant vendu le produit ce mois-là. */
|
||||
nbMag?: number;
|
||||
/** CA moyen par magasin sur le mois (mesure Qlik brute). */
|
||||
caMag?: number;
|
||||
/** Taux de marge réseau du mois (ratio brut Qlik, ex 0.32 = 32%). */
|
||||
margePct?: number;
|
||||
}
|
||||
|
||||
function makeXrfkey(): string {
|
||||
@@ -153,7 +206,13 @@ function openEngine(appGuid: string, sess: QlikSession, cfg: QlikConfig): Promis
|
||||
const url = `wss://${cfg.host}/app/${encodeURIComponent(appGuid)}?Xrfkey=${sess.xrfkey}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, {
|
||||
headers: { Cookie: sess.cookie, "X-Qlik-Xrfkey": sess.xrfkey },
|
||||
headers: {
|
||||
Cookie: sess.cookie,
|
||||
"X-Qlik-Xrfkey": sess.xrfkey,
|
||||
// Le proxy Qlik valide l'Origin des websockets (whitelist) — un navigateur l'envoie toujours.
|
||||
Origin: `https://${cfg.host}`,
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
},
|
||||
rejectUnauthorized: !cfg.tlsInsecure,
|
||||
handshakeTimeout: cfg.timeoutMs,
|
||||
});
|
||||
@@ -173,7 +232,14 @@ function openEngine(appGuid: string, sess: QlikSession, cfg: QlikConfig): Promis
|
||||
else p.resolve(msg.result as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
ws.on("unexpected-response", (_req, res) => { clearTimeout(timer); reject(new Error(`[qlik] ws HTTP ${res.statusCode}`)); });
|
||||
ws.on("unexpected-response", (_req, res) => {
|
||||
clearTimeout(timer);
|
||||
let body = "";
|
||||
res.on("data", (d: Buffer) => (body += d.toString()));
|
||||
res.on("end", () => reject(new Error(
|
||||
`[qlik] ws HTTP ${res.statusCode} server=${res.headers["server"]} wwwauth=${res.headers["www-authenticate"]} body=${body.slice(0, 160)}`,
|
||||
)));
|
||||
});
|
||||
ws.on("error", (e: Error) => { clearTimeout(timer); reject(e); });
|
||||
ws.on("close", () => { for (const p of pending.values()) p.reject(new Error("[qlik] ws ferme")); pending.clear(); });
|
||||
|
||||
@@ -200,8 +266,10 @@ function hyperCubeDef(cfg: QlikConfig, height: number) {
|
||||
{ qLibraryId: cfg.measCaId },
|
||||
{ qLibraryId: cfg.measQteId },
|
||||
{ qLibraryId: cfg.measNbMagId },
|
||||
{ qLibraryId: cfg.measCaMagId },
|
||||
{ qLibraryId: cfg.measMargePctId },
|
||||
],
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 4, qHeight: height }],
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: height }],
|
||||
qSuppressZero: false,
|
||||
qSuppressMissing: true,
|
||||
},
|
||||
@@ -213,6 +281,7 @@ type Matrix = Array<Array<{ qText?: string; qNum?: number }>>;
|
||||
export async function fetchNetworkMetrics(
|
||||
codeCentraux?: string[],
|
||||
cfg = getQlikConfig(),
|
||||
dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(),
|
||||
): Promise<Map<string, NetworkMetric>> {
|
||||
if (!cfg.appNetwork) throw new Error("[qlik] QLIK_APP_NETWORK manquant");
|
||||
const wanted = codeCentraux ? new Set(codeCentraux) : null;
|
||||
@@ -224,7 +293,39 @@ export async function fetchNetworkMetrics(
|
||||
const openRes = await conn.rpc("OpenDoc", { qDocName: cfg.appNetwork });
|
||||
const docHandle = (((openRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number) ?? 1;
|
||||
|
||||
const pageHeight = 2500; // 4 colonnes * 2500 = 10000 cellules max
|
||||
// Sélection du champ `Date` sur la fenêtre 12 mois complets glissants
|
||||
// (alignement avec la timeline de la grille). Équivalent à un set
|
||||
// analysis `{<Date={">=…<=…"}>}` appliqué aux master measures.
|
||||
let dateFieldHandle: number | null = null;
|
||||
if (dateFilter) {
|
||||
const gf = await conn.rpc("GetField", { qFieldName: "Date" }, docHandle);
|
||||
dateFieldHandle = (((gf.qReturn as Record<string, unknown>) ?? {}).qHandle as number);
|
||||
if (typeof dateFieldHandle !== "number") {
|
||||
console.warn(`[qlik] champ Date introuvable, extraction sans filtre date`);
|
||||
dateFilter = null;
|
||||
dateFieldHandle = null;
|
||||
} else {
|
||||
const batches = chunkSerials(dateFilter.dailySerials, 500);
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i];
|
||||
const sel = await conn.rpc(
|
||||
"SelectValues",
|
||||
{
|
||||
qFieldValues: batch.map((s) => ({ qNum: s, qText: String(s) })),
|
||||
qToggleMode: false,
|
||||
qSoftLock: true,
|
||||
},
|
||||
dateFieldHandle,
|
||||
);
|
||||
console.log(`[qlik] date filter lot ${i + 1}/${batches.length} (${batch.length} serials) — ${dateFilter.label}`);
|
||||
// Si SelectValues renvoie une erreur qError, on continue quand même :
|
||||
// l'absence de champ Date typé numérique lèverait une erreur Engine explicite.
|
||||
void sel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pageHeight = 1400; // 6 colonnes * 1400 = 8400 cellules max
|
||||
const createRes = await conn.rpc("CreateSessionObject", { qProp: hyperCubeDef(cfg, pageHeight) }, docHandle);
|
||||
const objHandle = (((createRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number);
|
||||
|
||||
@@ -232,7 +333,7 @@ export async function fetchNetworkMetrics(
|
||||
for (;;) {
|
||||
const dataRes = await conn.rpc(
|
||||
"GetHyperCubeData",
|
||||
{ qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 4, qHeight: pageHeight }] },
|
||||
{ qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: pageHeight }] },
|
||||
objHandle,
|
||||
);
|
||||
const pages = (dataRes.qDataPages as Array<{ qMatrix?: Matrix }>) ?? [];
|
||||
@@ -248,6 +349,9 @@ export async function fetchNetworkMetrics(
|
||||
caReseau: Number(row[1]?.qNum ?? 0) || 0,
|
||||
qteReseau: Number(row[2]?.qNum ?? 0) || 0,
|
||||
nbMagasinsReseau: Number(row[3]?.qNum ?? 0) || 0,
|
||||
caParMagasinReseau: Number(row[4]?.qNum ?? 0) || 0,
|
||||
margePctReseau: Number(row[5]?.qNum ?? 0) || 0,
|
||||
periode: dateFilter?.label,
|
||||
});
|
||||
}
|
||||
if (matrix.length < pageHeight) break;
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* CollectFlow — Helper de fenêtre temporelle pour Qlik réseau.
|
||||
*
|
||||
* Aligne la timeline d'extraction Qlik réseau sur la timeline de la grille :
|
||||
* - `monthsBack` mois complets glissants (défaut : 12)
|
||||
* - exclut systématiquement le mois courant
|
||||
*
|
||||
* Référence utilisée : `buildLast12MonthsRange()` de `api-ff-client.ts`
|
||||
* (même source que la grille). On duplique la logique ici (sans dépendance
|
||||
* Next.js) pour rester testable en CLI pur Node.
|
||||
*
|
||||
* Qlik référence les dates par un sérial numérique (jours écoulés depuis
|
||||
* l'epoch Qlik 1899-12-30). Repères validés :
|
||||
* 2025-01-01 → 45658
|
||||
* 2025-06-19 → 45827
|
||||
* 2026-01-01 → 46023
|
||||
* 2026-06-19 → 46192
|
||||
*
|
||||
* Le filtre côté Qlik s'applique via une sélection de champ `Date` (skill qlik) :
|
||||
* - valeurs numériques = serials Qlik
|
||||
* - la sélection par défaut (`{<Date={...}>}`) est héritée par toutes
|
||||
* les master measures qui référencent le champ `Date` (cas de l'app
|
||||
* "Magasins Vision Consolidée").
|
||||
*
|
||||
* Pourquoi `SelectValues` sur le champ plutôt que set analysis dans `qDef` ?
|
||||
* - les master items sont référencés par `qLibraryId` (réutilisation sans
|
||||
* devoir dupliquer l'expression)
|
||||
* - `qLibraryId` + `qDef` ensemble n'est pas supporté par le moteur QIX
|
||||
* - sélectionner toutes les dates de la fenêtre via `SelectValues` revient
|
||||
* fonctionnellement à `{<Date={">=…<=…"}>}` pour les mesures qui touchent
|
||||
* ce champ, sans dépendre de l'expression interne des master items
|
||||
*
|
||||
* Pas de secrets ici. Pure fonction utilitaire testable en CLI Node.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Bornes acceptées pour `monthsBack`. On reste sur 1..12 pour ne pas casser
|
||||
* la sémantique "12 mois glissants alignés sur la grille" : une valeur plus
|
||||
* grande n'apporterait pas de signal réseau supplémentaire (la grille
|
||||
* n'expose elle-même que ~12 mois). En-deçà de 1 mois, la fenêtre serait
|
||||
* vide (mois courant exclu) ou incohérente.
|
||||
*/
|
||||
export const QLIK_MONTHS_BACK_MIN = 1;
|
||||
export const QLIK_MONTHS_BACK_MAX = 12;
|
||||
export const QLIK_MONTHS_BACK_DEFAULT = 12;
|
||||
|
||||
/**
|
||||
* Lit `process.env[name]` et renvoie un entier borné entre `min` et `max`.
|
||||
* Renvoie `defaultValue` si la variable est absente / vide / non numérique.
|
||||
*
|
||||
* Volontairement exportée pour être réutilisée par `qlik-playwright.ts` et
|
||||
* `sync/route.ts` (cohérence avec `envNumber` côté Playwright, mais en pur
|
||||
* Node sans dépendance Next.js).
|
||||
*/
|
||||
export function envMonthsBack(name: string, defaultValue: number = QLIK_MONTHS_BACK_DEFAULT): number {
|
||||
const raw = process.env[name];
|
||||
if (raw == null || raw.trim() === "") return defaultValue;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return defaultValue;
|
||||
const clamped = Math.min(QLIK_MONTHS_BACK_MAX, Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(parsed)));
|
||||
return clamped;
|
||||
}
|
||||
|
||||
/** Epoch Qlik = 1899-12-30 (équivalent Excel/Lotus 1-2-3). */
|
||||
const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30); // 1899-12-30T00:00:00Z
|
||||
|
||||
/** Repères de validation (cf. skill qlik). */
|
||||
export const QLIK_DATE_ANCHORS: ReadonlyArray<{ iso: string; serial: number }> = [
|
||||
{ iso: "2025-01-01", serial: 45658 },
|
||||
{ iso: "2025-06-19", serial: 45827 },
|
||||
{ iso: "2026-01-01", serial: 46023 },
|
||||
{ iso: "2026-06-19", serial: 46192 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Convertit une date ISO `YYYY-MM-DD` en sérial Qlik numérique.
|
||||
* Inverse documenté : Qlik epoch = 1899-12-30 (jour 0).
|
||||
*
|
||||
* @throws si l'entrée n'est pas au format `YYYY-MM-DD`.
|
||||
*/
|
||||
export function isoDateToQlikSerial(isoDate: string): number {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
|
||||
if (!m) throw new Error(`isoDateToQlikSerial: format attendu YYYY-MM-DD, reçu "${isoDate}"`);
|
||||
const y = Number(m[1]);
|
||||
const mo = Number(m[2]);
|
||||
const d = Number(m[3]);
|
||||
const ms = Date.UTC(y, mo - 1, d);
|
||||
const serial = Math.round((ms - QLIK_EPOCH_MS) / 86_400_000);
|
||||
return serial;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un sérial Qlik en date ISO `YYYY-MM-DD` (utile pour tests/diagnostics).
|
||||
*/
|
||||
export function qlikSerialToIsoDate(serial: number): string {
|
||||
const ms = QLIK_EPOCH_MS + serial * 86_400_000;
|
||||
const d = new Date(ms);
|
||||
const y = d.getUTCFullYear();
|
||||
const mo = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const da = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${mo}-${da}`;
|
||||
}
|
||||
|
||||
export interface QlikDateFilter {
|
||||
/** Date ISO du 1er jour de la fenêtre (inclus). */
|
||||
dateDebut: string;
|
||||
/** Date ISO du dernier jour de la fenêtre (inclus). */
|
||||
dateFin: string;
|
||||
/** Sérial Qlik du 1er jour (= `Date >= …`). */
|
||||
qStart: number;
|
||||
/** Sérial Qlik du dernier jour (= `Date <= …`). */
|
||||
qEnd: number;
|
||||
/** Libellé humain `YYYY-MM_YYYY-MM` pour logs / cache. */
|
||||
label: string;
|
||||
/** Liste exhaustive des serials journaliers `[qStart..qEnd]` pour SelectValues. */
|
||||
dailySerials: number[];
|
||||
/** Expression set analysis prête à intégrer dans un `qDef` de mesure. */
|
||||
setAnalysis: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la fenêtre `monthsBack` mois complets (excluant mois courant).
|
||||
* Par défaut, utilise `now = new Date()` et `monthsBack = 12`.
|
||||
*
|
||||
* Logique alignée sur `buildLast12MonthsRange()` de `src/lib/api-ff-client.ts`
|
||||
* (défaut = 12 mois). Si la source officielle change, mettre à jour ici aussi
|
||||
* (garder en sync).
|
||||
*
|
||||
* Bornes : `monthsBack` est clampé dans `[QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX]`
|
||||
* (1..12). Une valeur invalide (NaN, ≤ 0, > 12) est ramenée aux bornes sans
|
||||
* exception — on veut que la sync reste possible même si l'env est mal
|
||||
* configuré.
|
||||
*/
|
||||
export function buildGridNetworkQlikDateFilter(
|
||||
now: Date = new Date(),
|
||||
monthsBack: number = QLIK_MONTHS_BACK_DEFAULT,
|
||||
): QlikDateFilter {
|
||||
const months = Math.min(
|
||||
QLIK_MONTHS_BACK_MAX,
|
||||
Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(Number.isFinite(monthsBack) ? monthsBack : QLIK_MONTHS_BACK_DEFAULT)),
|
||||
);
|
||||
// Fin : dernier jour du mois précédent le mois courant
|
||||
const endMonth = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||
// Début : `months` mois avant le mois courant (1er du mois)
|
||||
const startMonth = new Date(now.getFullYear(), now.getMonth() - months, 1);
|
||||
|
||||
const dateDebut = startMonth.toISOString().slice(0, 10);
|
||||
const dateFin = endMonth.toISOString().slice(0, 10);
|
||||
|
||||
const qStart = isoDateToQlikSerial(dateDebut);
|
||||
const qEnd = isoDateToQlikSerial(dateFin);
|
||||
|
||||
if (qEnd < qStart) {
|
||||
throw new Error(
|
||||
`buildGridNetworkQlikDateFilter: fenêtre inversée qEnd=${qEnd} < qStart=${qStart} (dateDebut=${dateDebut} dateFin=${dateFin})`,
|
||||
);
|
||||
}
|
||||
|
||||
const dailySerials: number[] = [];
|
||||
for (let s = qStart; s <= qEnd; s++) dailySerials.push(s);
|
||||
|
||||
const label = `${dateDebut.slice(0, 7)}_${dateFin.slice(0, 7)}`;
|
||||
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
|
||||
|
||||
return { dateDebut, dateFin, qStart, qEnd, label, dailySerials, setAnalysis };
|
||||
}
|
||||
|
||||
/**
|
||||
* Découpe les serials en lots pour appels `SelectValues` paginés.
|
||||
* Par défaut 500 valeurs/appel (largement < limite Qlik ~10k par requête).
|
||||
*/
|
||||
export function chunkSerials(serials: number[], batchSize = 500): number[][] {
|
||||
const out: number[][] = [];
|
||||
for (let i = 0; i < serials.length; i += batchSize) {
|
||||
out.push(serials.slice(i, i + batchSize));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Découpe une fenêtre temporelle en sous-fenêtres mensuelles calendaires
|
||||
* **inclusives et continues**.
|
||||
*
|
||||
* But : éviter qu'un seul hypercube Qlik ne doive charger 365 jours × N
|
||||
* codes d'un coup (provoque `code:15 Request aborted`). Avec cette découpe,
|
||||
* chaque mois ≈ 28–31 jours × N codes → beaucoup plus léger pour l'Engine.
|
||||
*
|
||||
* Règles :
|
||||
* - Le 1er mois commence à `filter.dateDebut` (peut être un jour quelconque,
|
||||
* pas forcément le 1er calendaire).
|
||||
* - Chaque mois intermédiaire est un mois calendaire **complet** (du 1er
|
||||
* au dernier jour du mois).
|
||||
* - Le dernier mois se termine à `filter.dateFin` (tronqué si pas fin de mois).
|
||||
* - Les mois sont contigus : fin d'un mois + 1 = début du suivant.
|
||||
* - Si `filter.dateDebut` et `filter.dateFin` tombent dans le même mois,
|
||||
* on renvoie un seul mois (identique à `filter` mais re-calculé).
|
||||
*
|
||||
* Chaque mois retourné est un `QlikDateFilter` autonome : `qStart`/`qEnd`,
|
||||
* `dailySerials` recomptés, `setAnalysis` et `label` au format `YYYY-MM`.
|
||||
*
|
||||
* Pré-condition : `dateDebut <= dateFin` au sens calendaire (pas vérifié ici,
|
||||
* la validation est faite par `buildGridNetworkQlikDateFilter`).
|
||||
*/
|
||||
export function getMonthRanges(filter: QlikDateFilter): QlikDateFilter[] {
|
||||
const start = filter.dateDebut; // YYYY-MM-DD
|
||||
const end = filter.dateFin; // YYYY-MM-DD
|
||||
|
||||
// [yyyy, mm, dd] depuis YYYY-MM-DD (déjà validé par isoDateToQlikSerial).
|
||||
const sy = Number(start.slice(0, 4));
|
||||
const sm = Number(start.slice(5, 7));
|
||||
// const sd = Number(start.slice(8, 10)); // non utilisé directement
|
||||
const ey = Number(end.slice(0, 4));
|
||||
const em = Number(end.slice(5, 7));
|
||||
const ed = Number(end.slice(8, 10));
|
||||
|
||||
// Dernier jour du mois (0-ième du mois suivant en UTC local Date).
|
||||
const lastDayOfMonth = (y: number, m: number): number => {
|
||||
// m: 1..12 ; on prend le jour 0 du mois suivant, qui donne le dernier jour du mois courant.
|
||||
return new Date(y, m, 0).getDate();
|
||||
};
|
||||
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
|
||||
const out: QlikDateFilter[] = [];
|
||||
|
||||
// Itère mois par mois du mois de début au mois de fin (inclus).
|
||||
let curY = sy;
|
||||
let curM = sm;
|
||||
// Sécurité anti-boucle infinie (au cas où).
|
||||
const MAX_MONTHS = 1200; // 100 ans, large.
|
||||
let safety = 0;
|
||||
while (safety++ < MAX_MONTHS) {
|
||||
const isFirst = curY === sy && curM === sm;
|
||||
const isLast = curY === ey && curM === em;
|
||||
|
||||
// Début du mois courant : si c'est le premier mois, on respecte `dateDebut` ;
|
||||
// sinon on prend le 1er calendaire.
|
||||
const monthStartDay = isFirst ? Number(start.slice(8, 10)) : 1;
|
||||
const monthStartDate = `${curY}-${pad(curM)}-${pad(monthStartDay)}`;
|
||||
|
||||
// Fin du mois courant : si c'est le dernier mois, on respecte `dateFin` ;
|
||||
// sinon on prend le dernier jour calendaire du mois.
|
||||
const monthEndDay = isLast ? ed : lastDayOfMonth(curY, curM);
|
||||
const monthEndDate = `${curY}-${pad(curM)}-${pad(monthEndDay)}`;
|
||||
|
||||
const qStart = isoDateToQlikSerial(monthStartDate);
|
||||
const qEnd = isoDateToQlikSerial(monthEndDate);
|
||||
|
||||
const dailySerials: number[] = [];
|
||||
for (let s = qStart; s <= qEnd; s++) dailySerials.push(s);
|
||||
|
||||
const label = `${curY}-${pad(curM)}`;
|
||||
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
|
||||
|
||||
out.push({ dateDebut: monthStartDate, dateFin: monthEndDate, qStart, qEnd, label, dailySerials, setAnalysis });
|
||||
|
||||
if (isLast) break;
|
||||
|
||||
// Avance d'un mois calendaire (gère le passage décembre → janvier).
|
||||
curM += 1;
|
||||
if (curM > 12) { curM = 1; curY += 1; }
|
||||
}
|
||||
|
||||
if (out.length === 0) {
|
||||
// Fenêtre vide : on retourne un seul mois vide aligné sur dateDebut.
|
||||
const qStart = filter.qStart;
|
||||
const qEnd = filter.qEnd;
|
||||
const label = start.slice(0, 7);
|
||||
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
|
||||
out.push({ dateDebut: start, dateFin: end, qStart, qEnd, label, dailySerials: [], setAnalysis });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -9,12 +9,24 @@ import "server-only";
|
||||
import { db } from "@/db";
|
||||
import { qlikNetworkMetrics } from "@/db/schema";
|
||||
import { inArray, sql } from "drizzle-orm";
|
||||
import type { NetworkMetric } from "@/lib/qlik-client";
|
||||
import type { NetworkMetric, QlikMonthMetrics } from "@/lib/qlik-client";
|
||||
|
||||
export interface NetworkMetricCached {
|
||||
caReseau: number;
|
||||
qteReseau: number;
|
||||
nbMagasinsReseau: number;
|
||||
caParMagasinReseau: number;
|
||||
margePctReseau: number;
|
||||
/** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */
|
||||
qteByMonth: Record<string, number> | null;
|
||||
/**
|
||||
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
|
||||
* null pour les lignes synchronisées avant l'ajout de la colonne — l'UI
|
||||
* retombe alors sur `qteByMonth`.
|
||||
*/
|
||||
metricsByMonth: Record<string, QlikMonthMetrics> | null;
|
||||
/** Période couverte par l'extraction (ex "2025-03_2026-02"). */
|
||||
periode: string | null;
|
||||
fetchedAt: string | null;
|
||||
}
|
||||
|
||||
@@ -36,6 +48,11 @@ export async function getNetworkMetricsByCodeCentrale(
|
||||
caReseau: Number(r.caReseau ?? 0) || 0,
|
||||
qteReseau: Number(r.qteReseau ?? 0) || 0,
|
||||
nbMagasinsReseau: Number(r.nbMagasinsReseau ?? 0) || 0,
|
||||
caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0,
|
||||
margePctReseau: Number(r.margePctReseau ?? 0) || 0,
|
||||
qteByMonth: (r.qteByMonth as Record<string, number> | null) ?? null,
|
||||
metricsByMonth: (r.metricsByMonth as Record<string, QlikMonthMetrics> | null) ?? null,
|
||||
periode: r.periode ?? null,
|
||||
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
|
||||
});
|
||||
}
|
||||
@@ -51,7 +68,11 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
|
||||
caReseau: String(m.caReseau),
|
||||
qteReseau: String(m.qteReseau),
|
||||
nbMagasinsReseau: m.nbMagasinsReseau,
|
||||
caParMagasinReseau: String(m.caParMagasinReseau),
|
||||
margePctReseau: String(m.margePctReseau),
|
||||
periode: m.periode ?? null,
|
||||
qteByMonth: m.qteByMonth ?? null,
|
||||
metricsByMonth: m.metricsByMonth ?? null,
|
||||
fetchedAt: now,
|
||||
}));
|
||||
|
||||
@@ -69,7 +90,11 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
|
||||
caReseau: sql`excluded.ca_reseau`,
|
||||
qteReseau: sql`excluded.qte_reseau`,
|
||||
nbMagasinsReseau: sql`excluded.nb_magasins_reseau`,
|
||||
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
|
||||
margePctReseau: sql`excluded.marge_pct_reseau`,
|
||||
periode: sql`excluded.periode`,
|
||||
qteByMonth: sql`excluded.qte_by_month`,
|
||||
metricsByMonth: sql`excluded.metrics_by_month`,
|
||||
fetchedAt: sql`excluded.fetched_at`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,892 @@
|
||||
/**
|
||||
* CollectFlow — Extraction Qlik via Playwright (Chromium headless).
|
||||
*
|
||||
* Le proxy Qlik refuse un websocket "raw" côté serveur (403). On ouvre donc
|
||||
* l'app dans Chromium avec une session authentifiée (cookie X-Qlik-Session
|
||||
* injecté via le flux ticket NTLM), on récupère le **qlik-csrf-token** que le
|
||||
* client Qlik utilise pour SON websocket, puis on ouvre notre propre websocket
|
||||
* Engine **in-page** avec ce token (le proxy l'accepte).
|
||||
*
|
||||
* Efficacité : on filtre d'abord Date par mois (~30 jours), puis on sélectionne
|
||||
* les Article Code demandés par petits lots. Chaque cube reste borné à
|
||||
* ~30 jours × batch d'articles, sans lire tout le catalogue Qlik.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { chromium, type Browser } from "playwright-core";
|
||||
import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric } from "@/lib/qlik-client";
|
||||
import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range";
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
if (!browserPromise) {
|
||||
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
|
||||
browserPromise = chromium.launch({
|
||||
headless: true,
|
||||
executablePath: execPath || undefined,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
|
||||
}).catch((e) => { browserPromise = null; throw e; });
|
||||
}
|
||||
return browserPromise;
|
||||
}
|
||||
|
||||
interface InPageResult {
|
||||
ok: boolean;
|
||||
rows?: Array<Array<string | number>>;
|
||||
/**
|
||||
* Métriques réseau par code central et par mois : { code: { "YYYY-MM": {…} } }.
|
||||
*
|
||||
* Le cube mensuel renvoie déjà les 6 mesures par (Article Code, Mois) ; on les
|
||||
* conserve toutes. Seule `qte` est systématiquement présente : les mois N-1
|
||||
* dérivés de « Quantité COMP » n'ont que la quantité (COMP ne porte pas les
|
||||
* autres mesures), et la passe complémentaire N-1 les complète quand le champ
|
||||
* `Année` existe.
|
||||
*/
|
||||
monthly?: Record<string, Record<string, { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number }>>;
|
||||
size?: number;
|
||||
error?: string;
|
||||
diag?: Record<string, unknown>;
|
||||
// Compteurs agrégés pour le résumé timing final côté Node (cf. qlik-playwright).
|
||||
months?: number;
|
||||
batches?: number;
|
||||
code15Retries?: number;
|
||||
fallbackCount?: number;
|
||||
}
|
||||
|
||||
function envNumber(name: string, defaultValue: number, min: number, max: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw == null || raw.trim() === "") return defaultValue;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return defaultValue;
|
||||
return Math.min(max, Math.max(min, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
function envNumberCsv(name: string, min: number, max: number): number[] {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split(",")
|
||||
.map((part) => Number(part.trim()))
|
||||
.filter((value) => Number.isFinite(value))
|
||||
.map((value) => Math.min(max, Math.max(min, Math.trunc(value))));
|
||||
}
|
||||
|
||||
function uniqueOrdered(values: number[]): number[] {
|
||||
const seen = new Set<number>();
|
||||
const out: number[] = [];
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function fetchNetworkMetricsPlaywright(
|
||||
codeCentraux?: string[],
|
||||
cfg: QlikConfig = getQlikConfig(),
|
||||
dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(),
|
||||
): Promise<Map<string, NetworkMetric>> {
|
||||
if (!cfg.appNetwork) throw new Error("[qlik-pw] QLIK_APP_NETWORK manquant");
|
||||
if (!cfg.user || !cfg.password) throw new Error("[qlik-pw] identifiants Qlik manquants");
|
||||
|
||||
// Démarre l'horloge pour le résumé timing final (cf. bloc [qlik-pw][summary]).
|
||||
const totalStart = Date.now();
|
||||
console.log(`[qlik-pw] host=${cfg.host} app=${cfg.appNetwork} — ${(codeCentraux ?? []).length} codes à sélectionner`);
|
||||
if (dateFilter) {
|
||||
console.log(`[qlik-pw] filtre Date=${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, ${dateFilter.setAnalysis})`);
|
||||
}
|
||||
const sess = await qlikNtlmSession(cfg);
|
||||
console.log(`[qlik-pw] session NTLM OK, cookie=${sess.cookie.split("=")[0]}=…`);
|
||||
const [cookieName, ...rest] = sess.cookie.split("=");
|
||||
const cookieValue = rest.join("=");
|
||||
const qlikSettleMs = envNumber("QLIK_SETTLE_MS", 150, 0, 2000);
|
||||
const qlikCode15MaxAttempts = envNumber("QLIK_CODE15_MAX_ATTEMPTS", 4, 1, 8);
|
||||
const qlikArticleBatchSize = envNumber("QLIK_ARTICLE_BATCH_SIZE", 150, 1, 500);
|
||||
const csvFallbacks = envNumberCsv("QLIK_ARTICLE_BATCH_FALLBACKS", 1, 500);
|
||||
const qlikArticleBatchFallbacks = uniqueOrdered([qlikArticleBatchSize, ...(csvFallbacks.length ? csvFallbacks : [75, 50]), 75, 50]);
|
||||
// Chemin rapide : sélectionner TOUS les Article Code une seule fois, puis ne
|
||||
// changer que la Date par mois (au lieu de re-sélectionner par lots de 150 chaque
|
||||
// mois). Repli automatique sur le chemin par lots en cas de code 15.
|
||||
// ACTIVÉ PAR DÉFAUT (validé fiable) — désactivable seulement avec QLIK_SELECT_ALL_CODES=0.
|
||||
const qlikSelectAllCodes = !["0", "false", "no", "off"].includes((process.env.QLIK_SELECT_ALL_CODES ?? "").trim().toLowerCase());
|
||||
// Découpage mensuel réel via la dimension "Mois" (cube [Article Code, Mois]) : donne
|
||||
// les vraies quantités par mois ET le vrai total (au lieu de 12× la valeur annuelle).
|
||||
// Activé par défaut ; désactivable via QLIK_MONTH_DIM=0 (revient à l'itération par mois).
|
||||
const qlikMonthDim = !["0", "false", "no", "off"].includes((process.env.QLIK_MONTH_DIM ?? "").trim().toLowerCase());
|
||||
const qlikMoisDimId = (process.env.QLIK_MOIS_DIM_ID ?? "pfGAwTs").trim();
|
||||
// Mesure "Quantité COMP" (année N-1) pour compléter les mois de l'année précédente
|
||||
// → 12 mois glissants (Quantité N ne couvre que l'année en cours).
|
||||
const qlikMeasQteCompId = (process.env.QLIK_MEAS_QTE_COMP_ID ?? "96862880-76cd-4957-bf25-b96901f2ac5f").trim();
|
||||
const yearN = new Date().getFullYear();
|
||||
console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim}`);
|
||||
|
||||
const browser = await getBrowser();
|
||||
console.log(`[qlik-pw] chromium lancé`);
|
||||
const ctx = await browser.newContext({
|
||||
httpCredentials: { username: cfg.user, password: cfg.password, origin: `https://${cfg.host}` },
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
await ctx.addCookies([{ name: cookieName, value: cookieValue, domain: cfg.host, path: "/", secure: true }]);
|
||||
|
||||
try {
|
||||
const page = await ctx.newPage();
|
||||
page.on("console", (msg) => console.log(`[qlik-pw][page] ${msg.text()}`));
|
||||
page.on("pageerror", (e) => console.log(`[qlik-pw][pageerror] ${e.message}`));
|
||||
|
||||
// Capture le qlik-csrf-token depuis le websocket que le client Qlik ouvre
|
||||
let csrfToken: string | null = null;
|
||||
let resolveToken: (() => void) | null = null;
|
||||
const tokenReady = new Promise<void>((r) => { resolveToken = r; });
|
||||
page.on("websocket", (ws) => {
|
||||
const m = ws.url().match(/qlik-csrf-token=([^&]+)/);
|
||||
if (m && !csrfToken) { csrfToken = decodeURIComponent(m[1]); resolveToken?.(); }
|
||||
});
|
||||
|
||||
await page.goto(`https://${cfg.host}/sense/app/${cfg.appNetwork}`, { waitUntil: "domcontentloaded", timeout: cfg.timeoutMs })
|
||||
.catch(() => { /* le client ouvre son ws ensuite */ });
|
||||
console.log(`[qlik-pw] page chargée, attente du qlik-csrf-token…`);
|
||||
await Promise.race([tokenReady, page.waitForTimeout(15000)]);
|
||||
if (!csrfToken) throw new Error("[qlik-pw] qlik-csrf-token introuvable (client Qlik non chargé ?)");
|
||||
console.log(`[qlik-pw] csrf-token capturé, ouverture du websocket Engine in-page…`);
|
||||
|
||||
// Pré-calcul des mois côté Node (pour pouvoir logger X/Y et propager au script in-page).
|
||||
const monthRanges: QlikDateFilter[] = dateFilter ? getMonthRanges(dateFilter) : [];
|
||||
const monthlyPayload = monthRanges.map((m) => ({
|
||||
label: m.label,
|
||||
dateDebut: m.dateDebut,
|
||||
dateFin: m.dateFin,
|
||||
serials: m.dailySerials, // ~30 valeurs → SelectValues en une fois
|
||||
}));
|
||||
|
||||
const result = (await page.evaluate(
|
||||
({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN }: {
|
||||
app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string;
|
||||
codes: string[]; token: string;
|
||||
monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[] }>;
|
||||
noDateMode: boolean;
|
||||
qlikSettleMs: number;
|
||||
qlikCode15MaxAttempts: number;
|
||||
qlikArticleBatchFallbacks: number[];
|
||||
selectAllCodes: boolean;
|
||||
monthDim: boolean;
|
||||
moisDimId: string;
|
||||
mqteComp: string;
|
||||
yearN: number;
|
||||
}) =>
|
||||
new Promise<InPageResult>((resolve) => {
|
||||
const loc = (window as unknown as { location: Location }).location;
|
||||
const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`;
|
||||
const ws = new WebSocket(url);
|
||||
let id = 0;
|
||||
const pend = new Map<number, { res: (v: { [k: string]: unknown }) => void; rej: (e: unknown) => void }>();
|
||||
const rpc = (method: string, params: unknown, handle = -1) => new Promise<{ [k: string]: unknown }>((res, rej) => {
|
||||
const i = ++id; pend.set(i, { res, rej });
|
||||
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
|
||||
});
|
||||
const fail = (error: string) => resolve({ ok: false, error });
|
||||
const ready = new Promise<void>((res, rej) => {
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
const m = JSON.parse(ev.data as string);
|
||||
if (m.method === "OnConnected") return res();
|
||||
if (m.id && pend.has(m.id)) {
|
||||
const x = pend.get(m.id)!;
|
||||
pend.delete(m.id);
|
||||
if (m.error) x.rej(new Error(JSON.stringify(m.error)));
|
||||
else x.res(m.result);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => rej(new Error("ws error (403 ?)"));
|
||||
setTimeout(() => rej(new Error("ws timeout")), 30000);
|
||||
});
|
||||
// Qlik Engine code 15 = "Request aborted" : déclenché quand une requête
|
||||
// (GetLayout / GetHyperCubeData / CreateSessionObject) entre en collision
|
||||
// avec une évaluation de sélection encore en cours côté Engine. On retente
|
||||
// quelques fois avec un court backoff et un log explicite de l'étape.
|
||||
const isEngineCode15 = (e: unknown): boolean => {
|
||||
const msg = String((e as Error)?.message ?? e);
|
||||
return /"code"\s*:\s*15\b/.test(msg);
|
||||
};
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
||||
const timed = async <T>(etape: string, method: string, fn: () => Promise<T>, timings?: Record<string, number>): Promise<{ value: T; ms: number }> => {
|
||||
const start = nowMs();
|
||||
const value = await fn();
|
||||
const ms = Math.round(nowMs() - start);
|
||||
const label = `${etape}:${method}`;
|
||||
if (timings) timings[label] = (timings[label] ?? 0) + ms;
|
||||
console.log(`[qlik-pw][timing] ${label} ms=${ms}`);
|
||||
return { value, ms };
|
||||
};
|
||||
const timingSummary = (timings: Record<string, number>) => JSON.stringify(timings);
|
||||
const rpcWithRetry = async (
|
||||
etape: string,
|
||||
method: string,
|
||||
params: unknown,
|
||||
handle: number,
|
||||
onCode15Retry?: () => void,
|
||||
): Promise<{ [k: string]: unknown }> => {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= qlikCode15MaxAttempts; attempt++) {
|
||||
try {
|
||||
return await rpc(method, params, handle);
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (!isEngineCode15(e) || attempt === qlikCode15MaxAttempts) throw e;
|
||||
onCode15Retry?.();
|
||||
const backoff = qlikSettleMs * attempt;
|
||||
console.log(`[qlik-pw] retry code15 étape=${etape} méthode=${method} tentative=${attempt}/${qlikCode15MaxAttempts} backoff=${backoff}ms`);
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await ready;
|
||||
console.log("ws connecté (OnConnected)");
|
||||
const open = await rpc("OpenDoc", { qDocName: app });
|
||||
const doc = (open.qReturn as { qHandle: number }).qHandle;
|
||||
console.log("OpenDoc OK handle=" + doc);
|
||||
|
||||
// Handles des champs Date / Article Code.
|
||||
let dfh = -1;
|
||||
let fh = -1;
|
||||
let yfh = -1;
|
||||
const df = await rpc("GetField", { qFieldName: "Date" }, doc);
|
||||
dfh = (df.qReturn as { qHandle: number }).qHandle;
|
||||
try {
|
||||
const yf = await rpc("GetField", { qFieldName: "Année" }, doc);
|
||||
yfh = (yf.qReturn as { qHandle: number }).qHandle;
|
||||
} catch { yfh = -1; }
|
||||
if (codes.length) {
|
||||
const gf = await rpc("GetField", { qFieldName: "Article Code" }, doc);
|
||||
fh = (gf.qReturn as { qHandle: number }).qHandle;
|
||||
}
|
||||
|
||||
// Résoudre les master measures par TITRE (le qLibraryId fiable = qInfo.qId, ≠ GUID QRS).
|
||||
const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
const ml = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title" } } } }, doc);
|
||||
const mll = await rpc("GetLayout", {}, (ml.qReturn as { qHandle: number }).qHandle);
|
||||
const items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qMeasureList?.qItems) ?? []);
|
||||
const byTitle = new Map<string, string>();
|
||||
for (const it of items) { const t = it.qMeta?.title ?? it.qData?.title ?? ""; if (t) byTitle.set(norm(t), it.qInfo.qId); }
|
||||
const pickMeasure = (title: string, fallback: string) => byTitle.get(norm(title)) ?? fallback;
|
||||
const rCamag = pickMeasure("CA par Magasin N", mcamag);
|
||||
const rMarge = pickMeasure("Marge % N", mmarge);
|
||||
// Résolution par TITRE aussi pour CA / Quantité / Nb magasins : évite tout
|
||||
// décalage si un qId d'env ne pointe pas sur la mesure attendue.
|
||||
const rCa = pickMeasure("CA N", mca);
|
||||
const rQte = pickMeasure("Quantité N", mqte);
|
||||
const rNbMag = pickMeasure("Magasin Ventes Nb N", mnb);
|
||||
console.log("[qlik-pw] mesures utilisées: " + JSON.stringify({
|
||||
"CA N": rCa, "Quantité N": rQte, "Magasin Ventes Nb N": rNbMag,
|
||||
"CA par Magasin N": rCamag, "Marge % N": rMarge,
|
||||
env: { mca, mqte, mnb },
|
||||
}));
|
||||
console.log("measures résolues par titre: " + JSON.stringify({ rCamag, rMarge }) + " (sur " + items.length + " mesures)");
|
||||
// DUMP diagnostic : inventaire des mesures + dimensions (titre + id) pour
|
||||
// identifier une mesure "Quantité" NON annuelle et/ou une dimension "Mois/Période"
|
||||
// permettant un vrai découpage mensuel du réseau.
|
||||
console.log("[qlik-pw][dump] MESURES (" + items.length + "): " + JSON.stringify(items.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId }))));
|
||||
try {
|
||||
const dl = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "DimensionList" }, qDimensionListDef: { qType: "dimension", qData: { title: "/qMetaDef/title" } } } }, doc);
|
||||
const dll = await rpc("GetLayout", {}, (dl.qReturn as { qHandle: number }).qHandle);
|
||||
const ditems = (((dll.qLayout as { qDimensionList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qDimensionList?.qItems) ?? []);
|
||||
console.log("[qlik-pw][dump] DIMENSIONS (" + ditems.length + "): " + JSON.stringify(ditems.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId }))));
|
||||
} catch (e) { console.log("[qlik-pw][dump] DimensionList error: " + String((e as Error)?.message || e)); }
|
||||
// L'hypercube session object est désormais créé JETABLE dans fetchCubeForSelection
|
||||
// (un objet par lot) puis DestroySessionObject — corrige "Request aborted" (code 15)
|
||||
// causé par la réutilisation du même objet sur des centaines de recalculs.
|
||||
|
||||
const out: Array<Array<string | number>> = [];
|
||||
// Quantité réseau par code central et par mois (label "YYYY-MM").
|
||||
// Accumulée en parallèle du total, sans toucher l'agrégation existante.
|
||||
const monthlyByCode: Record<string, Record<string, { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number }>> = {};
|
||||
const accMonthly = (batchRows: Array<Array<string | number>>, monthLabel: string) => {
|
||||
for (const r of batchRows) {
|
||||
const c = String(r[0] ?? "").trim();
|
||||
if (!c || c === "-") continue;
|
||||
const q = Number(r[2]) || 0;
|
||||
(monthlyByCode[c] ??= {});
|
||||
const cur = monthlyByCode[c][monthLabel];
|
||||
monthlyByCode[c][monthLabel] = {
|
||||
qte: (cur?.qte ?? 0) + q,
|
||||
ca: (cur?.ca ?? 0) + (Number(r[1]) || 0),
|
||||
nbMag: Math.max(cur?.nbMag ?? 0, Number(r[3]) || 0),
|
||||
caMag: Number(r[4]) || 0,
|
||||
margePct: Number(r[5]) || 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
// Compteurs agrégés pour le résumé timing final (loggué côté Node après la sync).
|
||||
let aggMonths = 0;
|
||||
let aggBatches = 0;
|
||||
let aggCode15Retries = 0;
|
||||
let aggFallbackCount = 0;
|
||||
|
||||
// Sélectionne Date + Article Code puis pagine le cube. Renvoie le nb de lignes
|
||||
// brutes lues (avant dédoublonnage côté Node).
|
||||
//
|
||||
// IMPORTANT : crée un hypercube session object JETABLE par lot (Date + Article Code
|
||||
// déjà sélectionnés) puis le détruit. Réutiliser le même objet sur des centaines de
|
||||
// lots force Qlik à recalculer en boucle et finit par "Request aborted" (code 15).
|
||||
const PAGE = 1400;
|
||||
// Petite latence après chaque mutation de sélection : laisser l'Engine
|
||||
// finaliser l'évaluation de la sélection avant la prochaine requête layout/cube.
|
||||
const createCubeForBatch = async (timings?: Record<string, number>, onCode15Retry?: () => void): Promise<{ handle: number; id: string }> => {
|
||||
const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net" }, qHyperCubeDef: {
|
||||
qDimensions: [{ qLibraryId: dim }],
|
||||
qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }],
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: PAGE }],
|
||||
} } }, doc, onCode15Retry), timings);
|
||||
const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
|
||||
return { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") };
|
||||
};
|
||||
const destroyCube = async (cube: { handle: number; id: string }, timings?: Record<string, number>) => {
|
||||
if (!cube.id) return;
|
||||
try {
|
||||
await timed("destroyCube", "DestroySessionObject", () => rpcWithRetry("destroyCube", "DestroySessionObject", { qId: cube.id }, doc), timings);
|
||||
} catch (e) {
|
||||
// Non fatal : l'objet est de toute façon scopé à la session websocket.
|
||||
// Un code 15 ici signifie que l'Engine l'a déjà libéré après un abort.
|
||||
console.log("[qlik-pw] DestroySessionObject ignoré: " + String((e as Error)?.message || e));
|
||||
}
|
||||
};
|
||||
const appendMatrix = (matrix: Array<Array<{ qText?: string; qNum?: number }>>, sink: Array<Array<string | number>>): number => {
|
||||
for (const r of matrix) sink.push([
|
||||
String(r[0]?.qText ?? ""),
|
||||
Number(r[1]?.qNum) || 0,
|
||||
Number(r[2]?.qNum) || 0,
|
||||
Number(r[3]?.qNum) || 0,
|
||||
Number(r[4]?.qNum) || 0,
|
||||
Number(r[5]?.qNum) || 0,
|
||||
]);
|
||||
return matrix.length;
|
||||
};
|
||||
const fetchCubeForSelection = async (sink: Array<Array<string | number>>, timings?: Record<string, number>, onCode15Retry?: () => void): Promise<number> => {
|
||||
const cube = await createCubeForBatch(timings, onCode15Retry);
|
||||
const oh = cube.handle;
|
||||
try {
|
||||
const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings);
|
||||
const hyperCube = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }> } }).qHyperCube;
|
||||
const size = hyperCube.qSize.qcy;
|
||||
const firstPage = hyperCube.qDataPages?.find((page) => (page.qArea?.qTop ?? 0) === 0);
|
||||
let got = 0;
|
||||
let top = 0;
|
||||
if (firstPage?.qMatrix?.length) {
|
||||
got += appendMatrix(firstPage.qMatrix, sink);
|
||||
top = PAGE;
|
||||
}
|
||||
for (; top < size; top += PAGE) {
|
||||
const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: PAGE }] }, oh, onCode15Retry), timings);
|
||||
const matrix = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
|
||||
if (!matrix.length) break;
|
||||
got += appendMatrix(matrix, sink);
|
||||
if (matrix.length < PAGE) break;
|
||||
}
|
||||
return got;
|
||||
} finally {
|
||||
await destroyCube(cube, timings);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Découpage mensuel réel via dimension "Mois" ────────────────────
|
||||
// Cube [Article Code, Mois] × 5 mesures. Chaque ligne = (code, mois, ca,
|
||||
// qte, nbMag, caMag, marge). Le "Mois" est au format "YYYYMM" → "YYYY-MM".
|
||||
const normMois = (m: string): string => (/^\d{6}$/.test(m) ? m.slice(0, 4) + "-" + m.slice(4) : m);
|
||||
const PAGEM = 1200; // 8 colonnes × 1200 = 9600 cellules < 10000 (limite Qlik)
|
||||
const fetchMonthCube = async (
|
||||
onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, caMag: number, marge: number, qteComp: number) => void,
|
||||
timings?: Record<string, number>,
|
||||
onCode15Retry?: () => void,
|
||||
): Promise<number> => {
|
||||
const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois" }, qHyperCubeDef: {
|
||||
qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }],
|
||||
qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }, { qLibraryId: mqteComp }],
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 8, qHeight: PAGEM }],
|
||||
} } }, doc, onCode15Retry), timings);
|
||||
const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
|
||||
const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") };
|
||||
const oh = cube.handle;
|
||||
const process = (matrix: Array<Array<{ qText?: string; qNum?: number }>>): number => {
|
||||
for (const r of matrix) {
|
||||
onRow(
|
||||
String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""),
|
||||
Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0,
|
||||
Number(r[5]?.qNum) || 0, Number(r[6]?.qNum) || 0, Number(r[7]?.qNum) || 0,
|
||||
);
|
||||
}
|
||||
return matrix.length;
|
||||
};
|
||||
try {
|
||||
const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings);
|
||||
const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }> } }).qHyperCube;
|
||||
const size = hc.qSize.qcy;
|
||||
let got = 0, top = 0;
|
||||
const firstPage = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0);
|
||||
if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEM; }
|
||||
for (; top < size; top += PAGEM) {
|
||||
const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 8, qHeight: PAGEM }] }, oh, onCode15Retry), timings);
|
||||
const matrix = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
|
||||
if (!matrix.length) break;
|
||||
got += process(matrix);
|
||||
if (matrix.length < PAGEM) break;
|
||||
}
|
||||
return got;
|
||||
} finally {
|
||||
await destroyCube(cube, timings);
|
||||
}
|
||||
};
|
||||
|
||||
// Sélectionne toute la fenêtre de dates une fois, puis parcourt les codes par
|
||||
// lots ; un seul cube [Article Code, Mois] par lot donne TOUS les mois d'un coup.
|
||||
// selectYear = null → passe principale (année N + N-1 dérivée via COMP).
|
||||
// selectYear = 2025 → passe complémentaire : on sélectionne l'année pour
|
||||
// récupérer directement ses mois (seul moyen d'obtenir août→déc N-1, que
|
||||
// COMP ne peut pas fournir puisque l'année N ne va pas jusque-là).
|
||||
let loggedSampleRow = false;
|
||||
const monthDimPath = async (selectYear: number | null): Promise<void> => {
|
||||
const isMainPass = selectYear === null;
|
||||
if (yfh !== -1) {
|
||||
await rpc("Clear", {}, yfh);
|
||||
await sleep(qlikSettleMs);
|
||||
if (selectYear !== null) {
|
||||
const ysel = await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh);
|
||||
console.log("[qlik-pw] (mois) sélection Année=" + selectYear + " → " + JSON.stringify(ysel.qReturn));
|
||||
await sleep(qlikSettleMs);
|
||||
}
|
||||
}
|
||||
const allSerials = monthlyPayload.flatMap((m) => m.serials);
|
||||
if (dfh !== -1 && allSerials.length) {
|
||||
await rpc("Clear", {}, dfh);
|
||||
await sleep(qlikSettleMs);
|
||||
await rpc("SelectValues", { qFieldValues: allSerials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, dfh);
|
||||
await sleep(qlikSettleMs);
|
||||
}
|
||||
aggMonths = monthlyPayload.length;
|
||||
// Lots plus petits = cubes plus légers (moins de pression mémoire côté
|
||||
// moteur Qlik, qui a tendance à saturer "Out of memory" sur les gros cubes).
|
||||
const fallbackSizes = [300, 150, 75];
|
||||
const totalCodes = codes.length;
|
||||
let codeIndex = 0, batchNumber = 0, fallbackIndex = 0;
|
||||
while (codes.length ? codeIndex < totalCodes : batchNumber === 0) {
|
||||
const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + fallbackSizes[fallbackIndex], totalCodes)) : [];
|
||||
const timings: Record<string, number> = {};
|
||||
let code15Retries = 0;
|
||||
batchNumber++;
|
||||
const bp = "[qlik-pw] (mois) lot " + batchNumber + " [" + codeIndex + "," + (codeIndex + batch.length) + ") size=" + batch.length;
|
||||
try {
|
||||
if (batch.length && fh !== -1) {
|
||||
await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings);
|
||||
await sleep(qlikSettleMs);
|
||||
await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: batch.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings);
|
||||
await sleep(qlikSettleMs);
|
||||
}
|
||||
const got = await timed("cube", "batch", () => fetchMonthCube((code, mois, ca, qte, nbMag, caMag, marge, qteComp) => {
|
||||
if (!code || code === "-") return;
|
||||
// Les totaux réseau ne viennent QUE de la passe principale
|
||||
// (sinon on doublerait CA/Qté avec la passe N-1).
|
||||
if (isMainPass) out.push([code, ca, qte, nbMag, caMag, marge]);
|
||||
const mm = normMois(mois);
|
||||
const y = parseInt(mm.slice(0, 4), 10);
|
||||
if (!loggedSampleRow) {
|
||||
loggedSampleRow = true;
|
||||
console.log("[qlik-pw][mesures] échantillon ligne mensuelle: " + JSON.stringify({
|
||||
code, mois: mm, "CA N": ca, "Quantité N (utilisée pour la courbe)": qte,
|
||||
"Magasin Ventes Nb N": nbMag, "CA par Magasin N": caMag, "Marge % N": marge, "Quantité COMP": qteComp,
|
||||
}));
|
||||
}
|
||||
(monthlyByCode[code] ??= {});
|
||||
// Les 5 mesures du mois courant de la ligne. Le cube les renvoie
|
||||
// déjà toutes : les conserver ne coûte aucune requête Qlik en plus.
|
||||
const full = { qte, ca, nbMag, caMag, margePct: marge };
|
||||
if (isMainPass && y === yearN) {
|
||||
// Mois de l'année N + son comparable N-1 (COMP = même mois, N-1).
|
||||
monthlyByCode[code][mm] = full;
|
||||
const mmPrev = String(y - 1) + mm.slice(4); // "2026-03" → "2025-03"
|
||||
// COMP ne porte QUE la quantité : les autres mesures restent
|
||||
// absentes tant que la passe N-1 ne les a pas complétées.
|
||||
if (monthlyByCode[code][mmPrev] === undefined) monthlyByCode[code][mmPrev] = { qte: qteComp };
|
||||
} else {
|
||||
// Passe année sélectionnée : valeurs directes, prioritaires sur COMP.
|
||||
monthlyByCode[code][mm] = full;
|
||||
}
|
||||
}, timings, () => { code15Retries++; }), timings);
|
||||
aggBatches++;
|
||||
aggCode15Retries += code15Retries;
|
||||
console.log("[qlik-pw][timing] " + bp + " — rows=" + got + " cumul=" + out.length + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
|
||||
if (!codes.length) break;
|
||||
codeIndex += batch.length;
|
||||
fallbackIndex = 0;
|
||||
} catch (batchErr) {
|
||||
const nextSize = fallbackSizes[fallbackIndex + 1];
|
||||
if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) {
|
||||
console.log("[qlik-pw] " + bp + " — fallback code15 " + batch.length + "→" + nextSize);
|
||||
fallbackIndex += 1;
|
||||
aggFallbackCount++;
|
||||
continue;
|
||||
}
|
||||
throw new Error(bp + " — " + String((batchErr as Error)?.message || batchErr));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Chemin rapide (flag QLIK_SELECT_ALL_CODES) : sélectionne TOUS les
|
||||
// Article Code une seule fois, puis n'itère que la sélection Date par
|
||||
// mois avec UN cube paginé (au lieu de re-sélectionner les codes par lots
|
||||
// de 150 à chaque mois — la SelectValues répétée coûte ~22 s × 120).
|
||||
// Renvoie true si le mois complet a réussi ; false si repli code 15 (les
|
||||
// résultats partiels et sélections sont réinitialisés avant le chemin par lots).
|
||||
const trySelectAllCodesPath = async (): Promise<boolean> => {
|
||||
const savedLen = out.length;
|
||||
try {
|
||||
if (codes.length && fh !== -1) {
|
||||
await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), {});
|
||||
await sleep(qlikSettleMs);
|
||||
const { value: sel } = await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", {
|
||||
qFieldValues: codes.map((c) => ({ qText: c })),
|
||||
qToggleMode: false,
|
||||
qSoftLock: true,
|
||||
}, fh), {});
|
||||
console.log("[qlik-pw] select-all — SelectValues Article Code (" + codes.length + " codes) → " + JSON.stringify(sel.qReturn));
|
||||
await sleep(qlikSettleMs);
|
||||
}
|
||||
const total = monthlyPayload.length;
|
||||
console.log("[qlik-pw] chemin rapide select-all — " + total + " mois, 1 sélection Article Code globale");
|
||||
for (let mi = 0; mi < total; mi++) {
|
||||
const m = monthlyPayload[mi];
|
||||
const prefix = "[qlik-pw] (rapide) mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)";
|
||||
const monthStart = nowMs();
|
||||
const monthTimings: Record<string, number> = {};
|
||||
aggMonths = mi + 1;
|
||||
let code15Retries = 0;
|
||||
// Sélection Date du mois uniquement (la sélection Article Code reste active).
|
||||
const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", {
|
||||
qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })),
|
||||
qToggleMode: false,
|
||||
qSoftLock: true,
|
||||
}, dfh), monthTimings);
|
||||
console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn));
|
||||
await sleep(qlikSettleMs);
|
||||
// Un seul cube paginé pour tous les articles du mois.
|
||||
const batchRows: Array<Array<string | number>> = [];
|
||||
const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, monthTimings, () => { code15Retries++; }), monthTimings);
|
||||
out.push(...batchRows);
|
||||
accMonthly(batchRows, m.label);
|
||||
aggBatches++;
|
||||
aggCode15Retries += code15Retries;
|
||||
console.log("[qlik-pw][timing] " + prefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(monthTimings));
|
||||
// Clear Date uniquement — on garde la sélection Article Code pour le mois suivant.
|
||||
if (mi < total - 1) {
|
||||
try { await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings); }
|
||||
catch (e) { console.log(prefix + " — Clear Date ignoré: " + String((e as Error)?.message || e)); }
|
||||
}
|
||||
console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " timings=" + timingSummary(monthTimings));
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (isEngineCode15(e)) {
|
||||
console.log("[qlik-pw] chemin rapide échoué (code 15) → repli sur le chemin par lots");
|
||||
out.length = savedLen;
|
||||
for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k];
|
||||
aggMonths = 0; aggBatches = 0; aggCode15Retries = 0;
|
||||
try { await rpc("Clear", {}, dfh); if (fh !== -1) await rpc("Clear", {}, fh); } catch { /* noop */ }
|
||||
await sleep(qlikSettleMs);
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
if (!noDateMode && monthDim) {
|
||||
// Découpage mensuel réel (dimension Mois) — remplace l'itération par mois.
|
||||
console.log("[qlik-pw] extraction via dimension Mois (" + monthlyPayload.length + " mois attendus, moisDim=" + moisDimId + ")");
|
||||
await monthDimPath(null);
|
||||
// Passe complémentaire : mois de l'année N-1 absents de l'année N
|
||||
// (ex. août→déc), impossibles à obtenir via COMP. On sélectionne
|
||||
// l'année N-1 pour que les mesures "N" portent sur cette année.
|
||||
if (yfh !== -1) {
|
||||
const before = Object.values(monthlyByCode).reduce((s, m) => s + Object.keys(m).length, 0);
|
||||
try {
|
||||
await monthDimPath(yearN - 1);
|
||||
const after = Object.values(monthlyByCode).reduce((s, m) => s + Object.keys(m).length, 0);
|
||||
console.log("[qlik-pw] (mois) passe N-1 terminée : " + before + " → " + after + " points mensuels");
|
||||
} catch (e2) {
|
||||
console.log("[qlik-pw] (mois) passe N-1 ignorée: " + String((e2 as Error)?.message || e2));
|
||||
}
|
||||
try { await rpc("Clear", {}, yfh); } catch { /* noop */ }
|
||||
} else {
|
||||
console.log("[qlik-pw] (mois) champ Année introuvable → pas de passe N-1");
|
||||
}
|
||||
} else if (noDateMode) {
|
||||
// Aucun filtre Date : extraction en un seul appel (tous les codes d'un coup).
|
||||
// On sélectionne uniquement Article Code ; Date reste libre.
|
||||
const timings: Record<string, number> = {};
|
||||
let code15Retries = 0;
|
||||
console.log("mode sans dateFilter — sélection de " + codes.length + " codes en une fois");
|
||||
if (codes.length && fh !== -1) {
|
||||
const { value: sel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings);
|
||||
console.log("SelectValues Article Code → " + JSON.stringify(sel.qReturn));
|
||||
}
|
||||
const batchRows: Array<Array<string | number>> = [];
|
||||
const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings);
|
||||
out.push(...batchRows);
|
||||
aggMonths = 1;
|
||||
aggBatches = 1;
|
||||
aggCode15Retries = code15Retries;
|
||||
console.log("[qlik-pw][timing] noDate rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
|
||||
} else if (selectAllCodes && (await trySelectAllCodesPath())) {
|
||||
// Chemin rapide terminé avec succès (voir trySelectAllCodesPath).
|
||||
// Si false (repli code 15), on tombe dans le chemin par lots ci-dessous.
|
||||
} else {
|
||||
// Itération par mois × lots de codes : pour chaque mois, SelectValues Date
|
||||
// (~30 serials), puis SelectValues Article Code par petits lots. Le cube
|
||||
// reste borné à ~30 jours × batch contrôlé, sans lire tout le catalogue Qlik.
|
||||
const total = monthlyPayload.length;
|
||||
const fallbackSizes = qlikArticleBatchFallbacks.length ? qlikArticleBatchFallbacks : [150, 75, 50];
|
||||
const fallbackLabel = fallbackSizes.join(">");
|
||||
const estimatedBatches = codes.length ? Math.ceil(codes.length / fallbackSizes[0]) : 1;
|
||||
console.log("itération par mois — " + total + " mois × ~" + estimatedBatches + " lot(s), batchFallbacks=" + fallbackLabel);
|
||||
for (let mi = 0; mi < total; mi++) {
|
||||
const m = monthlyPayload[mi];
|
||||
const prefix = "[qlik-pw] mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)";
|
||||
const monthStart = nowMs();
|
||||
const monthTimings: Record<string, number> = {};
|
||||
aggMonths = mi + 1;
|
||||
try {
|
||||
// 1) Sélection Date pour le mois (un seul SelectValues, ~30 valeurs).
|
||||
const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", {
|
||||
qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })),
|
||||
qToggleMode: false,
|
||||
qSoftLock: true,
|
||||
}, dfh), monthTimings);
|
||||
console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn));
|
||||
// Laisser l'Engine finaliser l'évaluation de la sélection Date
|
||||
// avant la première SelectValues Article Code du 1er lot.
|
||||
await sleep(qlikSettleMs);
|
||||
|
||||
// 2) Sélection Article Code par lots contrôlés.
|
||||
let codeIndex = 0;
|
||||
let batchNumber = 0;
|
||||
let fallbackIndex = 0;
|
||||
const totalCodes = codes.length || 0;
|
||||
while (codes.length ? codeIndex < totalCodes : batchNumber === 0) {
|
||||
const batchSize = codes.length ? fallbackSizes[fallbackIndex] : 0;
|
||||
const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + batchSize, totalCodes)) : [];
|
||||
const batchStart = codeIndex;
|
||||
const batchEnd = codeIndex + batch.length;
|
||||
const timings: Record<string, number> = {};
|
||||
let code15Retries = 0;
|
||||
batchNumber++;
|
||||
const batchPrefix = prefix + " — lot " + batchNumber + " [" + batchStart + "," + batchEnd + ") size=" + batch.length;
|
||||
try {
|
||||
if (fh !== -1) {
|
||||
await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings);
|
||||
// Laisser l'Engine appliquer le Clear avant la nouvelle sélection.
|
||||
await sleep(qlikSettleMs);
|
||||
const { value: csel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", {
|
||||
qFieldValues: batch.map((c) => ({ qText: c })),
|
||||
qToggleMode: false,
|
||||
qSoftLock: true,
|
||||
}, fh), timings);
|
||||
console.log(batchPrefix + " — SelectValues Article Code → " + JSON.stringify(csel.qReturn));
|
||||
// Laisser l'Engine finaliser l'évaluation de la sélection
|
||||
// avant de créer / interroger le cube (évite code 15 sur le 1er lot).
|
||||
await sleep(qlikSettleMs);
|
||||
}
|
||||
const batchRows: Array<Array<string | number>> = [];
|
||||
const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings);
|
||||
out.push(...batchRows);
|
||||
accMonthly(batchRows, m.label);
|
||||
aggBatches++;
|
||||
aggCode15Retries += code15Retries;
|
||||
console.log("[qlik-pw][timing] " + batchPrefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
|
||||
if (!codes.length) break;
|
||||
codeIndex = batchEnd;
|
||||
fallbackIndex = 0;
|
||||
} catch (batchErr) {
|
||||
const msg = String((batchErr as Error)?.message || batchErr);
|
||||
const nextFallbackIndex = fallbackIndex + 1;
|
||||
const nextSize = fallbackSizes[nextFallbackIndex];
|
||||
if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) {
|
||||
console.log("[qlik-pw][timing] " + batchPrefix + " — fallback code15 " + batch.length + "→" + nextSize + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
|
||||
fallbackIndex = nextFallbackIndex;
|
||||
aggFallbackCount++;
|
||||
continue;
|
||||
}
|
||||
console.error(batchPrefix + " — ABORT — " + msg);
|
||||
throw new Error(batchPrefix + " — " + msg);
|
||||
}
|
||||
}
|
||||
} catch (monthErr) {
|
||||
const msg = String((monthErr as Error)?.message || monthErr);
|
||||
console.error(prefix + " — ABORT — " + msg);
|
||||
throw new Error(prefix + " — " + msg);
|
||||
}
|
||||
// 3) Clear Date + Article Code avant le mois suivant (soulage l'Engine).
|
||||
if (mi < total - 1) {
|
||||
try {
|
||||
await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings);
|
||||
if (fh !== -1) await timed("clearCodeMonth", "Clear", () => rpc("Clear", {}, fh), monthTimings);
|
||||
} catch (clearErr) {
|
||||
console.log(prefix + " — Clear ignoré: " + String((clearErr as Error)?.message || clearErr));
|
||||
}
|
||||
}
|
||||
console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " cumul=" + out.length + " timings=" + timingSummary(monthTimings));
|
||||
}
|
||||
}
|
||||
|
||||
// SONDE (à la FIN pour apparaître dans la queue du log) : sélectionne ~50
|
||||
// codes + la fenêtre 12 mois, puis crée un petit cube [Article Code, Mois]
|
||||
// × Quantité N. Montre le FORMAT des valeurs "Mois" et si la quantité VARIE
|
||||
// par mois, pour concevoir le vrai découpage mensuel.
|
||||
try {
|
||||
const moisDimId = "pfGAwTs";
|
||||
const probeCodes = (codes ?? []).slice(0, 50);
|
||||
if (fh !== -1 && probeCodes.length) {
|
||||
await rpc("Clear", {}, fh);
|
||||
await rpc("SelectValues", { qFieldValues: probeCodes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh);
|
||||
}
|
||||
const allSerials = monthlyPayload.flatMap((m) => m.serials);
|
||||
if (dfh !== -1 && allSerials.length) {
|
||||
await rpc("SelectValues", { qFieldValues: allSerials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, dfh);
|
||||
}
|
||||
await sleep(qlikSettleMs);
|
||||
const probe = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "cf-probe" }, qHyperCubeDef: {
|
||||
qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }],
|
||||
qMeasures: [{ qLibraryId: mqte }],
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 3, qHeight: 40 }],
|
||||
} } }, doc);
|
||||
const pRet = probe.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
|
||||
const pl = await rpc("GetLayout", {}, pRet.qHandle);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hc = (pl.qLayout as any)?.qHyperCube;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const matrix: any[] = hc?.qDataPages?.[0]?.qMatrix ?? [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sample = matrix.slice(0, 40).map((row: any[]) => [row[0]?.qText, row[1]?.qText, row[2]?.qNum]);
|
||||
console.log("[qlik-pw][probe] cube [Article Code, Mois] x Quantite N — size=" + (hc?.qSize?.qcy ?? "?") + " echantillon: " + JSON.stringify(sample));
|
||||
try { await rpc("DestroySessionObject", { qId: String(pRet.qGenericId ?? pRet.qId ?? "") }, doc); } catch { /* noop */ }
|
||||
} catch (e) { console.log("[qlik-pw][probe] error: " + String((e as Error)?.message || e)); }
|
||||
|
||||
ws.close();
|
||||
resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount });
|
||||
} catch (e) { try { ws.close(); } catch { /* noop */ } fail(String((e as Error)?.message || e)); }
|
||||
})();
|
||||
}),
|
||||
{
|
||||
app: cfg.appNetwork,
|
||||
dim: cfg.dimCodeArticleId,
|
||||
mca: cfg.measCaId,
|
||||
mqte: cfg.measQteId,
|
||||
mnb: cfg.measNbMagId,
|
||||
mcamag: cfg.measCaMagId,
|
||||
mmarge: cfg.measMargePctId,
|
||||
codes: codeCentraux ?? [],
|
||||
token: csrfToken,
|
||||
monthlyPayload,
|
||||
noDateMode: !dateFilter,
|
||||
qlikSettleMs,
|
||||
qlikCode15MaxAttempts,
|
||||
qlikArticleBatchFallbacks,
|
||||
selectAllCodes: qlikSelectAllCodes,
|
||||
monthDim: qlikMonthDim,
|
||||
moisDimId: qlikMoisDimId,
|
||||
mqteComp: qlikMeasQteCompId,
|
||||
yearN,
|
||||
},
|
||||
)) as InPageResult;
|
||||
|
||||
if (!result.ok) throw new Error(`[qlik-pw] ${result.error}`);
|
||||
|
||||
// Résumé timing final : synthèse compacte de la sync pour audit/debug.
|
||||
// Les logs détaillés par étape restent en place (timings batch/mois) ;
|
||||
// ici on consolide les compteurs agrégés et la durée totale.
|
||||
const totalMs = Date.now() - totalStart;
|
||||
console.log(
|
||||
`[qlik-pw][summary] rows=${result.size ?? 0} months=${result.months ?? 0} batches=${result.batches ?? 0} code15Retries=${result.code15Retries ?? 0} fallbackCount=${result.fallbackCount ?? 0} totalMs=${totalMs}`,
|
||||
);
|
||||
|
||||
const wanted = codeCentraux ? new Set(codeCentraux) : null;
|
||||
const out = new Map<string, NetworkMetric>();
|
||||
const margeWeight = new Map<string, number>();
|
||||
for (const r of result.rows ?? []) {
|
||||
const code = String(r[0]).trim();
|
||||
if (!code || code === "-") continue;
|
||||
if (wanted && !wanted.has(code)) continue;
|
||||
|
||||
const ca = Number(r[1]) || 0;
|
||||
const qte = Number(r[2]) || 0;
|
||||
const nbMag = Number(r[3]) || 0;
|
||||
const margePct = Number(r[5]) || 0;
|
||||
const prev = out.get(code);
|
||||
if (!prev) {
|
||||
out.set(code, {
|
||||
codeCentrale: code,
|
||||
caReseau: ca,
|
||||
qteReseau: qte,
|
||||
nbMagasinsReseau: nbMag,
|
||||
caParMagasinReseau: nbMag > 0 ? ca / nbMag : 0,
|
||||
margePctReseau: margePct,
|
||||
periode: dateFilter?.label,
|
||||
});
|
||||
margeWeight.set(code, ca);
|
||||
continue;
|
||||
}
|
||||
|
||||
prev.caReseau += ca;
|
||||
prev.qteReseau += qte;
|
||||
prev.nbMagasinsReseau = Math.max(prev.nbMagasinsReseau, nbMag);
|
||||
const weight = margeWeight.get(code) ?? 0;
|
||||
const nextWeight = weight + ca;
|
||||
prev.margePctReseau = nextWeight > 0 ? ((prev.margePctReseau * weight) + (margePct * ca)) / nextWeight : Math.max(prev.margePctReseau, margePct);
|
||||
prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0;
|
||||
margeWeight.set(code, nextWeight);
|
||||
}
|
||||
// Rattache le détail mensuel (par code central) au métrique agrégé.
|
||||
// On produit DEUX représentations :
|
||||
// - `qteByMonth` : { "YYYY-MM": qté } — format historique, dont dépend la
|
||||
// colonne « Tendance / Réseau » de la Grille. Inchangé.
|
||||
// - `metricsByMonth` : toutes les mesures du mois, pour la fiche produit.
|
||||
const monthly = result.monthly ?? {};
|
||||
for (const [code, metric] of out) {
|
||||
const mm = monthly[code];
|
||||
if (!mm || Object.keys(mm).length === 0) continue;
|
||||
const qteByMonth: Record<string, number> = {};
|
||||
for (const [mois, v] of Object.entries(mm)) {
|
||||
qteByMonth[mois] = Number(v?.qte) || 0;
|
||||
}
|
||||
metric.qteByMonth = qteByMonth;
|
||||
metric.metricsByMonth = mm;
|
||||
}
|
||||
// Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques.
|
||||
// Si presque tous varient → la mesure respecte le mois (feature OK).
|
||||
// Si tous identiques → la mesure Qlik ignore la sélection Date.
|
||||
let nVar = 0, nFlat = 0;
|
||||
let exVar: NetworkMetric | null = null, exFlat: NetworkMetric | null = null;
|
||||
for (const m of out.values()) {
|
||||
const bm = m.qteByMonth;
|
||||
if (!bm) continue;
|
||||
const vals = Object.values(bm);
|
||||
if (vals.length < 2) continue;
|
||||
const allEq = vals.every((v) => v === vals[0]);
|
||||
if (allEq) { nFlat++; if (!exFlat) exFlat = m; }
|
||||
else { nVar++; if (!exVar) exVar = m; }
|
||||
}
|
||||
console.log(`[qlik-pw] qteByMonth distribution: ${nVar} codes VARIENT / ${nFlat} identiques (total ${nVar + nFlat})`);
|
||||
if (exVar) console.log(`[qlik-pw] exemple VARIE ${exVar.codeCentrale}:`, JSON.stringify(exVar.qteByMonth));
|
||||
if (exFlat) console.log(`[qlik-pw] exemple IDENTIQUE ${exFlat.codeCentrale}:`, JSON.stringify(exFlat.qteByMonth));
|
||||
console.log(`[qlik-pw] ${out.size} produits réseau (cube ${result.size})`);
|
||||
// Échantillon brut pour vérifier que les 4 nouvelles mesures (cols 4-7) renvoient des valeurs
|
||||
const sample = (result.rows ?? []).slice(0, 3).map((r) => ({
|
||||
code: r[0], ca: r[1], qte: r[2], nbMag: r[3], caMag: r[4], margePct: r[5],
|
||||
}));
|
||||
console.log(`[qlik-pw] échantillon lignes:`, JSON.stringify(sample));
|
||||
return out;
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
@@ -113,8 +113,14 @@ export interface ProductRow {
|
||||
caReseau?: number;
|
||||
qteReseau?: number;
|
||||
nbMagasinsReseau?: number;
|
||||
/** CA moyen par magasin réseau (productivité normalisée présence) */
|
||||
caParMagasinReseau?: number;
|
||||
/** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */
|
||||
margePctReseau?: number;
|
||||
/** Taux de présence réseau = nbMagasinsReseau / 270 */
|
||||
tauxPresenceReseau?: number;
|
||||
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal). */
|
||||
qteReseauByMonth?: Record<string, number> | null;
|
||||
/** Fraîcheur des données réseau (ISO) */
|
||||
networkFetchedAt?: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user