Merge pull request #27 from R0m1k3/claude/vigilant-goldberg-0pqg5x

Réécriture du serveur en Rust + image Docker mise à jour au démarrage
This commit is contained in:
LogiFlow authored and GitHub committed 2026-10-12 23:28:51 +02:00
commit 02e258bf12
286 files changed
+32316 -26347

No files matched your search

+14 -38
View File
@@ -1,38 +1,14 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
# BMad and Antigravity
_bmad-output/
.gemini/
.antigravityignore
.git
.github
.claude
.agent
.agents
_bmad
_bmad-output
node_modules
dist
data
server/target
*.md
!README.md
.env*
+36
View File
@@ -0,0 +1,36 @@
name: CI
# Vérifie le serveur Rust et l'interface sur chaque pull request.
on:
pull_request:
push:
branches: [main]
jobs:
server:
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: server
- run: cargo build --locked
- run: cargo test --locked
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run build
+44
View File
@@ -0,0 +1,44 @@
name: Image Docker
# Construit l'image à chaque mise à jour de main et la publie sur GHCR :
# le serveur la récupère au prochain `docker compose up -d` (pull_policy: always).
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: docker-publish
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Nom de l'image (minuscules)
id: image
run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.image.outputs.name }}:latest
${{ steps.image.outputs.name }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
+6
View File
@@ -44,3 +44,9 @@ next-env.d.ts
# données locales (configuration avec mots de passe, comptes de secours)
/data/
# Rust
/server/target/
# Vite
/dist/
/.claude/worktrees/
-229
View File
@@ -1,229 +0,0 @@
// <stdin>
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
// src/features/grid/components/network-charts.tsx
import { useState } from "react";
// src/features/grid/lib/months.ts
function fmtMonthShort(lab) {
const [y, m] = lab.split("-");
return `${m ?? lab}/${(y ?? "").slice(2)}`;
}
// src/features/grid/components/network-charts.tsx
import { jsx, jsxs } from "react/jsx-runtime";
var SERIE_COULEURS = ["var(--viz-1)", "var(--viz-2)", "var(--viz-3)"];
function NetworkLineChart({
labels: labels2,
values: values2,
stores: stores2,
perStore: perStore2
}) {
const [survol, setSurvol] = useState(null);
const n = values2.length;
const memeTaille = (s) => Array.isArray(s) && s.length === n && n > 0;
const avecParMag = memeTaille(perStore2);
const avecMagasins = memeTaille(stores2);
const fmt = (v, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
const facettes = [];
if (avecParMag) {
facettes.push({
titre: "Qt\xE9 / magasin",
valeurs: perStore2,
couleur: SERIE_COULEURS[0],
hauteur: 92,
aire: true,
decimales: perStore2.some((v) => v > 0 && v < 10) ? 1 : 0
});
facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[1], hauteur: 58 });
} else {
facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[0], hauteur: 110, aire: true });
}
if (avecMagasins) {
facettes.push({ titre: "Magasins vendeurs", valeurs: stores2, couleur: SERIE_COULEURS[2], hauteur: 46 });
}
const W = 520;
const padL = 16, padR = 16, axeH = 18, enteteH = 15, ecart = 12;
const plotW = W - padL - padR;
const H = facettes.reduce((t, f) => t + enteteH + f.hauteur + ecart, 0) - ecart + axeH;
const x = (i) => n > 1 ? padL + i / (n - 1) * plotW : padL + plotW / 2;
const ancrage = (i) => i === 0 ? "start" : i === n - 1 ? "end" : "middle";
let curseur = 0;
const disposees = facettes.map((f) => {
const hautEntete = curseur;
const haut = curseur + enteteH;
curseur = haut + f.hauteur + ecart;
const min = Math.min(0, ...f.valeurs);
const max = Math.max(...f.valeurs, min + 1);
const etendue = max - min || 1;
const y = (v) => haut + f.hauteur - (v - min) / etendue * f.hauteur;
const iMax = f.valeurs.indexOf(Math.max(...f.valeurs));
return { ...f, hautEntete, haut, base: y(min), max, y, iMax };
});
const basPlots = curseur - ecart;
return /* @__PURE__ */ jsxs("div", { className: "mt-3 w-full overflow-x-auto", children: [
/* @__PURE__ */ jsxs(
"svg",
{
viewBox: `0 0 ${W} ${H}`,
className: "w-full",
style: { minWidth: 420 },
role: "img",
"aria-label": "Ventes r\xE9seau mensuelles : " + disposees.map((f) => f.titre).join(", "),
onMouseLeave: () => setSurvol(null),
children: [
disposees.map((f) => {
const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" ");
const aEtiqueter = [.../* @__PURE__ */ new Set([f.iMax, n - 1])];
return /* @__PURE__ */ jsxs("g", { children: [
/* @__PURE__ */ jsx("rect", { x: padL, y: f.hautEntete + 3, width: 12, height: 3, rx: 1.5, fill: f.couleur }),
/* @__PURE__ */ jsx("text", { x: padL + 18, y: f.hautEntete + 7, fontSize: 9.5, fontWeight: 600, fill: "var(--text-secondary)", dominantBaseline: "middle", children: f.titre }),
/* @__PURE__ */ jsxs("text", { x: padL + plotW, y: f.hautEntete + 7, textAnchor: "end", fontSize: 9, fill: "var(--text-muted)", dominantBaseline: "middle", children: [
"max ",
fmt(f.max, f.decimales ?? 0),
f.unite ?? ""
] }),
/* @__PURE__ */ jsx("line", { x1: padL, y1: f.base, x2: padL + plotW, y2: f.base, stroke: "var(--border)", strokeWidth: 1 }),
f.aire && n > 1 && /* @__PURE__ */ jsx(
"polygon",
{
points: `${padL},${f.base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${f.base.toFixed(1)}`,
fill: f.couleur,
opacity: 0.1
}
),
n > 1 && /* @__PURE__ */ jsx("polyline", { points: pts, fill: "none", stroke: f.couleur, strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round" }),
aEtiqueter.map((i) => /* @__PURE__ */ jsx(
"circle",
{
cx: x(i),
cy: f.y(f.valeurs[i]),
r: 4,
fill: f.couleur,
stroke: "var(--bg-surface)",
strokeWidth: 2
},
`pt-${f.titre}-${i}`
)),
aEtiqueter.map((i) => {
const yPoint = f.y(f.valeurs[i]);
const dessus = yPoint - 8 >= f.haut + 8;
return /* @__PURE__ */ jsx(
"text",
{
x: x(i),
y: dessus ? yPoint - 8 : yPoint + 13,
textAnchor: ancrage(i),
fontSize: 9,
fontWeight: 700,
fill: "var(--text-primary)",
children: fmt(f.valeurs[i], f.decimales ?? 0)
},
`lab-${f.titre}-${i}`
);
}),
survol != null && /* @__PURE__ */ jsx(
"circle",
{
cx: x(survol),
cy: f.y(f.valeurs[survol]),
r: 3.5,
fill: f.couleur,
stroke: "var(--bg-surface)",
strokeWidth: 2
}
)
] }, f.titre);
}),
survol != null && /* @__PURE__ */ jsx("line", { x1: x(survol), y1: 0, x2: x(survol), y2: basPlots, stroke: "var(--border-strong)", strokeWidth: 1 }),
labels2.map((lab, i) => /* @__PURE__ */ jsx(
"text",
{
x: x(i),
y: H - 5,
textAnchor: ancrage(i),
fontSize: 8.5,
fill: survol === i ? "var(--text-primary)" : "var(--text-muted)",
fontWeight: survol === i ? 700 : 400,
children: fmtMonthShort(lab)
},
`mois-${lab}`
)),
survol != null && (() => {
const largeur = 158;
const hauteur = 16 + disposees.length * 13 + 8;
const gauche = x(survol) + 10 + largeur > W - 4;
const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10;
return /* @__PURE__ */ jsxs("g", { pointerEvents: "none", children: [
/* @__PURE__ */ jsx(
"rect",
{
x: bx,
y: 4,
width: largeur,
height: hauteur,
rx: 8,
fill: "var(--bg-elevated)",
stroke: "var(--border)",
strokeWidth: 1
}
),
/* @__PURE__ */ jsx("text", { x: bx + 9, y: 17, fontSize: 9.5, fontWeight: 700, fill: "var(--text-primary)", children: fmtMonthShort(labels2[survol]) }),
disposees.map((f, k) => /* @__PURE__ */ jsxs("g", { children: [
/* @__PURE__ */ jsx("rect", { x: bx + 9, y: 26 + k * 13, width: 8, height: 3, rx: 1.5, fill: f.couleur }),
/* @__PURE__ */ jsx("text", { x: bx + 22, y: 30 + k * 13, fontSize: 9, fill: "var(--text-secondary)", children: f.titre }),
/* @__PURE__ */ jsx(
"text",
{
x: bx + largeur - 9,
y: 30 + k * 13,
textAnchor: "end",
fontSize: 9,
fontWeight: 700,
fill: "var(--text-primary)",
children: fmt(f.valeurs[survol], f.decimales ?? 0)
}
)
] }, `bulle-${f.titre}`))
] });
})(),
labels2.map((lab, i) => /* @__PURE__ */ jsx(
"rect",
{
x: x(i) - plotW / (2 * Math.max(1, n - 1)),
y: 0,
width: plotW / Math.max(1, n - 1),
height: H,
fill: "transparent",
onMouseEnter: () => setSurvol(i)
},
`survol-${lab}`
))
]
}
),
/* @__PURE__ */ jsxs("details", { className: "mt-2", children: [
/* @__PURE__ */ jsx("summary", { className: "text-[11px] cursor-pointer select-none", style: { color: "var(--text-muted)" }, children: "Voir les valeurs mois par mois" }),
/* @__PURE__ */ jsx("div", { className: "mt-1.5 overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-[11px] tabular-nums", style: { borderCollapse: "collapse" }, children: [
/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { style: { color: "var(--text-muted)" }, children: [
/* @__PURE__ */ jsx("th", { className: "text-left font-medium py-1 pr-2", children: "Mois" }),
disposees.map((f) => /* @__PURE__ */ jsx("th", { className: "text-right font-medium py-1 pl-2", children: f.titre }, `th-${f.titre}`))
] }) }),
/* @__PURE__ */ jsx("tbody", { children: labels2.map((lab, i) => /* @__PURE__ */ jsxs("tr", { style: { borderTop: "1px solid var(--border)" }, children: [
/* @__PURE__ */ jsx("td", { className: "py-1 pr-2", style: { color: "var(--text-secondary)" }, children: fmtMonthShort(lab) }),
disposees.map((f) => /* @__PURE__ */ jsx("td", { className: "text-right py-1 pl-2", style: { color: "var(--text-primary)" }, children: fmt(f.valeurs[i], f.decimales ?? 0) }, `td-${f.titre}-${lab}`))
] }, `tr-${lab}`)) })
] }) })
] })
] });
}
// <stdin>
var labels = ["2025-08", "2025-09", "2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07"];
var values = [9953, 13258, 6353, 5429, 3741, 6522, 8211, 4437, 4035, 4066, 2936, 4470];
var stores = [208, 228, 196, 177, 175, 193, 206, 190, 179, 179, 167, 170];
var perStore = values.map((v, i) => stores[i] > 0 ? v / stores[i] : 0);
globalThis.__HTML__ = renderToStaticMarkup(
React.createElement(NetworkLineChart, { labels, values, stores, perStore })
);
+44 -57
View File
@@ -1,70 +1,57 @@
FROM node:20-alpine AS base
# CollectFlow — image de production.
#
# 1. web : compile l'interface React (Vite) en fichiers statiques.
# 2. server : compile le serveur Rust (binaire unique, optimisé).
# 3. runner : Debian minimale + Chromium (extraction Qlik) + les deux livrables.
#
# L'image est construite et publiée par GitHub Actions à chaque push sur main
# (ghcr.io/r0m1k3/collectflow:latest) ; docker-compose la tire au démarrage.
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
# ---------------------------------------------------------------------------
FROM node:22-alpine AS web
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
# Next.js telemetry is disabled
ENV NEXT_TELEMETRY_DISABLED=1
# Limit workers and increase memory to prevent OOM
ENV NODE_OPTIONS="--max-old-space-size=4096"
ENV NEXT_PRIVATE_WORKER_THREADS=0
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
# ---------------------------------------------------------------------------
FROM rust:1-bookworm AS server
WORKDIR /build
# Dépendances d'abord : couche réutilisée tant que Cargo.lock ne change pas.
COPY server/Cargo.toml server/Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs \
&& cargo build --release --locked \
&& rm -rf src target/release/deps/collectflow* target/release/collectflow*
COPY server/ ./
RUN cargo build --release --locked && cp target/release/collectflow /collectflow
# ---------------------------------------------------------------------------
FROM debian:bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
# Next.js telemetry is disabled
ENV NEXT_TELEMETRY_DISABLED=1
# Chromium pour l'extraction Qlik (websocket Engine ouvert dans la page) + polices.
RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium ca-certificates fonts-liberation fonts-freefont-ttf tzdata \
&& rm -rf /var/lib/apt/lists/*
# 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
# Même UID/GID que l'ancienne image Node : le volume /app/data existant reste lisible.
RUN groupadd --system --gid 1001 collectflow \
&& useradd --system --uid 1001 --gid collectflow --home-dir /app collectflow \
&& mkdir -p /app/data \
&& chown -R collectflow:collectflow /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=server /collectflow /app/collectflow
COPY --from=web --chown=collectflow:collectflow /app/dist /app/web
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache and data storage
RUN mkdir .next data
RUN chown nextjs:nodejs .next data
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
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
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
USER nextjs
ENV PORT=5643 \
HOSTNAME=0.0.0.0 \
DATA_DIR=/app/data \
STATIC_DIR=/app/web \
PLAYWRIGHT_CHROMIUM_PATH=/usr/bin/chromium
USER collectflow
EXPOSE 5643
VOLUME ["/app/data"]
ENV PORT=5643
# set hostname to localhost
ENV HOSTNAME="0.0.0.0"
# Run the DB init script, then start the standalone server
CMD node scripts/db-init.js && node server.js
CMD ["/app/collectflow"]
+39 -35
View File
@@ -2,56 +2,60 @@
CollectFlow est une application de révision d'assortiment et d'analyse de performances des produits en point de vente, conçue pour consolider les données issues de plusieurs fournisseurs et magasins.
## Architecture
| Partie | Technologie | Dossier |
|---|---|---|
| Serveur (API, base, FF Nancy, Qlik, synchro nocturne) | **Rust** — Axum, sqlx, chromiumoxide | `server/` |
| Interface | React 19 + Tailwind 4, compilée en fichiers statiques par Vite | `src/` |
Un seul binaire Rust sert l'API JSON (`/api/...`, dont l'API publique `/api/v1`) et l'interface compilée. Plus de Node.js à l'exécution.
## Prérequis
- **Node.js** 18+ (pour le développement local)
- **Docker** et **Docker Compose** (pour le déploiement conteneurisé)
- **PostgreSQL** 16+
- **Docker** et **Docker Compose** (déploiement)
- **PostgreSQL** 16+ (base séparée, non fournie par le conteneur)
- Pour développer : **Rust** stable (`rustup`) et **Node.js** 22+
## Lancer avec Docker
Le projet est configuré pour tourner dans un conteneur Docker optimisé (mode standalone de Next.js).
**Note :** Vous devez disposer d'une base de données PostgreSQL séparée (le conteneur ne lance que l'application web).
L'image est construite par GitHub Actions à chaque mise à jour de `main` et publiée sur `ghcr.io/r0m1k3/collectflow:latest`. Le `docker-compose.yml` la récupère à chaque démarrage (`pull_policy: always`) : **une nouvelle version s'installe au prochain `docker compose up -d`**, sans rien compiler sur le serveur.
### 1. Démarrer l'application
À la racine du projet, lancez :
```bash
npm run docker:build
# ou directement
docker-compose up -d --build
```
L'application sera accessible sur [http://localhost:5643](http://localhost:5643).
# Une seule fois, si le paquet GHCR est privé (jeton GitHub avec le droit read:packages) :
echo <JETON> | docker login ghcr.io -u <utilisateur-github> --password-stdin
*L'application sera attachée au réseau Docker externe `nginx_default` afin d'être exposée derrière votre reverse proxy Nginx. Assurez-vous que ce réseau existe (`docker network create nginx_default`).*
### 2. Arrêter l'application
```bash
npm run docker:down
# ou directement
docker-compose down
# Démarrer / mettre à jour :
docker compose up -d
```
## Développement Local (Sans Docker)
L'application est accessible sur [http://localhost:5643](http://localhost:5643). Elle est attachée au réseau Docker externe `nginx_default` pour être exposée derrière le reverse proxy Nginx (`docker network create nginx_default` s'il n'existe pas).
Si vous préférez développer en local, vous devrez configurer votre propre base de données PostgreSQL.
Construire l'image localement plutôt que la télécharger : `docker compose build`.
### Données et réglages
Le volume `collectflow-data` (`/app/data`) conserve les réglages saisis dans **Paramètres** (`.db-config.json` : base, Qlik, API FF, synchro nocturne), les comptes de secours (`users.json`) et le secret de session (`.auth-secret`, si `AUTH_SECRET` n'est pas défini). Il est repris tel quel depuis l'ancienne version Node : comptes et mots de passe restent valables (seules les sessions ouvertes sont à refaire).
Variables d'environnement utiles : `DATABASE_URL`, `AUTH_SECRET`, `FF_API_BASE_URL`, `QLIK_HOST` / `QLIK_USER` / `QLIK_PWD` (et les réglages fins `QLIK_*`), `PG_STATEMENT_TIMEOUT_MS`, `FF_CACHE_TTL_S`, `RUST_LOG` (niveau de journalisation ; par défaut `info`, avec sqlx et Chromium limités aux avertissements).
## Développement local
1. Installer les dépendances :
```bash
# Serveur (port 5643) — tables créées et compte admin/admin initialisé au premier démarrage
cd server
DATABASE_URL=postgres://postgres:postgres@localhost/collectflow cargo run
# Interface (port 5173, /api relayé vers le serveur)
npm install
```
2. Configurer la connexion DB :
Allez sur la page des Paramètres (`/settings`) dans l'application pour configurer l'accès à votre PostgreSQL local.
3. Lancer le serveur de développement :
```bash
npm run dev
```
## Structure du Projet (BMAD)
Vérifications : `cargo test` (dans `server/`), `npm run lint`, `npm run build`.
Ce projet respecte l'architecture **BMAD** (Business, Model, Application/API, Data) :
- `src/features/*` : Logique métier (Business) isolée par feature (ex: `grid`, `snapshots`)
- `src/types/*` : Interfaces TypeScript (Model)
- `src/app/*` : Routeurs Next.js UI et endpoints d'API (Application/API)
- `src/db/*` : Schémas Drizzle ORM et connexions (Data)
## Structure
- `server/src/` — serveur Rust, un module par domaine : `auth` (sessions, clés d'API), `ff` (base miroir et API FF Nancy), `qlik` (données réseau), `grid` (révision d'assortiment, API `/api/v1`), `produits`, `admin` (comptes, paramètres, synchronisation nocturne).
- `server/sql/init.sql` — schéma joué (idempotent) à chaque démarrage.
- `src/` — interface React : `app/` (écrans, mêmes dossiers que les URL), `features/*` par domaine, `components/` partagés, `router.tsx` (table des routes), `lib/rpc.ts` (appels au serveur).
- `server/PORTING.md` — contrat entre l'interface et le serveur (actions, données de pages, routes).
-31
View File
@@ -1,31 +0,0 @@
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json");
let url = "";
if (fs.existsSync(CONFIG_PATH)) {
url = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")).url;
}
const pool = new Pool({ connectionString: url || process.env.DATABASE_URL });
async function check() {
try {
const resSuppliers = await pool.query(\`SELECT DISTINCT code_fournisseur FROM ventes_produits LIMIT 5\`);
console.log("Suppliers in DB:", resSuppliers.rows.map(r => r.code_fournisseur));
for (const row of resSuppliers.rows) {
const supplier = row.code_fournisseur;
const countRes = await pool.query(\`SELECT COUNT(*) FROM ventes_produits WHERE code_fournisseur = $1\`, [supplier]);
const distinctCodein = await pool.query(\`SELECT COUNT(DISTINCT codein) FROM ventes_produits WHERE code_fournisseur = $1\`, [supplier]);
console.log(\`Supplier \${supplier}: Total Rows = \${countRes.rows[0].count}, Distinct Products = \${distinctCodein.rows[0].count}\`);
}
} catch (e) {
console.error("Error:", e);
} finally {
await pool.end();
}
}
check();
-19
View File
@@ -1,19 +0,0 @@
import { db } from "./src/db";
import { sql } from "drizzle-orm";
async function check() {
try {
const res = await db.execute(sql`SELECT COUNT(*) FROM ventes_produits`);
console.log("Total rows in ventes_produits:", res.rows[0].count);
const resDistinct = await db.execute(sql`SELECT COUNT(DISTINCT codein) FROM ventes_produits`);
console.log("Total distinct codein in ventes_produits:", resDistinct.rows[0].count);
const resSupplier = await db.execute(sql`SELECT code_fournisseur, COUNT(*) as row_count, COUNT(DISTINCT codein) as prod_count FROM ventes_produits GROUP BY code_fournisseur`);
console.log("Counts per supplier:", resSupplier.rows);
} catch (e) {
console.error("Error:", e);
}
}
check();
+7 -6
View File
@@ -1,20 +1,21 @@
version: "3.8"
services:
web:
# Image publiée par GitHub Actions à chaque mise à jour de main.
# `pull_policy: always` : chaque `docker compose up -d` récupère la
# dernière version avant de (re)démarrer le conteneur.
image: ghcr.io/r0m1k3/collectflow:latest
pull_policy: always
# Construction locale possible : `docker compose build` (utilise le Dockerfile).
build:
context: .
dockerfile: Dockerfile
ports:
- "5643:5643"
environment:
- NODE_ENV=production
- AUTH_TRUST_HOST=true
- AUTH_SECRET=my-super-secret-key-that-should-be-changed-in-production
- DATABASE_URL=postgres://postgres:MOT_DE_PASSE_ICI@postgresbase:5432/collectflow
- FF_API_BASE_URL=https://api.ffnancy.fr
# Mais comme CollectFlow permet de le configurer depuis l'interface (settings), ce n'est pas strictement obligatoire au lancement.
# La base, Qlik et l'API FF se règlent aussi depuis l'interface (Paramètres).
volumes:
- collectflow-data:/app/data
restart: unless-stopped
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from "drizzle-kit";
import * as dotenv from "dotenv";
dotenv.config({ path: ".env.local" });
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./src/db/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
+30 -12
View File
@@ -1,18 +1,36 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
/**
* Lint de l'interface (SPA Vite). Seul `src/` est vérifié : `server/` est en
* Rust.
*/
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
globalIgnores(["dist/**", "server/**", ".claude/**", "_bmad/**", "_bmad-output/**", "docs/**", "*.js", "*.mjs"]),
{
files: ["src/**/*.{ts,tsx}", "vite.config.mts"],
extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended],
plugins: { "react-refresh": reactRefresh },
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
rules: {
// Exports utilitaires historiques des composants (rechargement à chaud
// seulement moins fin pour ces fichiers).
"react-refresh/only-export-components": [
"warn",
{
allowConstantExport: true,
allowExportNames: ["buttonVariants", "toast", "confirmer", "useUrlTab", "PROFONDEUR_MAX"],
},
],
},
},
]);
export default eslintConfig;
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CollectFlow</title>
<meta name="description" content="Révision d'assortiment et suivi des ventes des magasins" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<!-- Inter chargé côté navigateur (et non au build) : aucune dépendance réseau
à Google Fonts pendant la compilation (Docker). -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet" />
<script>
// Thème appliqué avant le premier rendu (sans flash), comme le script
// que next-themes injectait dans la page rendue par le serveur.
try {
var theme = localStorage.getItem("collectflow-theme") === "dark" ? "dark" : "light";
document.documentElement.classList.add(theme);
document.documentElement.style.colorScheme = theme;
} catch (e) {}
</script>
</head>
<body class="font-sans antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-15
View File
@@ -1,15 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["pg", "ws", "httpntlm", "playwright-core"],
experimental: {
serverActions: {
bodySizeLimit: "50mb",
},
},
typescript: {
ignoreBuildErrors: true,
},
};
export default nextConfig;
+998 -8852
View File
File diff suppressed because it is too large. Load diff
+16 -20
View File
@@ -3,51 +3,47 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "node scripts/db-init.js && next start",
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:build": "docker-compose up -d --build"
"typecheck": "tsc --noEmit",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:build": "docker compose build"
},
"dependencies": {
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"exceljs": "^4.4.0",
"httpntlm": "^1.8.13",
"jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"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",
"react-router": "^7.18.4",
"tailwind-merge": "^3.5.0",
"ws": "^8.21.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.9",
"@vitejs/plugin-react": "^6.1.2",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.13.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3"
"typescript": "5.9.3",
"typescript-eslint": "^8.71.2",
"vite": "^8.3.4"
}
}
File renamed without changes.
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

-15
View File
@@ -1,15 +0,0 @@
const fs = require('fs');
async function test() {
try {
const res = await fetch("https://api.ffnancy.fr/api/articles?search=334152");
const data = await res.json();
console.log("ARTICLES SEARCH:", JSON.stringify(data, null, 2));
const res2 = await fetch("https://api.ffnancy.fr/api/articles/334152/referentiel");
const data2 = await res2.json();
console.log("ARTICLES REFERENTIEL BY CODEIN:", JSON.stringify(data2, null, 2));
} catch(e) {
console.log(e.message);
}
}
test();
-7
View File
@@ -1,7 +0,0 @@
const fs = require('fs');
async function test() {
const res = await fetch("https://api.ffnancy.fr/api/performance/dashboard?date=2024-04-20");
const data = await res.json();
console.log(JSON.stringify(data.top10_ca?.[0] || {}, null, 2));
}
test();
-239
View File
@@ -1,239 +0,0 @@
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const cwd = path.resolve(__dirname, '..'); // get app root when inside /scripts
async function main() {
console.log("[DB Init] Starting database migration & initialization...");
let connectionString = "";
try {
const CONFIG_PATH = path.join(cwd, "data", ".db-config.json");
if (fs.existsSync(CONFIG_PATH)) {
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
if (config.url) {
connectionString = config.url;
console.log("[DB Init] Found connection string in data/.db-config.json");
}
}
} catch (e) {
console.error("[DB Init] Error reading .db-config.json", e);
}
// Use environment variable as default IF NOT already loaded from config
if (!connectionString && process.env.DATABASE_URL) {
connectionString = process.env.DATABASE_URL;
console.log("[DB Init] Using environment variable DATABASE_URL as fallback.");
}
if (!connectionString) {
console.warn("[DB Init] No DATABASE_URL found in env or config. Skipping push.");
process.exit(0);
}
try {
const tempPool = new Pool({ connectionString, max: 1 });
console.log("[DB Init] Connection established. Running migrations via raw SQL...");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "ai_supplier_context" (
"code_fournisseur" varchar(20) PRIMARY KEY NOT NULL,
"context" text NOT NULL,
"updated_at" timestamp DEFAULT now()
);
`);
console.log("[DB Init] Table ai_supplier_context is verified/created.");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "session_snapshots" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer,
"code_fournisseur" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"magasin" varchar(20) NOT NULL,
"changes" jsonb NOT NULL,
"summary_json" jsonb,
"label" text,
"type" varchar(20) DEFAULT 'snapshot',
"created_at" timestamp DEFAULT now()
);
`);
// Index : la Grille relit le dernier snapshot du fournisseur à chaque calcul
// et à chaque enregistrement, et l'historique liste ceux de l'utilisateur.
// Sans eux, chaque lecture parcourait toute la table (qui ne fait que grossir).
await tempPool.query(`
CREATE INDEX IF NOT EXISTS "idx_snapshots_fournisseur_date"
ON "session_snapshots" ("code_fournisseur", "created_at" DESC);
CREATE INDEX IF NOT EXISTS "idx_snapshots_user_type_date"
ON "session_snapshots" ("user_id", "type", "created_at" DESC);
`);
console.log("[DB Init] Table session_snapshots is verified/created.");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "users" (
"id" serial PRIMARY KEY NOT NULL,
"username" varchar(50) NOT NULL UNIQUE,
"password_hash" text NOT NULL,
"role" varchar(20) NOT NULL DEFAULT 'user',
"created_at" timestamp DEFAULT now()
);
`);
console.log("[DB Init] Table users is verified/created.");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "commande_cadences" (
"id" serial PRIMARY KEY NOT NULL,
"code_fournisseur" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"site" varchar(20) NOT NULL,
"intervalle_semaines" integer NOT NULL,
"actif" boolean NOT NULL DEFAULT true,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
`);
await tempPool.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_cadence_fou_site"
ON "commande_cadences" ("code_fournisseur", "site");
`);
await tempPool.query(`
CREATE INDEX IF NOT EXISTS "idx_cadence_site"
ON "commande_cadences" ("site");
`);
console.log("[DB Init] Table commande_cadences is verified/created.");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "qlik_network_metrics" (
"code_centrale" varchar(20) PRIMARY KEY NOT NULL,
"ca_reseau" numeric(16, 2),
"qte_reseau" numeric(14, 2),
"nb_magasins_reseau" integer,
"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,
ADD COLUMN IF NOT EXISTS "libelle_reseau" varchar(255),
ADD COLUMN IF NOT EXISTS "fournisseur_reseau" varchar(255);
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.");
// Instantané persisté de la grille : lu par /api/v1 pour ne jamais
// déclencher de recalcul. Rempli en effet de bord par getProductRows().
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "grid_rows" (
"code_fournisseur" varchar(20) NOT NULL,
"codein" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"libelle1" varchar(500),
"gtin" varchar(30),
"reference" varchar(100),
"code_centrale" varchar(20),
"code1" varchar(20),
"code2" varchar(20),
"code3" varchar(20),
"code_gamme" varchar(20),
"code_gamme_init" varchar(20),
"total_ca" numeric(16, 2),
"total_quantite" numeric(14, 2),
"total_marge" numeric(16, 2),
"taux_marge" numeric(8, 4),
"stock_actuel" numeric(14, 2),
"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),
"payload" jsonb NOT NULL,
"computed_at" timestamp DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "uq_grid_rows_fou_codein"
ON "grid_rows" ("code_fournisseur", "codein");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_fournisseur" ON "grid_rows" ("code_fournisseur");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_codein" ON "grid_rows" ("codein");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_gamme" ON "grid_rows" ("code_gamme");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_code_centrale" ON "grid_rows" ("code_centrale");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_total_ca" ON "grid_rows" ("total_ca");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_libelle" ON "grid_rows" ("libelle1");
`);
console.log("[DB Init] Table grid_rows is verified/created.");
// Clés d'API : seul le hachage SHA-256 est stocké.
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "api_keys" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(100) NOT NULL,
"key_prefix" varchar(20) NOT NULL,
"key_hash" text NOT NULL UNIQUE,
"role" varchar(20) DEFAULT 'user' NOT NULL,
"created_by" varchar(50),
"created_at" timestamp DEFAULT now(),
"last_used_at" timestamp,
"revoked_at" timestamp
);
`);
console.log("[DB Init] Table api_keys is verified/created.");
// Paramétrage et suivi de la synchronisation nocturne, par fournisseur.
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "sync_fournisseurs" (
"code_fournisseur" varchar(20) PRIMARY KEY NOT NULL,
"nom_fournisseur" varchar(255),
"actif_sql" boolean NOT NULL DEFAULT true,
"actif_qlik" boolean NOT NULL DEFAULT true,
"desactive_motif" text,
"dernier_sql_at" timestamp,
"dernier_sql_statut" varchar(20),
"dernier_sql_lignes" integer,
"dernier_sql_erreur" text,
"dernier_sql_ms" integer,
"dernier_qlik_at" timestamp,
"dernier_qlik_statut" varchar(20),
"dernier_qlik_codes" integer,
"dernier_qlik_erreur" text,
"dernier_qlik_ms" integer,
"updated_at" timestamp DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "idx_sync_fou_sql" ON "sync_fournisseurs" ("actif_sql", "dernier_sql_at");
CREATE INDEX IF NOT EXISTS "idx_sync_fou_qlik" ON "sync_fournisseurs" ("actif_qlik", "dernier_qlik_at");
`);
console.log("[DB Init] Table sync_fournisseurs is verified/created.");
// Gammes proposées par l'API /api/v1 : en attente de validation dans la Grille.
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "gammes_a_valider" (
"code_fournisseur" varchar(20) NOT NULL,
"codein" varchar(20) NOT NULL,
"gamme" varchar(20) NOT NULL,
"gamme_precedente" varchar(20),
"auteur" varchar(100),
"created_at" timestamp DEFAULT now() NOT NULL,
PRIMARY KEY ("code_fournisseur", "codein")
);
`);
console.log("[DB Init] Table gammes_a_valider is verified/created.");
await tempPool.end();
console.log("[DB Init] Initialization successful. Exiting.");
process.exit(0);
} catch (error) {
console.error("[DB Init] Initialization failed:", error);
// Do not crash the app, just let it run (it uses fallback JSON anyway)
process.exit(0);
}
}
main();
-48
View File
@@ -1,48 +0,0 @@
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
async function main() {
console.log("--- DB DIAGNOSIS Start (Node) ---");
let connectionString = process.env.DATABASE_URL;
try {
const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json");
if (fs.existsSync(CONFIG_PATH)) {
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
if (config.url) {
connectionString = config.url;
}
}
} catch (err) {}
if (!connectionString) {
console.error("No connection string found.");
return;
}
const pool = new Pool({ connectionString });
try {
const res = await pool.query(`
SELECT
code_fournisseur,
nom_fournisseur,
COUNT(*) as total_rows,
COUNT(DISTINCT codein) as distinct_products
FROM ventes_produits
GROUP BY code_fournisseur, nom_fournisseur
ORDER BY total_rows DESC
`);
console.table(res.rows);
} catch (error) {
console.error("Diagnosis failed:", error);
} finally {
await pool.end();
}
}
main();
-27
View File
@@ -1,27 +0,0 @@
import { db } from "@/db";
import { ventesProduits } from "@/db/schema";
import { sql } from "drizzle-orm";
async function main() {
console.log("--- DB DIAGNOSIS Start ---");
try {
const results = await db.execute(sql`
SELECT
code_fournisseur,
nom_fournisseur,
COUNT(*) as total_rows,
COUNT(DISTINCT codein) as distinct_products
FROM ventes_produits
GROUP BY code_fournisseur, nom_fournisseur
ORDER BY total_rows DESC
`);
console.table(results.rows);
} catch (error) {
console.error("Diagnosis failed:", error);
}
console.log("--- DB DIAGNOSIS End ---");
}
main();
-232
View File
@@ -1,232 +0,0 @@
/**
* CollectFlow — Diagnostic des requêtes sur la base FF (lecture seule).
*
* Sert à préparer l'optimisation des requêtes lentes (Meilleures ventes, fiche
* produit, stocks, commandes) : le schéma et les index des tables FF ne sont pas
* dans le dépôt, il faut donc les relever sur la vraie base avant de réécrire
* quoi que ce soit.
*
* Usage (sur le serveur, ou dans le conteneur Docker de l'application) :
*
* node scripts/diagnostic-ff.js > diagnostic-ff.txt
* node scripts/diagnostic-ff.js --analyze > diagnostic-ff.txt
*
* Sans option, rien n'est exécuté : seuls les plans prévus (EXPLAIN) sont lus.
* `--analyze` exécute réellement les requêtes analysées pour mesurer leur durée
* (EXPLAIN ANALYZE) : à lancer de préférence en dehors des heures d'ouverture.
* La session est en lecture seule et chaque requête est coupée au-delà de 2 min.
*
* Connexion : `data/.db-config.json` (réglée dans Paramètres), sinon DATABASE_URL.
*/
const { Pool } = require("pg");
const fs = require("fs");
const path = require("path");
const ANALYZE = process.argv.includes("--analyze");
const TABLES = ["articles", "mvtart", "cube_stock", "artfou1", "fouident", "nomenclature", "grid_rows", "session_snapshots"];
const COLONNES = {
articles: ["no_id", "codein", "artcentrale", "nom_no_id", "libelle1"],
mvtart: ["no_id", "artnoid", "site", "datmvt", "genremvt"],
cube_stock: ["artnoid", "site", "qte"],
artfou1: ["no_id", "art_no_id", "code", "preference"],
fouident: ["code"],
};
function connexion() {
try {
const fichier = path.join(path.resolve(__dirname, ".."), "data", ".db-config.json");
if (fs.existsSync(fichier)) {
const config = JSON.parse(fs.readFileSync(fichier, "utf-8"));
if (config.url) return config.url;
}
} catch {
/* configuration illisible : on tente la variable d'environnement */
}
return process.env.DATABASE_URL || "";
}
function titre(texte) {
console.log(`\n${"=".repeat(78)}\n${texte}\n${"=".repeat(78)}`);
}
async function section(nom, fn) {
titre(nom);
const debut = Date.now();
try {
await fn();
} catch (e) {
console.log(` (indisponible : ${e.message})`);
}
console.log(` [${Date.now() - debut} ms]`);
}
async function plan(client, nom, requete, params = []) {
await section(`PLAN — ${nom}`, async () => {
const options = ANALYZE ? "ANALYZE, BUFFERS, FORMAT TEXT" : "FORMAT TEXT";
const { rows } = await client.query(`EXPLAIN (${options}) ${requete}`, params);
for (const r of rows) console.log(` ${r["QUERY PLAN"]}`);
});
}
// Requêtes reprises de src/lib/pg-ff-client.ts (même forme, paramètres d'exemple).
const HIT_PARADE = `
WITH ventes AS (
SELECT TRIM(a.codein::text) AS codein, m.site,
SUM(-m.qtemvt)::float AS qte_vendue, SUM(-m.mntmvtttc)::float AS ca_ttc, SUM(m.margemvt)::float AS marge
FROM mvtart m JOIN articles a ON a.no_id = m.artnoid
WHERE m.datmvt BETWEEN $1::date AND $2::date AND m.site IN ('292', '579') AND m.genremvt = 3 AND a.codein IS NOT NULL
GROUP BY TRIM(a.codein::text), m.site
),
attrs AS (
SELECT DISTINCT ON (TRIM(a.codein::text)) TRIM(a.codein::text) AS codein, a.libelle1::text AS libelle,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur
FROM articles a
LEFT JOIN LATERAL (SELECT af1.code FROM artfou1 af1 WHERE af1.art_no_id = a.no_id AND af1.preference = 1 ORDER BY af1.code LIMIT 1) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes)
ORDER BY TRIM(a.codein::text), a.no_id DESC
),
stock_agg AS (
SELECT TRIM(a2.codein::text) AS codein, SUM(cs.qte)::float AS stock_total
FROM cube_stock cs JOIN articles a2 ON a2.no_id = cs.artnoid
WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes)
GROUP BY TRIM(a2.codein::text)
)
SELECT v.*, ar.libelle, ar.fournisseur, s.stock_total
FROM ventes v LEFT JOIN attrs ar ON ar.codein = v.codein LEFT JOIN stock_agg s ON s.codein = v.codein
ORDER BY v.ca_ttc DESC`;
const DERNIERE_RECEPTION = `
SELECT af.code AS codefou, m.site, MAX(m.datmvt) AS derniere
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND m.datmvt IS NOT NULL
GROUP BY af.code, m.site`;
const MENSUEL_FOURNISSEUR = `
SELECT a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, SUM(m.qtemvt)
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = $1) af ON af.art_no_id = a.no_id
WHERE m.datmvt BETWEEN $2::date AND $3::date AND m.site IN ('292', '579')
GROUP BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')`;
const STOCK_SANS_VENTE = `
SELECT a.codein, m.site, MAX(m.datmvt)
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site
WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND m.datmvt < date_trunc('month', CURRENT_DATE)
AND NOT EXISTS (SELECT 1 FROM mvtart mv2 WHERE mv2.artnoid = m.artnoid AND mv2.site = m.site AND mv2.genremvt = 3)
GROUP BY a.codein, m.site, cs.qte
HAVING COALESCE(cs.qte::float, 0) <> 0`;
async function main() {
const url = connexion();
if (!url) {
console.error("Aucune connexion : ni data/.db-config.json ni DATABASE_URL.");
process.exit(1);
}
const pool = new Pool({ connectionString: url, max: 1, application_name: "collectflow-diagnostic" });
const client = await pool.connect();
try {
await client.query("SET default_transaction_read_only = on");
await client.query("SET statement_timeout = '120s'");
await client.query("SET max_parallel_workers_per_gather = 0"); // comme l'application
console.log(`Diagnostic FF — ${new Date().toISOString()} — mode ${ANALYZE ? "EXPLAIN ANALYZE (requêtes exécutées)" : "EXPLAIN (plans seuls)"}`);
await section("Serveur PostgreSQL et réglages", async () => {
const { rows } = await client.query(`
SELECT name, setting, unit FROM pg_settings
WHERE name IN ('server_version', 'work_mem', 'shared_buffers', 'effective_cache_size',
'random_page_cost', 'max_parallel_workers_per_gather', 'default_statistics_target')
ORDER BY name`);
for (const r of rows) console.log(` ${r.name.padEnd(32)} ${r.setting}${r.unit ? ` ${r.unit}` : ""}`);
});
await section("Taille des tables (lignes estimées, taille totale avec index)", async () => {
const { rows } = await client.query(`
SELECT c.relname, c.reltuples::bigint AS lignes, pg_size_pretty(pg_total_relation_size(c.oid)) AS taille,
s.last_analyze, s.last_autoanalyze
FROM pg_class c LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE c.relkind IN ('r', 'p', 'm', 'v') AND c.relname = ANY($1)
ORDER BY c.relname`, [TABLES]);
const trouvees = new Set(rows.map((r) => r.relname));
for (const r of rows) {
const analyse = r.last_analyze || r.last_autoanalyze;
console.log(` ${r.relname.padEnd(20)} ${String(r.lignes).padStart(12)} lignes ${String(r.taille).padStart(10)} dernière analyse : ${analyse ? new Date(analyse).toISOString() : "jamais"}`);
}
for (const t of TABLES) if (!trouvees.has(t)) console.log(` ${t.padEnd(20)} (absente)`);
});
await section("Types des colonnes utilisées dans les jointures et filtres", async () => {
for (const [table, colonnes] of Object.entries(COLONNES)) {
const { rows } = await client.query(`
SELECT column_name, data_type, character_maximum_length, table_schema
FROM information_schema.columns WHERE table_name = $1 AND column_name = ANY($2)
ORDER BY column_name`, [table, colonnes]);
for (const r of rows) {
console.log(` ${`${r.table_schema}.${table}.${r.column_name}`.padEnd(36)} ${r.data_type}${r.character_maximum_length ? `(${r.character_maximum_length})` : ""}`);
}
}
});
await section("Index existants", async () => {
const { rows } = await client.query(`
SELECT tablename, indexname, indexdef FROM pg_indexes
WHERE tablename = ANY($1) ORDER BY tablename, indexname`, [TABLES]);
for (const r of rows) console.log(` ${r.tablename.padEnd(18)} ${r.indexdef}`);
if (rows.length === 0) console.log(" (aucun index trouvé sur ces tables)");
});
await section("Codes article avec espaces (rend TRIM() nécessaire ou non)", async () => {
const { rows } = await client.query(`
SELECT count(*) AS total,
count(*) FILTER (WHERE codein::text <> TRIM(codein::text)) AS avec_espaces,
count(DISTINCT TRIM(codein::text)) AS distincts
FROM articles`);
const r = rows[0];
console.log(` codein : ${r.total} articles — ${r.avec_espaces} avec espaces — ${r.distincts} codes distincts`);
try {
const { rows: c } = await client.query(`
SELECT count(*) FILTER (WHERE artcentrale::text <> TRIM(artcentrale::text)) AS avec_espaces
FROM articles WHERE artcentrale IS NOT NULL`);
console.log(` artcentrale : ${c[0].avec_espaces} avec espaces`);
} catch (e) {
console.log(` artcentrale : (indisponible : ${e.message})`);
}
});
// Paramètres d'exemple tirés de la base elle-même.
let codein = null;
let codefou = null;
try {
codein = (await client.query("SELECT TRIM(codein::text) AS c FROM articles WHERE codein IS NOT NULL LIMIT 1")).rows[0]?.c ?? null;
codefou = (await client.query("SELECT code FROM artfou1 WHERE preference = 1 GROUP BY code ORDER BY count(*) DESC LIMIT 1")).rows[0]?.code ?? null;
} catch {
/* tables absentes : les plans concernés le signaleront */
}
const hier = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
const ilYa7Jours = new Date(Date.now() - 7 * 86_400_000).toISOString().slice(0, 10);
const ilYa1An = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
await plan(client, `article par code, avec TRIM (codein ${codein})`,
"SELECT a.no_id FROM articles a WHERE TRIM(a.codein::text) = $1", [codein ?? ""]);
await plan(client, `article par code, sans TRIM (codein ${codein})`,
"SELECT a.no_id FROM articles a WHERE a.codein::text = $1", [codein ?? ""]);
await plan(client, `Meilleures ventes, 7 derniers jours (${ilYa7Jours} → ${hier})`, HIT_PARADE, [ilYa7Jours, hier]);
await plan(client, "Dernière réception par fournisseur (Commandes)", DERNIERE_RECEPTION);
await plan(client, `Ventes mensuelles d'un fournisseur sur 12 mois (fournisseur ${codefou})`, MENSUEL_FOURNISSEUR, [codefou ?? "", ilYa1An, hier]);
await plan(client, "Stock sans vente (forme simplifiée)", STOCK_SANS_VENTE);
} finally {
client.release();
await pool.end();
}
}
main().catch((e) => {
console.error("Diagnostic interrompu :", e.message);
process.exit(1);
});
-190
View File
@@ -1,190 +0,0 @@
/**
* Discovery Qlik Sense — autonome, à lancer à la main :
*
* QLIK_PWD='<mot de passe>' node scripts/qlik-discover.mjs # liste les apps
* QLIK_PWD='<mot de passe>' node scripts/qlik-discover.mjs <appGUID> # + champs & mesures de l'app
*
* Env (défauts adaptés à La Foir'Fouille) :
* QLIK_HOST=reporting-magasins.lafoirfouille.fr
* QLIK_USER=FFSCH
* QLIK_DOMAIN=FOIRFOUILLE
* QLIK_PWD=<requis>
*
* But : récupérer le GUID de l'app réseau (~270 magasins) + les noms exacts du champ
* "code article" et des mesures (CA / Qté / nb magasins) pour remplir .env.local :
* QLIK_APP_NETWORK / QLIK_DIM_CODE_ARTICLE / QLIK_MEAS_CA / QLIK_MEAS_QTE / QLIK_MEAS_NB_MAG
*/
import httpntlm from "httpntlm";
import { WebSocket } from "ws";
import { randomBytes } from "node:crypto";
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // certif Qlik interne
const HOST = process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr";
const USER = process.env.QLIK_USER || "FFSCH";
// Domaine vide par défaut : requests_ntlm("FFSCH", pwd) marche sans domaine explicite.
const DOMAIN = process.env.QLIK_DOMAIN || "";
const PWD = process.env.QLIK_PWD || "";
const APP = process.argv[2] || process.env.QLIK_APP_NETWORK || "";
if (!PWD) {
console.error("ERREUR: définis QLIK_PWD. Ex: QLIK_PWD='xxx' node scripts/qlik-discover.mjs");
process.exit(1);
}
const xrfkey = randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
function ntlmGet(path) {
return new Promise((resolve, reject) => {
httpntlm.get(
{
url: `https://${HOST}${path}`,
username: USER,
password: PWD,
domain: DOMAIN,
workstation: "",
rejectUnauthorized: false,
headers: { "X-Qlik-Xrfkey": xrfkey, "User-Agent": "Mozilla/5.0 CollectFlow" },
},
(err, res) => (err ? reject(err) : resolve(res)),
);
});
}
// Récupère un targetId en initiant une requête non authentifiée vers /hub/.
async function getTargetId() {
const res = await fetch(`https://${HOST}/hub/`, { redirect: "manual" });
const loc = res.headers.get("location") || "";
const m = loc.match(/targetId=([0-9a-f-]+)/i);
return m ? m[1] : "";
}
// Flux SSO ticket Qlik :
// 1. NTLM sur /internal_windows_authentication/?targetId=... -> 302 /hub/?qlikTicket=XXX
// 2. GET /hub/?qlikTicket=XXX -> Qlik valide le ticket et pose le cookie X-Qlik-Session
async function authCookie() {
const tid = await getTargetId();
const res = await ntlmGet(`/internal_windows_authentication/?targetId=${tid}&xrfkey=${xrfkey}`);
const loc = res.headers.location || "";
const ticket = (loc.match(/qlikTicket=([^&\s]+)/) || [])[1];
console.log(` [auth] NTLM HTTP ${res.statusCode}, ticket=${ticket ? "oui" : "non"}`);
if (!ticket) throw new Error(`pas de qlikTicket (HTTP ${res.statusCode})`);
// Échange ticket -> cookies de session (on garde TOUS les cookies posés)
const r2 = await fetch(loc, { redirect: "manual" });
const sc = typeof r2.headers.getSetCookie === "function"
? r2.headers.getSetCookie()
: (r2.headers.get("set-cookie") ? [r2.headers.get("set-cookie")] : []);
const pairs = sc.map((c) => c.split(";")[0]);
const hasSession = pairs.some((c) => /X-Qlik-Session/i.test(c));
console.log(` [auth] échange ticket HTTP ${r2.status}, cookies=${JSON.stringify(pairs.map(p=>p.split("=")[0]))}`);
if (!hasSession) throw new Error("ticket non échangé contre un cookie de session");
return pairs.join("; ");
}
// QRS via cookie de session + Xrfkey.
async function qrsGet(path, cookie) {
const sep = path.includes("?") ? "&" : "?";
const res = await fetch(`https://${HOST}${path}${sep}xrfkey=${xrfkey}`, {
headers: { Cookie: cookie, "X-Qlik-Xrfkey": xrfkey, Accept: "application/json" },
});
if (res.status !== 200) throw new Error(`QRS ${path} HTTP ${res.status}`);
return res.json();
}
async function qrsApps(cookie) {
const apps = await qrsGet("/qrs/app", cookie);
return apps.map((a) => ({ id: a.id, name: a.name, published: a.published, stream: a.stream?.name }));
}
function engine(appGuid, cookie) {
const ident = randomBytes(8).toString("hex");
const url = `wss://${HOST}/app/${appGuid}/identity/${ident}?Xrfkey=${xrfkey}`;
const ws = new WebSocket(url, {
headers: { Cookie: cookie, "X-Qlik-Xrfkey": xrfkey, "User-Agent": "Mozilla/5.0" },
rejectUnauthorized: false,
});
let id = 1;
const pending = new Map();
const ready = new Promise((resolve, reject) => {
ws.on("message", (raw) => {
const m = JSON.parse(raw.toString());
if (m.method === "OnConnected") return resolve();
if (m.id != null && pending.has(m.id)) {
const p = pending.get(m.id);
pending.delete(m.id);
m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result);
}
});
ws.on("unexpected-response", (_req, res) => {
let body = "";
res.on("data", (d) => (body += d));
res.on("end", () => reject(new Error(`ws ${res.statusCode} server=${res.headers.server} wwwauth=${res.headers["www-authenticate"]} body=${body.slice(0, 120)}`)));
});
ws.on("error", reject);
});
const rpc = (method, params, handle = -1) =>
new Promise((resolve, reject) => {
const i = id++;
pending.set(i, { resolve, reject });
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
});
return { ready, rpc, close: () => ws.close() };
}
async function dumpFieldsMeasures(appGuid, cookie) {
const e = engine(appGuid, cookie);
await e.ready;
const open = await e.rpc("OpenDoc", { qDocName: appGuid });
const doc = open.qReturn.qHandle;
// Liste des champs
const fl = await e.rpc("CreateSessionObject", {
qProp: { qInfo: { qType: "FieldList" }, qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true } },
}, doc);
const flLayout = await e.rpc("GetLayout", {}, fl.qReturn.qHandle);
const fields = (flLayout.qLayout.qFieldList.qItems || []).map((f) => f.qName);
// Liste des mesures (master items)
const ml = await e.rpc("CreateSessionObject", {
qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } } },
}, doc);
const mlLayout = await e.rpc("GetLayout", {}, ml.qReturn.qHandle);
const measures = (mlLayout.qLayout.qMeasureList.qItems || []).map((m) => ({
title: m.qMeta?.title ?? m.qData?.title,
expr: m.qData?.expr,
}));
e.close();
return { fields, measures };
}
(async () => {
try {
console.log(`Auth NTLM ${DOMAIN}\\${USER} @ ${HOST} ...`);
const cookie = await authCookie();
console.log("✓ Authentifié.\n");
const apps = await qrsApps(cookie);
console.log(`=== ${apps.length} apps ===`);
for (const a of apps) console.log(` ${a.id} [${a.stream || "-"}] ${a.name}`);
if (APP) {
console.log(`\n=== Champs & mesures de l'app ${APP} ===`);
const { fields, measures } = await dumpFieldsMeasures(APP, cookie);
console.log(`\n-- Champs (${fields.length}) --`);
for (const f of fields) console.log(` ${f}`);
console.log(`\n-- Mesures master (${measures.length}) --`);
for (const m of measures) console.log(` ${m.title} = ${m.expr}`);
console.log(`\n→ Repère le champ "code article", et les expressions CA / Qté / nb magasins,`);
console.log(` puis remplis QLIK_APP_NETWORK / QLIK_DIM_CODE_ARTICLE / QLIK_MEAS_* dans .env.local.`);
} else {
console.log(`\n→ Relance avec le GUID de l'app réseau pour lister ses champs & mesures :`);
console.log(` QLIK_PWD='...' node scripts/qlik-discover.mjs <appGUID>`);
}
} catch (e) {
console.error("ERREUR:", e.message);
process.exit(1);
}
})();
-354
View File
@@ -1,354 +0,0 @@
/**
* CollectFlow — Validation des expressions Qlik et de la clé de jointure.
*
* À lancer DEPUIS le réseau qui atteint Qlik (typiquement le conteneur de l'app) :
*
* docker exec -e QLIK_PWD='<mot de passe>' -it <conteneur> node scripts/qlik-validate.mjs
*
* Répond aux deux questions encore ouvertes :
*
* 1. Les master measures « CA N » / « Quantité N » ignorent-elles la sélection
* Date, et les agrégations directes (`Sum(quantite)`, `Sum(ca_ht)`…) sont-elles
* un substitut fidèle ? Le script affiche, pour quelques articles réellement
* vendus, un tableau mois par mois comparant les deux — dont les mois d'août à
* décembre qui ressortaient vides.
*
* 2. Quelle valeur joint avec `articles.artcentrale` : la dimension « Article
* Code » ou le champ `article_no_centrale` ? Le script affiche les deux
* côte à côte.
*
* Aucun secret n'est écrit ni affiché : le mot de passe vient de l'environnement.
* Le script est en LECTURE SEULE (sélections en soft lock, objets de session
* détruits) — il ne modifie rien dans Qlik.
*/
import httpntlm from "httpntlm";
import { randomBytes } from "node:crypto";
import { chromium } from "playwright-core";
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // certificat interne
const HOST = process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr";
const USER = process.env.QLIK_USER || "FFSCH";
const PWD = process.env.QLIK_PWD || "";
const DOMAIN = process.env.QLIK_DOMAIN ?? "";
const APP = process.env.QLIK_APP_NETWORK || "9872ee6e-d64a-4b43-984a-076bf1f7f647";
const DIM_ARTICLE = process.env.QLIK_DIM_CODE_ARTICLE_ID || "yesCP";
const DIM_MOIS = process.env.QLIK_MOIS_DIM_ID || "pfGAwTs";
const NB_ARTICLES = Number(process.env.QLIK_VALIDATE_ARTICLES || "3");
if (!PWD) {
console.error("ERREUR : définir QLIK_PWD dans l'environnement.");
console.error(" docker exec -e QLIK_PWD='…' -it <conteneur> node scripts/qlik-validate.mjs");
process.exit(1);
}
const xrfkey = randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
// ─── Fenêtre 12 mois glissants, mois courant exclu (même règle que l'app) ────
const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30);
const serialDe = (y, m, d) => Math.round((Date.UTC(y, m - 1, d) - QLIK_EPOCH_MS) / 86_400_000);
function fenetre12Mois(now = new Date()) {
const debut = new Date(now.getFullYear(), now.getMonth() - 12, 1);
const fin = new Date(now.getFullYear(), now.getMonth(), 0);
const serials = [];
const s0 = serialDe(debut.getFullYear(), debut.getMonth() + 1, debut.getDate());
const s1 = serialDe(fin.getFullYear(), fin.getMonth() + 1, fin.getDate());
for (let s = s0; s <= s1; s++) serials.push(s);
const labels = [];
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
labels.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return { serials, labels, debut: debut.toISOString().slice(0, 10), fin: fin.toISOString().slice(0, 10) };
}
// ─── Auth NTLM → cookie de session ──────────────────────────────────────────
function ntlmGet(path) {
return new Promise((resolve, reject) => {
httpntlm.get(
{
url: `https://${HOST}${path}`,
username: USER,
password: PWD,
domain: DOMAIN,
workstation: "",
rejectUnauthorized: false,
headers: { "User-Agent": "Mozilla/5.0 CollectFlow", "X-Qlik-Xrfkey": xrfkey },
},
(err, res) => (err ? reject(err) : resolve(res)),
);
});
}
function cookieDepuis(setCookie) {
const arr = Array.isArray(setCookie) ? setCookie : setCookie ? [String(setCookie)] : [];
return arr.map((c) => String(c).split(";")[0]).find((c) => /X-Qlik-Session/i.test(c)) ?? null;
}
async function session() {
const hub = await fetch(`https://${HOST}/hub/`, { redirect: "manual" });
const targetId = ((hub.headers.get("location") ?? "").match(/targetId=([0-9a-f-]+)/i) ?? [])[1] ?? "";
const chemin = targetId
? `/internal_windows_authentication/?targetId=${targetId}&xrfkey=${xrfkey}`
: `/hub/?xrfkey=${xrfkey}`;
const res = await ntlmGet(chemin);
const direct = cookieDepuis(res.headers["set-cookie"]);
if (direct) return direct;
const loc = String(res.headers["location"] ?? "");
if (!/qlikTicket=/.test(loc)) throw new Error(`auth NTLM sans ticket (HTTP ${res.statusCode})`);
const ech = await fetch(loc, { redirect: "manual" });
const sc = typeof ech.headers.getSetCookie === "function" ? ech.headers.getSetCookie() : [];
const cookie = sc.map((c) => c.split(";")[0]).filter((c) => /X-Qlik-Session/i.test(c)).join("; ");
if (!cookie) throw new Error("ticket non échangé contre un cookie de session");
return cookie;
}
// ─── Script exécuté dans la page (websocket Engine accepté par le proxy) ────
function scriptInPage({ app, token, dimArticle, dimMois, serials, labels, nbArticles }) {
return new Promise((resolve) => {
const url = `wss://${location.host}/app/${app}?reloadUri=${encodeURIComponent(location.href)}&qlik-csrf-token=${token}`;
const ws = new WebSocket(url);
const sortie = [];
const log = (l) => { sortie.push(l); console.log("[cf] " + l); };
let id = 0;
const pend = new Map();
const rpc = (method, params, handle = -1) => new Promise((res, rej) => {
const i = ++id; pend.set(i, { res, rej });
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
});
const pret = new Promise((res, rej) => {
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.method === "OnConnected") return res();
if (m.id && pend.has(m.id)) {
const p = pend.get(m.id); pend.delete(m.id);
if (m.error) p.rej(new Error(JSON.stringify(m.error)));
else p.res(m.result);
}
};
ws.onerror = () => rej(new Error("websocket refusé (403 ?)"));
setTimeout(() => rej(new Error("timeout websocket")), 30000);
});
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
try {
await pret;
const doc = (await rpc("OpenDoc", { qDocName: app })).qReturn.qHandle;
// ─── 1. Expressions des master measures ──────────────────────
const ml = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "MeasureList" },
qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } },
},
}, doc);
const items = (await rpc("GetLayout", {}, ml.qReturn.qHandle)).qLayout.qMeasureList?.qItems ?? [];
const parTitre = new Map();
log("=== EXPRESSIONS DES MASTER MEASURES ===");
for (const it of items) {
const t = (it.qMeta?.title ?? it.qData?.title ?? "").trim();
if (!t) continue;
parTitre.set(t.toLowerCase(), it.qInfo.qId);
if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(t)) {
log(` « ${t} » = ${it.qData?.expr ?? "?"}`);
}
}
const mesure = (t) => parTitre.get(t.toLowerCase()) ?? null;
// ─── 2. Sélection de la fenêtre 12 mois ──────────────────────
const champDate = await rpc("GetField", { qFieldName: "Date" }, doc);
const dh = champDate.qReturn.qHandle;
await rpc("Clear", {}, dh);
await dormir(300);
for (let i = 0; i < serials.length; i += 500) {
await rpc("SelectValues", {
qFieldValues: serials.slice(i, i + 500).map((s) => ({ qNum: s, qText: String(s) })),
qToggleMode: i > 0,
qSoftLock: true,
}, dh);
}
await dormir(500);
log("");
log(`=== FENÊTRE SÉLECTIONNÉE : ${labels[0]} → ${labels[labels.length - 1]} (${serials.length} jours) ===`);
// ─── 3. Articles les plus vendus (par agrégation directe) ────
const cubeTop = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-val-top" },
qHyperCubeDef: {
qDimensions: [{ qLibraryId: dimArticle }],
qMeasures: [{ qDef: { qDef: "Sum(quantite)" }, qSortBy: { qSortByNumeric: -1 } }],
qInterColumnSortOrder: [1, 0],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: nbArticles }],
},
},
}, doc);
const topMatrix = (await rpc("GetLayout", {}, cubeTop.qReturn.qHandle))
.qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? [];
const codes = topMatrix.map((r) => String(r[0]?.qText ?? "")).filter(Boolean);
log(`articles testés (top ventes) : ${JSON.stringify(codes)}`);
if (codes.length === 0) { ws.close(); return resolve({ ok: false, error: "aucun article vendu sur la fenêtre", sortie }); }
const champArticle = await rpc("GetField", { qFieldName: "Article Code" }, doc);
const ah = champArticle.qReturn.qHandle;
await rpc("Clear", {}, ah);
await dormir(300);
await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, ah);
await dormir(500);
// ─── 4. Comparaison mois par mois ────────────────────────────
const mesures = [
{ qDef: { qDef: "Sum(quantite)" } },
{ qDef: { qDef: "Sum(ca_ht)" } },
{ qDef: { qDef: "Sum(ca_ttc)" } },
{ qDef: { qDef: "Count(DISTINCT [Magasin Code])" } },
{ qDef: { qDef: "Sum(marge)" } },
];
const idQte = mesure("Quantité N");
const idCa = mesure("CA N");
const idNbMag = mesure("Magasin Ventes Nb N");
if (idQte) mesures.push({ qLibraryId: idQte });
if (idCa) mesures.push({ qLibraryId: idCa });
if (idNbMag) mesures.push({ qLibraryId: idNbMag });
const largeur = 2 + mesures.length;
const cube = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-val-mois" },
qHyperCubeDef: {
qDimensions: [{ qLibraryId: dimArticle }, { qLibraryId: dimMois }],
qMeasures: mesures,
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: 400 }],
},
},
}, doc);
const hc = (await rpc("GetLayout", {}, cube.qReturn.qHandle)).qLayout.qHyperCube;
const lignes = hc.qDataPages?.[0]?.qMatrix ?? [];
log("");
log("=== COMPARAISON MOIS PAR MOIS (agrégation directe vs master measure) ===");
log("article mois Sum(quantite) Quantité N | Sum(ca_ht) Sum(ca_ttc) CA N | Count(mag) Mag Nb N");
const pad = (v, n) => String(v).padStart(n);
for (const r of lignes) {
const code = String(r[0]?.qText ?? "");
const moisBrut = String(r[1]?.qText ?? "");
const mois = /^\d{6}$/.test(moisBrut) ? moisBrut.slice(0, 4) + "-" + moisBrut.slice(4) : moisBrut;
const n = (i) => Number(r[2 + i]?.qNum ?? 0) || 0;
log(
code.padEnd(13) + mois.padEnd(9) +
pad(Math.round(n(0)), 13) + pad(Math.round(n(5)), 12) + " |" +
pad(Math.round(n(1)), 11) + pad(Math.round(n(2)), 13) + pad(Math.round(n(6)), 9) + " |" +
pad(Math.round(n(3)), 11) + pad(Math.round(n(7)), 10),
);
}
log("");
log("LECTURE : si « Quantité N » vaut 0 sur les mois de l'année précédente alors que");
log(" Sum(quantite) est renseigné, la master measure est bien inutilisable");
log(" pour une fenêtre glissante — et l'agrégation directe la remplace.");
log(" Si Sum(quantite) == Quantité N sur les mois de l'année en cours,");
log(" l'expression par défaut de CollectFlow est validée.");
log(" Comparer aussi Sum(ca_ht) et Sum(ca_ttc) à « CA N » pour choisir");
log(" QLIK_EXPR_CA.");
// ─── 5. Clé de jointure : Article Code vs article_no_centrale ─
const cubeCle = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-val-cle" },
qHyperCubeDef: {
qDimensions: [
{ qLibraryId: dimArticle },
{ qDef: { qFieldDefs: ["article_no_centrale"] } },
{ qDef: { qFieldDefs: ["article_codein"] } },
],
qMeasures: [],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 3, qHeight: 20 }],
},
},
}, doc);
const cles = (await rpc("GetLayout", {}, cubeCle.qReturn.qHandle))
.qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? [];
log("");
log("=== CLÉ DE JOINTURE ===");
log("Article Code article_no_centrale article_codein");
for (const r of cles) {
log(
String(r[0]?.qText ?? "").padEnd(20) +
String(r[1]?.qText ?? "").padEnd(22) +
String(r[2]?.qText ?? ""),
);
}
log("");
log("LECTURE : la bonne clé est celle qui a le format de `articles.artcentrale`");
log(" côté FF Nancy (ex. 10000167303 — préfixe 10000 + 6 chiffres).");
ws.close();
resolve({ ok: true, sortie });
} catch (e) {
try { ws.close(); } catch { /* noop */ }
resolve({ ok: false, error: String(e?.message || e), sortie });
}
})();
});
}
// ─── Orchestration ──────────────────────────────────────────────────────────
(async () => {
const f = fenetre12Mois();
console.log(`Qlik ${HOST} — app ${APP}`);
console.log(`Fenêtre : ${f.debut} → ${f.fin}\n`);
const cookie = await session();
console.log("✓ authentifié (NTLM + ticket)\n");
const [nom, ...reste] = cookie.split("=");
const navigateur = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
});
const ctx = await navigateur.newContext({
httpCredentials: { username: USER, password: PWD, origin: `https://${HOST}` },
ignoreHTTPSErrors: true,
});
await ctx.addCookies([{ name: nom, value: reste.join("="), domain: HOST, path: "/", secure: true }]);
try {
const page = await ctx.newPage();
let token = null;
let pret;
const attente = new Promise((r) => { pret = r; });
page.on("websocket", (ws) => {
const m = ws.url().match(/qlik-csrf-token=([^&]+)/);
if (m && !token) { token = decodeURIComponent(m[1]); pret(); }
});
await page.goto(`https://${HOST}/sense/app/${APP}`, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
await Promise.race([attente, page.waitForTimeout(20000)]);
if (!token) throw new Error("qlik-csrf-token introuvable (client Qlik non chargé ?)");
const res = await page.evaluate(scriptInPage, {
app: APP, token, dimArticle: DIM_ARTICLE, dimMois: DIM_MOIS,
serials: f.serials, labels: f.labels, nbArticles: NB_ARTICLES,
});
console.log(res.sortie.join("\n"));
if (!res.ok) {
console.error("\nÉCHEC : " + res.error);
process.exitCode = 1;
}
} finally {
await ctx.close();
await navigateur.close();
}
})().catch((e) => {
console.error("ERREUR :", e?.message || e);
process.exit(1);
});
-39
View File
@@ -1,39 +0,0 @@
const { computeProductScores } = require('./src/lib/score-engine');
// Simulate 50,000 rows
const largeDataset = Array.from({ length: 50000 }, (_, i) => ({
codein: `item-${i}`,
codeFournisseur: 'A032',
nomFournisseur: 'ACCESS DECO',
libelle1: `Product ${i}`,
gtin: '123456789',
reference: 'REF',
code1: '20',
libelleNiveau1: 'LIQUIDES',
code2: '2001',
libelleNiveau2: 'EAUX',
code3: '200101',
libelle3: 'EAU SOURCE',
codeGamme: 'A',
codeGammeInit: 'A',
codeGammeDraft: null,
sales12m: {},
totalQuantite: Math.random() * 1000,
totalCa: Math.random() * 5000,
totalMarge: Math.random() * 1000,
tauxMarge: 20,
score: 0,
workingStores: ['MAG1', 'MAG2']
}));
console.log(`Starting test with ${largeDataset.length} rows...`);
try {
const start = Date.now();
const result = computeProductScores(largeDataset);
const end = Date.now();
console.log(`Success! Processed ${result.length} rows in ${end - start}ms.`);
} catch (error) {
console.error('Failed!', error);
process.exit(1);
}
-25
View File
@@ -1,25 +0,0 @@
require('dotenv').config({ path: 'c:/GIT/CollectFlow/.env.local' });
const { Pool } = require('pg');
async function test() {
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
try {
const res = await pool.query("SELECT * FROM cube_stock WHERE cs.site = '292' LIMIT 5");
console.log("CUBE_STOCK:", res.rows);
} catch(e) {
console.log("ERROR cube_stock:", e.message);
}
try {
const res2 = await pool.query("SELECT a.codein, a.no_id FROM articles a LIMIT 5");
console.log("ARTICLES:", res2.rows);
} catch(e) {
console.log("ERROR articles:", e.message);
}
pool.end();
}
test();
-194
View File
@@ -1,194 +0,0 @@
/**
* 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);
+3477
View File
File diff suppressed because it is too large. Load diff
+63
View File
@@ -0,0 +1,63 @@
[package]
name = "collectflow"
version = "0.2.0"
edition = "2021"
description = "CollectFlow — serveur Rust (API, base, FF Nancy, Qlik, synchronisation)"
publish = false
[dependencies]
anyhow = "1"
axum = { version = "0.8", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie", "typed-header"] }
base64 = "0.22"
chromiumoxide = "0.9.1"
chrono = { version = "0.4", features = ["serde"] }
des = "0.8"
futures = "0.3"
hex = "0.4"
hmac = "0.12"
http-body-util = "0.1"
hyper = { version = "1", features = ["client", "http1"] }
hyper-util = { version = "0.1", features = ["tokio"] }
indexmap = { version = "2", features = ["serde"] }
md-5 = "0.10"
md4 = "0.10"
parking_lot = "0.12"
rand = "0.8"
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip", "cookies", "stream", "http2"] }
rust_decimal = "1"
rust_xlsxwriter = "0.99"
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
scrypt = { version = "0.11", default-features = false, features = ["std"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
sha2 = "0.10"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls-ring", "postgres", "json", "chrono", "rust_decimal", "macros"] }
subtle = "2"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "compression-gzip", "trace", "set-header"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
urlencoding = "2"
uuid = { version = "1", features = ["v4"] }
webpki-roots = "1"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 4
strip = true
# scrypt est lent sans optimisation : la connexion prenait plusieurs secondes en debug.
[profile.dev.package.scrypt]
opt-level = 3
[profile.dev.package.salsa20]
opt-level = 3
[profile.dev.package.pbkdf2]
opt-level = 3
[profile.dev.package.sha2]
opt-level = 3
+116
View File
@@ -0,0 +1,116 @@
# Serveur Rust — contrat avec l'interface et conventions
Ce document fixe le contrat entre le serveur Rust (`server/`) et l'interface
React (`src/`, compilée en SPA par Vite). Le serveur est la traduction fidèle de
l'ancienne version Next.js (mêmes requêtes SQL, mêmes règles, mêmes messages,
mêmes formes JSON) ; son code TypeScript reste consultable dans l'historique
git (`git show d2c3dc7:src/...`, dernier état avant la réécriture).
## Socle commun
| Besoin | Rust |
|---|---|
| Pool PostgreSQL (base app + recopie FF) | `crate::db::pool()? -> PgPool` (remplaçable à chaud, `db::refresh()`) |
| Ligne → JSON générique | `db::row_to_json`, `db::rows_to_json` |
| Lecture tolérante (`Number(r.x) \|\| 0`, `String(r.x ?? "")`) | `db::get_f64`, `db::f64_or0`, `db::get_string`, `db::string_or_empty` |
| Date ISO façon `toISOString()` | `db::iso(NaiveDateTime)`, `db::iso_utc` |
| Réglages `data/.db-config.json` | `config::read_config()`, `config::write_config(patch)`, `config::data_dir()`, `config::env_number(...)` |
| Cache FF (`cachedFF`) | `ff_cache::cached(key, CacheOpts { ttl, cache_if }, \|\| async { … }).await`, `ff_cache::invalidate()` |
| Journaux de job (`startCapture`…) | `logcapture::{start_capture, stop_capture, read_capture, list_captures}` |
| Session (routes internes) | extracteurs `auth::SessionUser`, `auth::AdminUser` (401/403 `{ error }`) ; `auth::Token` (jeton brut) ; `auth::require_session(token)` / `require_admin(token)` |
| Auth `/api/v1` | extracteur `auth::ApiAuth` (clé d'API ou session, erreurs au format v1) ; `ctx.require_admin()` |
| Enveloppe `/api/v1` | `api_v1_response::{ok, fail, build_pagination, ApiErrorCode}` |
| Erreurs | `error::{AppError, AppResult}` — `AppError` implémente `IntoResponse` (`{ error }`) et `From<anyhow::Error / sqlx::Error / reqwest::Error>` |
| Magasins / gammes | `domain::{MAGASINS, CODE_TOUS_MAGASINS, nom_magasin, est_gamme_valide}` |
| Actions / pages | `rpc::{Ctx, Handler, json}` + macro `handler!(fonction)` |
## Organisation
Chaque module possède son répertoire `server/src/<module>/` et expose dans son
`mod.rs` :
```rust
pub fn router() -> axum::Router; // routes HTTP (chemins identiques à Next.js)
pub fn actions() -> Vec<(&'static str, rpc::Handler)>; // ex. ("getUsers", handler!(get_users))
pub fn pages() -> Vec<(&'static str, rpc::Handler)>; // ex. ("dashboard", handler!(page_dashboard))
```
Le routeur Axum n'a pas d'état (`Router<()>`) : tout passe par les accès globaux
ci-dessus. Syntaxe des chemins Axum 0.8 : `/api/v1/products/{codein}`.
## Actions (ex-« server actions »)
`POST /api/actions/{nom}` — corps : tableau JSON des arguments positionnels,
réponse : la valeur de retour de la fonction TS, sérialisée à l'identique.
Une exception TS (ex. `requireSession()` qui lève) devient une `AppError`
(statut 401/403/500, corps `{ error }`) ; le client la relève en `Error`.
Une action s'écrit :
```rust
async fn get_users(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?; // = await requireAdmin()
let page: i64 = ctx.arg(0)?; // argument n°0 (null si absent → Option<T>)
…
json(resultat)
}
```
| Action | Source TS | Module Rust |
|---|---|---|
| `getFournisseurs`, `getMagasins` | `features/grid/actions.ts` | `ff` |
| `getFournisseursPourCadence`, `listCadences`, `upsertCadence`, `toggleCadence`, `removeCadence` | `features/commandes-auto/actions.ts` | `ff` |
| `getUsers`, `createUser`, `deleteUser`, `updatePassword` | `features/admin/api/user-actions.ts` | `admin` |
| `getApiKeys`, `createApiKey`, `revokeApiKey`, `deleteApiKey` | `features/admin/api/api-key-actions.ts` | `admin` |
| `getParametresAffichables`, `testDatabaseConnection`, `saveDatabaseSettings`, `saveQlikSettings`, `testQlikConnection`, `saveFfApiSettings`, `testFfApiConnection` | `features/settings/actions.ts` | `admin` |
| `readSyncSettings`, `saveSyncSettings` | `features/admin/api/sync-settings.ts` | `admin` |
| `getSnapshots`, `deleteSnapshot`, `saveSnapshot` | `features/snapshots/api/*.ts` | `grid` |
| `saveDraftChanges` | `features/grid/api/save-draft-changes.ts` | `grid` |
| `getGammesAValider`, `rejeterGammesAValider` | `features/grid/api/gammes-a-valider-actions.ts` | `grid` |
(`loginAction` disparaît : `POST /api/auth/login`.)
## Pages (données des anciennes pages serveur)
`GET /api/pages/{nom}?…` renvoie **uniquement les données** que la page serveur
obtenait de ses appels d'accès aux données (fonctions `pg*`, API FF, actions),
sous la forme exacte de leur valeur de retour TS. Toute la mise en forme pure
(pivots, listes de mois, totaux…) reste côté interface. En cas d'échec là où la
page affichait un `ErrorState` : statut 500 `{ error: message }`.
| Page | Paramètres | Réponse | Module |
|---|---|---|---|
| `dashboard` | — | `pgGetDashboardData()` (cache 30 min) | `ff` |
| `analytics` | `mode` (`fournisseur`\|`nomenclature`), `mois`, `moisN1` | lignes de `pgGetCaByFournisseur` ou `pgGetCaByNomenclature` (cachedFF, mêmes clés) | `ff` |
| `hit-parade` | `debut`, `fin` | lignes de `pgGetHitParade` (cachedFF) | `ff` |
| `stock-negatif` | `magasin` | `{ rowsNegatif, rowsSansVente, rowsSansVente6Mois }` (cachedFF) | `ff` |
| `commandes-auto` | — | `{ rows, cadences, fournisseurs }` | `ff` |
| `publicites` | `statut`, `page` | `{ publicites, total, pages, erreurPublicites?, historique, erreurHistorique?, pageSize }` | `ff` |
| `produit-fiche` | `codein` ou `cc` | `getProduitFiche(codein)` / `getProduitFicheByCodeCentrale(cc)` (ou `null`) | `produits` |
## Routes HTTP conservées (mêmes chemins, mêmes réponses)
`/api/ff-status`, `/api/diag`, `/api/diag/stock-fournisseur`, `/api/test-db`,
`/api/commandes-auto` → `ff` ; `/api/qlik/sync` → `qlik` ;
`/api/grid/rows`, `/api/grid/rows/store-patch`, `/api/export/excel`,
`/api/export/modified-gammes`, `/api/v1/*` → `grid` ;
`/api/produits/search`, `/api/produits/opportunites` → `produits` ;
`/api/admin/sync`, `/api/admin/sync/fournisseurs`, `/api/admin/grid-warmup`,
`/api/logs` → `admin`. Authentification : `/api/auth/{login,logout,session}`.
## Règles de traduction
- **Formes JSON identiques** : noms de champs exacts (`#[serde(rename_all = "camelCase")]`
ou `rename` explicite selon l'interface TS), champs optionnels TS absents ⇒
`#[serde(skip_serializing_if = "Option::is_none")]` (JSON.stringify omet `undefined`,
mais garde `null`). Une `Map` TS renvoyée au client se sérialise comme le faisait le TS
(souvent `Object.fromEntries`).
- **Même SQL** : recopier les requêtes telles quelles (paramètres `$1…`, `QueryBuilder`
ou `format!` contrôlé pour les fragments dynamiques — jamais de valeur utilisateur
interpolée sans échappement). Les colonnes `numeric` se lisent avec `db::get_f64`.
- **Même tolérance aux pannes** : une fonction TS qui avale l'erreur et rend `[]`
fait de même ; une qui lève propage une erreur.
- **Journaux** : `tracing::info!/warn!/error!` avec les mêmes préfixes (`[pg-ff]`, `[qlik]`…).
- **Dates** : le conteneur tourne en UTC ; `new Date()` ⇒ `chrono::Local::now()`.
- **Commentaires** en français, comme le code d'origine : garder les « pourquoi ».
- `cargo build` sans avertissement dans son module ; `cargo test` pour la logique pure.
+150
View File
@@ -0,0 +1,150 @@
-- Schéma CollectFlow : joué à chaque démarrage (idempotent).
-- Porté depuis scripts/db-init.js.
CREATE TABLE IF NOT EXISTS "ai_supplier_context" (
"code_fournisseur" varchar(20) PRIMARY KEY NOT NULL,
"context" text NOT NULL,
"updated_at" timestamp DEFAULT now()
);
CREATE TABLE IF NOT EXISTS "session_snapshots" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer,
"code_fournisseur" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"magasin" varchar(20) NOT NULL,
"changes" jsonb NOT NULL,
"summary_json" jsonb,
"label" text,
"type" varchar(20) DEFAULT 'snapshot',
"created_at" timestamp DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "idx_snapshots_fournisseur_date"
ON "session_snapshots" ("code_fournisseur", "created_at" DESC);
CREATE INDEX IF NOT EXISTS "idx_snapshots_user_type_date"
ON "session_snapshots" ("user_id", "type", "created_at" DESC);
CREATE TABLE IF NOT EXISTS "users" (
"id" serial PRIMARY KEY NOT NULL,
"username" varchar(50) NOT NULL UNIQUE,
"password_hash" text NOT NULL,
"role" varchar(20) NOT NULL DEFAULT 'user',
"created_at" timestamp DEFAULT now()
);
CREATE TABLE IF NOT EXISTS "commande_cadences" (
"id" serial PRIMARY KEY NOT NULL,
"code_fournisseur" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"site" varchar(20) NOT NULL,
"intervalle_semaines" integer NOT NULL,
"actif" boolean NOT NULL DEFAULT true,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS "uq_cadence_fou_site"
ON "commande_cadences" ("code_fournisseur", "site");
CREATE INDEX IF NOT EXISTS "idx_cadence_site"
ON "commande_cadences" ("site");
CREATE TABLE IF NOT EXISTS "qlik_network_metrics" (
"code_centrale" varchar(20) PRIMARY KEY NOT NULL,
"ca_reseau" numeric(16, 2),
"qte_reseau" numeric(14, 2),
"nb_magasins_reseau" integer,
"ca_par_magasin_reseau" numeric(16, 2),
"marge_pct_reseau" numeric(8, 4),
"periode" varchar(20),
"fetched_at" timestamp DEFAULT now()
);
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,
ADD COLUMN IF NOT EXISTS "libelle_reseau" varchar(255),
ADD COLUMN IF NOT EXISTS "fournisseur_reseau" varchar(255);
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau";
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
CREATE TABLE IF NOT EXISTS "grid_rows" (
"code_fournisseur" varchar(20) NOT NULL,
"codein" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"libelle1" varchar(500),
"gtin" varchar(30),
"reference" varchar(100),
"code_centrale" varchar(20),
"code1" varchar(20),
"code2" varchar(20),
"code3" varchar(20),
"code_gamme" varchar(20),
"code_gamme_init" varchar(20),
"total_ca" numeric(16, 2),
"total_quantite" numeric(14, 2),
"total_marge" numeric(16, 2),
"taux_marge" numeric(8, 4),
"stock_actuel" numeric(14, 2),
"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),
"payload" jsonb NOT NULL,
"computed_at" timestamp DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "uq_grid_rows_fou_codein"
ON "grid_rows" ("code_fournisseur", "codein");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_fournisseur" ON "grid_rows" ("code_fournisseur");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_codein" ON "grid_rows" ("codein");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_gamme" ON "grid_rows" ("code_gamme");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_code_centrale" ON "grid_rows" ("code_centrale");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_total_ca" ON "grid_rows" ("total_ca");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_libelle" ON "grid_rows" ("libelle1");
CREATE TABLE IF NOT EXISTS "api_keys" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(100) NOT NULL,
"key_prefix" varchar(20) NOT NULL,
"key_hash" text NOT NULL UNIQUE,
"role" varchar(20) DEFAULT 'user' NOT NULL,
"created_by" varchar(50),
"created_at" timestamp DEFAULT now(),
"last_used_at" timestamp,
"revoked_at" timestamp
);
CREATE TABLE IF NOT EXISTS "sync_fournisseurs" (
"code_fournisseur" varchar(20) PRIMARY KEY NOT NULL,
"nom_fournisseur" varchar(255),
"actif_sql" boolean NOT NULL DEFAULT true,
"actif_qlik" boolean NOT NULL DEFAULT true,
"desactive_motif" text,
"dernier_sql_at" timestamp,
"dernier_sql_statut" varchar(20),
"dernier_sql_lignes" integer,
"dernier_sql_erreur" text,
"dernier_sql_ms" integer,
"dernier_qlik_at" timestamp,
"dernier_qlik_statut" varchar(20),
"dernier_qlik_codes" integer,
"dernier_qlik_erreur" text,
"dernier_qlik_ms" integer,
"updated_at" timestamp DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "idx_sync_fou_sql" ON "sync_fournisseurs" ("actif_sql", "dernier_sql_at");
CREATE INDEX IF NOT EXISTS "idx_sync_fou_qlik" ON "sync_fournisseurs" ("actif_qlik", "dernier_qlik_at");
CREATE TABLE IF NOT EXISTS "gammes_a_valider" (
"code_fournisseur" varchar(20) NOT NULL,
"codein" varchar(20) NOT NULL,
"gamme" varchar(20) NOT NULL,
"gamme_precedente" varchar(20),
"auteur" varchar(100),
"created_at" timestamp DEFAULT now() NOT NULL,
PRIMARY KEY ("code_fournisseur", "codein")
);
+115
View File
@@ -0,0 +1,115 @@
//! Gestion des clés de l'API `/api/v1` (administrateurs) — port de `api-key-actions.ts`.
//!
//! La clé en clair n'existe qu'une fois : renvoyée par `createApiKey`, jamais
//! restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée
//! conserve sa ligne, pour garder la trace de son usage passé.
use crate::auth::api_key::generate_api_key;
use crate::error::AppResult;
use crate::rpc::{json, Ctx};
use chrono::NaiveDateTime;
use serde::Serialize;
use serde_json::{json as j, Value};
use sqlx::Row;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ApiKeyRow {
id: i32,
name: String,
key_prefix: String,
role: String,
created_by: Option<String>,
created_at: Option<String>,
last_used_at: Option<String>,
revoked_at: Option<String>,
}
fn ts(r: &sqlx::postgres::PgRow, col: &str) -> Option<String> {
r.try_get::<Option<NaiveDateTime>, _>(col).ok().flatten().map(crate::db::iso)
}
/// Liste les clés. Jamais de secret — seulement le préfixe lisible.
pub async fn get_api_keys(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let pool = crate::db::pool()?;
let rows = sqlx::query(
"SELECT id, name, key_prefix, role, created_by, created_at, last_used_at, revoked_at FROM api_keys ORDER BY created_at DESC",
)
.fetch_all(&pool)
.await?;
let out: Vec<ApiKeyRow> = rows
.iter()
.map(|r| ApiKeyRow {
id: r.try_get("id").unwrap_or_default(),
name: r.try_get("name").unwrap_or_default(),
key_prefix: r.try_get("key_prefix").unwrap_or_default(),
role: r.try_get("role").unwrap_or_default(),
created_by: r.try_get("created_by").ok().flatten(),
created_at: ts(r, "created_at"),
last_used_at: ts(r, "last_used_at"),
revoked_at: ts(r, "revoked_at"),
})
.collect();
json(out)
}
/// Crée une clé et renvoie sa valeur **en clair, une seule et unique fois**.
pub async fn create_api_key(ctx: Ctx) -> AppResult<Value> {
let moi = ctx.admin().await?;
let name: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
let role: String = ctx.arg::<Option<String>>(1)?.unwrap_or_else(|| "user".into());
let trimmed = name.trim();
if trimmed.is_empty() {
return Ok(j!({ "success": false, "error": "Le nom de la clé est obligatoire." }));
}
if trimmed.chars().count() > 100 {
return Ok(j!({ "success": false, "error": "Nom trop long (100 caractères max)." }));
}
let k = generate_api_key();
let pool = crate::db::pool()?;
let created_by = Some(moi.username.clone()).filter(|s| !s.is_empty());
match sqlx::query("INSERT INTO api_keys (name, key_prefix, key_hash, role, created_by) VALUES ($1, $2, $3, $4, $5)")
.bind(trimmed)
.bind(&k.key_prefix)
.bind(&k.key_hash)
.bind(&role)
.bind(created_by)
.execute(&pool)
.await
{
Ok(_) => Ok(j!({ "success": true, "key": k.key, "keyPrefix": k.key_prefix })),
Err(e) => {
tracing::error!("[api-keys] création KO: {e}");
Ok(j!({ "success": false, "error": "Erreur technique lors de la création de la clé." }))
}
}
}
/// Révoque une clé : refusée dès l'appel suivant, mais toujours listée.
pub async fn revoke_api_key(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let id: i64 = ctx.arg(0)?;
let pool = crate::db::pool()?;
match sqlx::query("UPDATE api_keys SET revoked_at = now() WHERE id = $1").bind(id as i32).execute(&pool).await {
Ok(_) => Ok(j!({ "success": true })),
Err(e) => {
tracing::error!("[api-keys] révocation KO: {e}");
Ok(j!({ "success": false, "error": "Erreur lors de la révocation." }))
}
}
}
/// Supprime définitivement une clé (nettoyage de la liste).
pub async fn delete_api_key(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let id: i64 = ctx.arg(0)?;
let pool = crate::db::pool()?;
match sqlx::query("DELETE FROM api_keys WHERE id = $1").bind(id as i32).execute(&pool).await {
Ok(_) => Ok(j!({ "success": true })),
Err(e) => {
tracing::error!("[api-keys] suppression KO: {e}");
Ok(j!({ "success": false, "error": "Erreur lors de la suppression." }))
}
}
}
+164
View File
@@ -0,0 +1,164 @@
//! Préchauffage de l'instantané de grille (`grid_rows`).
//!
//! L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la
//! Grille est ouverte. Sans préchauffage, un consommateur externe ne verrait que
//! les fournisseurs déjà consultés à la main. Ce job calcule chaque fournisseur
//! une fois, séquentiellement (le calcul est lourd en SQL).
use crate::auth::AdminUser;
use axum::extract::Query;
use axum::Json;
use parking_lot::Mutex;
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct WarmupJob {
/// "idle" | "running" | "success" | "error"
status: &'static str,
total: usize,
done: usize,
skipped: usize,
failed: usize,
#[serde(skip_serializing_if = "Option::is_none")]
current_fournisseur: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
started_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
finished_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
last_errors: Vec<String>,
}
impl WarmupJob {
fn idle() -> Self {
Self {
status: "idle",
total: 0,
done: 0,
skipped: 0,
failed: 0,
current_fournisseur: None,
started_at: None,
finished_at: None,
error: None,
last_errors: vec![],
}
}
}
static JOB: LazyLock<Arc<Mutex<Option<WarmupJob>>>> = LazyLock::new(|| Arc::new(Mutex::new(None)));
fn public_job() -> Value {
let job = JOB.lock().clone().unwrap_or_else(WarmupJob::idle);
let mut v = serde_json::to_value(job).unwrap_or(json!({}));
v["success"] = json!(true);
v
}
fn maintenant() -> String {
crate::db::iso_utc(chrono::Utc::now())
}
fn maj(f: impl FnOnce(&mut WarmupJob)) {
if let Some(j) = JOB.lock().as_mut() {
f(j);
}
}
async fn run_warmup(stale_hours: f64) {
let res: anyhow::Result<()> = async {
let fournisseurs = crate::ff::pg::pg_get_fournisseurs(None).await;
// Les fournisseurs calculés récemment sont sautés : un second passage ne
// coûte presque rien, le bouton est rejouable sans crainte.
let existing = crate::grid::store::list_grid_suppliers().await?;
let fresh_limit = chrono::Utc::now().naive_utc() - chrono::Duration::milliseconds((stale_hours * 3_600_000.0) as i64);
let fresh: HashSet<String> = existing
.iter()
.filter(|s| {
s.computed_at
.as_deref()
.and_then(|c| chrono::DateTime::parse_from_rfc3339(c).ok())
.map(|d| d.naive_utc() > fresh_limit)
.unwrap_or(false)
})
.map(|s| s.code_fournisseur.clone())
.collect();
maj(|j| j.total = fournisseurs.len());
tracing::info!("[grid-warmup] {} fournisseurs, {} déjà à jour (< {}h)", fournisseurs.len(), fresh.len(), stale_hours);
for f in &fournisseurs {
if fresh.contains(&f.code) {
maj(|j| j.skipped += 1);
continue;
}
maj(|j| j.current_fournisseur = Some(format!("{} — {}", f.code, f.nom)));
match crate::grid::product_rows::get_product_rows_for_sync(&f.code).await {
Ok(_) => maj(|j| j.done += 1),
Err(e) => {
let msg = format!("{}: {}", f.code, crate::error::short(&e, 120));
tracing::error!("[grid-warmup] {msg}");
maj(|j| {
j.failed += 1;
if j.last_errors.len() < 10 {
j.last_errors.push(msg);
}
});
}
}
}
Ok(())
}
.await;
match res {
Ok(()) => {
maj(|j| {
j.current_fournisseur = None;
j.status = "success";
j.finished_at = Some(maintenant());
});
if let Some(j) = JOB.lock().as_ref() {
tracing::info!("[grid-warmup] terminé — {} calculés, {} à jour, {} en échec", j.done, j.skipped, j.failed);
}
}
Err(e) => {
let msg = format!("{e:#}");
tracing::error!("[grid-warmup] échec global: {msg}");
maj(|j| {
j.status = "error";
j.error = Some(msg);
j.finished_at = Some(maintenant());
});
}
}
}
/// GET /api/admin/grid-warmup — avancement du préchauffage.
pub async fn get(_admin: AdminUser) -> Json<Value> {
Json(public_job())
}
/// POST /api/admin/grid-warmup?staleHours=24 — démarre en arrière-plan.
pub async fn post(_admin: AdminUser, Query(q): Query<HashMap<String, String>>) -> Json<Value> {
{
let mut job = JOB.lock();
if job.as_ref().map(|j| j.status == "running").unwrap_or(false) {
drop(job);
return Json(public_job());
}
// Comme `Number(param)` : absent ou vide → 0, illisible → défaut 24 h.
let raw = q
.get("staleHours")
.map(|s| if s.trim().is_empty() { 0.0 } else { s.trim().parse::<f64>().unwrap_or(f64::NAN) })
.unwrap_or(0.0);
let stale_hours = if raw.is_finite() { raw.clamp(0.0, 720.0) } else { 24.0 };
*job = Some(WarmupJob { status: "running", started_at: Some(maintenant()), ..WarmupJob::idle() });
tracing::info!("[grid-warmup] démarré (staleHours={stale_hours})");
tokio::spawn(run_warmup(stale_hours));
}
Json(public_job())
}
+41
View File
@@ -0,0 +1,41 @@
//! Journaux serveur des jobs (liste + téléchargement).
//!
//! `GET /api/logs` → liste des journaux disponibles (JSON)
//! `GET /api/logs?id=<jobId>` → le journal complet en pièce jointe (text/plain)
//!
//! Réservé aux admins : un journal d'extraction contient les diagnostics du
//! modèle Qlik et les codes articles manipulés (jamais de secret).
use crate::auth::AdminUser;
use crate::logcapture::{list_captures, read_capture};
use axum::extract::Query;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
pub struct LogsQuery {
id: Option<String>,
}
pub async fn get_logs(_admin: AdminUser, Query(q): Query<LogsQuery>) -> Response {
let Some(id) = q.id.filter(|s| !s.is_empty()) else {
return Json(json!({ "success": true, "logs": list_captures() })).into_response();
};
let Some(contenu) = read_capture(&id) else {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "Journal introuvable ou expiré" }))).into_response();
};
// Nom de fichier assaini : l'id finit dans un en-tête HTTP.
let nom: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect();
(
[
(header::CONTENT_TYPE, "text/plain; charset=utf-8".to_string()),
(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{nom}.log\"")),
(header::CACHE_CONTROL, "no-store".to_string()),
],
contenu,
)
.into_response()
}
+72
View File
@@ -0,0 +1,72 @@
//! Module `admin` — comptes, clés d'API, Paramètres, synchronisation nocturne.
pub mod api_keys;
pub mod grid_warmup;
pub mod logs;
mod qlik_bridge;
pub mod settings;
pub mod sync_fournisseurs;
pub mod sync_routes;
pub mod sync_scheduler;
pub mod sync_settings;
pub mod users;
use crate::error::AppResult;
use crate::handler;
use crate::rpc::{json, Ctx, Handler};
use axum::routing::get;
use axum::Router;
use serde_json::{Map, Value};
/// Routes HTTP du module.
pub fn router() -> Router {
Router::new()
.route("/api/logs", get(logs::get_logs))
.route("/api/admin/sync", get(sync_routes::get).patch(sync_routes::patch).post(sync_routes::post))
.route("/api/admin/grid-warmup", get(grid_warmup::get).post(grid_warmup::post))
.route("/api/admin/sync/fournisseurs", get(sync_fournisseurs::get).patch(sync_fournisseurs::patch))
}
async fn read_sync_settings_action(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
json(sync_settings::read_sync_settings())
}
async fn save_sync_settings_action(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let patch: Map<String, Value> = ctx.arg::<Option<Map<String, Value>>>(0)?.unwrap_or_default();
json(sync_settings::save_sync_settings(&patch)?)
}
/// Actions (`POST /api/actions/{nom}`) du module.
pub fn actions() -> Vec<(&'static str, Handler)> {
vec![
("getUsers", handler!(users::get_users)),
("createUser", handler!(users::create_user)),
("deleteUser", handler!(users::delete_user)),
("updatePassword", handler!(users::update_password)),
("getApiKeys", handler!(api_keys::get_api_keys)),
("createApiKey", handler!(api_keys::create_api_key)),
("revokeApiKey", handler!(api_keys::revoke_api_key)),
("deleteApiKey", handler!(api_keys::delete_api_key)),
("getParametresAffichables", handler!(settings::get_parametres_affichables)),
("testDatabaseConnection", handler!(settings::test_database_connection)),
("saveDatabaseSettings", handler!(settings::save_database_settings)),
("saveQlikSettings", handler!(settings::save_qlik_settings)),
("testQlikConnection", handler!(settings::test_qlik_connection)),
("saveFfApiSettings", handler!(settings::save_ff_api_settings)),
("testFfApiConnection", handler!(settings::test_ff_api_connection)),
("readSyncSettings", handler!(read_sync_settings_action)),
("saveSyncSettings", handler!(save_sync_settings_action)),
]
}
/// Données de pages (`GET /api/pages/{nom}`) du module.
pub fn pages() -> Vec<(&'static str, Handler)> {
vec![]
}
/// Tâches de fond lancées au démarrage (minuteur de la synchro nocturne).
pub fn start_background_tasks() {
sync_scheduler::start_sync_timer();
}
+37
View File
@@ -0,0 +1,37 @@
//! Points d'appel vers les modules `qlik` et `ff` utilisés par les Paramètres.
//! Branchés après l'intégration de ces modules.
use serde_json::Value;
/// Ouvre une session Qlik (NTLM → ticket → cookie) avec les identifiants donnés.
pub async fn tester_session(host: String, user: String, password: Option<String>) -> anyhow::Result<()> {
let enregistre = crate::qlik::client::get_qlik_config();
let mut cfg = enregistre.clone();
cfg.user = user;
cfg.password = password.unwrap_or(enregistre.password);
if !host.is_empty() {
cfg.host = host;
}
crate::qlik::client::qlik_ntlm_session(&cfg).await?;
Ok(())
}
/// Oublie l'URL mémorisée de l'API FF Nancy.
pub fn reset_ff_api_base() {
crate::ff::api::reset_ff_api_base_cache();
}
/// Diagnostic de l'API FF Nancy.
pub async fn diagnostiquer_api_ff(base: Option<String>) -> Value {
serde_json::to_value(crate::ff::diagnostic::diagnostiquer_api_ff(base).await).unwrap_or(Value::Null)
}
/// Extraction Qlik d'un fournisseur sur la fenêtre de la synchro (`QLIK_SYNC_MONTHS_BACK`),
/// puis enregistrement dans le cache réseau. Renvoie le nombre de codes enregistrés.
pub async fn extraire_et_enregistrer(codes: Vec<String>, fournisseur: &str) -> anyhow::Result<usize> {
use crate::qlik::date_range::{build_grid_network_qlik_date_filter_now, env_months_back, QLIK_MONTHS_BACK_DEFAULT};
let filtre = build_grid_network_qlik_date_filter_now(env_months_back("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT));
let metrics = crate::qlik::fetch_network_metrics_playwright(Some(codes), None, Some(filtre), Some(fournisseur.to_string())).await?;
let liste: Vec<_> = metrics.into_values().collect();
crate::qlik::upsert_network_metrics(&liste).await
}
+279
View File
@@ -0,0 +1,279 @@
//! Actions des Paramètres — réservées aux administrateurs.
//!
//! Aucun secret ne repart vers le navigateur : les mots de passe (base, Qlik)
//! et les clés d'IA restent dans `data/.db-config.json`. Un mot de passe laissé
//! vide dans le formulaire signifie « conserver celui qui est enregistré ».
use crate::config::{read_config, write_config};
use crate::error::AppResult;
use crate::rpc::{json, Ctx};
use serde::{Deserialize, Serialize};
use serde_json::{json as j, Map, Value};
use std::time::Duration;
#[derive(Debug, Clone, Deserialize)]
pub struct ChampsBaseDeDonnees {
#[serde(default)]
pub host: String,
#[serde(default)]
pub port: String,
#[serde(default)]
pub database: String,
#[serde(default)]
pub user: String,
/// Vide ou absent : le mot de passe enregistré est conservé.
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub ssl: bool,
}
struct UrlPostgres {
host: String,
port: String,
database: String,
user: String,
password: String,
ssl: bool,
}
fn decoder(v: &str) -> String {
urlencoding::decode(v).map(|c| c.into_owned()).unwrap_or_else(|_| v.to_string())
}
/// Découpe l'URL `postgres://` enregistrée en champs du formulaire.
fn lire_url_postgres(url: &str) -> Option<UrlPostgres> {
let re = regex::Regex::new(r"^[a-zA-Z][a-zA-Z0-9+.-]*://(?:([^:@/]*)(?::([^@/]*))?@)?(\[[^\]]+\]|[^:/?#]*)(?::(\d+))?(?:/([^?#]*))?").unwrap();
let Some(c) = re.captures(url) else {
tracing::error!("[Settings] URL de base enregistrée illisible");
return None;
};
Some(UrlPostgres {
host: c.get(3).map(|m| m.as_str().to_string()).unwrap_or_default(),
port: c.get(4).map(|m| m.as_str().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "5432".into()),
database: decoder(c.get(5).map(|m| m.as_str()).unwrap_or_default()),
user: decoder(c.get(1).map(|m| m.as_str()).unwrap_or_default()),
password: decoder(c.get(2).map(|m| m.as_str()).unwrap_or_default()),
ssl: url.contains("sslmode=require"),
})
}
fn construire_url_postgres(champs: &ChampsBaseDeDonnees, mot_de_passe: &str) -> String {
let user = urlencoding::encode(champs.user.trim()).into_owned();
let identifiants = if mot_de_passe.is_empty() {
user
} else {
format!("{user}:{}", urlencoding::encode(mot_de_passe))
};
let port = Some(champs.port.trim()).filter(|p| !p.is_empty()).unwrap_or("5432");
format!(
"postgres://{identifiants}@{}:{port}/{}{}",
champs.host.trim(),
champs.database.trim(),
if champs.ssl { "?sslmode=require" } else { "" }
)
}
/// Mot de passe saisi, sinon celui de l'URL enregistrée.
fn mot_de_passe_base(champs: &ChampsBaseDeDonnees) -> String {
if let Some(p) = champs.password.as_ref().filter(|p| !p.is_empty()) {
return p.clone();
}
read_config().url.as_deref().and_then(lire_url_postgres).map(|u| u.password).unwrap_or_default()
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DbAffichable {
host: String,
port: String,
database: String,
user: String,
ssl: bool,
mot_de_passe_enregistre: bool,
}
/// Réglages enregistrés, sans aucun secret.
pub async fn get_parametres_affichables(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let config = read_config();
let base = config.url.as_deref().and_then(lire_url_postgres);
let db = base.map(|b| DbAffichable {
host: b.host,
port: b.port,
database: b.database,
user: b.user,
ssl: b.ssl,
mot_de_passe_enregistre: !b.password.is_empty(),
});
let qlik_pwd_env = std::env::var("QLIK_PWD").map(|v| !v.is_empty()).unwrap_or(false);
json(j!({
"db": db,
"qlik": {
"host": config.qlik_host.clone().unwrap_or_default(),
"user": config.qlik_user.clone().unwrap_or_default(),
// Même ordre de priorité que la config Qlik : réglage enregistré, puis variable d'environnement.
"motDePasseEnregistre": config.qlik_password.as_deref().map(|p| !p.is_empty()).unwrap_or(false) || qlik_pwd_env,
},
"ffApiBaseUrl": config.ff_api_base_url.clone().unwrap_or_default(),
}))
}
fn refus(e: crate::error::AppError) -> Value {
j!({ "success": false, "error": e.message })
}
pub async fn test_database_connection(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await {
return Ok(refus(e));
}
let champs: ChampsBaseDeDonnees = ctx.arg(0)?;
let url = construire_url_postgres(&champs, &mot_de_passe_base(&champs));
tracing::info!("Testing connection to: {}", crate::db::mask_url(&url));
let essai = async {
let opts = crate::db::connect_options(&url)?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.acquire_timeout(Duration::from_secs(5))
.connect_with(opts)
.await?;
sqlx::query("SELECT 1").execute(&pool).await?;
pool.close().await;
Ok::<_, sqlx::Error>(())
};
match tokio::time::timeout(Duration::from_secs(8), essai).await {
Ok(Ok(())) => Ok(j!({ "success": true })),
Ok(Err(e)) => {
tracing::error!("Database connection test failed: {e}");
Ok(j!({ "success": false, "error": e.to_string() }))
}
Err(_) => Ok(j!({ "success": false, "error": "timeout exceeded when trying to connect" })),
}
}
pub async fn save_database_settings(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await {
return Ok(refus(e));
}
let champs: ChampsBaseDeDonnees = ctx.arg(0)?;
let url = construire_url_postgres(&champs, &mot_de_passe_base(&champs));
// Seule l'URL change : les clés d'IA et les réglages Qlik / API FF sont conservés.
tracing::info!("[Settings] Saving config to {}", crate::config::config_file().display());
let mut patch = Map::new();
patch.insert("url".into(), Value::String(url));
match write_config(patch) {
Ok(()) => {
tracing::info!("[Settings] Database configuration saved successfully.");
crate::db::refresh();
// Nouvelle base : schéma et compte admin à (re)vérifier.
tokio::spawn(async {
crate::migrations::run().await;
crate::auth::ensure_admin_exists().await;
});
Ok(j!({ "success": true }))
}
Err(e) => {
tracing::error!("Failed to save database configuration: {e}");
Ok(j!({ "success": false, "error": e.to_string() }))
}
}
}
/// Mot de passe vide ou absent : celui qui est enregistré est conservé.
pub async fn save_qlik_settings(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await {
return Ok(refus(e));
}
let host: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
let user: String = ctx.arg::<Option<String>>(1)?.unwrap_or_default();
let password: Option<String> = ctx.arg::<Option<String>>(2)?.filter(|p| !p.is_empty());
let mut patch = Map::new();
patch.insert("qlikHost".into(), Value::String(host));
patch.insert("qlikUser".into(), Value::String(user));
if let Some(p) = password {
patch.insert("qlikPassword".into(), Value::String(p));
}
match write_config(patch) {
Ok(()) => {
tracing::info!("[Settings] Qlik configuration saved.");
Ok(j!({ "success": true }))
}
Err(e) => {
tracing::error!("[Settings] Failed to save Qlik config: {e}");
Ok(j!({ "success": false, "error": e.to_string() }))
}
}
}
/// Mot de passe vide ou absent : on teste avec celui qui est enregistré.
pub async fn test_qlik_connection(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await {
return Ok(refus(e));
}
let host: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
let user: String = ctx.arg::<Option<String>>(1)?.unwrap_or_default();
let password: Option<String> = ctx.arg::<Option<String>>(2)?.filter(|p| !p.is_empty());
match super::qlik_bridge::tester_session(host, user, password).await {
Ok(()) => Ok(j!({ "success": true })),
Err(e) => Ok(j!({ "success": false, "error": format!("{e:#}") })),
}
}
/// Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut.
pub async fn save_ff_api_settings(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await {
return Ok(refus(e));
}
let brut: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
let cleaned = brut.trim().trim_end_matches('/').to_string();
let lower = cleaned.to_ascii_lowercase();
if !cleaned.is_empty() && !(lower.starts_with("http://") || lower.starts_with("https://")) {
return Ok(j!({ "success": false, "error": "L'URL doit commencer par http:// ou https://" }));
}
let mut patch = Map::new();
patch.insert("ffApiBaseUrl".into(), if cleaned.is_empty() { Value::Null } else { Value::String(cleaned.clone()) });
match write_config(patch) {
Ok(()) => {
// Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde.
super::qlik_bridge::reset_ff_api_base();
tracing::info!("[Settings] FF API base URL saved: {}", if cleaned.is_empty() { "(défaut)" } else { &cleaned });
Ok(j!({ "success": true }))
}
Err(e) => {
tracing::error!("[Settings] Failed to save FF API config: {e}");
Ok(j!({ "success": false, "error": e.to_string() }))
}
}
}
/// Teste l'API FF Nancy.
pub async fn test_ff_api_connection(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await {
return Ok(j!({ "success": false, "url": "", "error": e.message }));
}
let base: Option<String> = ctx.arg(0)?;
Ok(super::qlik_bridge::diagnostiquer_api_ff(base).await)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_roundtrip() {
let champs = ChampsBaseDeDonnees {
host: "db".into(),
port: "".into(),
database: "cf".into(),
user: "u@x".into(),
password: Some("p:ss/w".into()),
ssl: true,
};
let url = construire_url_postgres(&champs, "p:ss/w");
assert_eq!(url, "postgres://u%40x:p%3Ass%2Fw@db:5432/cf?sslmode=require");
let u = lire_url_postgres(&url).unwrap();
assert_eq!((u.host.as_str(), u.port.as_str(), u.database.as_str(), u.user.as_str(), u.password.as_str(), u.ssl), ("db", "5432", "cf", "u@x", "p:ss/w", true));
let u = lire_url_postgres("postgres://postgres:secret@postgresbase:5432/collectflow").unwrap();
assert_eq!((u.host.as_str(), u.user.as_str(), u.password.as_str(), u.database.as_str()), ("postgresbase", "postgres", "secret", "collectflow"));
}
}
+148
View File
@@ -0,0 +1,148 @@
//! `/api/admin/sync/fournisseurs` — paramétrage par fournisseur de la synchro nocturne.
use crate::auth::AdminUser;
use crate::error::{AppError, AppResult};
use axum::http::StatusCode;
use axum::Json;
use chrono::NaiveDateTime;
use serde::Serialize;
use serde_json::{json, Value};
use sqlx::postgres::PgRow;
use sqlx::Row;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncFournisseurRow {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub actif_sql: bool,
pub actif_qlik: bool,
pub desactive_motif: Option<String>,
pub dernier_sql_at: Option<String>,
pub dernier_sql_statut: Option<String>,
pub dernier_sql_lignes: Option<i32>,
pub dernier_sql_erreur: Option<String>,
pub dernier_sql_ms: Option<i32>,
pub dernier_qlik_at: Option<String>,
pub dernier_qlik_statut: Option<String>,
pub dernier_qlik_codes: Option<i32>,
pub dernier_qlik_erreur: Option<String>,
pub dernier_qlik_ms: Option<i32>,
pub updated_at: Option<String>,
}
fn ts(r: &PgRow, col: &str) -> Option<String> {
r.try_get::<Option<NaiveDateTime>, _>(col).ok().flatten().map(crate::db::iso)
}
pub fn from_row(r: &PgRow) -> SyncFournisseurRow {
SyncFournisseurRow {
code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
actif_sql: r.try_get("actif_sql").unwrap_or(true),
actif_qlik: r.try_get("actif_qlik").unwrap_or(true),
desactive_motif: r.try_get("desactive_motif").ok().flatten(),
dernier_sql_at: ts(r, "dernier_sql_at"),
dernier_sql_statut: r.try_get("dernier_sql_statut").ok().flatten(),
dernier_sql_lignes: r.try_get("dernier_sql_lignes").ok().flatten(),
dernier_sql_erreur: r.try_get("dernier_sql_erreur").ok().flatten(),
dernier_sql_ms: r.try_get("dernier_sql_ms").ok().flatten(),
dernier_qlik_at: ts(r, "dernier_qlik_at"),
dernier_qlik_statut: r.try_get("dernier_qlik_statut").ok().flatten(),
dernier_qlik_codes: r.try_get("dernier_qlik_codes").ok().flatten(),
dernier_qlik_erreur: r.try_get("dernier_qlik_erreur").ok().flatten(),
dernier_qlik_ms: r.try_get("dernier_qlik_ms").ok().flatten(),
updated_at: ts(r, "updated_at"),
}
}
/// GET — la liste complète des fournisseurs paramétrés, avec l'état de leur
/// dernière synchronisation (quelques centaines de lignes : pas de pagination).
pub async fn get(_admin: AdminUser) -> AppResult<Json<Value>> {
let pool = crate::db::pool()?;
let rows = sqlx::query("SELECT * FROM sync_fournisseurs ORDER BY coalesce(nom_fournisseur, code_fournisseur) ASC")
.fetch_all(&pool)
.await?;
let rows: Vec<SyncFournisseurRow> = rows.iter().map(from_row).collect();
let resume = json!({
"total": rows.len(),
"actifsSql": rows.iter().filter(|r| r.actif_sql).count(),
"actifsQlik": rows.iter().filter(|r| r.actif_qlik).count(),
"desactivesAuto": rows.iter().filter(|r| r.desactive_motif.as_deref().map(|m| !m.is_empty()).unwrap_or(false)).count(),
"enEchecSql": rows.iter().filter(|r| r.dernier_sql_statut.as_deref() == Some("echec")).count(),
"enEchecQlik": rows.iter().filter(|r| r.dernier_qlik_statut.as_deref() == Some("echec")).count(),
"jamaisSql": rows.iter().filter(|r| r.dernier_sql_at.is_none()).count(),
});
Ok(Json(json!({ "success": true, "fournisseurs": rows, "resume": resume })))
}
fn invalide(details: Vec<String>) -> AppError {
AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Paramètres invalides", "details": details }))
}
/// PATCH — active ou désactive des fournisseurs, pour l'une ou l'autre source.
///
/// Réactiver lève le motif de désactivation automatique : un fournisseur remis
/// en service par l'admin ne doit pas garder un « aucun article » trompeur.
pub async fn patch(_admin: AdminUser, body: axum::body::Bytes) -> AppResult<Json<Value>> {
let body: Value = serde_json::from_slice(&body)
.map_err(|_| AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Corps JSON invalide" })))?;
let mut details = Vec::new();
let codes: Vec<String> = match body.get("codes").and_then(|v| v.as_array()) {
Some(a) => {
let codes: Vec<String> = a.iter().filter_map(|v| v.as_str().map(String::from)).collect();
if codes.len() != a.len() || codes.iter().any(|c| c.is_empty()) {
details.push("Invalid input".to_string());
}
if codes.is_empty() {
details.push("Too small: expected array to have >=1 items".to_string());
}
if codes.len() > 2000 {
details.push("Too big: expected array to have <=2000 items".to_string());
}
codes
}
None => {
details.push("Invalid input: expected array".to_string());
vec![]
}
};
let bool_opt = |k: &str, details: &mut Vec<String>| match body.get(k) {
None | Some(Value::Null) => None,
Some(Value::Bool(b)) => Some(*b),
Some(_) => {
details.push("Invalid input: expected boolean".to_string());
None
}
};
let actif_sql = bool_opt("actifSql", &mut details);
let actif_qlik = bool_opt("actifQlik", &mut details);
if details.is_empty() && actif_sql.is_none() && actif_qlik.is_none() {
details.push("Préciser au moins actifSql ou actifQlik".to_string());
}
if !details.is_empty() {
return Err(invalide(details));
}
let reactivation = actif_sql == Some(true) || actif_qlik == Some(true);
let pool = crate::db::pool()?;
let mut qb = sqlx::QueryBuilder::<sqlx::Postgres>::new("UPDATE sync_fournisseurs SET updated_at = now()");
if let Some(v) = actif_sql {
qb.push(", actif_sql = ").push_bind(v);
}
if let Some(v) = actif_qlik {
qb.push(", actif_qlik = ").push_bind(v);
}
if reactivation {
qb.push(", desactive_motif = NULL");
}
qb.push(" WHERE code_fournisseur = ANY(").push_bind(codes).push(") RETURNING code_fournisseur");
let rows = qb.build().fetch_all(&pool).await?;
tracing::info!(
"[api/admin/sync] {} fournisseur(s) mis à jour (sql={} qlik={})",
rows.len(),
actif_sql.map(|b| b.to_string()).unwrap_or_else(|| "undefined".into()),
actif_qlik.map(|b| b.to_string()).unwrap_or_else(|| "undefined".into())
);
Ok(Json(json!({ "success": true, "misAJour": rows.len() })))
}
+126
View File
@@ -0,0 +1,126 @@
//! `/api/admin/sync` — réglages, état et pilotage de la synchronisation nocturne.
use super::sync_scheduler::{debut_fenetre, demander_arret, get_sync_scheduler_state, lancer_round, seed_sync_fournisseurs};
use super::sync_settings::{read_sync_settings, save_sync_settings};
use crate::auth::AdminUser;
use crate::error::{AppError, AppResult};
use axum::http::StatusCode;
use axum::Json;
use chrono::Local;
use serde_json::{json, Map, Value};
/// GET — réglages, état courant, et ouverture de fenêtre.
pub async fn get(_admin: AdminUser) -> AppResult<Json<Value>> {
let settings = read_sync_settings();
let maintenant = Local::now();
let debut = debut_fenetre(&settings, maintenant);
Ok(Json(json!({
"success": true,
"settings": settings,
"state": get_sync_scheduler_state(),
"dansLaFenetre": debut.is_some(),
"fenetreDebutAt": debut.map(|d| crate::db::iso_utc(d.with_timezone(&chrono::Utc))),
"maintenant": crate::db::iso_utc(maintenant.with_timezone(&chrono::Utc)),
})))
}
fn corps_json(body: &[u8]) -> AppResult<Value> {
serde_json::from_slice(body).map_err(|_| AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Corps JSON invalide" })))
}
/// Entier borné « coercé » comme `z.coerce.number().int().min().max()`.
fn coerce_int(v: &Value, min: i64, max: i64) -> Result<i64, String> {
let n = match v {
Value::Number(n) => n.as_f64(),
Value::String(s) if s.trim().is_empty() => Some(0.0),
Value::String(s) => s.trim().parse::<f64>().ok(),
Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
Value::Null => Some(0.0),
_ => None,
};
let Some(n) = n.filter(|n| n.is_finite()) else {
return Err("Invalid input: expected number, received NaN".into());
};
if n.fract() != 0.0 {
return Err("Invalid input: expected int, received number".into());
}
let n = n as i64;
if n < min {
return Err(format!("Too small: expected number to be >={min}"));
}
if n > max {
return Err(format!("Too big: expected number to be <={max}"));
}
Ok(n)
}
/// PATCH — met à jour les réglages.
pub async fn patch(_admin: AdminUser, body: axum::body::Bytes) -> AppResult<Json<Value>> {
let body = corps_json(&body)?;
let Some(obj) = body.as_object() else {
return Err(AppError::with_body(
StatusCode::BAD_REQUEST,
json!({ "error": "Paramètres invalides", "details": [": Invalid input: expected object"] }),
));
};
let mut patch = Map::new();
let mut details = Vec::new();
match obj.get("actif") {
None => {}
Some(Value::Bool(b)) => {
patch.insert("actif".into(), Value::Bool(*b));
}
Some(_) => details.push("actif: Invalid input: expected boolean".to_string()),
}
for (k, min, max) in [("heureDebut", 0, 23), ("heureFin", 0, 23), ("qlikParNuit", 0, 200), ("sqlParQlik", 1, 500), ("qlikMinJours", 0, 365)] {
if let Some(v) = obj.get(k) {
match coerce_int(v, min, max) {
Ok(n) => {
patch.insert(k.into(), Value::from(n));
}
Err(m) => details.push(format!("{k}: {m}")),
}
}
}
if !details.is_empty() {
return Err(AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Paramètres invalides", "details": details })));
}
let settings = save_sync_settings(&patch)?;
Ok(Json(json!({ "success": true, "settings": settings })))
}
/// POST — démarre un round manuellement, l'arrête, ou resynchronise la liste.
pub async fn post(_admin: AdminUser, body: axum::body::Bytes) -> AppResult<(StatusCode, Json<Value>)> {
let body = corps_json(&body)?;
let action = body.get("action").and_then(|v| v.as_str()).unwrap_or_default();
let forcer = match body.get("forcer") {
None => Some(false),
Some(Value::Bool(b)) => Some(*b),
Some(_) => None,
};
if !matches!(action, "start" | "stop" | "seed") || forcer.is_none() {
return Err(AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Action inconnue (start, stop ou seed)" })));
}
match action {
"seed" => {
let n = seed_sync_fournisseurs().await?;
Ok((StatusCode::OK, Json(json!({ "success": true, "fournisseurs": n }))))
}
"stop" => {
demander_arret();
Ok((StatusCode::OK, Json(json!({ "success": true, "state": get_sync_scheduler_state() }))))
}
_ => {
let etat = get_sync_scheduler_state();
if etat.en_cours {
return Ok((StatusCode::CONFLICT, Json(json!({ "error": "Un round est déjà en cours.", "state": etat }))));
}
// Lancé en tâche de fond : le round dure des minutes, le client suit via GET.
let forcer = forcer.unwrap_or(false);
tokio::spawn(async move { lancer_round("manuel", forcer).await });
// Laisse au round le temps de passer « en cours » avant de répondre.
tokio::task::yield_now().await;
Ok((StatusCode::OK, Json(json!({ "success": true, "state": get_sync_scheduler_state() }))))
}
}
}
+501
View File
@@ -0,0 +1,501 @@
//! Synchronisation nocturne des fournisseurs.
//!
//! Deux sources aux coûts très différents, donc deux files distinctes :
//! • **SQL** — l'instantané de grille (`get_product_rows`), ~43 s sur le plus
//! gros fournisseur. Alimente la Grille et `/api/v1`.
//! • **Qlik** — les métriques réseau, jusqu'à 8 min par fournisseur, sur un
//! serveur externe qui abandonne quand on le sollicite trop. Agrégats
//! mensuels : les rafraîchir chaque nuit n'apporterait rien.
//!
//! ORDRE DE PASSAGE — la file est « le plus ancien d'abord », en ignorant ceux
//! déjà traités depuis l'ouverture de la fenêtre courante. Sans curseur ni
//! compteur de tour :
//! • la nuit 2 reprend là où la nuit 1 s'est arrêtée ;
//! • quand plus personne n'est en attente, le tour est fini ; la nuit
//! suivante tout le monde redevient éligible ;
//! • un fournisseur en échec garde une date ancienne : il repasse en tête.
//!
//! La fenêtre ne peut pas interrompre un fournisseur en cours : elle signifie
//! « ne plus en **démarrer** après l'heure de fin ».
use super::sync_settings::{read_sync_settings, SyncSettings};
use chrono::{DateTime, Datelike, Duration, Local, NaiveDateTime, TimeZone, Timelike};
use parking_lot::Mutex;
use regex::Regex;
use serde::Serialize;
use sqlx::Row;
use std::collections::HashSet;
use std::sync::LazyLock;
use std::time::Instant;
// ─── État courant, en mémoire ────────────────────────────────────────────────
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncSchedulerState {
pub en_cours: bool,
/// "auto" = déclenché par la fenêtre nocturne, "manuel" = lancé depuis l'admin.
pub origine: Option<&'static str>,
pub debut_at: Option<String>,
pub fin_at: Option<String>,
pub fournisseur_courant: Option<String>,
pub etape: Option<&'static str>,
pub sql_faits: u32,
pub sql_echecs: u32,
pub qlik_faits: u32,
pub qlik_echecs: u32,
/// Renseigné quand la file s'est vidée : le tour est bouclé.
pub tour_termine: bool,
pub arret_demande: bool,
pub derniere_erreur: Option<String>,
}
static ETAT: LazyLock<Mutex<SyncSchedulerState>> = LazyLock::new(|| Mutex::new(SyncSchedulerState::default()));
pub fn get_sync_scheduler_state() -> SyncSchedulerState {
ETAT.lock().clone()
}
pub fn demander_arret() {
let mut e = ETAT.lock();
if e.en_cours {
e.arret_demande = true;
}
}
fn iso_local(d: DateTime<Local>) -> String {
crate::db::iso_utc(d.with_timezone(&chrono::Utc))
}
// ─── Fenêtre horaire ─────────────────────────────────────────────────────────
fn a_heure(maintenant: DateTime<Local>, heure: i64) -> DateTime<Local> {
let naive = maintenant.date_naive().and_hms_opt(heure.clamp(0, 23) as u32, 0, 0).unwrap();
Local.from_local_datetime(&naive).earliest().unwrap_or(maintenant)
}
/// Début de la fenêtre en cours, ou `None` si l'heure courante est en dehors.
///
/// Gère le passage par minuit (ex. 22h → 5h) : entre 0h et l'heure de fin, la
/// fenêtre a commencé **la veille**.
pub fn debut_fenetre(s: &SyncSettings, maintenant: DateTime<Local>) -> Option<DateTime<Local>> {
let h = maintenant.hour() as i64;
let debut_du_jour = a_heure(maintenant, s.heure_debut);
if s.heure_debut < s.heure_fin {
return (h >= s.heure_debut && h < s.heure_fin).then_some(debut_du_jour);
}
// Fenêtre à cheval sur minuit.
if h >= s.heure_debut {
return Some(debut_du_jour);
}
if h < s.heure_fin {
return Some(debut_du_jour - Duration::days(1));
}
None
}
// ─── Référentiel des fournisseurs ────────────────────────────────────────────
/// Aligne la table de paramétrage sur le référentiel FF : ajoute les nouveaux
/// fournisseurs (actifs par défaut), rafraîchit les noms. Ne supprime rien.
pub async fn seed_sync_fournisseurs() -> anyhow::Result<usize> {
let fournisseurs = crate::ff::pg::pg_get_fournisseurs(None).await;
if fournisseurs.is_empty() {
return Ok(0);
}
let pool = crate::db::pool()?;
for lot in fournisseurs.chunks(500) {
let mut qb = sqlx::QueryBuilder::<sqlx::Postgres>::new("INSERT INTO sync_fournisseurs (code_fournisseur, nom_fournisseur) ");
qb.push_values(lot, |mut b, f| {
b.push_bind(&f.code).push_bind(&f.nom);
});
// Seul le nom est réaligné : les choix d'activation appartiennent à l'admin.
qb.push(" ON CONFLICT (code_fournisseur) DO UPDATE SET nom_fournisseur = excluded.nom_fournisseur");
qb.build().execute(&pool).await?;
}
Ok(fournisseurs.len())
}
// ─── Files d'attente ─────────────────────────────────────────────────────────
struct Cible {
code: String,
nom: Option<String>,
}
fn cible(r: &sqlx::postgres::PgRow) -> Cible {
Cible { code: r.try_get("code_fournisseur").unwrap_or_default(), nom: r.try_get("nom_fournisseur").ok().flatten() }
}
fn naive(d: DateTime<Local>) -> NaiveDateTime {
// Colonnes `timestamp` sans fuseau, écrites par `now()` du serveur (UTC) :
// on compare en UTC.
d.with_timezone(&chrono::Utc).naive_utc()
}
/// Le plus ancien fournisseur SQL pas encore traité depuis l'ouverture de la fenêtre.
async fn prochain_sql(depuis: DateTime<Local>) -> anyhow::Result<Option<Cible>> {
let pool = crate::db::pool()?;
let row = sqlx::query(
"SELECT code_fournisseur, nom_fournisseur FROM sync_fournisseurs
WHERE actif_sql = true AND (dernier_sql_at IS NULL OR dernier_sql_at < $1)
ORDER BY dernier_sql_at ASC NULLS FIRST LIMIT 1",
)
.bind(naive(depuis))
.fetch_optional(&pool)
.await?;
Ok(row.as_ref().map(cible))
}
/// Délai avant de retenter un fournisseur Qlik en échec : la nuit suivante.
const QLIK_REPRISE_ECHEC_H: i64 = 18;
/// Le plus ancien fournisseur Qlik à rafraîchir : données plus vieilles que
/// `qlikMinJours`, ou d'avant le mois en cours (la fenêtre de 12 mois a glissé),
/// ou dernière extraction en échec il y a plus de 18 h.
async fn prochain_qlik(qlik_min_jours: i64, maintenant: DateTime<Local>) -> anyhow::Result<Option<Cible>> {
let seuil_age = maintenant - Duration::days(qlik_min_jours);
let debut_mois = Local
.from_local_datetime(&maintenant.date_naive().with_day(1).unwrap().and_hms_opt(0, 0, 0).unwrap())
.earliest()
.unwrap_or(maintenant);
let seuil = if seuil_age > debut_mois { seuil_age } else { debut_mois };
let seuil_echec = maintenant - Duration::hours(QLIK_REPRISE_ECHEC_H);
let pool = crate::db::pool()?;
let row = sqlx::query(
"SELECT code_fournisseur, nom_fournisseur FROM sync_fournisseurs
WHERE actif_qlik = true AND (
dernier_qlik_at IS NULL
OR dernier_qlik_at < $1
OR (dernier_qlik_statut = 'echec' AND dernier_qlik_at < $2)
)
ORDER BY dernier_qlik_at ASC NULLS FIRST LIMIT 1",
)
.bind(naive(seuil))
.bind(naive(seuil_echec))
.fetch_optional(&pool)
.await?;
Ok(row.as_ref().map(cible))
}
// ─── Traitement d'un fournisseur ─────────────────────────────────────────────
fn libelle(code: &str, nom: &Option<String>) -> String {
match nom {
Some(n) if !n.is_empty() => format!("{code} — {n}"),
_ => code.to_string(),
}
}
fn tronque(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
/// Instantané SQL. `dernier_sql_at` est renseigné **même en cas d'échec** :
/// sans cela, un fournisseur en échec resterait le plus ancien et bloquerait la file.
async fn traiter_sql(code: &str, nom: &Option<String>) {
let t0 = Instant::now();
{
let mut e = ETAT.lock();
e.fournisseur_courant = Some(libelle(code, nom));
e.etape = Some("sql");
}
let res = crate::grid::product_rows::get_product_rows_for_sync(code).await;
let ms = t0.elapsed().as_millis() as i32;
let pool = match crate::db::pool() {
Ok(p) => p,
Err(_) => return,
};
match res {
Ok(n) => {
let vide = n == 0;
// Un fournisseur sans article n'a rien à donner : on le sort des deux
// files plutôt que de le réessayer chaque nuit (motif visible par l'admin).
let sql = if vide {
"UPDATE sync_fournisseurs SET dernier_sql_at = now(), dernier_sql_statut = 'vide', dernier_sql_lignes = $2,
dernier_sql_erreur = NULL, dernier_sql_ms = $3, actif_sql = false, actif_qlik = false,
desactive_motif = 'Aucun article — désactivé automatiquement', updated_at = now() WHERE code_fournisseur = $1"
} else {
"UPDATE sync_fournisseurs SET dernier_sql_at = now(), dernier_sql_statut = 'succes', dernier_sql_lignes = $2,
dernier_sql_erreur = NULL, dernier_sql_ms = $3, updated_at = now() WHERE code_fournisseur = $1"
};
if let Err(e) = sqlx::query(sql).bind(code).bind(n as i32).bind(ms).execute(&pool).await {
tracing::error!("[sync-nuit] suivi SQL {code} non écrit : {e}");
}
if vide {
tracing::info!("[sync-nuit] {code} sans article — désactivé automatiquement");
} else {
ETAT.lock().sql_faits += 1;
}
}
Err(err) => {
let msg = format!("{err:#}");
{
let mut e = ETAT.lock();
e.sql_echecs += 1;
e.derniere_erreur = Some(format!("{code} (SQL) : {msg}"));
}
tracing::error!("[sync-nuit] SQL {code} en échec : {msg}");
let _ = sqlx::query(
"UPDATE sync_fournisseurs SET dernier_sql_at = now(), dernier_sql_statut = 'echec', dernier_sql_erreur = $2,
dernier_sql_ms = $3, updated_at = now() WHERE code_fournisseur = $1",
)
.bind(code)
.bind(tronque(&msg, 500))
.bind(ms)
.execute(&pool)
.await;
}
}
}
/// Format d'un code centrale plausible — même filtre que la synchro manuelle.
static CODE_CENTRALE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^[A-Z0-9][A-Z0-9_-]{1,29}$").unwrap());
/// Extraction Qlik. Même politique que le SQL sur l'horodatage en cas d'échec.
async fn traiter_qlik(code: &str, nom: &Option<String>) {
let t0 = Instant::now();
{
let mut e = ETAT.lock();
e.fournisseur_courant = Some(libelle(code, nom));
e.etape = Some("qlik");
}
let res: anyhow::Result<Option<usize>> = async {
let articles = crate::ff::pg::pg_get_articles_by_fournisseur(code).await?;
let mut vus = HashSet::new();
let codes: Vec<String> = articles
.iter()
.map(|a| a.code_centrale.clone().unwrap_or_default().trim().to_string())
.filter(|c| !c.is_empty() && c != "-" && CODE_CENTRALE.is_match(c))
.filter(|c| vus.insert(c.clone()))
.collect();
if codes.is_empty() {
return Ok(None);
}
let n = super::qlik_bridge::extraire_et_enregistrer(codes, code).await?;
Ok(Some(n))
}
.await;
let ms = t0.elapsed().as_millis() as i32;
let Ok(pool) = crate::db::pool() else { return };
match res {
Ok(None) => {
let _ = sqlx::query(
"UPDATE sync_fournisseurs SET dernier_qlik_at = now(), dernier_qlik_statut = 'vide', dernier_qlik_codes = 0,
dernier_qlik_erreur = NULL, dernier_qlik_ms = $2, updated_at = now() WHERE code_fournisseur = $1",
)
.bind(code)
.bind(ms)
.execute(&pool)
.await;
}
Ok(Some(n)) => {
let _ = sqlx::query(
"UPDATE sync_fournisseurs SET dernier_qlik_at = now(), dernier_qlik_statut = 'succes', dernier_qlik_codes = $2,
dernier_qlik_erreur = NULL, dernier_qlik_ms = $3, updated_at = now() WHERE code_fournisseur = $1",
)
.bind(code)
.bind(n as i32)
.bind(ms)
.execute(&pool)
.await;
ETAT.lock().qlik_faits += 1;
tracing::info!("[sync-nuit] Qlik {code} — {n} codes en {} s", t0.elapsed().as_secs());
}
Err(err) => {
let msg = format!("{err:#}");
{
let mut e = ETAT.lock();
e.qlik_echecs += 1;
e.derniere_erreur = Some(format!("{code} (Qlik) : {msg}"));
}
tracing::error!("[sync-nuit] Qlik {code} en échec : {msg}");
let _ = sqlx::query(
"UPDATE sync_fournisseurs SET dernier_qlik_at = now(), dernier_qlik_statut = 'echec', dernier_qlik_erreur = $2,
dernier_qlik_ms = $3, updated_at = now() WHERE code_fournisseur = $1",
)
.bind(code)
.bind(tronque(&msg, 500))
.bind(ms)
.execute(&pool)
.await;
}
}
}
// ─── Boucle de travail ───────────────────────────────────────────────────────
/// Traite les files jusqu'à épuisement, sortie de fenêtre, ou demande d'arrêt.
///
/// Les deux files avancent en parallèle : une extraction Qlik est intercalée
/// tous les `sqlParQlik` fournisseurs SQL. Sans cet entrelacement, Qlik ne
/// démarrerait jamais tant que la file SQL n'est pas vide.
pub async fn lancer_round(origine: &'static str, forcer_hors_fenetre: bool) {
let s = read_sync_settings();
let maintenant = Local::now();
let debut = debut_fenetre(&s, maintenant);
if !forcer_hors_fenetre && debut.is_none() {
return;
}
{
let mut e = ETAT.lock();
if e.en_cours {
return;
}
*e = SyncSchedulerState {
en_cours: true,
origine: Some(origine),
debut_at: Some(iso_local(maintenant)),
..Default::default()
};
}
// Un lancement manuel hors fenêtre borne la file sur « depuis ce matin ».
let depuis = debut.unwrap_or_else(|| a_heure(maintenant, 0));
tracing::info!("[sync-nuit] round {origine} démarré (fenêtre {}h-{}h, Qlik ≤ {})", s.heure_debut, s.heure_fin, s.qlik_par_nuit);
let res: anyhow::Result<()> = async {
seed_sync_fournisseurs().await?;
// Garde anti-boucle : l'avancement de la file repose sur l'écriture de
// `dernier*_at`, volontairement non bloquante en cas d'erreur.
let mut tentes: HashSet<String> = HashSet::new();
let mut depuis_dernier_qlik: i64 = 0;
loop {
if ETAT.lock().arret_demande {
break;
}
if !forcer_hors_fenetre && debut_fenetre(&s, Local::now()).is_none() {
tracing::info!("[sync-nuit] fin de fenêtre — arrêt après le fournisseur en cours");
break;
}
let qlik_possible = {
let e = ETAT.lock();
((e.qlik_faits + e.qlik_echecs) as i64) < s.qlik_par_nuit
};
// Qlik prioritaire quand le quota d'entrelacement est atteint.
if qlik_possible && depuis_dernier_qlik >= s.sql_par_qlik {
if let Some(c) = prochain_qlik(s.qlik_min_jours, Local::now()).await? {
if tentes.insert(format!("qlik:{}", c.code)) {
traiter_qlik(&c.code, &c.nom).await;
depuis_dernier_qlik = 0;
continue;
}
}
}
if let Some(c) = prochain_sql(depuis).await? {
if !tentes.insert(format!("sql:{}", c.code)) {
tracing::error!(
"[sync-nuit] {} ressort de la file après avoir été traité : le suivi n'a pas pu être écrit. Arrêt du round pour ne pas boucler.",
c.code
);
break;
}
traiter_sql(&c.code, &c.nom).await;
depuis_dernier_qlik += 1;
continue;
}
// File SQL vide : on consacre le temps restant à Qlik.
if qlik_possible {
if let Some(c) = prochain_qlik(s.qlik_min_jours, Local::now()).await? {
if tentes.insert(format!("qlik:{}", c.code)) {
traiter_qlik(&c.code, &c.nom).await;
depuis_dernier_qlik = 0;
continue;
}
}
}
ETAT.lock().tour_termine = true;
tracing::info!("[sync-nuit] plus rien en attente — tour terminé");
break;
}
Ok(())
}
.await;
if let Err(e) = res {
let msg = format!("{e:#}");
tracing::error!("[sync-nuit] round interrompu : {msg}");
ETAT.lock().derniere_erreur = Some(msg);
}
// Les pages (stock, hit-parade, CA…) relisent la base fraîche.
crate::ff_cache::invalidate();
let e = {
let mut e = ETAT.lock();
e.en_cours = false;
e.fournisseur_courant = None;
e.etape = None;
e.fin_at = Some(iso_local(Local::now()));
e.clone()
};
tracing::info!(
"[sync-nuit] round terminé — SQL {} ok / {} en échec, Qlik {} ok / {} en échec, tour bouclé={}",
e.sql_faits,
e.sql_echecs,
e.qlik_faits,
e.qlik_echecs,
e.tour_termine
);
}
/// Battement du minuteur : ne fait quelque chose que dans la fenêtre, et jamais
/// deux rounds à la fois. Silencieux hors fenêtre — appelé chaque minute.
pub async fn tick_scheduler() {
let s = read_sync_settings();
if !s.actif || ETAT.lock().en_cours {
return;
}
if debut_fenetre(&s, Local::now()).is_none() {
return;
}
lancer_round("auto", false).await;
}
/// Minuteur de la synchronisation nocturne : bat chaque minute et laisse
/// `tick_scheduler` décider s'il y a quelque chose à faire. Rien ne tourne si
/// l'application est arrêtée ; il se réarme tout seul au redémarrage.
pub fn start_sync_timer() {
tracing::info!("[sync-nuit] minuteur démarré (battement 60 s)");
tokio::spawn(async {
let mut intervalle = tokio::time::interval(std::time::Duration::from_secs(60));
intervalle.tick().await;
loop {
intervalle.tick().await;
// Un round dure longtemps : il tourne dans sa propre tâche pour qu'une
// panique éventuelle n'arrête pas le minuteur.
if let Err(e) = tokio::spawn(tick_scheduler()).await {
tracing::error!("[sync-nuit] battement en erreur : {e}");
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admin::sync_settings::SYNC_SETTINGS_DEFAUT;
fn at(h: u32) -> DateTime<Local> {
Local.with_ymd_and_hms(2026, 3, 10, h, 30, 0).unwrap()
}
#[test]
fn fenetre_simple() {
let s = SyncSettings { heure_debut: 1, heure_fin: 5, ..SYNC_SETTINGS_DEFAUT };
assert!(debut_fenetre(&s, at(0)).is_none());
assert_eq!(debut_fenetre(&s, at(3)).unwrap().hour(), 1);
assert!(debut_fenetre(&s, at(5)).is_none());
}
#[test]
fn fenetre_minuit() {
let s = SyncSettings { heure_debut: 22, heure_fin: 5, ..SYNC_SETTINGS_DEFAUT };
assert_eq!(debut_fenetre(&s, at(23)).unwrap().day(), 10);
let d = debut_fenetre(&s, at(2)).unwrap();
assert_eq!((d.day(), d.hour()), (9, 22));
assert!(debut_fenetre(&s, at(12)).is_none());
}
}
+121
View File
@@ -0,0 +1,121 @@
//! Réglages de la synchronisation nocturne, rangés dans `data/.db-config.json`
//! (clé `syncNuit`) comme les autres réglages de l'application.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SyncSettings {
/// Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter
/// Qlik et la base la nuit suivante sans que personne l'ait décidé.
pub actif: bool,
/// Heure d'ouverture de la fenêtre (0-23).
pub heure_debut: i64,
/// Heure de fermeture. Aucun fournisseur n'est **démarré** après.
pub heure_fin: i64,
/// Nombre maximum d'extractions Qlik par nuit (chacune peut prendre 8 min).
pub qlik_par_nuit: i64,
/// Une extraction Qlik est intercalée tous les N fournisseurs SQL.
pub sql_par_qlik: i64,
/// Âge minimal des données Qlik avant de les refaire.
pub qlik_min_jours: i64,
}
pub const SYNC_SETTINGS_DEFAUT: SyncSettings =
SyncSettings { actif: false, heure_debut: 0, heure_fin: 5, qlik_par_nuit: 6, sql_par_qlik: 20, qlik_min_jours: 7 };
fn borne(v: Option<&Value>, min: i64, max: i64, defaut: i64) -> i64 {
let n = match v {
Some(Value::Number(n)) => n.as_f64(),
Some(Value::String(s)) => s.trim().parse::<f64>().ok(),
Some(Value::Bool(b)) => Some(if *b { 1.0 } else { 0.0 }),
Some(Value::Null) => Some(0.0),
_ => None,
};
match n {
Some(f) if f.is_finite() => (f.trunc() as i64).clamp(min, max),
_ => defaut,
}
}
fn truthy(v: Option<&Value>) -> bool {
match v {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(false),
Some(Value::String(s)) => !s.is_empty(),
Some(_) => true,
}
}
/// Normalise ce qui vient du fichier : il est éditable à la main.
pub fn normaliser(brut: Option<&Map<String, Value>>) -> SyncSettings {
let d = SYNC_SETTINGS_DEFAUT;
let g = |k: &str| brut.and_then(|m| m.get(k));
let mut s = SyncSettings {
actif: truthy(g("actif")),
heure_debut: borne(g("heureDebut"), 0, 23, d.heure_debut),
heure_fin: borne(g("heureFin"), 0, 23, d.heure_fin),
qlik_par_nuit: borne(g("qlikParNuit"), 0, 200, d.qlik_par_nuit),
sql_par_qlik: borne(g("sqlParQlik"), 1, 500, d.sql_par_qlik),
qlik_min_jours: borne(g("qlikMinJours"), 0, 365, d.qlik_min_jours),
};
// Une fenêtre de durée nulle ne s'ouvrirait jamais : retour au défaut.
if s.heure_debut == s.heure_fin {
s.heure_debut = d.heure_debut;
s.heure_fin = d.heure_fin;
}
s
}
fn lire_fichier() -> Option<Map<String, Value>> {
let raw = std::fs::read_to_string(crate::config::config_file()).ok()?;
match serde_json::from_str::<Value>(&raw).ok()? {
Value::Object(m) => Some(m),
_ => None,
}
}
pub fn read_sync_settings() -> SyncSettings {
match lire_fichier() {
Some(cfg) => normaliser(cfg.get("syncNuit").and_then(|v| v.as_object())),
None => SYNC_SETTINGS_DEFAUT,
}
}
/// Fusionne `patch` (champs camelCase partiels) dans les réglages enregistrés.
pub fn save_sync_settings(patch: &Map<String, Value>) -> std::io::Result<SyncSettings> {
let existant = lire_fichier().unwrap_or_default();
let courant = normaliser(existant.get("syncNuit").and_then(|v| v.as_object()));
let mut fusion = match serde_json::to_value(courant) {
Ok(Value::Object(m)) => m,
_ => Map::new(),
};
for (k, v) in patch {
fusion.insert(k.clone(), v.clone());
}
let suivant = normaliser(Some(&fusion));
let mut p = Map::new();
p.insert("syncNuit".into(), serde_json::to_value(suivant).unwrap_or(Value::Null));
crate::config::write_config(p)?;
tracing::info!("[sync-nuit] réglages enregistrés : {}", serde_json::to_string(&suivant).unwrap_or_default());
Ok(suivant)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn normalise() {
let m = json!({ "actif": 1, "heureDebut": "2", "heureFin": 30, "qlikParNuit": -4, "sqlParQlik": 0 });
let s = normaliser(m.as_object());
assert!(s.actif);
assert_eq!((s.heure_debut, s.heure_fin, s.qlik_par_nuit, s.sql_par_qlik, s.qlik_min_jours), (2, 23, 0, 1, 7));
let m = json!({ "heureDebut": 4, "heureFin": 4 });
let s = normaliser(m.as_object());
assert_eq!((s.heure_debut, s.heure_fin), (0, 5));
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Gestion des comptes (administrateurs) — port de `user-actions.ts`.
use crate::auth::password::hash_password;
use crate::error::AppResult;
use crate::rpc::{json, Ctx};
use serde::Serialize;
use serde_json::{json as j, Value};
use sqlx::Row;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct UserRow {
id: i32,
username: String,
role: String,
created_at: Option<String>,
}
/// Tous les utilisateurs.
pub async fn get_users(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let pool = crate::db::pool()?;
let rows = sqlx::query("SELECT id, username, role, created_at FROM users").fetch_all(&pool).await?;
let users: Vec<UserRow> = rows
.iter()
.map(|r| UserRow {
id: r.try_get("id").unwrap_or_default(),
username: r.try_get("username").unwrap_or_default(),
role: r.try_get("role").unwrap_or_default(),
created_at: r.try_get::<Option<chrono::NaiveDateTime>, _>("created_at").ok().flatten().map(crate::db::iso),
})
.collect();
json(users)
}
/// Crée un utilisateur.
pub async fn create_user(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let username: String = ctx.arg(0)?;
let password: String = ctx.arg(1)?;
let role: Option<String> = ctx.arg(2)?;
let role = role.unwrap_or_else(|| "user".into());
let pool = crate::db::pool()?;
match sqlx::query("INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3)")
.bind(&username)
.bind(hash_password(&password))
.bind(&role)
.execute(&pool)
.await
{
Ok(_) => Ok(j!({ "success": true })),
Err(e) => {
tracing::error!("CreateUser Error: {e}");
Ok(j!({ "success": false, "error": "L'utilisateur existe déjà ou une erreur technique est survenue." }))
}
}
}
/// Supprime un utilisateur (jamais son propre compte).
pub async fn delete_user(ctx: Ctx) -> AppResult<Value> {
let moi = ctx.admin().await?;
let id: i64 = ctx.arg(0)?;
if moi.id.parse::<i64>().ok() == Some(id) {
return Ok(j!({ "success": false, "error": "Vous ne pouvez pas supprimer votre propre compte." }));
}
let pool = crate::db::pool()?;
match sqlx::query("DELETE FROM users WHERE id = $1").bind(id as i32).execute(&pool).await {
Ok(_) => {
crate::auth::authz::invalider(&id.to_string());
Ok(j!({ "success": true }))
}
Err(_) => Ok(j!({ "success": false, "error": "Erreur lors de la suppression." })),
}
}
/// Change le mot de passe d'un utilisateur.
pub async fn update_password(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let id: i64 = ctx.arg(0)?;
let new_password: String = ctx.arg(1)?;
let pool = crate::db::pool()?;
match sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
.bind(hash_password(&new_password))
.bind(id as i32)
.execute(&pool)
.await
{
Ok(_) => Ok(j!({ "success": true })),
Err(_) => Ok(j!({ "success": false, "error": "Erreur lors de la mise à jour du mot de passe." })),
}
}
+81
View File
@@ -0,0 +1,81 @@
//! Enveloppe de réponse de l'API publique `/api/v1`.
//!
//! Une forme unique : `{ data, pagination?, meta }` en succès,
//! `{ error: { code, message, details? } }` en échec, avec de vrais statuts HTTP.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
use serde_json::{json, Map, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiErrorCode {
Unauthorized,
Forbidden,
BadRequest,
NotFound,
/// Donnée pas encore calculée : 202, ce n'est pas un échec.
NotReady,
InternalError,
}
impl ApiErrorCode {
pub fn as_str(self) -> &'static str {
match self {
Self::Unauthorized => "unauthorized",
Self::Forbidden => "forbidden",
Self::BadRequest => "bad_request",
Self::NotFound => "not_found",
Self::NotReady => "not_ready",
Self::InternalError => "internal_error",
}
}
pub fn status(self) -> StatusCode {
match self {
Self::Unauthorized => StatusCode::UNAUTHORIZED,
Self::Forbidden => StatusCode::FORBIDDEN,
Self::BadRequest => StatusCode::BAD_REQUEST,
Self::NotFound => StatusCode::NOT_FOUND,
Self::NotReady => StatusCode::ACCEPTED,
Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Pagination {
pub page: i64,
pub limit: i64,
pub total: i64,
pub total_pages: i64,
/// `true` s'il reste des pages à lire.
pub has_more: bool,
}
pub fn build_pagination(page: i64, limit: i64, total: i64) -> Pagination {
let limit = limit.max(1);
let total_pages = ((total as f64) / (limit as f64)).ceil().max(1.0) as i64;
Pagination { page, limit, total, total_pages, has_more: page < total_pages }
}
/// Réponse de succès. `meta` porte la fraîcheur de la donnée (`computedAt`).
pub fn ok<T: Serialize>(data: T, pagination: Option<Pagination>, meta: Option<Map<String, Value>>) -> Response {
let mut body = Map::new();
body.insert("data".into(), serde_json::to_value(data).unwrap_or(Value::Null));
if let Some(p) = pagination {
body.insert("pagination".into(), serde_json::to_value(p).unwrap_or(Value::Null));
}
body.insert("meta".into(), Value::Object(meta.unwrap_or_default()));
Json(Value::Object(body)).into_response()
}
/// Réponse d'erreur. `details` sert notamment aux erreurs de validation.
pub fn fail(code: ApiErrorCode, message: impl Into<String>, details: Option<Value>) -> Response {
let mut err = json!({ "code": code.as_str(), "message": message.into() });
if let Some(d) = details {
err["details"] = d;
}
(code.status(), Json(json!({ "error": err }))).into_response()
}
+149
View File
@@ -0,0 +1,149 @@
//! Authentification de l'API publique `/api/v1`.
//!
//! Deux voies : clé d'API (`X-API-Key` ou `Authorization: Bearer …`) pour les
//! appelants non-navigateur, ou session du navigateur (l'interface utilise les
//! mêmes routes). Échec → 401 JSON au format `/api/v1`, jamais de redirection.
//!
//! Seul le hachage SHA-256 des clés est stocké (table `api_keys`).
use crate::api_v1_response::{fail, ApiErrorCode};
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::HeaderMap;
use axum::response::Response;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use parking_lot::Mutex;
use rand::RngCore;
use sha2::{Digest, Sha256};
use sqlx::Row;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use subtle::ConstantTimeEq;
const KEY_PREFIX: &str = "cf_";
const TRACE_USAGE: Duration = Duration::from_secs(60);
static DERNIERE_TRACE: LazyLock<Mutex<HashMap<i32, Instant>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone)]
pub struct AuthContext {
/// "api_key" | "session"
pub via: &'static str,
pub role: String,
/// Nom de la clé, ou nom de l'utilisateur connecté.
pub subject: String,
}
impl AuthContext {
/// Restreint un endpoint aux appelants administrateurs.
pub fn require_admin(&self) -> Result<(), Response> {
if self.role != "admin" {
return Err(fail(ApiErrorCode::Forbidden, "Rôle administrateur requis pour cette opération.", None));
}
Ok(())
}
}
pub fn hash_api_key(key: &str) -> String {
hex::encode(Sha256::digest(key.as_bytes()))
}
pub struct GeneratedKey {
pub key: String,
pub key_hash: String,
pub key_prefix: String,
}
/// Génère une clé : en clair (affichée une seule fois), son hachage, et un préfixe lisible.
pub fn generate_api_key() -> GeneratedKey {
let mut buf = [0u8; 24];
rand::thread_rng().fill_bytes(&mut buf);
let key = format!("{KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(buf));
GeneratedKey { key_hash: hash_api_key(&key), key_prefix: key.chars().take(11).collect(), key }
}
fn hashes_equal(a: &str, b: &str) -> bool {
match (hex::decode(a), hex::decode(b)) {
(Ok(x), Ok(y)) => !x.is_empty() && x.len() == y.len() && bool::from(x.as_slice().ct_eq(y.as_slice())),
_ => false,
}
}
fn read_key(headers: &HeaderMap) -> Option<String> {
if let Some(v) = headers.get("x-api-key").and_then(|v| v.to_str().ok()) {
if !v.trim().is_empty() {
return Some(v.trim().to_string());
}
}
let authz = headers.get("authorization").and_then(|v| v.to_str().ok())?;
let re = regex::Regex::new(r"(?i)^Bearer\s+(.+)$").unwrap();
re.captures(authz).map(|c| c[1].trim().to_string())
}
/// Authentifie une requête `/api/v1`.
pub async fn require_api_auth(headers: &HeaderMap) -> Result<AuthContext, Response> {
if let Some(presented) = read_key(headers) {
let presented_hash = hash_api_key(&presented);
let pool = crate::db::pool().map_err(|e| fail(ApiErrorCode::InternalError, e.message, None))?;
let row = sqlx::query("SELECT id, name, role, key_hash, revoked_at FROM api_keys WHERE key_hash = $1 LIMIT 1")
.bind(&presented_hash)
.fetch_optional(&pool)
.await
.map_err(|e| fail(ApiErrorCode::InternalError, e.to_string(), None))?;
let Some(row) = row else {
return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API invalide.", None));
};
let key_hash: String = row.try_get("key_hash").unwrap_or_default();
if !hashes_equal(&key_hash, &presented_hash) {
return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API invalide.", None));
}
let revoked: Option<chrono::NaiveDateTime> = row.try_get("revoked_at").ok().flatten();
if revoked.is_some() {
return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API révoquée.", None));
}
let id: i32 = row.try_get("id").unwrap_or_default();
// Trace d'usage — jamais bloquante, au plus une écriture par minute et par clé.
let tracer = {
let mut m = DERNIERE_TRACE.lock();
let due = m.get(&id).map(|t| t.elapsed() >= TRACE_USAGE).unwrap_or(true);
if due {
m.insert(id, Instant::now());
}
due
};
if tracer {
tokio::spawn(async move {
if let Err(e) = sqlx::query("UPDATE api_keys SET last_used_at = now() WHERE id = $1").bind(id).execute(&pool).await {
tracing::error!("[api-auth] lastUsedAt KO: {}", crate::error::short(&e, 120));
}
});
}
return Ok(AuthContext {
via: "api_key",
role: row.try_get("role").unwrap_or_else(|_| "user".into()),
subject: row.try_get("name").unwrap_or_default(),
});
}
// Repli sur la session du navigateur.
if let Some(c) = super::session::from_headers(headers) {
return Ok(AuthContext { via: "session", role: c.role, subject: c.name });
}
Err(fail(
ApiErrorCode::Unauthorized,
"Authentification requise : fournissez un en-tête X-API-Key (ou Authorization: Bearer), ou connectez-vous.",
None,
))
}
/// Extracteur pour les routes `/api/v1`.
pub struct ApiAuth(pub AuthContext);
impl<S: Send + Sync> FromRequestParts<S> for ApiAuth {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(ApiAuth(require_api_auth(&parts.headers).await?))
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Contrôle d'accès commun aux actions, routes et pages.
//!
//! Le jeton de session seul ne suffit pas : il reste valide jusqu'à son
//! expiration, même si le compte a été supprimé ou rétrogradé entre-temps. On
//! relit donc l'utilisateur dans `users` (cache court), et le rôle en base
//! l'emporte sur celui du jeton.
//!
//! Base injoignable : on se contente du jeton (mode dégradé), sans quoi le
//! compte de secours `data/users.json` ne pourrait plus rien faire — y compris
//! configurer la base.
use super::session::{from_headers, Claims};
use crate::error::AppError;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use parking_lot::Mutex;
use serde::Serialize;
use sqlx::Row;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
pub const MESSAGE_SESSION: &str = "Session expirée : reconnectez-vous.";
pub const MESSAGE_ADMIN: &str = "Action réservée aux administrateurs.";
const CACHE_TTL: Duration = Duration::from_secs(60);
const PAUSE_BASE_INJOIGNABLE: Duration = Duration::from_secs(15);
#[derive(Debug, Clone, Serialize)]
pub struct UtilisateurCourant {
pub id: String,
pub username: String,
pub role: String,
}
impl UtilisateurCourant {
pub fn is_admin(&self) -> bool {
self.role == "admin"
}
/// Identifiant numérique (`users.id`), `None` pour le compte de secours.
pub fn id_numerique(&self) -> Option<i32> {
self.id.parse::<i32>().ok().filter(|v| *v > 0)
}
}
#[derive(Clone)]
struct Fiche {
exists: bool,
role: String,
username: String,
at: Instant,
}
struct CacheState {
fiches: HashMap<String, Fiche>,
injoignable_depuis: Option<Instant>,
}
static CACHE: LazyLock<Mutex<CacheState>> =
LazyLock::new(|| Mutex::new(CacheState { fiches: HashMap::new(), injoignable_depuis: None }));
/// Oublie la fiche d'un utilisateur (après modification de son compte).
pub fn invalider(id: &str) {
CACHE.lock().fiches.remove(id);
}
/// Fiche de l'utilisateur en base, ou `None` si la base n'a pas pu répondre.
async fn lire_fiche(id: &str) -> Option<Fiche> {
{
let c = CACHE.lock();
if let Some(f) = c.fiches.get(id) {
if f.at.elapsed() < CACHE_TTL {
return Some(f.clone());
}
}
if c.injoignable_depuis.map(|t| t.elapsed() < PAUSE_BASE_INJOIGNABLE).unwrap_or(false) {
return None;
}
}
let id_num: i32 = if id.chars().all(|c| c.is_ascii_digit()) && !id.is_empty() {
id.parse::<i32>().unwrap_or(-1)
} else {
-1
};
let res = async {
let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?;
let row = sqlx::query("SELECT username, role FROM users WHERE id = $1 LIMIT 1")
.bind(id_num)
.fetch_optional(&pool)
.await?;
anyhow::Ok(row)
}
.await;
match res {
Ok(row) => {
let fiche = match row {
Some(r) => Fiche {
exists: true,
username: r.try_get("username").unwrap_or_default(),
role: r.try_get("role").unwrap_or_default(),
at: Instant::now(),
},
None => Fiche { exists: false, username: String::new(), role: String::new(), at: Instant::now() },
};
CACHE.lock().fiches.insert(id.to_string(), fiche.clone());
Some(fiche)
}
Err(e) => {
CACHE.lock().injoignable_depuis = Some(Instant::now());
tracing::warn!("[authz] Base injoignable, contrôle sur le seul jeton de session : {}", crate::error::short(&e, 200));
None
}
}
}
/// Utilisateur connecté, relu en base. 401 sinon.
pub async fn require_session(token: Option<&Claims>) -> Result<UtilisateurCourant, AppError> {
let jeton = token.ok_or_else(|| AppError::unauthorized(MESSAGE_SESSION))?;
match lire_fiche(&jeton.id).await {
None => Ok(UtilisateurCourant { id: jeton.id.clone(), username: jeton.name.clone(), role: jeton.role.clone() }),
Some(f) if !f.exists => Err(AppError::unauthorized(MESSAGE_SESSION)),
Some(f) => Ok(UtilisateurCourant { id: jeton.id.clone(), username: f.username, role: f.role }),
}
}
/// Administrateur connecté. 401 ou 403 sinon.
pub async fn require_admin(token: Option<&Claims>) -> Result<UtilisateurCourant, AppError> {
let u = require_session(token).await?;
if !u.is_admin() {
return Err(AppError::forbidden(MESSAGE_ADMIN));
}
Ok(u)
}
/// Extracteur : utilisateur connecté (sinon 401 JSON `{ error }`).
pub struct SessionUser(pub UtilisateurCourant);
impl<S: Send + Sync> FromRequestParts<S> for SessionUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let claims = from_headers(&parts.headers);
Ok(SessionUser(require_session(claims.as_ref()).await?))
}
}
/// Extracteur : administrateur connecté (sinon 401/403 JSON `{ error }`).
pub struct AdminUser(pub UtilisateurCourant);
impl<S: Send + Sync> FromRequestParts<S> for AdminUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let claims = from_headers(&parts.headers);
Ok(AdminUser(require_admin(claims.as_ref()).await?))
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Comptes de secours `data/users.json`, utilisés seulement quand la base est
//! injoignable (première installation, base pas encore configurée).
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserJson {
pub id: String,
pub username: String,
pub password_hash: String,
pub role: String,
}
fn path() -> PathBuf {
crate::config::data_dir().join("users.json")
}
fn default_admin() -> UserJson {
UserJson {
id: "0".into(),
username: "admin".into(),
password_hash: super::password::hash_password("admin"),
role: "admin".into(),
}
}
/// Comptes de secours (créés avec admin/admin si le fichier n'existe pas).
pub fn get_fallback_users() -> Vec<UserJson> {
let p = path();
if !p.exists() {
let admin = default_admin();
save_fallback_users(&[admin.clone()]);
return vec![admin];
}
match std::fs::read_to_string(&p).ok().and_then(|s| serde_json::from_str(&s).ok()) {
Some(users) => users,
None => {
tracing::error!("[AUTH] Error reading users.json");
vec![]
}
}
}
pub fn save_fallback_users(users: &[UserJson]) {
let p = path();
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Err(e) = std::fs::write(&p, serde_json::to_string_pretty(users).unwrap_or_default()) {
tracing::error!("[AUTH] Error writing users.json: {e}");
}
}
/// Base injoignable : le fichier de secours doit contenir au moins un administrateur.
pub fn assurer_admin_de_secours() {
if get_fallback_users().is_empty() {
tracing::info!("[AUTH] Initializing local JSON fallback with admin/admin...");
save_fallback_users(&[default_admin()]);
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Authentification : comptes, sessions, clés d'API.
pub mod api_key;
pub mod authz;
pub mod fallback;
pub mod password;
pub mod session;
pub use api_key::ApiAuth;
pub use authz::{require_admin, require_session, AdminUser, SessionUser, UtilisateurCourant};
pub use session::{Claims, Token};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;
use sqlx::Row;
use tokio::sync::Mutex;
// ---------------------------------------------------------------------------
// Amorçage du compte admin
// ---------------------------------------------------------------------------
/// `true` une fois l'amorçage réussi pour ce processus.
static AMORCE: Mutex<bool> = Mutex::const_new(false);
/// Prépare les comptes au premier démarrage, une seule fois par processus.
///
/// - Base joignable : crée la table `users` si besoin, puis admin/admin
/// **uniquement si la table est vide**.
/// - Base injoignable : s'assure que le fichier de secours contient un
/// administrateur ; on retentera la base à la prochaine connexion.
pub async fn ensure_admin_exists() -> bool {
let mut done = AMORCE.lock().await;
if *done {
return false;
}
match amorcer().await {
Ok(cree) => {
*done = true;
cree
}
Err(e) => {
tracing::warn!("[AUTH] Database seeding failed (expected if DB not configured yet): {e:#}");
false
}
}
}
async fn amorcer() -> anyhow::Result<bool> {
let pool = match crate::db::pool() {
Ok(p) => p,
Err(e) => {
fallback::assurer_admin_de_secours();
anyhow::bail!(e.message);
}
};
if let Err(e) = sqlx::query("SELECT 1").execute(&pool).await {
fallback::assurer_admin_de_secours();
return Err(e.into());
}
sqlx::raw_sql(
r#"CREATE TABLE IF NOT EXISTS "users" (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
created_at TIMESTAMP DEFAULT NOW()
)"#,
)
.execute(&pool)
.await?;
let nb: i64 = sqlx::query_scalar("SELECT count(*) FROM users").fetch_one(&pool).await?;
if nb > 0 {
return Ok(false);
}
tracing::info!("[AUTH] Table users vide : création du compte admin/admin.");
let res = sqlx::query("INSERT INTO users (username, password_hash, role) VALUES ('admin', $1, 'admin') ON CONFLICT DO NOTHING")
.bind(password::hash_password("admin"))
.execute(&pool)
.await?;
Ok(res.rows_affected() > 0)
}
// ---------------------------------------------------------------------------
// Connexion / déconnexion
// ---------------------------------------------------------------------------
struct CompteTrouve {
id: String,
username: String,
password_hash: String,
role: String,
}
/// Vérifie les identifiants. `None` si refusés.
async fn authorize(username: &str, password: &str) -> Option<CompteTrouve> {
ensure_admin_exists().await;
if username.is_empty() || password.is_empty() {
return None;
}
tracing::info!("[AUTH] Checking user \"{username}\"...");
let db_lookup = async {
let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?;
let row = sqlx::query("SELECT id, username, password_hash, role FROM users WHERE username = $1")
.bind(username)
.fetch_optional(&pool)
.await?;
anyhow::Ok(row.map(|r| CompteTrouve {
id: r.try_get::<i32, _>("id").unwrap_or_default().to_string(),
username: r.try_get("username").unwrap_or_default(),
password_hash: r.try_get("password_hash").unwrap_or_default(),
role: r.try_get("role").unwrap_or_else(|_| "user".into()),
}))
};
let user = match db_lookup.await {
Ok(u) => {
tracing::info!("[AUTH] DB check: User found: {}", u.is_some());
u
}
Err(_) => {
// Base injoignable uniquement : un compte absent de la base ne doit
// pas pouvoir se connecter avec le fichier de secours.
tracing::warn!("[AUTH] DB unreachable, falling back to JSON.");
fallback::get_fallback_users().into_iter().find(|u| u.username == username).map(|u| CompteTrouve {
id: u.id,
username: u.username,
password_hash: u.password_hash,
role: u.role,
})
}
}?;
let ok = password::verify_password(password, &user.password_hash);
tracing::info!("[AUTH] Password valid for \"{username}\": {ok}");
ok.then_some(user)
}
#[derive(Deserialize)]
struct LoginBody {
#[serde(default)]
username: String,
#[serde(default)]
password: String,
}
async fn login(headers: HeaderMap, Json(body): Json<LoginBody>) -> Response {
let Some(user) = authorize(body.username.trim(), &body.password).await else {
return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Utilisateur ou mot de passe incorrect." }))).into_response();
};
let claims = Claims {
id: user.id.clone(),
name: user.username.clone(),
role: user.role.clone(),
exp: chrono::Utc::now().timestamp() + session::MAX_AGE_SECS,
};
(
[(header::SET_COOKIE, session::set_cookie(&claims, &headers)), (header::CACHE_CONTROL, "no-store".into())],
Json(json!({ "ok": true, "user": { "id": user.id, "name": user.username, "role": user.role } })),
)
.into_response()
}
async fn logout() -> Response {
([(header::SET_COOKIE, session::clear_cookie())], Json(json!({ "ok": true }))).into_response()
}
/// Session courante pour l'interface (`useSession`). Relue en base : un compte
/// supprimé n'a plus de session.
async fn current_session(Token(token): Token) -> Response {
let user = match token.as_ref() {
Some(_) => authz::require_session(token.as_ref()).await.ok(),
None => None,
};
let body = match user {
Some(u) => json!({ "user": { "id": u.id, "name": u.username, "role": u.role } }),
None => json!({ "user": null }),
};
([(header::CACHE_CONTROL, "no-store")], Json(body)).into_response()
}
pub fn router() -> Router {
Router::new()
.route("/api/auth/login", post(login))
.route("/api/auth/logout", post(logout))
.route("/api/auth/session", get(current_session))
}
+60
View File
@@ -0,0 +1,60 @@
//! Mots de passe : scrypt au format `sel:hachage` (hex), compatible avec les
//! comptes créés par l'ancienne version Node (`scryptSync(pwd, sel, 64)`,
//! N=16384, r=8, p=1, le sel hexadécimal étant utilisé tel quel comme octets).
use rand::RngCore;
use subtle::ConstantTimeEq;
fn derive(password: &str, salt: &str) -> Option<[u8; 64]> {
let params = scrypt::Params::new(14, 8, 1, 64).ok()?;
let mut out = [0u8; 64];
scrypt::scrypt(password.as_bytes(), salt.as_bytes(), &params, &mut out).ok()?;
Some(out)
}
pub fn hash_password(password: &str) -> String {
let mut salt = [0u8; 16];
rand::thread_rng().fill_bytes(&mut salt);
let salt = hex::encode(salt);
let hash = derive(password, &salt).map(hex::encode).unwrap_or_default();
format!("{salt}:{hash}")
}
pub fn verify_password(password: &str, stored: &str) -> bool {
let Some((salt, hash)) = stored.split_once(':') else { return false };
if salt.is_empty() || hash.is_empty() {
return false;
}
let Ok(expected) = hex::decode(hash) else { return false };
let Some(actual) = derive(password, salt) else { return false };
expected.len() == actual.len() && bool::from(actual.as_slice().ct_eq(expected.as_slice()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let h = hash_password("admin");
assert!(verify_password("admin", &h));
assert!(!verify_password("nope", &h));
}
#[test]
fn compatible_node() {
// require("crypto").scryptSync("admin", "00112233445566778899aabbccddeeff", 64).toString("hex")
let stored = "00112233445566778899aabbccddeeff:".to_string()
+ &hex::encode(derive("admin", "00112233445566778899aabbccddeeff").unwrap());
assert!(verify_password("admin", &stored));
}
}
#[cfg(test)]
mod node_compat {
#[test]
fn hash_from_node() {
assert!(super::verify_password("s3cret", "abcdef0123456789abcdef0123456789:8e07d3f906fe581051e47f776fd3fb6307401dc4702369a6db2b7fbb892eb23571af616a59cf13a162cfd584035381c062b7d17fd4ca0d82a2cdf0e1ce2330aa"));
assert!(!super::verify_password("s3cre", "abcdef0123456789abcdef0123456789:8e07d3f906fe581051e47f776fd3fb6307401dc4702369a6db2b7fbb892eb23571af616a59cf13a162cfd584035381c062b7d17fd4ca0d82a2cdf0e1ce2330aa"));
}
}
+124
View File
@@ -0,0 +1,124 @@
//! Jeton de session signé (cookie HttpOnly), remplaçant le JWT NextAuth.
//!
//! Contenu : identifiant, nom et rôle de l'utilisateur + expiration, encodés en
//! base64url et signés HMAC-SHA256 avec `AUTH_SECRET` (ou un secret aléatoire
//! conservé dans `data/.auth-secret`).
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::HeaderMap;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::convert::Infallible;
use std::sync::LazyLock;
pub const COOKIE_NAME: &str = "cf_session";
/// Durée de vie d'une session : 30 jours (comme NextAuth).
pub const MAX_AGE_SECS: i64 = 30 * 24 * 3600;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
/// Identifiant (`users.id`, ou « 0 » pour le compte de secours).
pub id: String,
pub name: String,
pub role: String,
/// Expiration (secondes Unix).
pub exp: i64,
}
static SECRET: LazyLock<Vec<u8>> = LazyLock::new(|| {
if let Ok(s) = std::env::var("AUTH_SECRET") {
if !s.trim().is_empty() {
return s.into_bytes();
}
}
let path = crate::config::data_dir().join(".auth-secret");
if let Ok(s) = std::fs::read_to_string(&path) {
if !s.trim().is_empty() {
return s.trim().as_bytes().to_vec();
}
}
let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf);
let s = hex::encode(buf);
let _ = std::fs::create_dir_all(crate::config::data_dir());
if let Err(e) = std::fs::write(&path, &s) {
tracing::warn!("[auth] secret de session non persisté ({e}) : les sessions ne survivront pas au redémarrage");
}
s.into_bytes()
});
fn sign(payload: &str) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(&SECRET).expect("hmac");
mac.update(payload.as_bytes());
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
}
pub fn encode(claims: &Claims) -> String {
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap_or_default());
let sig = sign(&payload);
format!("{payload}.{sig}")
}
pub fn decode(token: &str) -> Option<Claims> {
let (payload, sig) = token.split_once('.')?;
let mut mac = Hmac::<Sha256>::new_from_slice(&SECRET).ok()?;
mac.update(payload.as_bytes());
let sig_bytes = URL_SAFE_NO_PAD.decode(sig).ok()?;
mac.verify_slice(&sig_bytes).ok()?;
let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
if claims.exp < chrono::Utc::now().timestamp() {
return None;
}
Some(claims)
}
/// Jeton lu dans l'en-tête `Cookie`, s'il est présent et valide.
pub fn from_headers(headers: &HeaderMap) -> Option<Claims> {
for value in headers.get_all(axum::http::header::COOKIE) {
let Ok(s) = value.to_str() else { continue };
for part in s.split(';') {
let part = part.trim();
if let Some(v) = part.strip_prefix(COOKIE_NAME).and_then(|r| r.strip_prefix('=')) {
if let Some(c) = decode(v) {
return Some(c);
}
}
}
}
None
}
fn is_https(headers: &HeaderMap) -> bool {
headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|v| v.eq_ignore_ascii_case("https"))
.unwrap_or(false)
}
/// En-tête `Set-Cookie` qui ouvre la session.
pub fn set_cookie(claims: &Claims, req_headers: &HeaderMap) -> String {
let secure = if is_https(req_headers) { "; Secure" } else { "" };
format!("{COOKIE_NAME}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age={MAX_AGE_SECS}{secure}", encode(claims))
}
/// En-tête `Set-Cookie` qui ferme la session.
pub fn clear_cookie() -> String {
format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0")
}
/// Jeton de la requête (jamais de rejet : `None` si absent ou invalide).
#[derive(Debug, Clone)]
pub struct Token(pub Option<Claims>);
impl<S: Send + Sync> FromRequestParts<S> for Token {
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(Token(from_headers(&parts.headers)))
}
}
+99
View File
@@ -0,0 +1,99 @@
//! Fichier de configuration `data/.db-config.json` (réglages saisis dans Paramètres).
//!
//! Le fichier contient des mots de passe et des clés d'API : il ne repart jamais
//! tel quel vers le navigateur. Les clés inconnues sont conservées à l'écriture.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::path::PathBuf;
/// Répertoire des données persistantes (volume Docker `/app/data`).
pub fn data_dir() -> PathBuf {
std::env::var("DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_default().join("data"))
}
pub fn config_file() -> PathBuf {
data_dir().join(".db-config.json")
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DbConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub open_router_key: Option<String>,
#[serde(default)]
pub open_router_model: Option<String>,
#[serde(default)]
pub ai_provider: Option<String>,
#[serde(default)]
pub google_ai_key: Option<String>,
#[serde(default)]
pub google_ai_model: Option<String>,
/// Qlik Sense — données réseau.
#[serde(default)]
pub qlik_host: Option<String>,
#[serde(default)]
pub qlik_user: Option<String>,
#[serde(default)]
pub qlik_password: Option<String>,
/// API REST FF Nancy (ex. https://api.ffnancy.fr).
#[serde(default)]
pub ff_api_base_url: Option<String>,
}
fn read_raw_sync() -> Map<String, Value> {
std::fs::read_to_string(config_file())
.ok()
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.and_then(|v| match v {
Value::Object(m) => Some(m),
_ => None,
})
.unwrap_or_default()
}
/// Lit la config existante (vide si absente ou illisible).
pub fn read_config() -> DbConfig {
serde_json::from_value(Value::Object(read_raw_sync())).unwrap_or_default()
}
/// Fusionne `patch` dans la config enregistrée : les clés absentes du patch sont
/// conservées ; une clé passée à `null` est retirée.
pub fn write_config(patch: Map<String, Value>) -> std::io::Result<()> {
let mut existing = read_raw_sync();
for (k, v) in patch {
if v.is_null() {
existing.remove(&k);
} else {
existing.insert(k, v);
}
}
std::fs::create_dir_all(data_dir())?;
let text = serde_json::to_string_pretty(&Value::Object(existing)).unwrap_or_else(|_| "{}".into());
std::fs::write(config_file(), text)
}
/// Chaîne non vide, sinon `None`.
pub fn non_empty(s: Option<String>) -> Option<String> {
s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
}
/// URL de connexion PostgreSQL : réglage enregistré, sinon `DATABASE_URL`.
pub fn database_url() -> Option<String> {
non_empty(read_config().url).or_else(|| non_empty(std::env::var("DATABASE_URL").ok()))
}
/// Variable d'environnement numérique bornée (équivalent de `envNumber` côté TS).
pub fn env_number(name: &str, default: i64, min: i64, max: i64) -> i64 {
match std::env::var(name) {
Ok(raw) if !raw.trim().is_empty() => match raw.trim().parse::<f64>() {
Ok(v) if v.is_finite() => (v.trunc() as i64).clamp(min, max),
_ => default,
},
_ => default,
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Pool PostgreSQL unique par processus, remplaçable à chaud.
//!
//! L'URL vient de `data/.db-config.json` (Paramètres), sinon de `DATABASE_URL`.
//! Enregistrer de nouveaux réglages appelle [`refresh`] : le pool suivant est
//! recréé à la première requête.
//!
//! La même base porte les tables de l'application et la recopie nocturne des
//! tables FF Nancy (`articles`, `mvtart`, `cube_stock`, `fouident`…).
use crate::error::{AppError, AppResult};
use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
use parking_lot::RwLock;
use serde_json::{Map, Value};
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgRow};
use sqlx::{Column, Executor, PgPool, Row, TypeInfo, ValueRef};
use std::str::FromStr;
use std::sync::LazyLock;
use std::time::Duration;
static POOL: LazyLock<RwLock<Option<PgPool>>> = LazyLock::new(|| RwLock::new(None));
/// Masque le mot de passe d'une URL de connexion pour les journaux.
pub fn mask_url(url: &str) -> String {
let re = regex::Regex::new(r":([^@/]+)@").unwrap();
re.replace(url, ":****@").into_owned()
}
/// Options de connexion communes (pool applicatif et tests de connexion).
pub fn connect_options(url: &str) -> Result<PgConnectOptions, sqlx::Error> {
let mut opts = PgConnectOptions::from_str(url)?.application_name("collectflow");
// Délai maximal par requête : opt-in (les calculs nocturnes des plus gros
// fournisseurs peuvent être longs).
if let Some(ms) = std::env::var("PG_STATEMENT_TIMEOUT_MS").ok().and_then(|v| v.parse::<u64>().ok()).filter(|v| *v > 0) {
opts = opts.options([("statement_timeout", ms.to_string())]);
}
Ok(opts)
}
fn build_pool(url: &str) -> Result<PgPool, sqlx::Error> {
let opts = connect_options(url)?;
Ok(PgPoolOptions::new()
.max_connections(20)
.idle_timeout(Duration::from_secs(30))
.acquire_timeout(Duration::from_secs(10))
// Pas de workers parallèles PostgreSQL : ils consomment /dev/shm, trop
// petit dans le conteneur, et font échouer les grosses agrégations.
.after_connect(|conn, _meta| {
Box::pin(async move {
if let Err(e) = conn.execute("SET max_parallel_workers_per_gather = 0").await {
tracing::error!("[DB] SET max_parallel_workers_per_gather failed: {e}");
}
Ok(())
})
})
.connect_lazy_with(opts))
}
/// Pool courant, créé à la demande. Erreur 500 si aucune base n'est configurée.
pub fn pool() -> AppResult<PgPool> {
if let Some(p) = POOL.read().as_ref() {
return Ok(p.clone());
}
let mut guard = POOL.write();
if let Some(p) = guard.as_ref() {
return Ok(p.clone());
}
let url = crate::config::database_url().ok_or_else(|| {
tracing::error!("[DB] CRITICAL: No database connection string available!");
AppError::internal("Base de données non configurée (Paramètres → Base de données).")
})?;
tracing::info!("[DB] Initializing connection with: {}", mask_url(&url));
let p = build_pool(&url).map_err(|e| AppError::internal(format!("URL de base invalide : {e}")))?;
*guard = Some(p.clone());
Ok(p)
}
/// Oublie le pool courant (réglages modifiés dans Paramètres).
pub fn refresh() {
tracing::info!("[DB] Refreshing database connection...");
if let Some(old) = POOL.write().take() {
tokio::spawn(async move { old.close().await });
}
}
// ---------------------------------------------------------------------------
// Conversion générique d'une ligne en JSON
// ---------------------------------------------------------------------------
/// Horodatage au format JS `toISOString()` (`2026-01-31T12:34:56.789Z`).
pub fn iso(dt: NaiveDateTime) -> String {
dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
pub fn iso_utc(dt: DateTime<Utc>) -> String {
iso(dt.naive_utc())
}
/// Convertit une ligne en objet JSON, colonne par colonne, selon le type SQL.
///
/// Les `numeric` deviennent des nombres (le pilote Node les rendait en chaînes,
/// que le code convertissait avec `Number()`), les horodatages des chaînes ISO.
pub fn row_to_json(row: &PgRow) -> Value {
let mut obj = Map::new();
for (i, col) in row.columns().iter().enumerate() {
obj.insert(col.name().to_string(), cell_to_json(row, i));
}
Value::Object(obj)
}
pub fn rows_to_json(rows: &[PgRow]) -> Vec<Value> {
rows.iter().map(row_to_json).collect()
}
fn cell_to_json(row: &PgRow, i: usize) -> Value {
let raw = match row.try_get_raw(i) {
Ok(r) => r,
Err(_) => return Value::Null,
};
if raw.is_null() {
return Value::Null;
}
let ty = raw.type_info().name().to_ascii_uppercase();
macro_rules! get {
($t:ty) => {
row.try_get::<$t, _>(i).ok()
};
}
let num = |f: Option<f64>| f.and_then(serde_json::Number::from_f64).map(Value::Number).unwrap_or(Value::Null);
match ty.as_str() {
"BOOL" => get!(bool).map(Value::Bool).unwrap_or(Value::Null),
"INT2" => get!(i16).map(|v| Value::from(v)).unwrap_or(Value::Null),
"INT4" => get!(i32).map(|v| Value::from(v)).unwrap_or(Value::Null),
"INT8" => get!(i64).map(|v| Value::from(v)).unwrap_or(Value::Null),
"FLOAT4" => num(get!(f32).map(|v| v as f64)),
"FLOAT8" => num(get!(f64)),
"NUMERIC" => num(get!(rust_decimal::Decimal).and_then(|d| d.to_string().parse::<f64>().ok())),
"JSON" | "JSONB" => get!(Value).unwrap_or(Value::Null),
"TIMESTAMP" => get!(NaiveDateTime).map(|d| Value::String(iso(d))).unwrap_or(Value::Null),
"TIMESTAMPTZ" => get!(DateTime<Utc>).map(|d| Value::String(iso_utc(d))).unwrap_or(Value::Null),
"DATE" => get!(NaiveDate)
.map(|d| Value::String(iso(d.and_hms_opt(0, 0, 0).unwrap())))
.unwrap_or(Value::Null),
"TEXT[]" | "VARCHAR[]" | "_TEXT" | "_VARCHAR" => get!(Vec<String>).map(Value::from).unwrap_or(Value::Null),
_ => get!(String).map(Value::String).unwrap_or(Value::Null),
}
}
// ---------------------------------------------------------------------------
// Lecture tolérante de colonnes (équivalent des `Number(r.x) || 0` du code TS)
// ---------------------------------------------------------------------------
/// Valeur numérique d'une colonne quel que soit son type SQL (int, numeric,
/// float, texte numérique). `None` si NULL ou illisible.
pub fn get_f64(row: &PgRow, col: &str) -> Option<f64> {
let raw = row.try_get_raw(col).ok()?;
if raw.is_null() {
return None;
}
let ty = raw.type_info().name().to_ascii_uppercase();
match ty.as_str() {
"INT2" => row.try_get::<i16, _>(col).ok().map(|v| v as f64),
"INT4" => row.try_get::<i32, _>(col).ok().map(|v| v as f64),
"INT8" => row.try_get::<i64, _>(col).ok().map(|v| v as f64),
"FLOAT4" => row.try_get::<f32, _>(col).ok().map(|v| v as f64),
"FLOAT8" => row.try_get::<f64, _>(col).ok(),
"NUMERIC" => row
.try_get::<rust_decimal::Decimal, _>(col)
.ok()
.and_then(|d| d.to_string().parse::<f64>().ok()),
_ => row.try_get::<String, _>(col).ok().and_then(|s| s.trim().parse::<f64>().ok()),
}
.filter(|v| v.is_finite())
}
/// `Number(r.col) || 0`.
pub fn f64_or0(row: &PgRow, col: &str) -> f64 {
get_f64(row, col).unwrap_or(0.0)
}
/// Valeur texte d'une colonne quel que soit son type (nombres et dates convertis).
pub fn get_string(row: &PgRow, col: &str) -> Option<String> {
let idx = row.columns().iter().position(|c| c.name() == col)?;
match cell_to_json(row, idx) {
Value::Null => None,
Value::String(s) => Some(s),
Value::Number(n) => {
// 12.0 → "12", comme String(12) en JS.
let f = n.as_f64().unwrap_or_default();
if f.fract() == 0.0 && f.abs() < 1e15 {
Some(format!("{}", f as i64))
} else {
Some(n.to_string())
}
}
other => Some(other.to_string()),
}
}
/// `String(r.col ?? "")`.
pub fn string_or_empty(row: &PgRow, col: &str) -> String {
get_string(row, col).unwrap_or_default()
}
+52
View File
@@ -0,0 +1,52 @@
//! Référentiels partagés : magasins et gammes (miroir de `src/lib/magasins.ts`
//! et `src/lib/gammes.ts` côté interface).
pub struct Magasin {
/// Code FF du site (clé de toutes les requêtes).
pub code: &'static str,
/// Nom complet.
pub nom: &'static str,
/// Nom court.
pub court: &'static str,
}
pub const MAGASINS: [Magasin; 2] = [
Magasin { code: "292", nom: "Frouard (Nancy)", court: "Frouard" },
Magasin { code: "579", nom: "Houdemont", court: "Houdemont" },
];
/// Code utilisé par la Grille et l'API pour « tous nos magasins ».
pub const CODE_TOUS_MAGASINS: &str = "TOTAL";
pub const LIBELLE_TOUS_MAGASINS: &str = "Nos 2 magasins";
/// Anciens codes encore présents dans certaines réponses de l'API FF.
fn alias(code: &str) -> &str {
match code {
"F01" => "292",
"F02" => "579",
c => c,
}
}
pub fn trouver_magasin(code: &str) -> Option<&'static Magasin> {
let c = alias(code.trim());
MAGASINS.iter().find(|m| m.code == c)
}
/// Nom lisible d'un magasin. `TOTAL` (ou vide) → « Nos 2 magasins ».
pub fn nom_magasin(code: Option<&str>, court: bool) -> String {
match code {
None | Some("") | Some(CODE_TOUS_MAGASINS) => LIBELLE_TOUS_MAGASINS.to_string(),
Some(c) => match trouver_magasin(c) {
Some(m) => (if court { m.court } else { m.nom }).to_string(),
None => c.to_string(),
},
}
}
/// Codes de gamme valides.
pub const CODES_GAMME: [&str; 5] = ["A", "B", "C", "Y", "Z"];
pub fn est_gamme_valide(code: &str) -> bool {
CODES_GAMME.contains(&code)
}
+105
View File
@@ -0,0 +1,105 @@
//! Erreurs HTTP communes.
//!
//! Les routes « internes » (appelées par l'interface) répondent `{ "error": "…" }`
//! avec le statut adéquat ; l'API publique `/api/v1` a sa propre enveloppe
//! (`crate::api_v1_response`).
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{json, Value};
#[derive(Debug)]
pub struct AppError {
pub status: StatusCode,
pub message: String,
/// Corps JSON complet à renvoyer à la place de `{ error }`.
pub body: Option<Value>,
}
pub type AppResult<T> = Result<T, AppError>;
impl AppError {
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self { status, message: message.into(), body: None }
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, message)
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(StatusCode::UNAUTHORIZED, message)
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new(StatusCode::FORBIDDEN, message)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
}
/// Réponse au corps libre (ex. `{ success: false, error }` avec un statut 500).
pub fn with_body(status: StatusCode, body: Value) -> Self {
let message = body.get("error").and_then(|v| v.as_str()).unwrap_or_default().to_string();
Self { status, message, body: Some(body) }
}
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.status, self.message)
}
}
impl std::error::Error for AppError {}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let body = self.body.unwrap_or_else(|| json!({ "error": self.message }));
(self.status, Json(body)).into_response()
}
}
impl From<anyhow::Error> for AppError {
fn from(e: anyhow::Error) -> Self {
// Une AppError enveloppée dans anyhow garde son statut.
match e.downcast::<AppError>() {
Ok(app) => app,
Err(e) => {
tracing::error!("[error] {e:#}");
Self::internal(format!("{e:#}"))
}
}
}
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
tracing::error!("[db] {e}");
Self::internal(e.to_string())
}
}
impl From<serde_json::Error> for AppError {
fn from(e: serde_json::Error) -> Self {
Self::bad_request(e.to_string())
}
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
Self::internal(e.to_string())
}
}
impl From<reqwest::Error> for AppError {
fn from(e: reqwest::Error) -> Self {
Self::internal(e.to_string())
}
}
/// Message d'une erreur quelconque tronqué à `n` caractères (pour les journaux).
pub fn short(e: &dyn std::fmt::Display, n: usize) -> String {
let s = e.to_string();
s.chars().take(n).collect()
}
+311
View File
@@ -0,0 +1,311 @@
//! Actions du module FF : `getFournisseurs`, `getMagasins`
//! (`src/features/grid/actions.ts`) et le cadencier de commande
//! (`src/features/commandes-auto/actions.ts`).
use super::api::get_sites_from_api;
use super::cache::{get_derniere_reception_cached, get_fournisseurs_cached};
use super::js::{js_number, message_erreur};
use crate::db::pool;
use crate::error::AppResult;
use crate::rpc::{json, Ctx};
use chrono::{DateTime, Local, Utc};
use serde::Serialize;
use serde_json::Value;
use sqlx::Row;
// ---------------------------------------------------------------------------
// Grille
// ---------------------------------------------------------------------------
/// Liste de tous les fournisseurs (PostgreSQL FF).
pub async fn get_fournisseurs(ctx: Ctx) -> AppResult<Value> {
ctx.session().await?;
// Relu à chaque navigation dans la Grille (changement de fournisseur, de
// magasin…) : le référentiel ne change qu'avec la recopie nocturne.
json(get_fournisseurs_cached().await?)
}
/// Liste des magasins (sites).
pub async fn get_magasins(ctx: Ctx) -> AppResult<Value> {
ctx.session().await?;
json(get_sites_from_api().await)
}
// ---------------------------------------------------------------------------
// Cadencier
// ---------------------------------------------------------------------------
/// Statut d'une cadence : "a_commander" | "bientot" | "ok" | "inconnu".
pub type CadenceStatut = &'static str;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CadenceView {
pub id: i32,
pub codefou: String,
pub nomfou: String,
pub site: String,
pub intervalle_semaines: i32,
pub actif: bool,
/// Date ISO de la dernière réception, ou null si inconnue
pub derniere_reception: Option<String>,
/// Date ISO d'échéance (dernière réception + X semaines), ou null
pub echeance: Option<String>,
/// Jours restants avant l'échéance (négatif = en retard), ou null
pub jours_restants: Option<i64>,
pub statut: CadenceStatut,
}
const MS_PAR_JOUR: i64 = 24 * 60 * 60 * 1000;
/// Seuil (en jours) en dessous duquel une cadence passe en "bientôt à commander"
const SEUIL_BIENTOT_JOURS: i64 = 7;
fn calc_statut(jours_restants: Option<i64>) -> CadenceStatut {
match jours_restants {
None => "inconnu",
Some(j) if j <= 0 => "a_commander",
Some(j) if j <= SEUIL_BIENTOT_JOURS => "bientot",
Some(_) => "ok",
}
}
/// Liste des fournisseurs (référentiel) pour le sélecteur de cadence.
pub async fn get_fournisseurs_pour_cadence(ctx: Ctx) -> AppResult<Value> {
ctx.session().await?;
json(get_fournisseurs_cached().await?)
}
struct CadenceRow {
id: i32,
code_fournisseur: String,
nom_fournisseur: Option<String>,
site: String,
intervalle_semaines: i32,
actif: bool,
}
/// Toutes les cadences enrichies du calcul d'échéance (sans contrôle d'accès).
/// Liste vide en cas d'erreur.
pub async fn lister_cadences() -> Vec<CadenceView> {
let res = async {
let rows = sqlx::query(
"SELECT id, code_fournisseur, nom_fournisseur, site, intervalle_semaines, actif FROM commande_cadences",
)
.fetch_all(&pool()?)
.await?;
let rows: Vec<CadenceRow> = rows
.iter()
.map(|r| CadenceRow {
id: r.try_get("id").unwrap_or_default(),
code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(),
nom_fournisseur: r.try_get("nom_fournisseur").unwrap_or_default(),
site: r.try_get("site").unwrap_or_default(),
intervalle_semaines: r.try_get("intervalle_semaines").unwrap_or_default(),
actif: r.try_get("actif").unwrap_or_default(),
})
.collect();
// Réceptions des seuls fournisseurs du cadencier : la lecture sur tout
// l'historique de tous les fournisseurs était la plus lourde de la page.
let codes: Vec<String> = rows.iter().map(|r| r.code_fournisseur.clone()).collect();
let receptions = get_derniere_reception_cached(&codes).await?;
anyhow::Ok((rows, receptions))
}
.await;
let (rows, receptions) = match res {
Ok(v) => v,
Err(e) => {
tracing::error!("[cadence] listCadences error: {}", message_erreur(&e));
return vec![];
}
};
let now = Local::now().with_timezone(&Utc).timestamp_millis();
rows.into_iter()
.map(|r| {
let reception_iso = receptions.get(&format!("{}|{}", r.code_fournisseur, r.site)).cloned();
let mut echeance_iso = None;
let mut jours_restants = None;
if let Some(iso) = reception_iso.as_deref() {
if let Ok(d) = DateTime::parse_from_rfc3339(iso) {
let echeance = d.timestamp_millis() + r.intervalle_semaines as i64 * 7 * MS_PAR_JOUR;
echeance_iso = DateTime::<Utc>::from_timestamp_millis(echeance).map(crate::db::iso_utc);
jours_restants = Some(((echeance - now) as f64 / MS_PAR_JOUR as f64).ceil() as i64);
}
}
CadenceView {
id: r.id,
nomfou: r.nom_fournisseur.clone().unwrap_or_else(|| r.code_fournisseur.clone()),
codefou: r.code_fournisseur,
site: r.site,
intervalle_semaines: r.intervalle_semaines,
actif: r.actif,
derniere_reception: reception_iso,
echeance: echeance_iso,
jours_restants,
statut: if r.actif { calc_statut(jours_restants) } else { "inconnu" },
}
})
.collect()
}
/// Retourne toutes les cadences enrichies du calcul d'échéance à partir
/// de la dernière réception (FF Nancy).
pub async fn list_cadences(ctx: Ctx) -> AppResult<Value> {
ctx.session().await?;
json(lister_cadences().await)
}
/// Réponse des actions d'écriture du cadencier.
#[derive(Debug, Clone, Serialize)]
pub struct ResultatCadence {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl ResultatCadence {
fn ok() -> Self {
Self { ok: true, error: None }
}
fn ko(error: Option<String>) -> Self {
Self { ok: false, error }
}
}
/// `Math.round` (arrondi vers +∞ pour les demis).
fn math_round(x: f64) -> f64 {
(x + 0.5).floor()
}
/// Crée ou met à jour la cadence d'un fournisseur sur un site.
/// Entrée : `{ codefou, nomfou?, site, intervalleSemaines, actif? }`.
pub async fn upsert_cadence(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.session().await {
return json(ResultatCadence::ko(Some(e.message)));
}
let input = ctx.raw(0);
if !input.is_object() {
return Err(crate::error::AppError::internal("Cannot read properties of undefined (reading 'codefou')"));
}
let texte = |k: &str| input.get(k).and_then(|v| v.as_str()).map(|s| s.trim().to_string());
let codefou = texte("codefou").unwrap_or_default();
let site = texte("site").unwrap_or_default();
let intervalle = math_round(js_number(input.get("intervalleSemaines").unwrap_or(&Value::Null)));
// `input.intervalleSemaines` absent → Number(undefined) = NaN.
let intervalle = if input.get("intervalleSemaines").is_none() { f64::NAN } else { intervalle };
if codefou.is_empty() || site.is_empty() {
return json(ResultatCadence::ko(Some("Fournisseur et magasin requis.".into())));
}
if !intervalle.is_finite() || !(1.0..=104.0).contains(&intervalle) {
return json(ResultatCadence::ko(Some("L'intervalle doit être compris entre 1 et 104 semaines.".into())));
}
let nomfou: Option<String> = input.get("nomfou").and_then(|v| v.as_str()).map(|s| s.to_string());
let actif = input.get("actif").and_then(|v| v.as_bool()).unwrap_or(true);
let res = async {
sqlx::query(
r#"INSERT INTO commande_cadences (code_fournisseur, nom_fournisseur, site, intervalle_semaines, actif)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (code_fournisseur, site) DO UPDATE SET
nom_fournisseur = $2,
intervalle_semaines = $4,
actif = $5,
updated_at = $6"#,
)
.bind(&codefou)
.bind(&nomfou)
.bind(&site)
.bind(intervalle as i32)
.bind(actif)
.bind(Local::now().naive_local())
.execute(&pool()?)
.await?;
anyhow::Ok(())
}
.await;
match res {
Ok(()) => json(ResultatCadence::ok()),
Err(e) => {
tracing::error!("[cadence] upsertCadence error: {}", message_erreur(&e));
json(ResultatCadence::ko(Some("Échec de l'enregistrement.".into())))
}
}
}
/// Active / désactive une cadence sans la supprimer.
/// Arguments : `(codefou, site, actif)`.
pub async fn toggle_cadence(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.session().await {
return json(ResultatCadence::ko(Some(e.message)));
}
let codefou: String = ctx.arg(0)?;
let site: String = ctx.arg(1)?;
let actif: bool = ctx.arg(2)?;
let res = async {
sqlx::query("UPDATE commande_cadences SET actif = $1, updated_at = $2 WHERE code_fournisseur = $3 AND site = $4")
.bind(actif)
.bind(Local::now().naive_local())
.bind(&codefou)
.bind(&site)
.execute(&pool()?)
.await?;
anyhow::Ok(())
}
.await;
match res {
Ok(()) => json(ResultatCadence::ok()),
Err(e) => {
tracing::error!("[cadence] toggleCadence error: {}", message_erreur(&e));
json(ResultatCadence::ko(None))
}
}
}
/// Supprime définitivement une cadence.
/// Arguments : `(codefou, site)`.
pub async fn remove_cadence(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.session().await {
return json(ResultatCadence::ko(Some(e.message)));
}
let codefou: String = ctx.arg(0)?;
let site: String = ctx.arg(1)?;
let res = async {
sqlx::query("DELETE FROM commande_cadences WHERE code_fournisseur = $1 AND site = $2")
.bind(&codefou)
.bind(&site)
.execute(&pool()?)
.await?;
anyhow::Ok(())
}
.await;
match res {
Ok(()) => json(ResultatCadence::ok()),
Err(e) => {
tracing::error!("[cadence] removeCadence error: {}", message_erreur(&e));
json(ResultatCadence::ko(None))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn statuts() {
assert_eq!(calc_statut(None), "inconnu");
assert_eq!(calc_statut(Some(0)), "a_commander");
assert_eq!(calc_statut(Some(-3)), "a_commander");
assert_eq!(calc_statut(Some(7)), "bientot");
assert_eq!(calc_statut(Some(8)), "ok");
assert_eq!(math_round(2.5), 3.0);
assert_eq!(math_round(-1.5), -1.0);
}
}
+714
View File
@@ -0,0 +1,714 @@
//! CollectFlow — client de l'API FF Nancy (port de `src/lib/api-ff-client.ts`).
//!
//! Client HTTP centralisé pour l'API REST https://api.ffnancy.fr
//! Sync nuit SQL Server → données fraîches J-1
//!
//! Stratégie mouvements : appeler SANS filtre genremvt (tous types)
//! - genremvt = 3 → alimenter sales12m (ventes)
//! - Tous types → lire qtestock pour reconstruire stock12m
use super::js::{coalesce, encode, get, js_number, js_number_or0, js_string, message_erreur, opt_string, truthy};
use chrono::{Datelike, Local, NaiveDate};
use parking_lot::Mutex;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{LazyLock, OnceLock};
use std::time::{Duration, Instant};
pub const FF_API_BASE_DEFAULT: &str = "https://api.ffnancy.fr";
const FF_API_BASE_TTL: Duration = Duration::from_secs(30);
/// Délai maximal d'un appel à l'API FF (`FF_API_TIMEOUT_MS`, 30 s par défaut).
/// Sans délai, une API muette figeait indéfiniment la page qui l'attendait.
pub fn ff_fetch_timeout() -> Duration {
let ms = std::env::var("FF_API_TIMEOUT_MS")
.ok()
.and_then(|v| v.trim().parse::<f64>().ok())
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(30_000.0);
Duration::from_millis(ms as u64)
}
/// Client HTTP de tout appel à l'API FF (équivalent de `ffFetchInit()`) :
/// jamais de cache HTTP, et un délai maximal.
pub fn ff_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(ff_fetch_timeout())
.build()
.unwrap_or_else(|_| reqwest::Client::new())
})
}
/// `fetch(url, ffFetchInit())`.
pub async fn ff_get(url: &str) -> reqwest::Result<reqwest::Response> {
ff_client().get(url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await
}
/// Libellé HTTP d'un statut (équivalent de `res.statusText`).
pub fn status_text(status: reqwest::StatusCode) -> &'static str {
status.canonical_reason().unwrap_or("")
}
static FF_API_BASE_CACHE: LazyLock<Mutex<Option<(String, Instant)>>> = LazyLock::new(|| Mutex::new(None));
/// URL de l'API FF Nancy, résolue **au moment de l'appel**.
///
/// Ordre de priorité : réglage enregistré dans Paramètres → variable
/// d'environnement `FF_API_BASE_URL` → valeur par défaut. Auparavant l'URL était
/// figée au chargement du module depuis l'environnement seul : impossible de la
/// corriger depuis l'application quand le serveur changeait d'adresse.
///
/// Le résultat est mémorisé 30 s pour ne pas relire le fichier à chaque requête.
pub async fn get_ff_api_base() -> String {
if let Some((value, at)) = FF_API_BASE_CACHE.lock().as_ref() {
if at.elapsed() < FF_API_BASE_TTL {
return value.clone();
}
}
// Fichier absent ou illisible : on retombe sur l'environnement.
let configured = tokio::task::spawn_blocking(|| crate::config::read_config().ff_api_base_url)
.await
.ok()
.flatten()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let env = std::env::var("FF_API_BASE_URL").ok().filter(|s| !s.is_empty());
let value = configured
.or(env)
.unwrap_or_else(|| FF_API_BASE_DEFAULT.to_string())
.trim_end_matches('/')
.to_string();
*FF_API_BASE_CACHE.lock() = Some((value.clone(), Instant::now()));
value
}
/// Vide le cache — appelé après un enregistrement dans Paramètres.
pub fn reset_ff_api_base_cache() {
*FF_API_BASE_CACHE.lock() = None;
}
// ---------------------------------------------------------------------------
// Types API (shapes des réponses brutes)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FfFournisseur {
pub codefou: String,
pub nomfou: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FfArticle {
pub codein: String,
pub libelle1: String,
pub codefou: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nomfou: Option<String>,
/// conditionnement
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pcb: Option<f64>,
/// prix de vente central
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pv_central: Option<f64>,
/// SQL Server internal ID (pour endpoint /mensuel)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub noid: Option<i64>,
/// référence fournisseur (ref_fou_principale)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
/// code-barres EAN/GTIN
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gtin: Option<String>,
/// article actif
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actif: Option<bool>,
/// article suspendu
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suspendu: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FfMouvement {
pub codein: String,
/// 3 = vente client
pub genremvt: f64,
/// ISO date (YYYY-MM-DD)
pub datemvt: String,
pub qte: f64,
/// montant HT
pub montant: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub marge: Option<f64>,
/// stock APRÈS ce mouvement — clé pour stock12m
pub qtestock: f64,
pub site: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codefou_reel: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nom_fournisseur_reel: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FfStock {
pub codein: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub site: Option<String>,
pub stockdispo: f64,
pub qte: f64,
pub valstock: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prmp: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pv: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dernieresventes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dernierereception: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nbjoursdernsventes: Option<f64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FfCommande {
pub codein: String,
pub qtecde: f64,
}
/// Une table de `/api/sync/status`, forme normalisée.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FfSyncTable {
pub nom: String,
pub derniere_sync: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nb_lignes: Option<f64>,
/// "ok" ou état d'erreur renvoyé par la synchronisation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub statut: Option<String>,
#[serde(default)]
pub erreur: Option<String>,
}
/// Statut de synchronisation. `tables` reste en JSON brut : la forme déjà
/// attendue (`{ lastSync, tables }`) était renvoyée telle quelle par le TS.
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FfSyncStatus {
pub last_sync: String,
pub tables: Vec<Value>,
}
/// Normalise la réponse de `/api/sync/status`.
///
/// L'API renvoie `{ sync: [{ table_name, last_sync, rows_synced, status, error_msg }] }`,
/// alors que l'application attendait `{ lastSync, tables: [{ nom, derniereSync, nbLignes }] }`.
/// Les champs lus n'existaient donc pas : le panneau des Paramètres affichait une
/// coquille vide sans la moindre erreur, puisque la requête HTTP réussissait.
///
/// Les deux formes sont acceptées ici, pour rester compatible si l'API évolue.
pub fn normalize_sync_status(raw: &Value) -> Option<FfSyncStatus> {
let d = raw.as_object()?;
// Forme déjà attendue par l'application.
if let Some(Value::Array(tables)) = d.get("tables") {
let last = d.get("lastSync").filter(|v| !v.is_null()).map(js_string).unwrap_or_default();
return Some(FfSyncStatus { last_sync: last, tables: tables.clone() });
}
// Forme réelle de l'API FF Nancy.
let sync = match d.get("sync") {
Some(Value::Array(a)) => a,
_ => return None,
};
let tables: Vec<FfSyncTable> = sync
.iter()
.map(|t| FfSyncTable {
nom: coalesce(t, &["table_name", "nom"]).map(js_string).unwrap_or_else(|| "—".into()),
derniere_sync: coalesce(t, &["last_sync", "derniereSync"]).map(js_string).unwrap_or_default(),
nb_lignes: Some(coalesce(t, &["rows_synced", "nbLignes"]).map(js_number_or0).unwrap_or(0.0)),
statut: get(t, "status").filter(|v| !v.is_null()).map(js_string),
erreur: get(t, "error_msg").filter(|v| !v.is_null()).map(js_string),
})
.collect();
// Pas de date globale dans la réponse : on prend la plus récente des tables.
let mut dates: Vec<&String> = tables.iter().map(|t| &t.derniere_sync).filter(|s| !s.is_empty()).collect();
dates.sort();
let last_sync = dates.last().map(|s| s.to_string()).unwrap_or_default();
let tables = tables.into_iter().map(|t| serde_json::to_value(t).unwrap_or(Value::Null)).collect();
Some(FfSyncStatus { last_sync, tables })
}
/// Stock agrégé sur tous les sites pour un codein
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AggregatedStock {
/// sum stockdispo
pub stock_actuel: f64,
/// sum qte
pub stock_total: f64,
/// sum valstock
pub stock_valeur: f64,
/// prmp (premier site non-zéro)
pub pa: f64,
/// pv premier site
pub prix_vente: f64,
/// max date tous sites
pub derniere_vente: String,
/// max dernierereception tous sites
pub derniere_livraison: String,
/// min tous sites
pub nb_jours_derniere_vente: f64,
}
// ---------------------------------------------------------------------------
// Pagination générique
// ---------------------------------------------------------------------------
/// Liste contenue dans une réponse : tableau direct, ou enveloppe courante.
pub fn extract_list(data: &Value) -> Vec<Value> {
if let Value::Array(a) = data {
return a.clone();
}
let Some(d) = data.as_object() else { return vec![] };
// Essaie les wrappers courants
for key in [
"items", "data", "result", "results", "records", "list", "mouvements", "articles", "fournisseurs", "ventes", "stocks",
"commandes",
] {
if let Some(Value::Array(a)) = d.get(key) {
return a.clone();
}
}
// Cherche la première propriété qui est un tableau
for val in d.values() {
if let Value::Array(a) = val {
return a.clone();
}
}
vec![]
}
fn first_keys(items: &[Value]) -> String {
match items.first().and_then(|v| v.as_object()) {
Some(o) => o.keys().cloned().collect::<Vec<_>>().join(","),
None => {
if items.is_empty() {
"empty".into()
} else {
String::new()
}
}
}
}
async fn fetch_json(url: &str) -> anyhow::Result<Option<Value>> {
let res = ff_get(url).await?;
if !res.status().is_success() {
return Ok(None);
}
Ok(Some(res.json::<Value>().await?))
}
/// Toutes les pages d'une liste paginée. Une erreur réseau est propagée ; une
/// réponse HTTP en échec donne une page vide.
pub async fn fetch_all_pages(build_url: impl Fn(u32) -> String, page_size: usize) -> anyhow::Result<Vec<Value>> {
// Page 1 — récupère les items et tente d'obtenir le total pour paralléliser
let url1 = build_url(1);
tracing::info!("[api-ff] GET {url1}");
let res1 = ff_get(&url1).await?;
if !res1.status().is_success() {
tracing::error!("[api-ff] HTTP {} — {url1}", res1.status().as_u16());
return Ok(vec![]);
}
let data1: Value = res1.json().await?;
let items1 = extract_list(&data1);
tracing::info!("[api-ff] page 1: {} items, keys: {}", items1.len(), first_keys(&items1));
// Si la première page est incomplète, pas besoin de continuer
if items1.len() < page_size {
return Ok(items1);
}
// Cherche un total dans la réponse pour paralléliser les pages suivantes
let total = js_number(coalesce(&data1, &["count", "total", "nb_total", "totalCount"]).unwrap_or(&json!(0)));
if total > page_size as f64 {
let total_pages = (total / page_size as f64).ceil() as u32;
let remaining: Vec<u32> = (2..=total_pages).collect();
// Fetch en chunks de 10 pages en parallèle
let mut all = items1;
for chunk in remaining.chunks(10) {
let results = futures::future::try_join_all(chunk.iter().map(|&p| {
let url = build_url(p);
async move {
tracing::info!("[api-ff] GET {url}");
anyhow::Ok(fetch_json(&url).await?.map(|d| extract_list(&d)).unwrap_or_default())
}
}))
.await?;
for r in results {
all.extend(r);
}
}
return Ok(all);
}
// Fallback parallèle sans total : fetch 10 pages à la fois, arrêt dès qu'une page est incomplète
let mut all = items1;
let mut start_page = 2u32;
loop {
let results = futures::future::try_join_all((0..10).map(|i| {
let p = start_page + i;
let url = build_url(p);
async move {
tracing::info!("[api-ff] GET {url}");
anyhow::Ok(fetch_json(&url).await?.map(|d| extract_list(&d)).unwrap_or_default())
}
}))
.await?;
let mut done = false;
for items in results {
let n = items.len();
all.extend(items);
if n < page_size {
done = true;
break;
}
}
if done {
break;
}
start_page += 10;
}
Ok(all)
}
// ---------------------------------------------------------------------------
// Helpers date
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DateRange {
pub date_debut: String,
pub date_fin: String,
}
/// Retourne la fenêtre des 12 mois complets précédant le mois en cours.
/// En mars 2026 → { dateDebut: "2025-03-01", dateFin: "2026-02-28" }
pub fn build_last12_months_range() -> DateRange {
build_last12_months_range_at(Local::now().date_naive())
}
pub fn build_last12_months_range_at(today: NaiveDate) -> DateRange {
let first_of_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today);
// Fin : dernier jour du mois précédent le mois courant
let end_month = first_of_month.pred_opt().unwrap_or(first_of_month);
// Début : 12 mois avant le mois courant
let start_month = first_of_month.checked_sub_months(chrono::Months::new(12)).unwrap_or(first_of_month);
DateRange {
date_debut: start_month.format("%Y-%m-%d").to_string(),
date_fin: end_month.format("%Y-%m-%d").to_string(),
}
}
// ---------------------------------------------------------------------------
// Articles
// ---------------------------------------------------------------------------
fn opt_f64(v: Option<&Value>) -> Option<f64> {
v.filter(|x| !x.is_null()).map(js_number).filter(|f| !f.is_nan())
}
pub async fn get_articles_by_fournisseur(codefou: &str) -> anyhow::Result<Vec<FfArticle>> {
let base = get_ff_api_base().await;
let enc = encode(codefou);
let raw = fetch_all_pages(|page| format!("{base}/api/articles?codefou={enc}&page={page}&limit=500"), 500).await?;
Ok(raw
.iter()
.map(|r| FfArticle {
codein: coalesce(r, &["codein", "Codein", "code_article", "codeArticle"])
.map(js_string)
.unwrap_or_else(|| get(r, "id").filter(|v| !v.is_null()).map(js_string).unwrap_or_default()),
libelle1: coalesce(r, &["libelle1", "Libelle1", "libelle", "designation", "nom"]).map(js_string).unwrap_or_default(),
codefou: coalesce(r, &["codefou_principal", "codefou", "Codefou", "code_fournisseur", "codeFournisseur"])
.map(js_string)
.unwrap_or_else(|| codefou.to_string()),
nomfou: Some(
coalesce(r, &["nom_fou_principal", "nomfou", "Nomfou", "nom_fournisseur", "nomFournisseur"])
.map(js_string)
.unwrap_or_default(),
),
pcb: opt_f64(coalesce(r, &["pcb_principal", "pcb", "Pcb", "conditionnement", "colisage"])),
pv_central: opt_f64(coalesce(r, &["pv_central", "PvCentral", "prix_vente_mini", "pv", "prixVente"])),
noid: opt_f64(coalesce(r, &["no_id", "noid", "NoId"])).map(|f| f as i64),
reference: opt_string(coalesce(r, &["ref_fou_principale", "reference", "ref_fournisseur"])),
gtin: opt_string(coalesce(r, &["gtin", "Gtin", "ean", "barcode"])),
actif: get(r, "actif").filter(|v| !v.is_null()).map(|v| {
let n = js_number(v);
n != 0.0 && !n.is_nan()
}),
suspendu: get(r, "suspendu").filter(|v| !v.is_null()).map(|v| {
let n = js_number(v);
n != 0.0 && !n.is_nan()
}),
})
.filter(|a| !a.codein.is_empty())
.collect())
}
// ---------------------------------------------------------------------------
// Mensuel — stock fin de mois + ventes + réceptions par article et par site
// GET /api/articles/:no_id/mensuel?dateDebut=&dateFin=
// ---------------------------------------------------------------------------
/// Chaîne tolérante : un nombre devient sa forme texte, `null` une chaîne vide.
fn de_string_lenient<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
let v = Value::deserialize(d)?;
Ok(if v.is_null() { String::new() } else { js_string(&v) })
}
/// Ventes du mois. Valeurs telles que renvoyées par l'API (chaînes numériques,
/// ex. `"-2.000"`) : lire avec [`super::js::js_number`].
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FfMensuelVentes {
#[serde(default)]
pub nb_passages: Value,
/// négatif (ex: "-2.000")
#[serde(default)]
pub qte_vendue: Value,
/// négatif (ex: "-26.6600")
#[serde(default)]
pub ca_ht: Value,
#[serde(default)]
pub ca_ttc: Value,
/// positif (ex: "12.7600")
#[serde(default)]
pub marge: Value,
#[serde(default)]
pub taux_marge: Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FfMensuelReceptions {
#[serde(default)]
pub nb_receptions: Value,
#[serde(default)]
pub qte_recue: Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FfMensuelEntry {
/// "YYYY-MM"
#[serde(default, deserialize_with = "de_string_lenient")]
pub mois: String,
/// code site ("292", "579")
#[serde(default, deserialize_with = "de_string_lenient")]
pub site: String,
/// stock fin de mois
#[serde(default)]
pub stock_fin_mois: Value,
/// PRMP fin de mois
#[serde(default)]
pub prmp_fin_mois: Value,
#[serde(default)]
pub ventes: Option<FfMensuelVentes>,
#[serde(default)]
pub receptions: Option<FfMensuelReceptions>,
/// Autres champs de la réponse, conservés tels quels.
#[serde(flatten, default)]
pub autres: serde_json::Map<String, Value>,
}
/// Récupère les données mensuelles (stock + ventes + réceptions) pour un lot d'articles.
/// Utilise no_id comme identifiant (obligatoire pour l'API).
/// Retourne Map<codein, FfMensuelEntry[]>
pub async fn get_mensuel_by_articles(
articles: &[FfArticle],
date_debut: &str,
date_fin: &str,
batch_size: usize,
) -> HashMap<String, Vec<FfMensuelEntry>> {
let mut result = HashMap::new();
let base = get_ff_api_base().await;
for batch in articles.chunks(batch_size.max(1)) {
let found = futures::future::join_all(batch.iter().map(|art| {
let base = base.clone();
async move {
let no_id = art.noid.filter(|n| *n != 0)?;
let url = format!("{base}/api/articles/{}/mensuel?dateDebut={date_debut}&dateFin={date_fin}", encode(&no_id.to_string()));
let res = async {
let res = ff_get(&url).await?;
if !res.status().is_success() {
return anyhow::Ok(None);
}
let data: Value = res.json().await?;
let entries: Vec<FfMensuelEntry> = match get(&data, "data") {
Some(Value::Array(a)) => a.iter().filter_map(|e| serde_json::from_value(e.clone()).ok()).collect(),
_ => vec![],
};
Ok(Some(entries))
}
.await;
match res {
Ok(Some(entries)) if !entries.is_empty() => Some((art.codein.clone(), entries)),
Ok(_) => None,
Err(err) => {
tracing::error!("[api-ff] getMensuel error for {}: {}", art.codein, message_erreur(&err));
None
}
}
}
}))
.await;
result.extend(found.into_iter().flatten());
}
result
}
// ---------------------------------------------------------------------------
// Referentiel — article complet : nomenclature, gammes, stock, prix, fournisseurs
// GET /api/articles/:no_id/referentiel
// ---------------------------------------------------------------------------
/// Référentiel complet d'un article, JSON brut de l'API :
/// `{ article: { no_id, codein, nom_code, nom_libelle, nom_niveau, nom_chemin_pere },
/// gammes: [{ gamme_code, gamme_libelle, saison_code, saison_libelle }],
/// stock: [{ site, stockdispo, qte, prmp, valstock, pv, dernierevente, dernierereception, stockmort }],
/// prix: { achat, vente_par_site: [{ site, pv }] } | null,
/// fournisseurs: [{ codefou, ref_fou, pcb, prixachat }],
/// performance: { derniere_vente, derniere_entree, qte_totale_vendue, ca_ttc_total, marge_totale } | null }`
pub type FfReferentiel = Value;
/// Récupère le référentiel complet (nomenclature, gamme, stock, prix) pour un lot d'articles.
/// Utilise no_id comme identifiant.
/// Retourne Map<codein, FfReferentiel>
pub async fn get_referentiel_by_articles(articles: &[FfArticle], batch_size: usize) -> HashMap<String, FfReferentiel> {
let mut result = HashMap::new();
let base = get_ff_api_base().await;
for batch in articles.chunks(batch_size.max(1)) {
let found = futures::future::join_all(batch.iter().map(|art| {
let base = base.clone();
async move {
let no_id = art.noid.filter(|n| *n != 0)?;
let url = format!("{base}/api/articles/{}/referentiel", encode(&no_id.to_string()));
let res = async {
let res = ff_get(&url).await?;
if !res.status().is_success() {
return anyhow::Ok(None);
}
let data: Value = res.json().await?;
Ok(get(&data, "article").filter(|a| truthy(a)).is_some().then_some(data))
}
.await;
match res {
Ok(Some(data)) => Some((art.codein.clone(), data)),
Ok(None) => None,
Err(err) => {
tracing::error!("[api-ff] getReferentiel error for {}: {}", art.codein, message_erreur(&err));
None
}
}
}
}))
.await;
result.extend(found.into_iter().flatten());
}
result
}
// ---------------------------------------------------------------------------
// Commandes en cours
// ---------------------------------------------------------------------------
pub async fn get_commandes_by_fournisseur(codefou: &str) -> HashMap<String, f64> {
let mut result: HashMap<String, f64> = HashMap::new();
let base = get_ff_api_base().await;
let res = async {
let res = ff_get(&format!("{base}/api/commandes/articles?codefou={}", encode(codefou))).await?;
if !res.status().is_success() {
return anyhow::Ok(());
}
let data: Value = res.json().await?;
let list = if data.is_array() { Some(&data) } else { coalesce(&data, &["items", "data"]) };
let list = match list {
None => return Ok(()),
Some(Value::Array(a)) => a,
Some(_) => anyhow::bail!("list is not iterable"),
};
for c in list {
let codein = get(c, "codein").map(js_string).unwrap_or_else(|| "undefined".into());
let qte = get(c, "qtecde").filter(|v| !v.is_null()).map(js_number).unwrap_or(0.0);
*result.entry(codein).or_insert(0.0) += qte;
}
Ok(())
}
.await;
if let Err(err) = res {
tracing::error!("[api-ff] getCommandesByFournisseur error: {}", message_erreur(&err));
}
result
}
// ---------------------------------------------------------------------------
// Sites (magasins)
// ---------------------------------------------------------------------------
pub async fn get_sites_from_api() -> Vec<super::pg::CodeNom> {
vec![
super::pg::CodeNom { code: "292".into(), nom: "Frouard (Nancy)".into() },
super::pg::CodeNom { code: "579".into(), nom: "Houdemont".into() },
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn last12_range() {
let r = build_last12_months_range_at(NaiveDate::from_ymd_opt(2026, 3, 15).unwrap());
assert_eq!(r, DateRange { date_debut: "2025-03-01".into(), date_fin: "2026-02-28".into() });
let r = build_last12_months_range_at(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
assert_eq!(r, DateRange { date_debut: "2025-01-01".into(), date_fin: "2025-12-31".into() });
}
#[test]
fn sync_status_forms() {
let api = json!({ "sync": [
{ "table_name": "mvtart", "last_sync": "2026-01-02T03:00:00Z", "rows_synced": "12", "status": "ok", "error_msg": null },
{ "table_name": "articles", "last_sync": "2026-01-03T03:00:00Z", "rows_synced": null, "status": null, "error_msg": "x" }
]});
let s = serde_json::to_value(normalize_sync_status(&api).unwrap()).unwrap();
assert_eq!(
s,
json!({ "lastSync": "2026-01-03T03:00:00Z", "tables": [
{ "nom": "mvtart", "derniereSync": "2026-01-02T03:00:00Z", "nbLignes": 12.0, "statut": "ok", "erreur": null },
{ "nom": "articles", "derniereSync": "2026-01-03T03:00:00Z", "nbLignes": 0.0, "erreur": "x" }
]})
);
let deja = json!({ "lastSync": "a", "tables": [{ "nom": "t" }] });
let s = serde_json::to_value(normalize_sync_status(&deja).unwrap()).unwrap();
assert_eq!(s, json!({ "lastSync": "a", "tables": [{ "nom": "t" }] }));
assert!(normalize_sync_status(&json!({ "x": 1 })).is_none());
assert!(normalize_sync_status(&json!(null)).is_none());
}
#[test]
fn extract_list_wrappers() {
assert_eq!(extract_list(&json!([1, 2])).len(), 2);
assert_eq!(extract_list(&json!({ "articles": [1] })).len(), 1);
assert_eq!(extract_list(&json!({ "foo": 1, "bar": [1, 2, 3] })).len(), 3);
assert!(extract_list(&json!("x")).is_empty());
}
}
+34
View File
@@ -0,0 +1,34 @@
//! Lectures FF partagées par plusieurs pages, mises en cache
//! (port de `getFournisseursCached` / `getDerniereReceptionCached` de `src/lib/ff-cache.ts`).
use super::pg::{pg_get_derniere_reception_par_fournisseur, pg_get_fournisseurs, CodeNom};
use crate::ff_cache::{cached, CacheOpts};
use std::collections::{BTreeSet, HashMap};
/// Référentiel fournisseurs (Grille, cadencier). Vide = erreur avalée, non gardé.
pub async fn get_fournisseurs_cached() -> anyhow::Result<Vec<CodeNom>> {
cached(
"fournisseurs",
CacheOpts { ttl: None, cache_if: Some(|rows: &Vec<CodeNom>| !rows.is_empty()) },
|| async { anyhow::Ok(pg_get_fournisseurs(None).await) },
)
.await
}
/// Dernière réception par fournisseur et magasin, pour les fournisseurs du
/// cadencier seulement : de loin la lecture la plus lourde de la page Commandes.
/// Clé = liste triée, donc recalculée quand une cadence est ajoutée ou retirée.
/// Vide = erreur avalée (ou aucune réception), non gardé.
pub async fn get_derniere_reception_cached(codes: &[String]) -> anyhow::Result<HashMap<String, String>> {
let liste: Vec<String> = codes.iter().map(|c| c.trim().to_string()).collect::<BTreeSet<_>>().into_iter().collect();
if liste.is_empty() {
return Ok(HashMap::new());
}
let key = format!("derniere-reception:{}", liste.join(","));
cached(
&key,
CacheOpts { ttl: None, cache_if: Some(|map: &HashMap<String, String>| !map.is_empty()) },
move || async move { anyhow::Ok(pg_get_derniere_reception_par_fournisseur(Some(&liste)).await) },
)
.await
}
+149
View File
@@ -0,0 +1,149 @@
//! CollectFlow — Diagnostic de l'API FF Nancy
//! (port de `src/features/settings/ff-api-diagnostic.ts`).
//!
//! Partagé par le bouton « Tester » des Paramètres (administrateurs) et par la
//! route `/api/ff-status` (tout utilisateur connecté). Chaque appelant fait son
//! propre contrôle d'accès.
use super::api::{normalize_sync_status, status_text, FfSyncStatus, FF_API_BASE_DEFAULT};
use super::js::truthy;
use serde::Serialize;
use serde_json::Value;
use std::time::{Duration, Instant};
/// Résultat du diagnostic : `{ success: true, url, ms, status }` ou
/// `{ success: false, url, error }`.
#[derive(Debug, Clone, Serialize)]
pub struct DiagnosticApiFf {
pub success: bool,
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<FfSyncStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl DiagnosticApiFf {
fn echec(url: &str, error: String) -> Self {
Self { success: false, url: url.to_string(), ms: None, status: None, error: Some(error) }
}
}
/// Cause réseau d'une erreur `reqwest` : (code façon Node, message).
fn cause_reseau(e: &reqwest::Error) -> Option<(&'static str, String)> {
let mut source: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(e);
let mut messages = vec![];
while let Some(s) = source {
if let Some(io) = s.downcast_ref::<std::io::Error>() {
match io.kind() {
std::io::ErrorKind::ConnectionRefused => return Some(("ECONNREFUSED", io.to_string())),
std::io::ErrorKind::ConnectionReset => return Some(("ECONNRESET", io.to_string())),
std::io::ErrorKind::HostUnreachable => return Some(("EHOSTUNREACH", io.to_string())),
std::io::ErrorKind::NetworkUnreachable => return Some(("ENETUNREACH", io.to_string())),
_ => {}
}
}
messages.push(s.to_string());
source = s.source();
}
let tout = messages.join(" ").to_lowercase();
if tout.contains("dns error") || tout.contains("failed to lookup address") || tout.contains("name or service not known") {
return Some(("ENOTFOUND", messages.last().cloned().unwrap_or_default()));
}
if tout.contains("connection refused") {
return Some(("ECONNREFUSED", messages.last().cloned().unwrap_or_default()));
}
None
}
/// Message complet d'une erreur et de ses causes.
fn message_complet(e: &dyn std::error::Error) -> String {
let mut parts = vec![e.to_string()];
let mut source = e.source();
while let Some(s) = source {
let m = s.to_string();
if !parts.iter().any(|p| p.contains(&m)) {
parts.push(m);
}
source = s.source();
}
parts.join(": ")
}
/// Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
///
/// L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
/// si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
/// donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
pub async fn diagnostiquer_api_ff(ff_api_base_url: Option<String>) -> DiagnosticApiFf {
let saisie = ff_api_base_url.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let base = match saisie {
Some(b) => b,
None => {
let configured = tokio::task::spawn_blocking(|| crate::config::read_config().ff_api_base_url)
.await
.ok()
.flatten()
.filter(|s| !s.is_empty());
configured
.or_else(|| std::env::var("FF_API_BASE_URL").ok().filter(|s| !s.is_empty()))
.unwrap_or_else(|| FF_API_BASE_DEFAULT.to_string())
}
};
let base = base.trim_end_matches('/').to_string();
let url = format!("{base}/api/sync/status");
let started = Instant::now();
let client = reqwest::Client::builder().timeout(Duration::from_millis(8000)).build().unwrap_or_default();
match client.get(&url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await {
Ok(res) => {
let ms = started.elapsed().as_millis() as u64;
if !res.status().is_success() {
let txt = status_text(res.status());
return DiagnosticApiFf::echec(
&url,
format!(
"Le serveur a répondu HTTP {} ({}) en {ms} ms.",
res.status().as_u16(),
if txt.is_empty() { "sans message" } else { txt }
),
);
}
let body = res.json::<Value>().await.ok().filter(truthy);
let Some(body) = body else {
return DiagnosticApiFf::echec(&url, "Réponse HTTP 200 mais corps illisible (JSON attendu).".into());
};
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
match normalize_sync_status(&body) {
None => DiagnosticApiFf::echec(&url, "Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).".into()),
Some(status) => DiagnosticApiFf { success: true, url, ms: Some(ms), status: Some(status), error: None },
}
}
Err(e) => {
let ms = started.elapsed().as_millis() as u64;
let raw = message_complet(&e);
// La cause réelle se cache derrière l'erreur de haut niveau : on la déplie.
let hint = if e.is_timeout() {
format!("Aucune réponse en {ms} ms — serveur injoignable ou trop lent.")
} else {
match cause_reseau(&e) {
Some(("ENOTFOUND", _)) => "Nom d'hôte introuvable (DNS) — vérifiez l'URL.".to_string(),
Some(("ECONNREFUSED", _)) => "Connexion refusée — le service n'écoute pas sur cette adresse.".to_string(),
Some((code, msg)) => {
if msg.is_empty() {
code.to_string()
} else {
format!("{code} — {msg}")
}
}
None => raw.clone(),
}
};
tracing::error!("[Settings] FF API test KO ({url}): {raw}");
DiagnosticApiFf::echec(&url, hint)
}
}
}
+292
View File
@@ -0,0 +1,292 @@
//! Test d'intégration du module FF sur une base de test.
//!
//! Ne s'exécute que si `FF_TEST_DATABASE_URL` désigne une base contenant les
//! tables de l'application et des tables FF minimales (articles, artfou1,
//! mvtart, cube_stock…). Une fausse API FF Nancy est servie localement : toutes
//! les requêtes SQL et tous les appels HTTP du module sont joués au moins une
//! fois (erreurs de syntaxe SQL, de décodage de types, formes JSON).
//!
//! FF_TEST_DATABASE_URL=postgres://…/ff_fixture cargo test ff::fixture_tests -- --nocapture
use super::*;
use crate::auth::Claims;
use crate::rpc::Ctx;
use axum::body::Body;
use axum::extract::{Path, Query};
use axum::http::Request;
use axum::Json;
use serde_json::{json, Value};
use std::collections::HashMap;
use tower::ServiceExt;
/// Fausse API FF Nancy (formes de réponse reprises du code d'origine).
async fn fausse_api() -> String {
async fn dashboard(Query(q): Query<HashMap<String, String>>) -> Json<Value> {
let date = q.get("date").cloned().unwrap_or_default();
Json(json!({
"date": date,
"sites": [
{ "site": "292", "ca_ttc": "1500.50", "ca_ttc_n1": 1200, "trafic": 80, "trafic_n1": "70" },
{ "site": "579", "ca_ttc": 900, "ca_ttc_n1": 0, "trafic": 40, "trafic_n1": 35 }
],
"top10_ca": [
{ "codein": "100001", "libelle1": "TAPIS", "site": "292", "ca_ttc": "45.5", "qte_vendue": 3, "marge": 12 },
{ "codein": "100002", "libelle1": "CREME", "site": "292", "ca_ttc": 60, "qte_vendue": 1, "marge": 2 },
{ "codein": "100003", "libelle1": "BOUGIE", "site": "579", "ca_ttc": 10, "qte_vendue": 1, "marge": 1 }
],
"top10_qte": [{ "codein": "100001", "libelle1": "TAPIS", "site": "292", "ca_ttc": 45.5, "qte_vendue": 3, "marge": 12 }],
"top10_marge": []
}))
}
async fn articles(Query(q): Query<HashMap<String, String>>) -> Json<Value> {
if let Some(c) = q.get("codein") {
if c == "100003" {
return Json(json!({ "articles": [] }));
}
return Json(json!({ "articles": [{ "no_id": if c == "100001" { 10 } else { 11 }, "codein": c }] }));
}
let page: u32 = q.get("page").and_then(|p| p.parse().ok()).unwrap_or(1);
if page > 1 {
return Json(json!({ "articles": [] }));
}
Json(json!({ "count": 2, "articles": [
{ "codein": "100001", "libelle1": "TAPIS", "codefou_principal": "F001", "pcb_principal": "6", "no_id": 10, "actif": "1", "suspendu": 0 },
{ "Codein": "100002", "libelle": "CREME", "pv": 3.5, "noid": "11", "gtin": "300" }
]}))
}
async fn referentiel(Path(id): Path<String>) -> Json<Value> {
Json(json!({
"article": { "no_id": id, "codein": "x" },
"stock": [{ "site": "292", "qte": "4" }, { "site": "579", "qte": 2 }],
"fournisseurs": [{ "codefou": "F001", "nom_fou": " Fournisseur Un ", "preference": 0 }, { "codefou": "F002", "nom_fou": null, "preference": 1 }]
}))
}
async fn dernier(Path(id): Path<String>) -> Json<Value> {
if id == "10" {
Json(json!({ "fournisseurs_par_site": [{ "site": "292", "nom_fournisseur": "" }, { "site": "579", "nom_fournisseur": " Dernier Fou " }] }))
} else {
Json(json!({ "fournisseurs_par_site": [] }))
}
}
async fn mensuel(Path(id): Path<String>) -> Json<Value> {
Json(json!({ "data": [{ "mois": "2026-01", "site": 292, "stock_fin_mois": "5.000", "prmp_fin_mois": "1",
"ventes": { "nb_passages": "2", "qte_vendue": "-2.000", "ca_ht": "-26.66", "ca_ttc": "-30", "marge": "12.76", "taux_marge": "40" },
"receptions": null, "id": id }] }))
}
async fn commandes_auto(Query(q): Query<HashMap<String, String>>) -> Json<Value> {
Json(json!({ "count": 3, "query": q, "propositions": [
{ "site": "292", "codefou": "F001", "nom_fou": "Fournisseur Un", "franco_ht": "150", "nb_articles": 4, "montant_propo_ht": 99.5, "franco_atteint": "NON", "ecart_franco": -50.5 },
{ "site": "579", "codefou": "F002", "nom_fou": "Fournisseur Deux", "franco_ht": null, "nb_articles": 1, "montant_propo_ht": 10, "franco_atteint": "N/A", "ecart_franco": 0 },
{ "site": "292", "codefou": "F009", "nomfou": "Sans franco", "franco": 0, "nb_articles": 1, "montant_cde": 5, "franco_atteint": 1 }
]}))
}
async fn commandes_auto_detail(Path(codefou): Path<String>, Query(q): Query<HashMap<String, String>>) -> Json<Value> {
match (codefou.as_str(), q.get("site").map(|s| s.as_str())) {
("F002", None) => Json(json!({ "resume": [{ "franco_ht": "300" }] })),
("F009", Some("000")) => Json(json!({ "summary": { "franco": 75 } })),
_ => Json(json!({ "error": "inconnu" })),
}
}
async fn commandes_articles() -> Json<Value> {
Json(json!([{ "codein": "100001", "qtecde": 5 }, { "codein": "100001", "qtecde": 3 }, { "codein": "100002" }]))
}
async fn sync_status() -> Json<Value> {
Json(json!({ "sync": [{ "table_name": "mvtart", "last_sync": "2026-10-12T02:00:00Z", "rows_synced": 42, "status": "ok", "error_msg": null }] }))
}
async fn publicites(Query(q): Query<HashMap<String, String>>) -> Json<Value> {
Json(json!({ "publicites": [{ "tcr_code": "P1", "intitule": "Promo", "query": q }], "total": "1", "pages": 1 }))
}
async fn historique() -> axum::http::StatusCode {
axum::http::StatusCode::BAD_GATEWAY
}
use axum::routing::get;
let app = axum::Router::new()
.route("/api/performance/dashboard", get(dashboard))
.route("/api/articles", get(articles))
.route("/api/articles/{id}/referentiel", get(referentiel))
.route("/api/articles/{id}/dernier-fournisseur", get(dernier))
.route("/api/articles/{id}/mensuel", get(mensuel))
.route("/api/commandes-auto", get(commandes_auto))
.route("/api/commandes-auto/{codefou}", get(commandes_auto_detail))
.route("/api/commandes/articles", get(commandes_articles))
.route("/api/sync/status", get(sync_status))
.route("/api/fournisseurs", get(|| async { Json(json!({ "fournisseurs": [{ "codefou": "F001" }] })) }))
.route("/api/publicites", get(publicites))
.route("/api/publicites/historique", get(historique));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}
fn montrer(nom: &str, v: impl serde::Serialize) -> Value {
let v = serde_json::to_value(v).unwrap();
println!("── {nom}\n{}", serde_json::to_string_pretty(&v).unwrap());
v
}
async fn appel_route(token: &str, uri: &str) -> (u16, Value) {
let req = Request::builder()
.uri(uri)
.header("cookie", format!("{}={token}", crate::auth::session::COOKIE_NAME))
.body(Body::empty())
.unwrap();
let res = router().oneshot(req).await.unwrap();
let status = res.status().as_u16();
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
fn ctx(claims: &Claims, query: &[(&str, &str)], args: Vec<Value>) -> Ctx {
Ctx {
token: Some(claims.clone()),
query: query.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
args,
}
}
#[tokio::test]
async fn module_ff_sur_base_de_test() {
let Ok(url) = std::env::var("FF_TEST_DATABASE_URL") else {
eprintln!("FF_TEST_DATABASE_URL absent : test d'intégration FF ignoré.");
return;
};
let data_dir = std::env::temp_dir().join(format!("cf-ff-test-{}", std::process::id()));
std::fs::create_dir_all(&data_dir).unwrap();
std::env::set_var("DATA_DIR", &data_dir);
std::env::set_var("DATABASE_URL", &url);
std::env::set_var("AUTH_SECRET", "secret-de-test");
let base = fausse_api().await;
std::env::set_var("FF_API_BASE_URL", &base);
api::reset_ff_api_base_cache();
let p = crate::db::pool().unwrap();
sqlx::query("INSERT INTO users (id, username, password_hash, role) VALUES (9001, 'test-ff', 'x', 'admin') ON CONFLICT DO NOTHING")
.execute(&p)
.await
.unwrap();
let claims = Claims { id: "9001".into(), name: "test-ff".into(), role: "admin".into(), exp: chrono::Utc::now().timestamp() + 3600 };
let token = crate::auth::session::encode(&claims);
let r12 = api::build_last12_months_range();
let (d, f) = (r12.date_debut.as_str(), r12.date_fin.as_str());
let codes = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
// ── pg.rs
let fous = montrer("pg_get_fournisseurs", pg::pg_get_fournisseurs(None).await);
assert_eq!(fous.as_array().unwrap().len(), 2, "F003 (nom vide) écarté");
montrer("pg_get_fournisseurs(search)", pg::pg_get_fournisseurs(Some("deux")).await);
let arts = pg::pg_get_articles_by_fournisseur("F001").await.unwrap();
montrer("pg_get_articles_by_fournisseur", &arts);
assert!(arts.iter().all(|a| a.codein.trim() != "100004"), "article suspendu exclu");
assert!(arts.iter().any(|a| a.code_centrale.as_deref() == Some("10000111111") && a.pa == Some(4.5)));
montrer("pg_get_mensuel_by_fournisseur", pg::pg_get_mensuel_by_fournisseur("F001", d, f).await.unwrap());
montrer("pg_get_gammes_by_fournisseur", pg::pg_get_gammes_by_fournisseur("F001").await.unwrap());
let g = pg::pg_get_gammes_by_codeins(&codes(&["100001", " 100002 ", "", "100001"])).await.unwrap();
montrer("pg_get_gammes_by_codeins", &g);
assert_eq!(g.get("100001").map(|s| s.as_str()), Some("C"));
montrer("pg_get_nomenclature_by_fournisseur", pg::pg_get_nomenclature_by_fournisseur("F001").await.unwrap());
montrer("pg_get_stock_by_fournisseur", pg::pg_get_stock_by_fournisseur("F001").await.unwrap());
let pv = pg::pg_get_prix_vente_by_fournisseur("F001").await.unwrap();
montrer("pg_get_prix_vente_by_fournisseur", &pv);
assert_eq!(pv.values().next().and_then(|m| m.get("292")).copied(), Some(12.5), "site élagué");
montrer("pg_get_commandes_by_fournisseur", pg::pg_get_commandes_by_fournisseur("F001").await.unwrap());
let mois = chrono::Local::now().date_naive().format("%Y-%m").to_string();
let n1 = pages::get_mois_n1(&mois);
montrer("pg_get_ca_by_fournisseur", pg::pg_get_ca_by_fournisseur(&mois, &n1).await.unwrap());
montrer("pg_get_ca_by_nomenclature", pg::pg_get_ca_by_nomenclature(&r12.date_fin[..7], &mois).await.unwrap());
let hp = pg::pg_get_hit_parade(d, &chrono::Local::now().date_naive().to_string()).await.unwrap();
montrer("pg_get_hit_parade", &hp);
assert!(!hp.is_empty());
montrer("pg_get_stock_for_codeins", pg::pg_get_stock_for_codeins(&codes(&["100001", "100003"])).await.unwrap());
montrer("pg_diag_fournisseur_derniere_entree", pg::pg_diag_fournisseur_derniere_entree().await.unwrap());
montrer("pg_get_stock_sans_vente", pg::pg_get_stock_sans_vente(None).await.unwrap());
montrer("pg_get_stock_sans_vente(292)", pg::pg_get_stock_sans_vente(Some("292")).await.unwrap());
let neg = pg::pg_get_stock_negatif(None).await.unwrap();
montrer("pg_get_stock_negatif", &neg);
assert!(!neg.is_empty());
montrer("pg_get_stock_negatif(579)", pg::pg_get_stock_negatif(Some("579")).await.unwrap());
montrer("pg_get_sans_vente_6_mois", pg::pg_get_sans_vente_6_mois(None).await.unwrap());
montrer("pg_get_sans_vente_6_mois(579)", pg::pg_get_sans_vente_6_mois(Some("579")).await.unwrap());
let rec = pg::pg_get_derniere_reception_par_fournisseur(None).await;
montrer("pg_get_derniere_reception_par_fournisseur(None)", &rec);
assert!(!rec.is_empty());
montrer("pg_get_derniere_reception_par_fournisseur(F001)", pg::pg_get_derniere_reception_par_fournisseur(Some(&codes(&["F001 "]))).await);
assert!(pg::pg_get_derniere_reception_par_fournisseur(Some(&[])).await.is_empty());
let search = pg::pg_search_produits("tapis poussiere", 50).await;
montrer("pg_search_produits", &search);
assert_eq!(search.len(), 1);
assert!(search[0].has_reseau);
montrer("pg_search_produits(numérique)", pg::pg_search_produits("100003", 50).await);
montrer("pg_search_produits(code centrale)", pg::pg_search_produits("10000222", 50).await);
montrer("pg_search_produits(!!!)", pg::pg_search_produits("!!!", 50).await);
montrer("pg_get_produits_by_code_centrale", pg::pg_get_produits_by_code_centrale(&codes(&["10000222222", "inconnu"])).await);
assert_eq!(pg::pg_get_codein_by_code_centrale("10000222222").await.as_deref(), Some("100002"));
let det = pg::pg_get_produit_detail("100001").await;
montrer("pg_get_produit_detail", &det);
assert_eq!(det.as_ref().and_then(|d| d.code1.clone()).as_deref(), Some("33"), "hiérarchie remontée");
montrer("pg_get_fournisseurs_by_codein", pg::pg_get_fournisseurs_by_codein("100003").await);
montrer("pg_get_stock_by_codein", pg::pg_get_stock_by_codein("100001").await);
assert_eq!(pg::pg_get_commandes_by_codein("100001").await, 30.0);
montrer("pg_get_gamme_history_by_codein", pg::pg_get_gamme_history_by_codein("100001").await);
montrer("pg_get_mensuel_by_codein", pg::pg_get_mensuel_by_codein("100001", d, f).await);
let opp = pg::pg_get_opportunites_famille(3, d, f, 30).await;
montrer("pg_get_opportunites_famille", &opp);
assert_eq!(opp.len(), 2);
// ── API FF (fausse)
let dash = pg::pg_get_dashboard_data().await.unwrap();
let dv = montrer("pg_get_dashboard_data", &dash);
assert_eq!(dv["top10BySite"][0]["ca"][1]["fournisseur"], json!("Dernier Fou"));
assert_eq!(dv["top10BySite"][0]["ca"][0]["fournisseur"], json!("F002"));
let ca = montrer("pg_get_commandes_auto", pg::pg_get_commandes_auto().await);
assert_eq!(ca[1]["franco"], json!(300.0));
assert_eq!(ca[2]["franco"], json!(75.0));
let ffarts = api::get_articles_by_fournisseur("F001").await.unwrap();
montrer("get_articles_by_fournisseur", &ffarts);
montrer("get_mensuel_by_articles", api::get_mensuel_by_articles(&ffarts, d, f, 50).await);
montrer("get_referentiel_by_articles", api::get_referentiel_by_articles(&ffarts, 50).await);
montrer("get_commandes_by_fournisseur", api::get_commandes_by_fournisseur("F001").await);
montrer("diagnostiquer_api_ff", diagnostic::diagnostiquer_api_ff(None).await);
montrer("diagnostiquer_api_ff(injoignable)", diagnostic::diagnostiquer_api_ff(Some("http://127.0.0.1:9".into())).await);
// ── cache.rs
montrer("get_derniere_reception_cached", cache::get_derniere_reception_cached(&codes(&["F002", "F001"])).await.unwrap());
// ── Routes
for uri in ["/api/ff-status", "/api/diag/stock-fournisseur", "/api/test-db?codein=100001", "/api/commandes-auto?site=292", "/api/diag"] {
let (status, body) = appel_route(&token, uri).await;
montrer(&format!("GET {uri} → {status}"), &body);
assert_eq!(status, 200, "{uri}");
}
// ── Actions
let c = |args: Vec<Value>| ctx(&claims, &[], args);
montrer("getFournisseurs", actions::get_fournisseurs(c(vec![])).await.unwrap());
montrer("getMagasins", actions::get_magasins(c(vec![])).await.unwrap());
montrer("getFournisseursPourCadence", actions::get_fournisseurs_pour_cadence(c(vec![])).await.unwrap());
montrer("upsertCadence", actions::upsert_cadence(c(vec![json!({ "codefou": " F002 ", "site": "292", "intervalleSemaines": "2.5" })])).await.unwrap());
montrer("upsertCadence(KO)", actions::upsert_cadence(c(vec![json!({ "codefou": "F002", "site": "292", "intervalleSemaines": 0 })])).await.unwrap());
montrer("toggleCadence", actions::toggle_cadence(c(vec![json!("F002"), json!("292"), json!(false)])).await.unwrap());
let cad = montrer("listCadences", actions::list_cadences(c(vec![])).await.unwrap());
assert!(cad.as_array().unwrap().iter().any(|x| x["derniereReception"].is_string() && x["joursRestants"].is_number()));
montrer("removeCadence", actions::remove_cadence(c(vec![json!("F002"), json!("292")])).await.unwrap());
let sans = Ctx::default();
assert_eq!(actions::get_fournisseurs(sans.clone()).await.unwrap_err().status.as_u16(), 401);
assert_eq!(actions::remove_cadence(sans.clone()).await.unwrap(), json!({ "ok": false, "error": "Session expirée : reconnectez-vous." }));
// ── Pages
montrer("page dashboard", pages::page_dashboard(ctx(&claims, &[], vec![])).await.unwrap());
montrer("page analytics", pages::page_analytics(ctx(&claims, &[("mode", "fournisseur")], vec![])).await.unwrap());
montrer("page analytics (nomenclature)", pages::page_analytics(ctx(&claims, &[("mode", "x"), ("mois", &mois)], vec![])).await.unwrap());
montrer("page hit-parade", pages::page_hit_parade(ctx(&claims, &[], vec![])).await.unwrap());
montrer("page stock-negatif", pages::page_stock_negatif(ctx(&claims, &[("magasin", "292")], vec![])).await.unwrap());
montrer("page commandes-auto", pages::page_commandes_auto(ctx(&claims, &[], vec![])).await.unwrap());
let pubs = montrer("page publicites", pages::page_publicites(ctx(&claims, &[("statut", "en_cours"), ("page", "2")], vec![])).await.unwrap());
assert_eq!(pubs["pageSize"], json!(50));
assert!(pubs["erreurHistorique"].is_string());
assert!(pubs.get("erreurPublicites").is_none());
assert_eq!(pages::page_dashboard(sans).await.unwrap_err().status.as_u16(), 401);
sqlx::query("DELETE FROM users WHERE id = 9001").execute(&p).await.unwrap();
}
+172
View File
@@ -0,0 +1,172 @@
//! Conversions « à la JavaScript » sur des valeurs JSON brutes de l'API FF.
//!
//! Le code TS d'origine lisait les réponses de l'API sans schéma et convertissait
//! au fil de l'eau (`Number(x) || 0`, `String(x)`, tests de vérité). Ces aides
//! reproduisent ces conversions pour garder exactement les mêmes résultats.
use serde_json::Value;
/// `Number(v)` : `NaN` si la valeur n'est pas convertible.
pub fn js_number(v: &Value) -> f64 {
match v {
Value::Null => 0.0,
Value::Bool(b) => {
if *b {
1.0
} else {
0.0
}
}
Value::Number(n) => n.as_f64().unwrap_or(f64::NAN),
Value::String(s) => {
let t = s.trim();
if t.is_empty() {
0.0
} else {
// Rust accepte « inf » / « nan », pas JS (seul « Infinity » l'est, cas ignoré).
let l = t.to_ascii_lowercase();
if l.contains("inf") || l.contains("nan") {
f64::NAN
} else {
t.parse::<f64>().unwrap_or(f64::NAN)
}
}
}
Value::Array(a) => match a.len() {
0 => 0.0,
1 => js_number(&a[0]),
_ => f64::NAN,
},
Value::Object(_) => f64::NAN,
}
}
/// `Number(v) || 0` (NaN et 0 ramenés à 0).
pub fn js_number_or0(v: &Value) -> f64 {
let n = js_number(v);
if n.is_nan() {
0.0
} else {
n
}
}
/// `Number(v)` sur une valeur éventuellement absente (`undefined` → NaN).
pub fn js_number_opt(v: Option<&Value>) -> f64 {
match v {
None => f64::NAN,
Some(v) => js_number(v),
}
}
/// Formatage d'un nombre comme `String(n)` en JS (12 → "12", 1.5 → "1.5").
pub fn js_number_to_string(f: f64) -> String {
if f.is_nan() {
"NaN".into()
} else if f.is_infinite() {
if f > 0.0 {
"Infinity".into()
} else {
"-Infinity".into()
}
} else if f.fract() == 0.0 && f.abs() < 1e21 {
format!("{}", f as i128)
} else {
format!("{f}")
}
}
/// `String(v)`.
pub fn js_string(v: &Value) -> String {
match v {
Value::Null => "null".into(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => match n.as_f64() {
Some(f) if n.is_f64() => js_number_to_string(f),
_ => n.to_string(),
},
Value::String(s) => s.clone(),
Value::Array(a) => a
.iter()
.map(|x| if x.is_null() { String::new() } else { js_string(x) })
.collect::<Vec<_>>()
.join(","),
Value::Object(_) => "[object Object]".into(),
}
}
/// Valeur « vraie » au sens JS (`if (v)`).
pub fn truthy(v: &Value) -> bool {
match v {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(true),
Value::String(s) => !s.is_empty(),
Value::Array(_) | Value::Object(_) => true,
}
}
/// `v?.[key]` : `None` si absent (undefined).
pub fn get<'a>(v: &'a Value, key: &str) -> Option<&'a Value> {
v.as_object().and_then(|o| o.get(key))
}
/// `a ?? b ?? …` sur les clés d'un objet : première valeur ni absente ni `null`.
pub fn coalesce<'a>(v: &'a Value, keys: &[&str]) -> Option<&'a Value> {
keys.iter().filter_map(|k| get(v, k)).find(|x| !x.is_null())
}
/// Chaîne d'une valeur JSON, `None` si absente ou `null` (`x ?? undefined`).
pub fn opt_string(v: Option<&Value>) -> Option<String> {
match v {
None | Some(Value::Null) => None,
Some(x) => Some(js_string(x)),
}
}
/// Message d'une erreur et de ses causes, sans répéter une cause déjà incluse
/// dans le message précédent (les erreurs SQL recopient leur cause).
pub fn message_erreur(e: &anyhow::Error) -> String {
let mut parts: Vec<String> = vec![];
for cause in e.chain() {
let m = cause.to_string();
if !parts.iter().any(|p| p.contains(&m)) {
parts.push(m);
}
}
parts.join(": ")
}
/// `encodeURIComponent(String(v))`.
pub fn encode(v: &str) -> String {
urlencoding::encode(v).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn number_conversions() {
assert_eq!(js_number(&json!("12.5")), 12.5);
assert_eq!(js_number(&json!(" ")), 0.0);
assert!(js_number(&json!("abc")).is_nan());
assert_eq!(js_number_or0(&json!("abc")), 0.0);
assert_eq!(js_number(&json!(true)), 1.0);
assert_eq!(js_number(&Value::Null), 0.0);
assert!(js_number_opt(None).is_nan());
}
#[test]
fn string_conversions() {
assert_eq!(js_string(&json!(12)), "12");
assert_eq!(js_string(&json!(12.0)), "12");
assert_eq!(js_string(&json!(1.5)), "1.5");
assert_eq!(js_string(&json!("x")), "x");
assert_eq!(js_number_to_string(2.0), "2");
assert!(truthy(&json!("a")));
assert!(!truthy(&json!("")));
assert!(!truthy(&json!(0)));
}
}
+55
View File
@@ -0,0 +1,55 @@
//! Module `ff` — données FF Nancy : base miroir PostgreSQL (`pg`), API REST
//! (`api`), lectures partagées en cache (`cache`), diagnostic de l'API
//! (`diagnostic`), routes, actions et données de pages.
pub mod actions;
pub mod api;
pub mod cache;
pub mod diagnostic;
pub mod js;
pub mod pages;
pub mod pg;
pub mod routes;
#[cfg(test)]
mod fixture_tests;
use crate::handler;
use crate::rpc::Handler;
use axum::routing::get;
use axum::Router;
/// Routes HTTP du module.
pub fn router() -> Router {
Router::new()
.route("/api/ff-status", get(routes::ff_status))
.route("/api/diag", get(routes::diag))
.route("/api/diag/stock-fournisseur", get(routes::diag_stock_fournisseur))
.route("/api/test-db", get(routes::test_db))
.route("/api/commandes-auto", get(routes::commandes_auto))
}
/// Actions (`POST /api/actions/{nom}`) du module.
pub fn actions() -> Vec<(&'static str, Handler)> {
vec![
("getFournisseurs", handler!(actions::get_fournisseurs)),
("getMagasins", handler!(actions::get_magasins)),
("getFournisseursPourCadence", handler!(actions::get_fournisseurs_pour_cadence)),
("listCadences", handler!(actions::list_cadences)),
("upsertCadence", handler!(actions::upsert_cadence)),
("toggleCadence", handler!(actions::toggle_cadence)),
("removeCadence", handler!(actions::remove_cadence)),
]
}
/// Données de pages (`GET /api/pages/{nom}`) du module.
pub fn pages() -> Vec<(&'static str, Handler)> {
vec![
("dashboard", handler!(pages::page_dashboard)),
("analytics", handler!(pages::page_analytics)),
("hit-parade", handler!(pages::page_hit_parade)),
("stock-negatif", handler!(pages::page_stock_negatif)),
("commandes-auto", handler!(pages::page_commandes_auto)),
("publicites", handler!(pages::page_publicites)),
]
}
+341
View File
@@ -0,0 +1,341 @@
//! Données des anciennes pages serveur du module FF (`GET /api/pages/{nom}`) :
//! accueil, ventes par mois, meilleures ventes, stocks à surveiller,
//! commandes fournisseurs et publicités.
use super::actions::lister_cadences;
use super::api::get_ff_api_base;
use super::cache::get_fournisseurs_cached;
use super::js::{get, js_number, js_number_to_string, message_erreur};
use super::pg::{
pg_get_ca_by_fournisseur, pg_get_ca_by_nomenclature, pg_get_commandes_auto, pg_get_dashboard_data, pg_get_hit_parade,
pg_get_sans_vente_6_mois, pg_get_stock_negatif, pg_get_stock_sans_vente, PgCommandeAutoRow,
};
use crate::error::{AppError, AppResult};
use crate::ff_cache::{cached, CacheOpts};
use crate::rpc::{json, Ctx};
use chrono::{Datelike, Local, NaiveDate};
use serde::Serialize;
use serde_json::{json as json_value, Value};
use std::time::Duration;
/// Message d'erreur affiché par l'ancien `ErrorState`.
fn erreur_page(e: anyhow::Error) -> AppError {
AppError::internal(message_erreur(&e))
}
// ---------------------------------------------------------------------------
// Accueil
// ---------------------------------------------------------------------------
/// `pgGetDashboardData()`, gardé 30 minutes : ce sont les ventes de la veille,
/// elles ne changent pas dans la journée.
pub async fn page_dashboard(ctx: Ctx) -> AppResult<Value> {
ctx.session().await?;
let opts = CacheOpts { ttl: Some(Duration::from_secs(30 * 60)), cache_if: None };
match cached("dashboard-data", opts, || async { pg_get_dashboard_data().await }).await {
Ok(d) => json(d),
Err(e) => {
tracing::error!("[Dashboard] pgGetDashboardData failed: {}", message_erreur(&e));
let msg: String = message_erreur(&e).chars().take(500).collect();
Err(AppError::internal(msg))
}
}
}
// ---------------------------------------------------------------------------
// Ventes par mois
// ---------------------------------------------------------------------------
/// « YYYY-MM » d'une date.
fn format_mois(d: NaiveDate) -> String {
format!("{}-{:02}", d.year(), d.month())
}
/// Calcul du mois N-1 (même mois année précédente).
pub fn get_mois_n1(mois: &str) -> String {
let mut parts = mois.split('-').map(|p| p.trim().parse::<i32>().ok());
match (parts.next().flatten(), parts.next().flatten()) {
(Some(year), Some(month)) => {
// new Date(year, month - 13) : -13 mois, débordements normalisés.
let total = year * 12 + (month - 13);
let (y, m0) = (total.div_euclid(12), total.rem_euclid(12));
format!("{y}-{:02}", m0 + 1)
}
_ => "NaN-NaN".to_string(),
}
}
/// Lignes de `pgGetCaByFournisseur` (mode `fournisseur`) ou
/// `pgGetCaByNomenclature` (toute autre valeur), mises en cache.
pub async fn page_analytics(ctx: Ctx) -> AppResult<Value> {
ctx.session().await?;
// Toute autre valeur que « fournisseur » affichait déjà la vue par famille.
let mode_fournisseur = ctx.q("mode").unwrap_or("fournisseur") == "fournisseur";
// Mois par défaut = dernier mois complet (le mois précédent) : le mois en
// cours, incomplet, se comparerait mal à un mois N-1 entier.
let today = Local::now().date_naive();
let premier = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today);
let dernier_mois_complet = format_mois(premier.pred_opt().unwrap_or(premier));
let mois = ctx.q("mois").map(|s| s.to_string()).unwrap_or(dernier_mois_complet);
let mois_n1 = ctx.q("moisN1").map(|s| s.to_string()).unwrap_or_else(|| get_mois_n1(&mois));
let res = if mode_fournisseur {
let (m, n1) = (mois.clone(), mois_n1.clone());
cached(&format!("ca-fournisseur:{mois}:{mois_n1}"), CacheOpts::default(), move || async move {
pg_get_ca_by_fournisseur(&m, &n1).await
})
.await
.and_then(|rows| Ok(serde_json::to_value(rows)?))
} else {
let (m, n1) = (mois.clone(), mois_n1.clone());
cached(&format!("ca-nomenclature:{mois}:{mois_n1}"), CacheOpts::default(), move || async move {
pg_get_ca_by_nomenclature(&m, &n1).await
})
.await
.and_then(|rows| Ok(serde_json::to_value(rows)?))
};
res.map_err(|e| {
tracing::error!("[analytics] chargement impossible : {}", message_erreur(&e));
erreur_page(e)
})
}
// ---------------------------------------------------------------------------
// Meilleures ventes
// ---------------------------------------------------------------------------
/// Lignes de `pgGetHitParade(debut, fin)` (par défaut : du 1er du mois à aujourd'hui).
pub async fn page_hit_parade(ctx: Ctx) -> AppResult<Value> {
ctx.session().await?;
let today = Local::now().date_naive();
let first_of_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today);
let date_debut = ctx.q("debut").map(|s| s.to_string()).unwrap_or_else(|| first_of_month.format("%Y-%m-%d").to_string());
let date_fin = ctx.q("fin").map(|s| s.to_string()).unwrap_or_else(|| today.format("%Y-%m-%d").to_string());
let (d, f) = (date_debut.clone(), date_fin.clone());
match cached(&format!("hit-parade:{date_debut}:{date_fin}"), CacheOpts::default(), move || async move {
pg_get_hit_parade(&d, &f).await
})
.await
{
Ok(rows) => json(rows),
Err(e) => {
tracing::error!("[hit-parade] chargement impossible : {}", message_erreur(&e));
Err(erreur_page(e))
}
}
}
// ---------------------------------------------------------------------------
// Stocks à surveiller
// ---------------------------------------------------------------------------
/// `{ rowsNegatif, rowsSansVente, rowsSansVente6Mois }` pour un magasin (ou tous).
pub async fn page_stock_negatif(ctx: Ctx) -> AppResult<Value> {
ctx.session().await?;
let magasin = ctx.q("magasin").unwrap_or("").to_string();
// Données FF recopiées chaque nuit : mises en cache (cf. ff_cache).
let site = if magasin.is_empty() { None } else { Some(magasin.clone()) };
let (s1, s2, s3) = (site.clone(), site.clone(), site);
let (k1, k2, k3) =
(format!("stock-negatif:{magasin}"), format!("stock-sans-vente:{magasin}"), format!("stock-sans-vente-6-mois:{magasin}"));
let res = tokio::try_join!(
cached(&k1, CacheOpts::default(), move || async move { pg_get_stock_negatif(s1.as_deref()).await }),
cached(&k2, CacheOpts::default(), move || async move { pg_get_stock_sans_vente(s2.as_deref()).await }),
cached(&k3, CacheOpts::default(), move || async move {
pg_get_sans_vente_6_mois(s3.as_deref()).await
}),
);
match res {
Ok((rows_negatif, rows_sans_vente, rows_sans_vente_6_mois)) => Ok(json_value!({
"rowsNegatif": rows_negatif,
"rowsSansVente": rows_sans_vente,
"rowsSansVente6Mois": rows_sans_vente_6_mois,
})),
Err(e) => {
tracing::error!("[stock-negatif] chargement impossible : {}", message_erreur(&e));
Err(erreur_page(e))
}
}
}
// ---------------------------------------------------------------------------
// Commandes fournisseurs
// ---------------------------------------------------------------------------
/// `{ rows, cadences, fournisseurs }`.
pub async fn page_commandes_auto(ctx: Ctx) -> AppResult<Value> {
ctx.session().await?;
let (rows, cadences, fournisseurs) = tokio::join!(
// Propositions de l'API FF (un appel, plus un par fournisseur sans franco) :
// gardées 2 minutes, pour que chaque modification du cadencier — qui
// recharge la page — n'attende pas de nouveau toute l'API.
cached(
"commandes-auto",
CacheOpts { ttl: Some(Duration::from_secs(2 * 60)), cache_if: Some(|rows: &Vec<PgCommandeAutoRow>| !rows.is_empty()) },
|| async { anyhow::Ok(pg_get_commandes_auto().await) },
),
lister_cadences(),
get_fournisseurs_cached(),
);
Ok(json_value!({
"rows": rows.map_err(erreur_page)?,
"cadences": cadences,
"fournisseurs": fournisseurs.map_err(erreur_page)?,
}))
}
// ---------------------------------------------------------------------------
// Publicités
// ---------------------------------------------------------------------------
const PAGE_SIZE: u32 = 50;
/// Délai maximal d'un appel à l'API FF : sans lui, une API muette figeait la page.
const FF_API_TIMEOUT: Duration = Duration::from_millis(15_000);
/// Réponses de l'API gardées 10 minutes (les erreurs ne le sont jamais) : la liste
/// et l'historique étaient re-téléchargés à chaque clic sur un filtre ou une page.
const PUB_CACHE_TTL: Duration = Duration::from_secs(10 * 60);
#[derive(Debug, Clone, Serialize)]
struct PublicitesResult {
data: Value,
total: f64,
pages: f64,
error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct HistoriqueResult {
data: Value,
error: Option<String>,
}
fn client_publicites() -> reqwest::Client {
reqwest::Client::builder().timeout(FF_API_TIMEOUT).build().unwrap_or_default()
}
async fn fetch_publicites_api(statut: String, page: String) -> PublicitesResult {
let mut params: Vec<(&str, String)> = vec![("page", page), ("limit", PAGE_SIZE.to_string())];
if !statut.is_empty() && statut != "toutes" {
params.push(("statut", statut));
}
let base = get_ff_api_base().await;
let res = async {
let res = client_publicites()
.get(format!("{base}/api/publicites"))
.query(&params)
.header(reqwest::header::CACHE_CONTROL, "no-store")
.send()
.await?;
if !res.status().is_success() {
return anyhow::Ok(PublicitesResult {
data: json_value!([]),
total: 0.0,
pages: 0.0,
error: Some(format!(
"Erreur API: {} {}",
res.status().as_u16(),
super::api::status_text(res.status())
)),
});
}
let body: Value = res.json().await?;
let champ = |k: &str| get(&body, k).filter(|v| !v.is_null());
Ok(PublicitesResult {
data: champ("publicites").cloned().unwrap_or_else(|| json_value!([])),
total: champ("total").map(js_number).unwrap_or(0.0),
pages: champ("pages").map(js_number).unwrap_or(0.0),
error: None,
})
}
.await;
res.unwrap_or_else(|e| PublicitesResult {
data: json_value!([]),
total: 0.0,
pages: 0.0,
error: Some(format!("Impossible de joindre l'API: {}", message_erreur(&e))),
})
}
async fn fetch_historique_api() -> HistoriqueResult {
let base = get_ff_api_base().await;
let res = async {
let res = client_publicites()
.get(format!("{base}/api/publicites/historique"))
.header(reqwest::header::CACHE_CONTROL, "no-store")
.send()
.await?;
if !res.status().is_success() {
return anyhow::Ok(HistoriqueResult {
data: json_value!([]),
error: Some(format!("Erreur historique: {}", res.status().as_u16())),
});
}
let body: Value = res.json().await?;
Ok(HistoriqueResult {
data: get(&body, "publicites").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json_value!([])),
error: None,
})
}
.await;
res.unwrap_or_else(|e| HistoriqueResult { data: json_value!([]), error: Some(format!("Impossible de joindre l'API: {}", message_erreur(&e))) })
}
/// `{ publicites, total, pages, erreurPublicites?, historique, erreurHistorique?, pageSize }`.
pub async fn page_publicites(ctx: Ctx) -> AppResult<Value> {
ctx.session().await?;
let statut = ctx.query.get("statut").cloned().unwrap_or_else(|| "toutes".into());
// Math.max(1, Number(page) || 1)
let page_num = {
let n = ctx.query.get("page").map(|p| js_number(&Value::String(p.clone()))).unwrap_or(f64::NAN);
let n = if n.is_nan() || n == 0.0 { 1.0 } else { n };
n.max(1.0)
};
let page = js_number_to_string(page_num);
let (s, p) = (statut.clone(), page.clone());
let cle = format!("publicites:{statut}:{page}");
let (pub_result, histo_result) = tokio::join!(
cached(
&cle,
CacheOpts { ttl: Some(PUB_CACHE_TTL), cache_if: Some(|r: &PublicitesResult| r.error.is_none()) },
move || async move { anyhow::Ok(fetch_publicites_api(s, p).await) },
),
cached(
"publicites-historique",
CacheOpts { ttl: Some(PUB_CACHE_TTL), cache_if: Some(|r: &HistoriqueResult| r.error.is_none()) },
|| async { anyhow::Ok(fetch_historique_api().await) },
),
);
let pub_result = pub_result.map_err(erreur_page)?;
let histo_result = histo_result.map_err(erreur_page)?;
let mut out = serde_json::Map::new();
out.insert("publicites".into(), pub_result.data);
out.insert("total".into(), json_value!(pub_result.total));
out.insert("pages".into(), json_value!(pub_result.pages));
if let Some(e) = pub_result.error {
out.insert("erreurPublicites".into(), Value::String(e));
}
out.insert("historique".into(), histo_result.data);
if let Some(e) = histo_result.error {
out.insert("erreurHistorique".into(), Value::String(e));
}
out.insert("pageSize".into(), json_value!(PAGE_SIZE));
Ok(Value::Object(out))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mois_n1() {
assert_eq!(get_mois_n1("2026-03"), "2025-03");
assert_eq!(get_mois_n1("2026-12"), "2025-12");
assert_eq!(get_mois_n1("2026-01"), "2025-01");
assert_eq!(get_mois_n1("abc"), "NaN-NaN");
}
}
+2996
View File
File diff suppressed because it is too large. Load diff
+516
View File
@@ -0,0 +1,516 @@
//! Routes HTTP du module FF : `/api/ff-status`, `/api/diag`,
//! `/api/diag/stock-fournisseur`, `/api/test-db`, `/api/commandes-auto`.
use super::api::{ff_client, get_ff_api_base};
use super::diagnostic::diagnostiquer_api_ff;
use super::js::{encode, js_string, message_erreur};
use super::pg::{pg_diag_fournisseur_derniere_entree, pg_get_stock_for_codeins};
use crate::auth::{AdminUser, SessionUser};
use crate::db::{pool, rows_to_json};
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use chrono::{Datelike, Local, NaiveDate};
use serde_json::{json, Map, Value};
use std::collections::HashMap;
/// `String(e)` d'une erreur JS (`Error: message`).
fn string_erreur(e: &dyn std::fmt::Display) -> String {
format!("Error: {e}")
}
// ---------------------------------------------------------------------------
// GET /api/ff-status
// ---------------------------------------------------------------------------
/// GET /api/ff-status — état de l'API REST FF Nancy.
///
/// S'appuie sur le même diagnostic que le bouton « Tester » des Paramètres, afin
/// qu'un échec dise *pourquoi* (DNS, connexion refusée, délai dépassé, code HTTP)
/// et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans
/// détail, impossible à distinguer d'une simple erreur d'URL.
pub async fn ff_status(_user: SessionUser) -> Response {
let res = diagnostiquer_api_ff(None).await;
if !res.success {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "API FF Nancy non disponible", "url": res.url, "detail": res.error })),
)
.into_response();
}
Json(res.status).into_response()
}
// ---------------------------------------------------------------------------
// GET /api/diag/stock-fournisseur
// ---------------------------------------------------------------------------
/// Diagnostic de l'attribution « fournisseur de la dernière entrée » utilisée par
/// la page Gestion de Stock.
///
/// `colonneDetectee: null` signifie que `mvtart` ne porte aucune des colonnes
/// fournisseur connues : la page retombe alors sur `artfou1.preference = 1`.
/// `colonnesMvtart` liste ce qui existe réellement, pour compléter la liste
/// blanche `MVTART_FOU_CANDIDATES` si le nom diffère.
pub async fn diag_stock_fournisseur(_admin: AdminUser) -> Response {
match pg_diag_fournisseur_derniere_entree().await {
Ok(d) => Json(d).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": string_erreur(&message_erreur(&e)) }))).into_response(),
}
}
// ---------------------------------------------------------------------------
// GET /api/test-db
// ---------------------------------------------------------------------------
pub async fn test_db(_admin: AdminUser, Query(q): Query<HashMap<String, String>>) -> Response {
let codein = q.get("codein").filter(|c| !c.is_empty()).cloned().unwrap_or_else(|| "334152".into());
let res = async {
let map = pg_get_stock_for_codeins(std::slice::from_ref(&codein)).await?;
let fou = sqlx::query(
r#"
SELECT
a.codein::text AS codein,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur
FROM articles a
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
WHERE a.codein::text = $1
"#,
)
.bind(&codein)
.fetch_all(&pool()?)
.await?;
let stock: Map<String, Value> = map.into_iter().map(|(k, v)| (k, serde_json::to_value(v).unwrap_or(Value::Null))).collect();
anyhow::Ok(json!({ "stock": stock, "fou": rows_to_json(&fou) }))
}
.await;
match res {
Ok(v) => Json(v).into_response(),
Err(e) => {
// Pas de pile d'appels dans la réponse : elle reste dans les journaux du serveur.
tracing::error!("[api/test-db] {}", message_erreur(&e));
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": message_erreur(&e) }))).into_response()
}
}
}
// ---------------------------------------------------------------------------
// GET /api/commandes-auto
// ---------------------------------------------------------------------------
/// GET /api/commandes-auto[?site=292&codefou=N009]
///
/// Proxy vers https://api.ffnancy.fr/api/commandes-auto
/// Transfère les query params site et codefou si présents.
pub async fn commandes_auto(_user: SessionUser, Query(q): Query<HashMap<String, String>>) -> Response {
let res = async {
let mut params: Vec<(&str, &str)> = vec![];
if let Some(site) = q.get("site").filter(|s| !s.is_empty()) {
params.push(("site", site));
}
if let Some(codefou) = q.get("codefou").filter(|s| !s.is_empty()) {
params.push(("codefou", codefou));
}
let base = get_ff_api_base().await;
let url = format!("{base}/api/commandes-auto");
let res = ff_client().get(&url).query(&params).send().await?;
if !res.status().is_success() {
let status = res.status();
return anyhow::Ok(
(status, Json(json!({ "error": format!("API upstream HTTP {}", status.as_u16()) }))).into_response(),
);
}
let data: Value = res.json().await?;
Ok(Json(data).into_response())
}
.await;
match res {
Ok(r) => r,
Err(e) => {
tracing::error!("[api/commandes-auto] {}", message_erreur(&e));
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Erreur serveur" }))).into_response()
}
}
}
// ---------------------------------------------------------------------------
// GET /api/diag
// ---------------------------------------------------------------------------
/// Résumé d'une sonde : statut HTTP et aperçu du contenu.
async fn probe(url: String) -> Value {
let res = async {
let res = reqwest::Client::new().get(&url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await?;
let status = res.status().as_u16();
let text = res.text().await?;
anyhow::Ok((status, text))
}
.await;
let (status, text) = match res {
Ok(v) => v,
Err(e) => return json!({ "error": string_erreur(&message_erreur(&e)), "url": url }),
};
let parsed: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.chars().take(800).collect()));
let keys_of = |v: Option<&Value>| -> Value {
match v.and_then(|x| x.as_object()) {
Some(o) => Value::from(o.keys().cloned().collect::<Vec<_>>()),
None => json!([]),
}
};
let summary = match &parsed {
Value::Array(a) => json!({ "count": a.len(), "firstKeys": keys_of(a.first()), "sample": a.iter().take(2).cloned().collect::<Vec<_>>() }),
Value::Object(o) => {
let keys: Vec<String> = o.keys().cloned().collect();
match o.iter().find(|(_, v)| v.is_array()) {
Some((k, Value::Array(arr))) => json!({
"wrapper": k,
"count": arr.len(),
"firstKeys": keys_of(arr.first()),
"sample": arr.iter().take(2).cloned().collect::<Vec<_>>(),
}),
// Objet simple : montrer toutes les clés et le contenu complet
_ => json!({ "keys": keys, "full": parsed }),
}
}
other => other.clone(),
};
json!({ "status": status, "url": url, "summary": summary })
}
/// Première liste d'une réponse : tableau direct ou première propriété tableau.
fn premiere_liste(data: &Value) -> Vec<Value> {
match data {
Value::Array(a) => a.clone(),
Value::Object(o) => o.values().find_map(|v| v.as_array().cloned()).unwrap_or_default(),
_ => vec![],
}
}
async fn get_json(url: &str) -> anyhow::Result<Value> {
let res = reqwest::Client::new().get(url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await?;
Ok(res.json::<Value>().await?)
}
/// Champ texte d'un élément (`(x as Record).champ as string ?? null`).
fn champ(v: Option<&Value>, key: &str) -> Option<String> {
v.and_then(|x| x.get(key)).filter(|x| !x.is_null()).map(js_string)
}
pub async fn diag(_admin: AdminUser, Query(q): Query<HashMap<String, String>>) -> Response {
let ff_api_base = get_ff_api_base().await;
// Date range: 12 derniers mois
let today = Local::now().date_naive();
let first = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today);
let date_fin = first.pred_opt().unwrap_or(first).format("%Y-%m-%d").to_string();
let date_debut = first.checked_sub_months(chrono::Months::new(12)).unwrap_or(first).format("%Y-%m-%d").to_string();
// Récupérer un codefou réel + un codein réel depuis l'API
let mut codefou: Option<String> = q.get("codefou").filter(|s| !s.is_empty()).cloned();
let mut first_codein: Option<String> = q.get("codein").filter(|s| !s.is_empty()).cloned();
let forced_noid: Option<String> = q.get("noid").filter(|s| !s.is_empty()).cloned(); // ex: ?noid=129632
if codefou.is_none() {
if let Ok(data) = get_json(&format!("{ff_api_base}/api/fournisseurs?limit=1")).await {
let list = premiere_liste(&data);
codefou = champ(list.first(), "codefou");
}
if codefou.is_none() {
codefou = Some("D005".into());
}
}
let codefou = codefou.unwrap_or_default();
let codefou_enc = encode(&codefou);
if first_codein.is_none() {
if let Ok(data) = get_json(&format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=1")).await {
let list = premiere_liste(&data);
first_codein = champ(list.first(), "codein");
}
}
// Si un noid est forcé, on probe directement mensuel + referentiel pour cet article
let mensuel_noid_forced = async {
match &forced_noid {
Some(n) => {
probe(format!("{ff_api_base}/api/articles/{}/mensuel?dateDebut={date_debut}&dateFin={date_fin}", encode(n))).await
}
None => json!({ "skipped": "no ?noid= param" }),
}
};
let referentiel_noid_forced = async {
match &forced_noid {
Some(n) => probe(format!("{ff_api_base}/api/articles/{}/referentiel", encode(n))).await,
None => json!({ "skipped": "no ?noid= param" }),
}
};
// Test mensuel avec no_id auto-résolu
let mensuel_noid = async {
if first_codein.is_none() {
return json!({ "skipped": "no article" });
}
if let Ok(data) = get_json(&format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=1")).await {
let list = premiere_liste(&data);
if let Some(noid) = champ(list.first(), "no_id").filter(|s| !s.is_empty()) {
return probe(format!(
"{ff_api_base}/api/articles/{}/mensuel?dateDebut={date_debut}&dateFin={date_fin}",
encode(&noid)
))
.await;
}
}
json!({ "skipped": "no no_id available" })
};
// Referentiel complet (nomenclature + gamme + stock + prix)
let referentiel = async {
if first_codein.is_none() {
return json!({ "skipped": "no codefou" });
}
if let Ok(data) = get_json(&format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=5")).await {
let list = premiere_liste(&data);
let art = list.get(4).or(list.first());
if let Some(no_id) = champ(art, "no_id").filter(|s| !s.is_empty()) {
return probe(format!("{ff_api_base}/api/articles/{}/referentiel", encode(&no_id))).await;
}
}
json!({ "skipped": "no no_id" })
};
let (fournisseurs, articles, mvt_date_debut, mensuel_noid, referentiel, mensuel_forced, referentiel_forced) = tokio::join!(
probe(format!("{ff_api_base}/api/fournisseurs?limit=2")),
probe(format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=2")),
probe(format!(
"{ff_api_base}/api/mouvements/articles?codefou={codefou_enc}&dateDebut={date_debut}&dateFin={date_fin}&limit=2"
)),
mensuel_noid,
referentiel,
mensuel_noid_forced,
referentiel_noid_forced,
);
// Test PostgreSQL + diagnostic ventesProduits
let (postgresql, ventes_produits_diag) = match async {
let pool = pool()?;
let r = sqlx::query("SELECT COUNT(*) as count FROM session_snapshots").fetch_all(&pool).await?;
// Diagnostic ventesProduits : cherche par les 2 codes possibles d'AUXENCE
let vp_by_name = sqlx::query(
r#"
SELECT code_fournisseur, COUNT(*) as nb_rows,
COUNT(code_gamme) as nb_gamme,
COUNT(code_gamme_init) as nb_gamme_init,
COUNT(code3) as nb_code3
FROM ventes_produits
WHERE code_fournisseur IN ('AUXENCE', 'A025', $1)
GROUP BY code_fournisseur
"#,
)
.bind(&codefou)
.fetch_all(&pool)
.await?;
// Sample de quelques codeins dans ventesProduits pour ce fournisseur
let vp_sample = sqlx::query(
r#"
SELECT codein, code_fournisseur, code_gamme, code_gamme_init, code3, libelle3
FROM ventes_produits
WHERE code_fournisseur IN ('AUXENCE', 'A025', $1)
LIMIT 3
"#,
)
.bind(&codefou)
.fetch_all(&pool)
.await?;
let snapshots = r.first().map(|row| crate::db::f64_or0(row, "count")).unwrap_or(0.0);
anyhow::Ok((
json!({ "ok": true, "snapshots": snapshots }),
json!({ "byFournisseur": rows_to_json(&vp_by_name), "sample": rows_to_json(&vp_sample) }),
))
}
.await
{
Ok(v) => v,
Err(e) => {
let msg = string_erreur(&message_erreur(&e));
(json!({ "ok": false, "error": msg }), json!({ "error": msg }))
}
};
// Diagnostic analytics CA TTC
let mvtart_diag = match async {
let pool = pool()?;
// 1. Totaux mars 2025 avec mntmvtht et mntmvtttc pour comparer avec API (HT=225k, TTC=269k)
let totaux = sqlx::query(
r#"
SELECT site,
ABS(SUM(mntmvtht))::float AS sum_ht,
ABS(SUM(mntmvtttc))::float AS sum_ttc,
COUNT(*) AS nb_lignes
FROM mvtart
WHERE genremvt = 3
AND TO_CHAR(datmvt, 'YYYY-MM') = '2025-03'
AND site IN ('292', '579')
GROUP BY site
"#,
)
.fetch_all(&pool)
.await?;
// 2. Distribution des valeurs de preference dans artfou1
let pref_dist = sqlx::query(
r#"
SELECT preference, COUNT(*) AS nb
FROM artfou1
GROUP BY preference
ORDER BY preference
"#,
)
.fetch_all(&pool)
.await?;
// 3. Ventes mars 2025 non attribuées (aucune ligne artfou1 avec preference=1)
let sans_preference = sqlx::query(
r#"
SELECT COUNT(DISTINCT m.artnoid) AS nb_articles_sans_pref
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
WHERE m.genremvt = 3
AND TO_CHAR(m.datmvt, 'YYYY-MM') = '2025-03'
AND m.site IN ('292','579')
AND NOT EXISTS (
SELECT 1 FROM artfou1 af
WHERE af.art_no_id = a.no_id AND af.preference = 1
)
"#,
)
.fetch_all(&pool)
.await?;
anyhow::Ok(json!({
"totaux_202503": rows_to_json(&totaux),
"artfou1_preference_distribution": rows_to_json(&pref_dist),
"articles_vendus_sans_pref1": rows_to_json(&sans_preference),
}))
}
.await
{
Ok(v) => v,
Err(e) => json!({ "error": string_erreur(&message_erreur(&e)) }),
};
// Diagnostic franco : colonnes DB avec "franco" + sample fouident + raw commandes-auto
let franco_diag = match async {
let pool = pool()?;
let franco_columns = sqlx::query(
r#"
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE column_name ILIKE '%franco%'
OR column_name ILIKE '%min_cmd%'
OR column_name ILIKE '%mincde%'
OR column_name ILIKE '%min_commande%'
ORDER BY table_name, column_name
"#,
)
.fetch_all(&pool)
.await?;
// Sample des 3 premières lignes de fouident pour voir toutes les colonnes
let fouident_sample = rows_to_json(&sqlx::query("SELECT * FROM fouident LIMIT 3").fetch_all(&pool).await?);
let foucad_sample = rows_to_json(&sqlx::query("SELECT * FROM foucad LIMIT 5").fetch_all(&pool).await?);
let cols = |rows: &[Value]| -> Value {
match rows.first().and_then(|r| r.as_object()) {
Some(o) => Value::from(o.keys().cloned().collect::<Vec<_>>()),
None => json!([]),
}
};
anyhow::Ok(json!({
"francoColumns": rows_to_json(&franco_columns),
"fouidentColumns": cols(&fouident_sample),
"fouidentSample": fouident_sample,
"foucadColumns": cols(&foucad_sample),
"foucadSample": foucad_sample,
}))
}
.await
{
Ok(v) => v,
Err(e) => json!({ "error": string_erreur(&message_erreur(&e)) }),
};
// Raw commandes-auto : récupère toutes les lignes et filtre celles avec franco_ht manquant
let commandes_auto_franco_audit = match get_json(&format!("{ff_api_base}/api/commandes-auto")).await {
Ok(ca_data) => {
let ca_rows: Vec<Value> = match &ca_data {
Value::Array(a) => a.clone(),
d => d
.get("propositions")
.filter(|v| !v.is_null())
.or_else(|| d.get("data").filter(|v| !v.is_null()))
.and_then(|v| v.as_array().cloned())
.unwrap_or_default(),
};
let franco = |r: &Value| r.get("franco_ht").filter(|v| !v.is_null()).map(super::js::js_number);
let with_franco = ca_rows.iter().filter(|r| franco(r).map(|f| f > 0.0).unwrap_or(false)).count();
let without: Vec<&Value> = ca_rows.iter().filter(|r| franco(r).map(|f| f == 0.0).unwrap_or(true)).collect();
json!({
"total": ca_rows.len(),
"avecFranco": with_franco,
"sansFranco": without.len(),
"lignesSansFranco": without.iter().map(|r| {
let mut o = Map::new();
for k in ["site", "codefou", "nom_fou", "franco_ht", "franco_atteint"] {
if let Some(v) = r.get(k) {
o.insert(k.to_string(), v.clone());
}
}
Value::Object(o)
}).collect::<Vec<_>>(),
})
}
Err(e) => json!({ "error": string_erreur(&message_erreur(&e)) }),
};
// Probe des fournisseurs N/A pour trouver où est leur franco
let (detail_m036_292, detail_m030_579, fournisseur_m036, fournisseur_m030) = tokio::join!(
probe(format!("{ff_api_base}/api/commandes-auto/M036?site=292")),
probe(format!("{ff_api_base}/api/commandes-auto/M030?site=579")),
probe(format!("{ff_api_base}/api/fournisseurs/M036")),
probe(format!("{ff_api_base}/api/fournisseurs/M030")),
);
let franco_na_probe = json!({
"detailM036_292": detail_m036_292,
"detailM030_579": detail_m030_579,
"fournisseurM036": fournisseur_m036,
"fournisseurM030": fournisseur_m030,
});
// Raw commandes-auto (premier fournisseur) pour voir les champs réels retournés par l'API
let raw_commandes_auto = probe(format!("{ff_api_base}/api/commandes-auto")).await;
Json(json!({
"timestamp": crate::db::iso_utc(chrono::Utc::now()),
"dateRange": { "dateDebut": date_debut, "dateFin": date_fin },
"codefouTested": codefou,
"firstCodeinTested": first_codein,
"forcedNoid": forced_noid.unwrap_or_else(|| "(none — pass ?noid=129632 to test a specific article)".into()),
"fournisseurs": fournisseurs,
"articles": articles,
"mouvements_dateDebut": mvt_date_debut,
"endpoints_auto": {
"articles/:no_id/mensuel": mensuel_noid,
"articles/:no_id/referentiel": referentiel,
},
"endpoints_forced_noid": {
"articles/:noid/mensuel": mensuel_forced,
"articles/:noid/referentiel": referentiel_forced,
},
"postgresql": postgresql,
"ventesProduitsDiag": ventes_produits_diag,
"mvtartDiag": mvtart_diag,
"francoDiag": franco_diag,
"rawCommandesAuto": raw_commandes_auto,
"commandesAutoFrancoAudit": commandes_auto_franco_audit,
"francoNaProbe": franco_na_probe,
}))
.into_response()
}
+156
View File
@@ -0,0 +1,156 @@
//! Cache mémoire des lectures « FF » (base miroir et API FF Nancy).
//!
//! Les tables FF ne sont recopiées qu'une fois par nuit : recalculer à chaque
//! visite les mêmes agrégations lourdes coûtait plusieurs secondes par page.
//! - durée de vie bornée (`FF_CACHE_TTL_S`, 30 min par défaut) ;
//! - les appels simultanés pour la même clé partagent une seule requête ;
//! - une erreur n'est jamais mise en cache ;
//! - [`invalidate`] vide tout (fin de synchro nocturne).
use futures::future::{BoxFuture, FutureExt, Shared};
use parking_lot::Mutex;
use std::any::Any;
use std::collections::HashMap;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
const MAX_ENTRIES: usize = 100;
type AnyArc = Arc<dyn Any + Send + Sync>;
type Pending = Shared<BoxFuture<'static, Result<AnyArc, Arc<String>>>>;
enum Entry {
Ready { value: AnyArc, expires_at: Instant, inserted: u64 },
Pending(Pending),
}
struct Store {
map: HashMap<String, Entry>,
seq: u64,
}
static STORE: LazyLock<Mutex<Store>> = LazyLock::new(|| Mutex::new(Store { map: HashMap::new(), seq: 0 }));
static GENERATION: AtomicU64 = AtomicU64::new(0);
pub fn default_ttl() -> Duration {
let s = std::env::var("FF_CACHE_TTL_S").ok().and_then(|v| v.parse::<u64>().ok()).filter(|v| *v > 0).unwrap_or(30 * 60);
Duration::from_secs(s)
}
/// Options d'une lecture mise en cache.
pub struct CacheOpts<T> {
/// Durée de vie propre à cette clé.
pub ttl: Option<Duration>,
/// Ne garder le résultat que s'il passe ce test (ex. liste non vide : une
/// lecture qui avale ses erreurs ne doit pas figer une panne passagère).
pub cache_if: Option<fn(&T) -> bool>,
}
impl<T> Default for CacheOpts<T> {
fn default() -> Self {
Self { ttl: None, cache_if: None }
}
}
/// Lecture mise en cache sous `key`.
pub async fn cached<T, F, Fut>(key: &str, opts: CacheOpts<T>, load: F) -> anyhow::Result<T>
where
T: Clone + Send + Sync + 'static,
F: FnOnce() -> Fut,
Fut: Future<Output = anyhow::Result<T>> + Send + 'static,
{
let pending = {
let store = STORE.lock();
match store.map.get(key) {
Some(Entry::Ready { value, expires_at, .. }) if *expires_at > Instant::now() => {
if let Some(v) = value.downcast_ref::<T>() {
return Ok(v.clone());
}
None
}
Some(Entry::Pending(p)) => Some(p.clone()),
_ => None,
}
};
let pending = match pending {
Some(p) => p,
None => {
let generation = GENERATION.load(Ordering::SeqCst);
let key_owned = key.to_string();
let ttl = opts.ttl.unwrap_or_else(default_ttl);
let cache_if = opts.cache_if;
let fut = load();
let shared: Pending = async move {
let res = fut.await;
let mut store = STORE.lock();
match res {
Ok(value) => {
let keep = cache_if.map(|f| f(&value)).unwrap_or(true);
let any: AnyArc = Arc::new(value);
if generation != GENERATION.load(Ordering::SeqCst) {
// Invalidation pendant le chargement : résultat rendu mais pas gardé.
} else if keep {
store.seq += 1;
let inserted = store.seq;
store.map.insert(
key_owned,
Entry::Ready { value: any.clone(), expires_at: Instant::now() + ttl, inserted },
);
evict(&mut store);
} else {
store.map.remove(&key_owned);
}
Ok(any)
}
Err(e) => {
if generation == GENERATION.load(Ordering::SeqCst) {
store.map.remove(&key_owned);
}
Err(Arc::new(format!("{e:#}")))
}
}
}
.boxed()
.shared();
STORE.lock().map.insert(key.to_string(), Entry::Pending(shared.clone()));
shared
}
};
match pending.await {
Ok(any) => any
.downcast_ref::<T>()
.cloned()
.ok_or_else(|| anyhow::anyhow!("ff_cache: type inattendu pour la clé {key}")),
Err(msg) => Err(anyhow::anyhow!("{msg}")),
}
}
fn evict(store: &mut Store) {
while store.map.len() > MAX_ENTRIES {
let oldest = store
.map
.iter()
.filter_map(|(k, e)| match e {
Entry::Ready { inserted, .. } => Some((k.clone(), *inserted)),
_ => None,
})
.min_by_key(|(_, i)| *i)
.map(|(k, _)| k);
match oldest {
Some(k) => {
store.map.remove(&k);
}
None => break,
}
}
}
/// Vide tout le cache FF (fin de synchro nocturne, données fraîches).
pub fn invalidate() {
GENERATION.fetch_add(1, Ordering::SeqCst);
STORE.lock().map.clear();
}
+143
View File
@@ -0,0 +1,143 @@
//! CollectFlow — Affectation de gammes via l'API `/api/v1`
//! (port de `src/lib/api-gammes.ts`).
//!
//! L'API n'enregistre pas la gamme : elle la **propose**. La proposition est
//! rangée dans `gammes_a_valider` et apparaît dans la Grille comme une
//! modification non enregistrée — on y voit donc quels produits changent de gamme
//! avant de valider (« Enregistrer ») ou de rejeter (« Annuler les modifications »).
//! Tant qu'elle n'est pas validée, `codeGamme` reste inchangée et la proposition
//! est exposée dans `gammeAValider`.
//!
//! Tout ou rien : si un seul article est inconnu chez le fournisseur, rien n'est
//! proposé et la réponse liste les articles en cause.
use super::gammes_a_valider::{proposer_gammes, PropositionGamme};
use super::product_rows::{attendre_persistance, get_product_rows, GetProductRowsInput};
use super::store::{get_grid_freshness, get_grid_rows_gammes};
use crate::api_v1_response::{fail, ApiErrorCode};
use axum::response::Response;
use serde::Serialize;
use serde_json::{json, Value};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResultatGamme {
pub codein: String,
/// Gamme proposée.
pub gamme: String,
/// Gamme enregistrée au moment de l'appel (modifications validées comprises).
pub gamme_precedente: Option<String>,
/// Gamme présente en base FF, non modifiée par l'API.
pub code_gamme_serveur: Option<String>,
/// `true` = la gamme change : proposition déposée, à valider dans la Grille.
/// `false` = l'article a déjà cette gamme (une proposition antérieure en
/// attente pour lui est retirée).
pub modifie: bool,
/// `a_valider` quand `modifie`, sinon `inchangee`.
pub statut: &'static str,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AffectationGammes {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub resultats: Vec<ResultatGamme>,
}
/// Lit un corps JSON, ou renvoie une réponse 400 prête à être retournée.
pub fn lire_corps_json(body: &[u8]) -> Result<Value, Response> {
serde_json::from_slice(body)
.map_err(|_| fail(ApiErrorCode::BadRequest, "Corps de requête JSON attendu (Content-Type: application/json).", None))
}
/// Propose des gammes pour des articles d'un fournisseur (à valider dans la Grille).
///
/// Si le fournisseur n'a encore jamais été calculé, son instantané est calculé à la
/// demande (`compute`), comme sur `/grid` : sans lui, impossible de vérifier que les
/// articles lui appartiennent.
///
/// `auteur` : nom de la clé d'API ou de l'utilisateur — affiché dans la Grille.
pub async fn affecter_gammes(
code_fournisseur: &str,
changes: &[(String, String)],
compute: bool,
auteur: &str,
) -> Result<AffectationGammes, Response> {
// Un même article cité deux fois : la dernière valeur l'emporte (à la place
// de sa première occurrence, comme une Map JS).
let mut demandes: Vec<(String, String)> = Vec::new();
for (codein, gamme) in changes {
match demandes.iter_mut().find(|(c, _)| c == codein) {
Some(d) => d.1 = gamme.clone(),
None => demandes.push((codein.clone(), gamme.clone())),
}
}
let codeins: Vec<String> = demandes.iter().map(|(c, _)| c.clone()).collect();
let interne = |e: crate::error::AppError| fail(ApiErrorCode::InternalError, e.message, None);
if compute && get_grid_freshness(code_fournisseur).await.map_err(interne)?.is_none() {
tracing::info!("[api/v1/gammes] instantané absent pour {code_fournisseur} — calcul à la demande");
let calcul = get_product_rows(GetProductRowsInput::total(code_fournisseur, false)).await;
attendre_persistance(code_fournisseur).await;
if let Err(e) = calcul {
tracing::error!("[api/v1/gammes] calcul KO pour {code_fournisseur}: {e:#}");
return Err(fail(
ApiErrorCode::InternalError,
format!("Le calcul de la grille a échoué pour le fournisseur « {code_fournisseur} »."),
None,
));
}
}
let actuelles = get_grid_rows_gammes(code_fournisseur, &codeins).await.map_err(interne)?;
let inconnus: Vec<&String> = codeins.iter().filter(|c| !actuelles.contains_key(*c)).collect();
if !inconnus.is_empty() {
return Err(fail(
ApiErrorCode::NotFound,
format!(
"{} article(s) introuvable(s) chez le fournisseur « {code_fournisseur} » : aucune gamme n'a été enregistrée.",
inconnus.len()
),
Some(json!({ "fournisseur": code_fournisseur, "inconnus": inconnus })),
));
}
let mut resultats = Vec::with_capacity(demandes.len());
let mut propositions = Vec::new();
let mut annules = Vec::new();
let mut nom_fournisseur: Option<String> = None;
for (codein, gamme) in demandes {
let actuelle = &actuelles[&codein];
if nom_fournisseur.is_none() {
nom_fournisseur = actuelle.nom_fournisseur.clone();
}
let modifie = actuelle.code_gamme.as_deref() != Some(gamme.as_str());
if modifie {
propositions.push(PropositionGamme {
codein: codein.clone(),
gamme: gamme.clone(),
gamme_precedente: actuelle.code_gamme.clone(),
});
} else {
annules.push(codein.clone());
}
resultats.push(ResultatGamme {
codein,
gamme,
gamme_precedente: actuelle.code_gamme.clone(),
code_gamme_serveur: actuelle.code_gamme_init.clone(),
modifie,
statut: if modifie { "a_valider" } else { "inchangee" },
});
}
if let Err(e) = proposer_gammes(code_fournisseur, &propositions, &annules, auteur).await {
tracing::error!("[api/v1/gammes] proposition KO pour {code_fournisseur}: {}", e.message);
return Err(fail(ApiErrorCode::InternalError, "L'enregistrement des gammes à valider a échoué.", None));
}
tracing::info!("[api/v1/gammes] {code_fournisseur} — {} gamme(s) à valider proposée(s) par {auteur}", propositions.len());
Ok(AffectationGammes { code_fournisseur: code_fournisseur.to_string(), nom_fournisseur, resultats })
}
+488
View File
@@ -0,0 +1,488 @@
//! CollectFlow — Validation des paramètres de l'API `/api/v1`
//! (port de `src/lib/api-schemas.ts`, schémas zod).
//!
//! Toute entrée utilisateur passe par ici : bornes de pagination, listes blanches
//! de tri, projection de champs. Les erreurs remontent en `400 bad_request` avec
//! le détail des issues zod (`parsed.error.issues`), pour que l'appelant sache
//! quoi corriger — voir [`Issues::to_json`].
//!
//! Les paramètres d'URL arrivent comme `Object.fromEntries(searchParams)` : une
//! table nom → valeur (la dernière occurrence l'emporte), cf. [`query_map`].
use super::gammes::GAMMES;
use super::store::{GridSortKey, GRID_SORT_KEYS};
use super::zod::{self, child, Issues, Seg};
use serde_json::{Map, Value};
use std::collections::HashMap;
/// `Object.fromEntries(req.nextUrl.searchParams)`.
pub fn query_map(pairs: Vec<(String, String)>) -> HashMap<String, String> {
pairs.into_iter().collect()
}
fn p(k: &str) -> Vec<Seg> {
vec![Seg::from(k)]
}
/// `z.string().min(1[, message]).optional()` sur un paramètre d'URL.
fn opt_non_vide(q: &HashMap<String, String>, k: &str, message: Option<&str>, issues: &mut Issues) -> Option<String> {
let v = q.get(k)?;
zod::check_len(v, &p(k), Some((1, message)), None, issues).then(|| v.clone())
}
/// `z.string().min(1, message)` obligatoire sur un paramètre d'URL.
fn requis(q: &HashMap<String, String>, k: &str, message: &str, issues: &mut Issues) -> Option<String> {
match q.get(k) {
None => {
issues.invalid_type(&p(k), "string", None);
None
}
Some(v) => zod::check_len(v, &p(k), Some((1, Some(message))), None, issues).then(|| v.clone()),
}
}
/// `z.enum([...]).default(d)` / `.optional()` sur un paramètre d'URL.
fn enum_param(q: &HashMap<String, String>, k: &str, values: &[&str], defaut: Option<&str>, issues: &mut Issues) -> Option<String> {
match q.get(k) {
None => defaut.map(str::to_string),
Some(v) if values.contains(&v.as_str()) => Some(v.clone()),
Some(_) => {
issues.invalid_value(&p(k), values, None);
None
}
}
}
/// `z.coerce.number().int().min(1)` avec défaut éventuel.
fn int_param(q: &HashMap<String, String>, k: &str, defaut: Option<i64>, max: Option<f64>, issues: &mut Issues) -> Option<i64> {
match q.get(k) {
None => defaut,
Some(v) => zod::coerce_int(v, &p(k), 1.0, max, issues),
}
}
/// Message du tri inconnu (liste des clés acceptées).
pub fn message_tri_inconnu() -> String {
format!("Tri inconnu. Valeurs acceptées : {}", GRID_SORT_KEYS.join(", "))
}
/// Paramètres de tri et de projection communs (`sortShape`).
#[derive(Debug, Clone, PartialEq)]
pub struct SortParams {
pub sort: Option<String>,
/// `"asc"` | `"desc"` (défaut `"desc"`).
pub order: String,
/// Liste de champs de ProductRow à conserver, séparés par des virgules.
pub fields: Option<String>,
/// `"1"` (défaut) : relit les métriques réseau Qlik et la gamme serveur au moment
/// de l'appel. `"0"` : sert l'instantané brut, un peu plus rapide.
pub enrich: String,
}
fn sort_shape(q: &HashMap<String, String>, issues: &mut Issues) -> SortParams {
let sort = q.get("sort").and_then(|v| {
if GRID_SORT_KEYS.contains(&v.as_str()) {
Some(v.clone())
} else {
issues.custom(&p("sort"), &message_tri_inconnu());
None
}
});
let order = enum_param(q, "order", &["asc", "desc"], Some("desc"), issues).unwrap_or_default();
let fields = q.get("fields").cloned();
let enrich = enum_param(q, "enrich", &["0", "1"], Some("1"), issues).unwrap_or_default();
SortParams { sort, order, fields, enrich }
}
/// `/api/v1/grid` — le fournisseur est obligatoire (recherche transversale : /products/search).
#[derive(Debug, Clone, PartialEq)]
pub struct GridQueryParams {
pub fournisseur: String,
pub gamme: Option<String>,
pub code1: Option<String>,
pub code2: Option<String>,
pub code3: Option<String>,
/// Préfixe de nomenclature (« 32 », « 3202 », « 320211 »), tel que renvoyé par
/// /api/v1/nomenclatures. À préférer à code1/code2.
pub nomenclature: Option<String>,
pub search: Option<String>,
/// `"1"` (défaut) : calcule l'instantané à la demande s'il n'existe pas encore ;
/// `"0"` : comportement strict, échoue vite en `not_ready`.
pub compute: String,
pub sort: SortParams,
pub page: i64,
/// **Aucun plafond, et aucune valeur par défaut** : sans `limit`, `/grid`
/// renvoie *toutes* les lignes du fournisseur.
pub limit: Option<i64>,
}
const MESSAGE_NOMENCLATURE: &str = "La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)";
/// `gridQuerySchema.safeParse(...)`.
pub fn parse_grid_query(q: &HashMap<String, String>) -> Result<GridQueryParams, Issues> {
let mut issues = Issues::default();
let fournisseur = requis(q, "fournisseur", "Paramètre 'fournisseur' requis", &mut issues);
let gamme = opt_non_vide(q, "gamme", None, &mut issues);
let code1 = opt_non_vide(q, "code1", None, &mut issues);
let code2 = opt_non_vide(q, "code2", None, &mut issues);
let code3 = opt_non_vide(q, "code3", None, &mut issues);
let nomenclature = q.get("nomenclature").and_then(|v| {
let chemin = p("nomenclature");
let mut ok = zod::check_len(v, &chemin, Some((1, None)), Some((20, None)), &mut issues);
if v.is_empty() || !v.bytes().all(|b| b.is_ascii_digit()) {
issues.invalid_regex(&chemin, "/^[0-9]+$/", MESSAGE_NOMENCLATURE);
ok = false;
}
ok.then(|| v.clone())
});
let search = opt_non_vide(q, "search", None, &mut issues);
let compute = enum_param(q, "compute", &["0", "1"], Some("1"), &mut issues).unwrap_or_default();
let sort = sort_shape(q, &mut issues);
let page = int_param(q, "page", Some(1), None, &mut issues);
let limit = int_param(q, "limit", None, None, &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(GridQueryParams {
fournisseur: fournisseur.unwrap_or_default(),
gamme,
code1,
code2,
code3,
nomenclature,
search,
compute,
sort,
page: page.unwrap_or(1),
limit,
})
}
/// `/api/v1/products/search` — `fournisseur` facultatif : c'est la recherche transversale.
#[derive(Debug, Clone, PartialEq)]
pub struct ProductSearchParams {
pub q: String,
pub fournisseur: Option<String>,
pub gamme: Option<String>,
pub sort: SortParams,
pub page: i64,
/// Défaut 100, sans plafond.
pub limit: i64,
}
/// `productSearchSchema.safeParse(...)`.
pub fn parse_product_search(q: &HashMap<String, String>) -> Result<ProductSearchParams, Issues> {
let mut issues = Issues::default();
let texte = match q.get("q") {
None => {
issues.invalid_type(&p("q"), "string", None);
None
}
Some(v) => zod::check_len(v, &p("q"), Some((1, Some("Paramètre 'q' requis"))), Some((120, None)), &mut issues).then(|| v.clone()),
};
let fournisseur = opt_non_vide(q, "fournisseur", None, &mut issues);
let gamme = opt_non_vide(q, "gamme", None, &mut issues);
let sort = sort_shape(q, &mut issues);
let page = int_param(q, "page", Some(1), None, &mut issues);
let limit = int_param(q, "limit", Some(100), None, &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(ProductSearchParams { q: texte.unwrap_or_default(), fournisseur, gamme, sort, page: page.unwrap_or(1), limit: limit.unwrap_or(100) })
}
#[derive(Debug, Clone, PartialEq)]
pub struct FournisseursQueryParams {
pub search: Option<String>,
/// `"1"` = uniquement les fournisseurs déjà présents dans l'instantané de grille.
pub with_data: Option<String>,
}
/// `fournisseursQuerySchema.safeParse(...)`.
pub fn parse_fournisseurs_query(q: &HashMap<String, String>) -> Result<FournisseursQueryParams, Issues> {
let mut issues = Issues::default();
let search = opt_non_vide(q, "search", None, &mut issues);
let with_data = enum_param(q, "withData", &["0", "1"], None, &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(FournisseursQueryParams { search, with_data })
}
/// `/api/v1/nomenclatures` — inventaire des postes d'un fournisseur.
#[derive(Debug, Clone, PartialEq)]
pub struct NomenclaturesQueryParams {
pub fournisseur: String,
/// 1 = univers, 2 = famille, 3 = sous-famille (selon la nomenclature FF).
pub niveau: u8,
/// Restreint à un poste parent (préfixe).
pub parent: Option<String>,
}
/// `nomenclaturesQuerySchema.safeParse(...)`.
pub fn parse_nomenclatures_query(q: &HashMap<String, String>) -> Result<NomenclaturesQueryParams, Issues> {
let mut issues = Issues::default();
let fournisseur = requis(q, "fournisseur", "Paramètre 'fournisseur' requis", &mut issues);
let niveau = int_param(q, "niveau", Some(1), Some(3.0), &mut issues);
let parent = opt_non_vide(q, "parent", None, &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(NomenclaturesQueryParams { fournisseur: fournisseur.unwrap_or_default(), niveau: niveau.unwrap_or(1).clamp(1, 3) as u8, parent })
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProductDetailParams {
pub fournisseur: Option<String>,
/// `"1"` (défaut) : calcule l'instantané à la demande si le produit n'y figure pas
/// encore. Nécessite `fournisseur`, le calcul se faisant par lot fournisseur.
pub compute: String,
}
/// `productDetailSchema.safeParse(...)`.
pub fn parse_product_detail(q: &HashMap<String, String>) -> Result<ProductDetailParams, Issues> {
let mut issues = Issues::default();
let fournisseur = opt_non_vide(q, "fournisseur", None, &mut issues);
let compute = enum_param(q, "compute", &["0", "1"], Some("1"), &mut issues).unwrap_or_default();
if !issues.is_empty() {
return Err(issues);
}
Ok(ProductDetailParams { fournisseur, compute })
}
/// Message des gammes inconnues (`gammeShape`).
pub fn message_gamme_inconnue() -> String {
let codes: Vec<&str> = GAMMES.iter().map(|g| g.code).collect();
format!("Gamme inconnue. Valeurs acceptées : {}", codes.join(", "))
}
fn gamme_shape(v: Option<&Value>, chemin: &[Seg], issues: &mut Issues) -> Option<String> {
let codes: Vec<&str> = GAMMES.iter().map(|g| g.code).collect();
zod::enum_value(v, &codes, chemin, Some(&message_gamme_inconnue()), issues)
}
/// `z.boolean().default(true)`.
fn bool_defaut_vrai(o: &Map<String, Value>, k: &str, issues: &mut Issues) -> Option<bool> {
match o.get(k) {
None => Some(true),
v => zod::boolean(v, &p(k), issues),
}
}
/// `PUT /api/v1/products/:codein/gamme` — affecte ou change la gamme d'un article.
#[derive(Debug, Clone, PartialEq)]
pub struct ProductGammeBody {
pub gamme: String,
/// Obligatoire seulement si l'article est référencé chez plusieurs fournisseurs.
pub fournisseur: Option<String>,
/// `true` (défaut) : calcule l'instantané du fournisseur s'il n'existe pas encore.
pub compute: bool,
}
/// `productGammeBodySchema.safeParse(corps)`.
pub fn parse_product_gamme_body(corps: &Value) -> Result<ProductGammeBody, Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(corps), &[], &mut issues) else { return Err(issues) };
let gamme = gamme_shape(o.get("gamme"), &p("gamme"), &mut issues);
let fournisseur = match o.get("fournisseur") {
None => Some(None),
v => zod::string(v, &p("fournisseur"), &mut issues)
.and_then(|s| zod::check_len(&s, &p("fournisseur"), Some((1, None)), None, &mut issues).then_some(Some(s))),
};
let compute = bool_defaut_vrai(o, "compute", &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(ProductGammeBody { gamme: gamme.unwrap_or_default(), fournisseur: fournisseur.flatten(), compute: compute.unwrap_or(true) })
}
/// Un changement de `POST /api/v1/gammes`.
#[derive(Debug, Clone, PartialEq)]
pub struct GammeChange {
pub codein: String,
pub gamme: String,
}
/// `POST /api/v1/gammes` — affecte des gammes à plusieurs articles d'un fournisseur.
#[derive(Debug, Clone, PartialEq)]
pub struct GammesBatchBody {
pub fournisseur: String,
pub changes: Vec<GammeChange>,
pub compute: bool,
}
/// `gammesBatchBodySchema.safeParse(corps)`.
pub fn parse_gammes_batch_body(corps: &Value) -> Result<GammesBatchBody, Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(corps), &[], &mut issues) else { return Err(issues) };
let fournisseur = zod::string(o.get("fournisseur"), &p("fournisseur"), &mut issues)
.and_then(|s| zod::check_len(&s, &p("fournisseur"), Some((1, Some("Champ 'fournisseur' requis"))), None, &mut issues).then_some(s));
let chemin = p("changes");
let mut changes = Vec::new();
match o.get("changes") {
Some(Value::Array(a)) => {
for (i, x) in a.iter().enumerate() {
let ci = child(&chemin, i);
let Some(c) = zod::object(Some(x), &ci, &mut issues) else { continue };
let codein = zod::string(c.get("codein"), &child(&ci, "codein"), &mut issues)
.and_then(|s| zod::check_len(&s, &child(&ci, "codein"), Some((1, None)), None, &mut issues).then_some(s));
let gamme = gamme_shape(c.get("gamme"), &child(&ci, "gamme"), &mut issues);
if let (Some(codein), Some(gamme)) = (codein, gamme) {
changes.push(GammeChange { codein, gamme });
}
}
if a.is_empty() {
issues.too_small(&chemin, "array", 1.0, Some("Au moins un changement attendu"));
}
if a.len() > 10_000 {
issues.too_big(&chemin, "array", 10_000.0, Some("10 000 changements au maximum par appel"));
}
}
autre => issues.invalid_type(&chemin, "array", autre),
}
let compute = bool_defaut_vrai(o, "compute", &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(GammesBatchBody { fournisseur: fournisseur.unwrap_or_default(), changes, compute: compute.unwrap_or(true) })
}
/// Projection de champs : `?fields=codein,libelle1,totalCa`.
///
/// Sert à alléger la réponse — un `ProductRow` complet porte les séries mensuelles
/// et les ventilations par magasin, ce qui est volumineux sur plusieurs centaines de
/// lignes. `codein` est toujours conservé pour que la ligne reste identifiable.
pub fn pick_fields(row: Value, fields: Option<&str>) -> Value {
let Some(fields) = fields.filter(|f| !f.is_empty()) else { return row };
let mut wanted: Vec<&str> = Vec::new();
for f in fields.split(',').map(super::js::js_trim).filter(|f| !f.is_empty()) {
if !wanted.contains(&f) {
wanted.push(f);
}
}
if wanted.is_empty() {
return row;
}
if !wanted.contains(&"codein") {
wanted.push("codein");
}
let Value::Object(source) = row else { return row };
let mut out = Map::new();
for key in wanted {
if let Some(v) = source.get(key) {
out.insert(key.to_string(), v.clone());
}
}
Value::Object(out)
}
/// Normalise le tri validé vers la clé attendue par grid-store.
pub fn to_sort_key(sort: Option<&str>) -> Option<GridSortKey> {
sort.and_then(GridSortKey::from_key)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn q(pairs: &[(&str, &str)]) -> HashMap<String, String> {
query_map(pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect())
}
fn issues_json(i: Issues) -> String {
serde_json::to_string(&i.to_json()).unwrap()
}
#[test]
fn grid_query_comme_zod() {
let err = parse_grid_query(&q(&[
("fournisseur", "x"),
("nomenclature", ""),
("sort", "bad"),
("order", "up"),
("enrich", "2"),
("compute", "x"),
("page", "0"),
("limit", "-1"),
]))
.unwrap_err();
assert_eq!(
issues_json(err),
r#"[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["nomenclature"],"message":"Too small: expected string to have >=1 characters"},{"origin":"string","code":"invalid_format","format":"regex","pattern":"/^[0-9]+$/","path":["nomenclature"],"message":"La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)"},{"code":"invalid_value","values":["0","1"],"path":["compute"],"message":"Invalid option: expected one of \"0\"|\"1\""},{"code":"custom","path":["sort"],"message":"Tri inconnu. Valeurs acceptées : libelle1, codein, codeFournisseur, codeGamme, totalCa, totalQuantite, totalMarge, tauxMarge, stockActuel, caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, computedAt"},{"code":"invalid_value","values":["asc","desc"],"path":["order"],"message":"Invalid option: expected one of \"asc\"|\"desc\""},{"code":"invalid_value","values":["0","1"],"path":["enrich"],"message":"Invalid option: expected one of \"0\"|\"1\""},{"origin":"number","code":"too_small","minimum":1,"inclusive":true,"path":["page"],"message":"Too small: expected number to be >=1"},{"origin":"number","code":"too_small","minimum":1,"inclusive":true,"path":["limit"],"message":"Too small: expected number to be >=1"}]"#
);
let err = parse_grid_query(&q(&[("fournisseur", "x"), ("nomenclature", "123456789012345678901a")])).unwrap_err();
assert_eq!(
issues_json(err),
r#"[{"origin":"string","code":"too_big","maximum":20,"inclusive":true,"path":["nomenclature"],"message":"Too big: expected string to have <=20 characters"},{"origin":"string","code":"invalid_format","format":"regex","pattern":"/^[0-9]+$/","path":["nomenclature"],"message":"La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)"}]"#
);
let ok = parse_grid_query(&q(&[("fournisseur", "x")])).unwrap();
assert_eq!((ok.compute.as_str(), ok.sort.order.as_str(), ok.sort.enrich.as_str(), ok.page, ok.limit), ("1", "desc", "1", 1, None));
let ok = parse_grid_query(&q(&[("fournisseur", "x"), ("page", "2"), ("limit", "50"), ("sort", "codein"), ("fields", "")])).unwrap();
assert_eq!((ok.page, ok.limit, ok.sort.sort.as_deref(), ok.sort.fields.as_deref()), (2, Some(50), Some("codein"), Some("")));
}
#[test]
fn autres_schemas_comme_zod() {
assert_eq!(
issues_json(parse_nomenclatures_query(&q(&[])).unwrap_err()),
r#"[{"expected":"string","code":"invalid_type","path":["fournisseur"],"message":"Invalid input: expected string, received undefined"}]"#
);
assert_eq!(
issues_json(parse_nomenclatures_query(&q(&[("fournisseur", "x"), ("niveau", "4"), ("parent", "")])).unwrap_err()),
r#"[{"origin":"number","code":"too_big","maximum":3,"inclusive":true,"path":["niveau"],"message":"Too big: expected number to be <=3"},{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["parent"],"message":"Too small: expected string to have >=1 characters"}]"#
);
assert_eq!(parse_nomenclatures_query(&q(&[("fournisseur", "x"), ("niveau", "2.0")])).unwrap().niveau, 2);
assert_eq!(
issues_json(parse_fournisseurs_query(&q(&[("withData", "2"), ("search", "")])).unwrap_err()),
r#"[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["search"],"message":"Too small: expected string to have >=1 characters"},{"code":"invalid_value","values":["0","1"],"path":["withData"],"message":"Invalid option: expected one of \"0\"|\"1\""}]"#
);
assert_eq!(
issues_json(parse_product_search(&q(&[("q", "x"), ("page", "1.5"), ("limit", "0")])).unwrap_err()),
r#"[{"expected":"int","format":"safeint","code":"invalid_type","path":["page"],"message":"Invalid input: expected int, received number"},{"origin":"number","code":"too_small","minimum":1,"inclusive":true,"path":["limit"],"message":"Too small: expected number to be >=1"}]"#
);
assert_eq!(parse_product_search(&q(&[("q", "x")])).unwrap().limit, 100);
assert_eq!(
issues_json(parse_product_detail(&q(&[("compute", "2")])).unwrap_err()),
r#"[{"code":"invalid_value","values":["0","1"],"path":["compute"],"message":"Invalid option: expected one of \"0\"|\"1\""}]"#
);
}
#[test]
fn corps_comme_zod() {
assert_eq!(
issues_json(parse_product_gamme_body(&json!({ "gamme": "Q", "compute": "yes", "fournisseur": 3 })).unwrap_err()),
r#"[{"code":"invalid_value","values":["A","B","C","Y","Z"],"path":["gamme"],"message":"Gamme inconnue. Valeurs acceptées : A, B, C, Y, Z"},{"expected":"string","code":"invalid_type","path":["fournisseur"],"message":"Invalid input: expected string, received number"},{"expected":"boolean","code":"invalid_type","path":["compute"],"message":"Invalid input: expected boolean, received string"}]"#
);
assert_eq!(
issues_json(parse_product_gamme_body(&json!([])).unwrap_err()),
r#"[{"expected":"object","code":"invalid_type","path":[],"message":"Invalid input: expected object, received array"}]"#
);
assert_eq!(
issues_json(parse_gammes_batch_body(&json!({ "fournisseur": "", "changes": [] })).unwrap_err()),
r#"[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["fournisseur"],"message":"Champ 'fournisseur' requis"},{"origin":"array","code":"too_small","minimum":1,"inclusive":true,"path":["changes"],"message":"Au moins un changement attendu"}]"#
);
assert_eq!(
issues_json(parse_gammes_batch_body(&json!({ "changes": [{ "codein": "", "gamme": "Q" }, { "gamme": 1 }, 5] })).unwrap_err()),
r#"[{"expected":"string","code":"invalid_type","path":["fournisseur"],"message":"Invalid input: expected string, received undefined"},{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["changes",0,"codein"],"message":"Too small: expected string to have >=1 characters"},{"code":"invalid_value","values":["A","B","C","Y","Z"],"path":["changes",0,"gamme"],"message":"Gamme inconnue. Valeurs acceptées : A, B, C, Y, Z"},{"expected":"string","code":"invalid_type","path":["changes",1,"codein"],"message":"Invalid input: expected string, received undefined"},{"code":"invalid_value","values":["A","B","C","Y","Z"],"path":["changes",1,"gamme"],"message":"Gamme inconnue. Valeurs acceptées : A, B, C, Y, Z"},{"expected":"object","code":"invalid_type","path":["changes",2],"message":"Invalid input: expected object, received number"}]"#
);
assert_eq!(
issues_json(parse_gammes_batch_body(&json!({ "fournisseur": "f", "changes": "x", "compute": null })).unwrap_err()),
r#"[{"expected":"array","code":"invalid_type","path":["changes"],"message":"Invalid input: expected array, received string"},{"expected":"boolean","code":"invalid_type","path":["compute"],"message":"Invalid input: expected boolean, received null"}]"#
);
let ok = parse_gammes_batch_body(&json!({ "fournisseur": "f", "changes": [{ "codein": "1", "gamme": "A" }] })).unwrap();
assert!(ok.compute);
assert_eq!(ok.changes, vec![GammeChange { codein: "1".into(), gamme: "A".into() }]);
}
#[test]
fn projection_de_champs() {
let row = json!({ "codein": "1", "libelle1": "L", "totalCa": 3, "gtin": "G" });
assert_eq!(pick_fields(row.clone(), None), row);
assert_eq!(pick_fields(row.clone(), Some(" , ")), row);
let p = pick_fields(row, Some("totalCa, libelle1,absent"));
assert_eq!(serde_json::to_string(&p).unwrap(), r#"{"totalCa":3,"libelle1":"L","codein":"1"}"#);
assert_eq!(to_sort_key(Some("caReseau")), Some(GridSortKey::CaReseau));
assert_eq!(to_sort_key(Some("x")), None);
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Routes `/api/v1` du module qui ne dépendent que de l'instantané `grid_rows`
//! (phase 1) : `GET /api/v1/nomenclatures`.
use super::api_schemas::{parse_nomenclatures_query, query_map};
use super::store::{get_grid_freshness, list_grid_nomenclatures};
use crate::api_v1_response::{fail, ok, ApiErrorCode};
use crate::auth::ApiAuth;
use axum::extract::Query;
use axum::response::Response;
use serde_json::{json, Map, Value};
/// `GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=…`
///
/// Répartition des articles d'un fournisseur par poste de nomenclature, avec le
/// nombre d'articles et le CA de chacun.
///
/// C'est la porte d'entrée pour traiter un gros fournisseur : certains dépassent
/// 130 000 articles, et tout récupérer d'un coup n'a de sens ni pour une IA ni
/// pour une application. On liste d'abord les postes — quelques dizaines de
/// lignes — puis on interroge `/grid?fournisseur=…&nomenclature=…` poste par poste.
///
/// Les codes sont hiérarchiques par préfixe (6 chiffres, de 31xxxx à 40xxxx) :
/// niveau 1 = « 32 », niveau 2 = « 3202 », niveau 3 = « 320211 ». `parent` accepte
/// un préfixe pour descendre l'arborescence sans tout charger.
pub async fn get_nomenclatures(ApiAuth(_auth): ApiAuth, Query(params): Query<Vec<(String, String)>>) -> Response {
let params = query_map(params);
let parsed = match parse_nomenclatures_query(&params) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
let fournisseur = parsed.fournisseur;
// Même distinction que /grid : « pas encore calculé » n'est pas « vide ».
let freshness = match get_grid_freshness(&fournisseur).await {
Ok(f) => f,
Err(e) => return fail(ApiErrorCode::InternalError, e.message, None),
};
let Some(freshness) = freshness else {
return fail(
ApiErrorCode::NotReady,
format!(
"Aucun instantané pour le fournisseur « {fournisseur} ». Appelez /api/v1/grid?fournisseur={fournisseur} une fois pour le calculer."
),
Some(json!({ "fournisseur": fournisseur })),
);
};
let postes = match list_grid_nomenclatures(&fournisseur, parsed.niveau, parsed.parent.as_deref()).await {
Ok(p) => p,
Err(e) => return fail(ApiErrorCode::InternalError, e.message, None),
};
let nb_articles: i64 = postes.iter().map(|p| p.nb_articles).sum();
let mut meta = Map::new();
meta.insert("fournisseur".into(), json!(fournisseur));
meta.insert("niveau".into(), json!(parsed.niveau));
meta.insert("parent".into(), parsed.parent.map(Value::String).unwrap_or(Value::Null));
meta.insert("nbPostes".into(), json!(postes.len()));
// Total couvert par les postes listés — à rapprocher de snapshotRowCount.
meta.insert("nbArticles".into(), json!(nb_articles));
meta.insert("snapshotRowCount".into(), json!(freshness.row_count));
meta.insert("snapshotComputedAt".into(), json!(freshness.computed_at));
// Le paramètre de /grid à utiliser avec les `code` renvoyés ci-dessus.
// `nomenclature` filtre par préfixe et fonctionne aux trois niveaux.
meta.insert("filtreGrid".into(), json!("nomenclature"));
ok(postes, None, Some(meta))
}
+551
View File
@@ -0,0 +1,551 @@
//! Routes `/api/v1` adossées au calcul de la grille (phase 2) : `/grid`,
//! `/products/search`, `/products/{codein}`, `/products/{codein}/gamme`,
//! `/gammes`, `/fournisseurs`, `/network/{codeCentrale}`.
use super::api_gammes::{affecter_gammes, lire_corps_json};
use super::api_schemas::{
parse_fournisseurs_query, parse_gammes_batch_body, parse_grid_query, parse_product_detail, parse_product_gamme_body,
parse_product_search, pick_fields, query_map, to_sort_key, GridQueryParams,
};
use super::enrich::enrich_rows;
use super::product_rows::{attendre_persistance, get_product_rows, GetProductRowsInput};
use super::store::{
get_grid_freshness, get_grid_row_by_codein, list_grid_suppliers, list_grid_suppliers_for_codein, query_grid_rows, GridFreshness,
GridQuery, SortOrder,
};
use super::types::ProductRow;
use crate::api_v1_response::{build_pagination, fail, ok, ApiErrorCode};
use crate::auth::ApiAuth;
use crate::error::AppError;
use axum::body::{Body, Bytes};
use axum::extract::{Path, Query};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use futures::channel::mpsc;
use futures::SinkExt;
use serde_json::{json, Map, Value};
use std::collections::HashMap;
fn interne(e: AppError) -> Response {
fail(ApiErrorCode::InternalError, e.message, None)
}
fn meta(entrees: Vec<(&str, Value)>) -> Option<Map<String, Value>> {
Some(entrees.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
}
/// Lignes prêtes à sérialiser : enrichies (`enrich=1`) puis projetées (`fields`).
async fn lignes_json(rows: Vec<ProductRow>, enrichir: bool, fields: Option<&str>) -> Vec<Value> {
let valeurs: Vec<Value> = if enrichir {
enrich_rows(rows).await.into_iter().map(|r| serde_json::to_value(r).unwrap_or(Value::Null)).collect()
} else {
rows.into_iter().map(|r| serde_json::to_value(r).unwrap_or(Value::Null)).collect()
};
valeurs.into_iter().map(|v| pick_fields(v, fields)).collect()
}
fn ordre(order: &str) -> Option<SortOrder> {
Some(if order == "asc" { SortOrder::Asc } else { SortOrder::Desc })
}
/// `GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=&compute=`
///
/// Lignes de grille d'un fournisseur, filtrées / triées / paginées **en SQL** depuis
/// l'instantané persisté (`grid_rows`).
///
/// Si le fournisseur n'a pas encore d'instantané, l'endpoint le **calcule à la demande**
/// (`compute=1`, défaut) ; `compute=0` restaure le comportement strict : échec
/// immédiat en `202 not_ready`.
pub async fn get_grid(ApiAuth(auth): ApiAuth, Query(params): Query<Vec<(String, String)>>) -> Response {
let q = match parse_grid_query(&query_map(params)) {
Ok(q) => q,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
// Distingue « fournisseur jamais calculé » de « filtres sans résultat » (200 vide).
let mut freshness = match get_grid_freshness(&q.fournisseur).await {
Ok(f) => f,
Err(e) => return interne(e),
};
let mut computed_on_demand = false;
if freshness.is_none() {
if q.compute == "0" {
return fail(
ApiErrorCode::NotReady,
format!(
"Aucun instantané pour le fournisseur « {} ». Relancez sans « compute=0 » pour le calculer à la demande.",
q.fournisseur
),
Some(json!({ "fournisseur": q.fournisseur })),
);
}
tracing::info!("[api/v1/grid] instantané absent pour {} — calcul à la demande (via {})", q.fournisseur, auth.via);
// magasin TOTAL : c'est la seule variante persistée par getProductRows,
// et ProductRow embarque déjà les ventilations par magasin.
match get_product_rows(GetProductRowsInput::total(&q.fournisseur, false)).await {
// La persistance est lancée en arrière-plan par le calcul : on attend
// qu'elle soit écrite avant de relire l'instantané.
Ok(_) => attendre_persistance(&q.fournisseur).await,
Err(e) => {
tracing::error!("[api/v1/grid] calcul à la demande KO pour {}: {e:#}", q.fournisseur);
return fail(
ApiErrorCode::InternalError,
format!("Le calcul de la grille a échoué pour le fournisseur « {} ».", q.fournisseur),
Some(json!({ "fournisseur": q.fournisseur })),
);
}
}
freshness = match get_grid_freshness(&q.fournisseur).await {
Ok(f) => f,
Err(e) => return interne(e),
};
if freshness.is_none() {
// Calcul réussi mais aucune ligne : le code fournisseur n'existe pas, ou
// il n'a aucun article. À distinguer d'une panne.
return fail(
ApiErrorCode::NotFound,
format!("Aucun article pour le fournisseur « {} ». Vérifiez le code auprès de /api/v1/fournisseurs.", q.fournisseur),
Some(json!({ "fournisseur": q.fournisseur })),
);
}
computed_on_demand = true;
}
let freshness = freshness.unwrap_or(GridFreshness { row_count: 0, computed_at: None });
let filtres = GridQuery {
code_fournisseur: Some(q.fournisseur.clone()),
gamme: q.gamme.clone(),
code1: q.code1.clone(),
code2: q.code2.clone(),
code3: q.code3.clone(),
nomenclature: q.nomenclature.clone(),
search: q.search.clone(),
sort: to_sort_key(q.sort.sort.as_deref()),
order: ordre(&q.sort.order),
page: None,
limit: None,
};
// Sans `limit`, la réponse peut porter tout un fournisseur — et certains en
// comptent plus de 130 000. On diffuse par lots, à mémoire constante.
let Some(limit) = q.limit else {
return stream_all_rows(filtres, q, freshness, computed_on_demand);
};
let result = match query_grid_rows(&GridQuery { page: Some(q.page), limit: Some(limit), ..filtres }).await {
Ok(r) => r,
Err(e) => return interne(e),
};
let enrichi = q.sort.enrich == "1";
let nb = result.rows.len();
let rows = lignes_json(result.rows, enrichi, q.sort.fields.as_deref()).await;
let pagination = build_pagination(q.page, limit, result.total);
let has_more = pagination.has_more;
let mut m = meta(vec![
("fournisseur", json!(q.fournisseur)),
("computedAt", json!(result.computed_at)),
("snapshotComputedAt", json!(freshness.computed_at)),
("snapshotRowCount", json!(freshness.row_count)),
("enrichi", json!(enrichi)),
// true = l'instantané n'existait pas et vient d'être calculé par cet appel.
("computedOnDemand", json!(computed_on_demand)),
// true = cette réponse contient **toutes** les lignes du fournisseur.
("complet", json!(!has_more)),
])
.unwrap_or_default();
if has_more {
m.insert(
"avertissement".into(),
json!(format!(
"Réponse partielle : {nb} lignes sur {}, parce que « limit » a été précisé. Retirez « limit » pour obtenir tout le fournisseur en un appel, ou passez à page={}.",
result.total,
q.page + 1
)),
);
}
ok(rows, Some(pagination), Some(m))
}
/// Lots internes : compromis entre nombre d'allers-retours SQL et mémoire retenue.
const STREAM_BATCH: i64 = 1000;
/// Diffuse **toutes** les lignes du fournisseur en une seule réponse JSON, sans
/// jamais la construire entièrement en mémoire.
///
/// La forme reste celle des autres réponses (`{ data, pagination, meta }`) ;
/// `pagination` et `meta` sont écrits en dernier, une fois le total connu. Une
/// erreur survenant après les premiers octets ne peut plus changer le code HTTP :
/// on ferme alors le document avec `meta.erreur`.
fn stream_all_rows(filtres: GridQuery, q: GridQueryParams, freshness: GridFreshness, computed_on_demand: bool) -> Response {
let (mut tx, rx) = mpsc::channel::<Result<Bytes, std::io::Error>>(4);
tokio::spawn(async move {
let mut envoyees: i64 = 0;
let mut total: i64 = 0;
let mut computed_at: Option<String> = None;
let mut erreur: Option<String> = None;
let enrichi = q.sort.enrich == "1";
if tx.send(Ok(Bytes::from_static(b"{\"data\":["))).await.is_err() {
return;
}
let mut page = 1;
loop {
let lot = match query_grid_rows(&GridQuery { page: Some(page), limit: Some(STREAM_BATCH), ..filtres.clone() }).await {
Ok(l) => l,
Err(e) => {
erreur = Some(e.message);
break;
}
};
if page == 1 {
total = lot.total;
computed_at = lot.computed_at.clone();
}
let n = lot.rows.len() as i64;
if n == 0 {
break;
}
let mut morceau = Vec::new();
for r in lignes_json(lot.rows, enrichi, q.sort.fields.as_deref()).await {
if envoyees > 0 {
morceau.push(b',');
}
morceau.extend(serde_json::to_vec(&r).unwrap_or_default());
envoyees += 1;
}
if tx.send(Ok(Bytes::from(morceau))).await.is_err() {
return;
}
if n < STREAM_BATCH {
break;
}
page += 1;
}
if let Some(e) = &erreur {
tracing::error!("[api/v1/grid] diffusion interrompue pour {} après {envoyees} lignes: {e}", q.fournisseur);
}
let complet = erreur.is_none() && envoyees == total;
let mut meta = json!({
"fournisseur": q.fournisseur,
"computedAt": computed_at,
"snapshotComputedAt": freshness.computed_at,
"snapshotRowCount": freshness.row_count,
"enrichi": enrichi,
"computedOnDemand": computed_on_demand,
"diffuse": true,
"complet": complet,
});
if let Some(e) = erreur {
meta["erreur"] = json!(format!("Diffusion interrompue après {envoyees} lignes sur {total} : {e}"));
}
let fin = json!({
"pagination": { "page": 1, "limit": envoyees, "total": total, "totalPages": 1, "hasMore": !complet },
"meta": meta,
});
let texte = serde_json::to_string(&fin).unwrap_or_default();
let _ = tx.send(Ok(Bytes::from(format!("],{}", &texte[1..])))).await;
});
(
[
(header::CONTENT_TYPE, "application/json; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"),
// Empêche un proxy de tamponner la réponse entière avant de la relayer.
(header::HeaderName::from_static("x-accel-buffering"), "no"),
],
Body::from_stream(rx),
)
.into_response()
}
/// `GET /api/v1/products/search?q=…&fournisseur=&gamme=&sort=&order=&page=&limit=&fields=`
///
/// **Recherche transversale** sur libellé, codein, GTIN, référence et code centrale —
/// tous fournisseurs confondus. Lit uniquement l'instantané `grid_rows`.
pub async fn get_products_search(ApiAuth(_auth): ApiAuth, Query(params): Query<Vec<(String, String)>>) -> Response {
let q = match parse_product_search(&query_map(params)) {
Ok(q) => q,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
let result = match query_grid_rows(&GridQuery {
code_fournisseur: q.fournisseur.clone(),
gamme: q.gamme.clone(),
search: Some(q.q.clone()),
sort: to_sort_key(q.sort.sort.as_deref()),
order: ordre(&q.sort.order),
page: Some(q.page),
limit: Some(q.limit),
..Default::default()
})
.await
{
Ok(r) => r,
Err(e) => return interne(e),
};
let enrichi = q.sort.enrich == "1";
let total = result.total;
let computed_at = result.computed_at.clone();
let rows = lignes_json(result.rows, enrichi, q.sort.fields.as_deref()).await;
ok(
rows,
Some(build_pagination(q.page, q.limit, total)),
meta(vec![
("query", json!(q.q)),
("computedAt", json!(computed_at)),
("scope", json!(q.fournisseur.as_ref().map(|f| format!("fournisseur:{f}")).unwrap_or_else(|| "tous fournisseurs".into()))),
("enrichi", json!(enrichi)),
]),
)
}
/// `GET /api/v1/products/{codein}?fournisseur=…`
///
/// Fiche complète d'un produit, enrichie des métriques réseau Qlik lues **en cache**.
/// `fournisseur` lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs ;
/// sans lui, l'instantané le plus récent est renvoyé.
pub async fn get_product(ApiAuth(_auth): ApiAuth, Path(codein): Path<String>, Query(params): Query<Vec<(String, String)>>) -> Response {
if codein.is_empty() {
return fail(ApiErrorCode::BadRequest, "Paramètre 'codein' requis.", None);
}
let parsed = match parse_product_detail(&query_map(params)) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
let mut found = match get_grid_row_by_codein(&codein, parsed.fournisseur.as_deref()).await {
Ok(f) => f,
Err(e) => return interne(e),
};
// Absent de l'instantané : on peut le calculer, mais seulement si l'appelant a
// précisé le fournisseur — le calcul se fait par lot fournisseur, pas par article.
if found.is_none() && parsed.compute == "1" {
if let Some(f) = parsed.fournisseur.as_deref() {
tracing::info!("[api/v1/products] {codein} absent — calcul du lot {f} à la demande");
let calcul = async {
get_product_rows(GetProductRowsInput::total(f, false)).await?;
attendre_persistance(f).await;
get_grid_row_by_codein(&codein, Some(f)).await.map_err(|e| anyhow::anyhow!(e.message))
};
match calcul.await {
Ok(r) => found = r,
Err(e) => tracing::error!("[api/v1/products] calcul KO pour {f}: {e:#}"),
}
}
}
let Some(found) = found else {
return fail(
ApiErrorCode::NotFound,
match &parsed.fournisseur {
Some(f) => format!("Produit « {codein} » introuvable chez le fournisseur « {f} »."),
None => format!(
"Aucun instantané pour le produit « {codein} ». Ajoutez « fournisseur=… » pour que l'API calcule le lot à la demande."
),
},
None,
);
};
// Métriques réseau + gamme serveur relues maintenant : le payload date du dernier
// calcul de la grille, alors que ces deux données évoluent indépendamment.
let Some(enriched) = enrich_rows(vec![found.row]).await.into_iter().next() else {
return fail(ApiErrorCode::InternalError, "Enrichissement impossible.", None);
};
let network_fetched_at = enriched.network.as_ref().and_then(|n| n.fetched_at.clone());
let has_network = enriched.network.is_some();
ok(
enriched,
None,
meta(vec![
("computedAt", json!(found.computed_at)),
("networkFetchedAt", json!(network_fetched_at)),
("hasNetwork", json!(has_network)),
]),
)
}
fn maintenant_iso() -> String {
crate::db::iso_utc(chrono::Utc::now())
}
/// `PUT /api/v1/products/{codein}/gamme` `{ "gamme": "A", "fournisseur"?: "…" }`
///
/// Propose une gamme pour un article. La proposition reste **à valider** dans la
/// Grille. `fournisseur` n'est requis que si l'article est référencé chez plusieurs
/// fournisseurs, ou s'il n'apparaît pas encore dans l'instantané.
pub async fn put_product_gamme(ApiAuth(auth): ApiAuth, Path(codein): Path<String>, body: Bytes) -> Response {
if codein.is_empty() {
return fail(ApiErrorCode::BadRequest, "Paramètre 'codein' requis.", None);
}
let corps = match lire_corps_json(&body) {
Ok(c) => c,
Err(r) => return r,
};
let parsed = match parse_product_gamme_body(&corps) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Corps de requête invalide.", Some(issues.to_json())),
};
let fournisseur = match parsed.fournisseur {
Some(f) => f,
None => {
let fournisseurs = match list_grid_suppliers_for_codein(&codein).await {
Ok(f) => f,
Err(e) => return interne(e),
};
if fournisseurs.is_empty() {
return fail(
ApiErrorCode::NotFound,
format!(
"Aucun instantané pour le produit « {codein} ». Ajoutez « fournisseur » au corps de la requête pour que l'API calcule le lot à la demande."
),
None,
);
}
if fournisseurs.len() > 1 {
return fail(
ApiErrorCode::BadRequest,
format!("Le produit « {codein} » est référencé chez plusieurs fournisseurs : précisez « fournisseur »."),
Some(json!({ "fournisseurs": fournisseurs })),
);
}
fournisseurs[0].code_fournisseur.clone()
}
};
let res = match affecter_gammes(&fournisseur, &[(codein, parsed.gamme)], parsed.compute, &auth.subject).await {
Ok(r) => r,
Err(r) => return r,
};
let mut data = match res.resultats.first().map(serde_json::to_value) {
Some(Ok(Value::Object(m))) => m,
_ => Map::new(),
};
data.insert("codeFournisseur".into(), json!(res.code_fournisseur));
data.insert("nomFournisseur".into(), json!(res.nom_fournisseur));
ok(Value::Object(data), None, meta(vec![("enregistreLe", json!(maintenant_iso()))]))
}
/// `POST /api/v1/gammes` `{ "fournisseur": "…", "changes": [{ "codein": "…", "gamme": "A" }, …] }`
///
/// Propose la gamme de plusieurs articles d'un fournisseur en un appel, à valider
/// dans la Grille. Tout ou rien : un article inconnu chez le fournisseur fait
/// échouer l'appel (404).
pub async fn post_gammes(ApiAuth(auth): ApiAuth, body: Bytes) -> Response {
let corps = match lire_corps_json(&body) {
Ok(c) => c,
Err(r) => return r,
};
let parsed = match parse_gammes_batch_body(&corps) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Corps de requête invalide.", Some(issues.to_json())),
};
let changes: Vec<(String, String)> = parsed.changes.into_iter().map(|c| (c.codein, c.gamme)).collect();
let res = match affecter_gammes(&parsed.fournisseur, &changes, parsed.compute, &auth.subject).await {
Ok(r) => r,
Err(r) => return r,
};
let demandes = res.resultats.len();
let modifies = res.resultats.iter().filter(|r| r.modifie).count();
let a_valider = res.resultats.iter().filter(|r| r.statut == "a_valider").count();
ok(
&res.resultats,
None,
meta(vec![
("codeFournisseur", json!(res.code_fournisseur)),
("nomFournisseur", json!(res.nom_fournisseur)),
("demandes", json!(demandes)),
("modifies", json!(modifies)),
("aValider", json!(a_valider)),
("enregistreLe", json!(maintenant_iso())),
]),
)
}
/// `GET /api/v1/fournisseurs?search=…&withData=1`
///
/// Liste des fournisseurs : tout le référentiel (`fouident`), enrichi de la
/// fraîcheur de l'instantané de grille quand il existe. `withData=1` restreint aux
/// seuls fournisseurs déjà présents dans l'instantané.
pub async fn get_fournisseurs(ApiAuth(_auth): ApiAuth, Query(params): Query<Vec<(String, String)>>) -> Response {
let parsed = match parse_fournisseurs_query(&query_map(params)) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
let snapshot = match list_grid_suppliers().await {
Ok(s) => s,
Err(e) => return interne(e),
};
if parsed.with_data.as_deref() == Some("1") {
let term = parsed.search.as_ref().map(|s| s.to_lowercase());
let data: Vec<Value> = snapshot
.iter()
.filter(|s| match &term {
None => true,
Some(t) => {
s.code_fournisseur.to_lowercase().contains(t.as_str())
|| s.nom_fournisseur.as_deref().unwrap_or("").to_lowercase().contains(t.as_str())
}
})
.map(|s| {
json!({ "code": s.code_fournisseur, "nom": s.nom_fournisseur, "hasSnapshot": true, "rowCount": s.row_count, "computedAt": s.computed_at })
})
.collect();
let total = data.len();
return ok(data, None, meta(vec![("total", json!(total)), ("scope", json!("instantané uniquement"))]));
}
let by_code: HashMap<&str, &super::store::GridSupplier> = snapshot.iter().map(|s| (s.code_fournisseur.as_str(), s)).collect();
let fournisseurs = crate::ff::pg::pg_get_fournisseurs(parsed.search.as_deref()).await;
let data: Vec<Value> = fournisseurs
.iter()
.map(|f| {
let snap = by_code.get(f.code.as_str());
json!({
"code": f.code,
"nom": f.nom,
"hasSnapshot": snap.is_some(),
"rowCount": snap.map(|s| s.row_count).unwrap_or(0),
"computedAt": snap.and_then(|s| s.computed_at.clone()),
})
})
.collect();
let total = data.len();
ok(data, None, meta(vec![("total", json!(total)), ("withSnapshot", json!(by_code.len()))]))
}
/// `GET /api/v1/network/{codeCentrale}`
///
/// Métriques réseau Qlik d'un produit et sa courbe de quantités mensuelles.
/// Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik.
pub async fn get_network(ApiAuth(_auth): ApiAuth, Path(code_centrale): Path<String>) -> Response {
if code_centrale.is_empty() {
return fail(ApiErrorCode::BadRequest, "Paramètre 'codeCentrale' requis.", None);
}
let metrics = match crate::qlik::network_cache::get_network_metrics_by_code_centrale(std::slice::from_ref(&code_centrale)).await {
Ok(m) => m,
Err(e) => return fail(ApiErrorCode::InternalError, format!("{e:#}"), None),
};
let Some(found) = metrics.get(&code_centrale) else {
return fail(
ApiErrorCode::NotFound,
format!(
"Aucune donnée réseau en cache pour le code centrale « {code_centrale} ». Lancez une synchronisation Qlik ou une recherche réseau."
),
None,
);
};
let mut data = Map::new();
data.insert("codeCentrale".into(), json!(code_centrale));
if let Ok(Value::Object(m)) = serde_json::to_value(found) {
data.extend(m);
}
ok(Value::Object(data), None, meta(vec![("fetchedAt", json!(found.fetched_at)), ("source", json!("cache"))]))
}
+293
View File
@@ -0,0 +1,293 @@
//! Enregistrement des gammes (port de `src/features/grid/api/enregistrer-gammes.ts`,
//! `save-draft-changes.ts` et `gammes-a-valider-actions.ts`).
use super::gammes_a_valider::{lister_gammes_a_valider, supprimer_gammes_a_valider};
use super::session::{final_user_id, verifier_session};
use super::snapshots::date_fr;
use super::zod::{self, child, Issues, Seg};
use super::{rows_cache, store};
use crate::db;
use crate::error::AppResult;
use crate::rpc::Ctx;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use sqlx::Row;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangementGamme {
pub codein: String,
/// Gamme avant la modification (état serveur), conservée pour l'historique.
pub code_gamme_before: Option<String>,
pub code_gamme: String,
}
#[derive(Debug, Clone, Default)]
pub struct EnregistrementGammes {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub changes: Vec<ChangementGamme>,
pub user_id: Option<i64>,
/// Libellé du snapshot, « Draft — date » par défaut.
pub label: Option<String>,
}
/// Enregistre des gammes pour un fournisseur — chemin unique de la Grille et de /api/v1.
///
/// Les changements sont fusionnés par-dessus le dernier snapshot du fournisseur,
/// puis reportés dans le cache mémoire de la Grille et dans l'instantané `grid_rows`.
/// Lève une erreur si l'écriture du snapshot échoue.
pub async fn enregistrer_gammes(input: &EnregistrementGammes) -> AppResult<usize> {
let EnregistrementGammes { code_fournisseur, nom_fournisseur, magasin, changes, user_id, label } = input;
if changes.is_empty() {
return Ok(0);
}
let pool = db::pool()?;
// Charger le dernier snapshot existant pour merger les changements
let existing = sqlx::query("select changes from session_snapshots where code_fournisseur = $1 order by created_at desc limit 1")
.bind(code_fournisseur)
.fetch_optional(&pool)
.await?;
let prev_changes: Map<String, Value> = match existing.map(|r| r.try_get::<Value, _>("changes")) {
Some(Ok(Value::Object(m))) => m,
_ => Map::new(),
};
// Merger les nouveaux changements par-dessus l'existant
let mut merged_changes = prev_changes;
for c in changes {
merged_changes.insert(c.codein.clone(), json!({ "before": c.code_gamme_before, "after": c.code_gamme }));
}
// Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur
sqlx::query(
"insert into session_snapshots (user_id, code_fournisseur, nom_fournisseur, magasin, label, changes, summary_json, type) \
values ($1, $2, $3, $4, $5, $6, null, 'snapshot')",
)
.bind(user_id)
.bind(code_fournisseur)
.bind(nom_fournisseur)
.bind(magasin)
.bind(label.clone().unwrap_or_else(|| format!("Draft — {}", date_fr())))
.bind(Value::Object(merged_changes))
.execute(&pool)
.await?;
// Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées :
// l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la
// réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer.
rows_cache::patch_grid_rows_cache(code_fournisseur, changes.iter().map(|c| (c.codein.as_str(), c.code_gamme.as_str())));
let code = code_fournisseur.clone();
let paires: Vec<(String, String)> = changes.iter().map(|c| (c.codein.clone(), c.code_gamme.clone())).collect();
tokio::spawn(async move {
if let Err(e) = store::update_grid_rows_gamme(&code, paires.iter().map(|(c, g)| (c.as_str(), g.as_str()))).await {
tracing::error!("[enregistrerGammes] mise à jour grid_rows KO: {}", crate::error::short(&e.message, 200));
}
});
Ok(changes.len())
}
// ---------------------------------------------------------------------------
// saveDraftChanges
// ---------------------------------------------------------------------------
/// Données validées de `SaveDraftsSchema`.
#[derive(Debug, Clone)]
pub struct SaveDraftsInput {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub changes: Vec<ChangementGamme>,
/// Propositions de l'API examinées dans la Grille : validées (présentes dans
/// `changes`) ou écartées. Elles quittent la file « à valider ».
pub gammes_a_valider_traitees: Vec<String>,
}
/// `z.array(z.string())`.
fn tableau_de_chaines(v: Option<&Value>, chemin: &[Seg], issues: &mut Issues) -> Option<Vec<String>> {
let Some(Value::Array(a)) = v else {
issues.invalid_type(chemin, "array", v);
return None;
};
let mut out = Vec::with_capacity(a.len());
let mut ok = true;
for (i, x) in a.iter().enumerate() {
match zod::string(Some(x), &child(chemin, i), issues) {
Some(s) => out.push(s),
None => ok = false,
}
}
ok.then_some(out)
}
/// `SaveDraftsSchema.safeParse(raw)`.
pub fn parse_save_drafts(raw: &Value) -> Result<SaveDraftsInput, Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(raw), &[], &mut issues) else { return Err(issues) };
let p = |k: &str| vec![Seg::from(k)];
let code_fournisseur = zod::string(o.get("codeFournisseur"), &p("codeFournisseur"), &mut issues);
let nom_fournisseur = zod::opt_string(o.get("nomFournisseur"), &p("nomFournisseur"), &mut issues);
let magasin = zod::string(o.get("magasin"), &p("magasin"), &mut issues);
let chemin = p("changes");
let mut changes = Vec::new();
match o.get("changes") {
Some(Value::Array(a)) => {
for (i, x) in a.iter().enumerate() {
let ci = child(&chemin, i);
let Some(c) = zod::object(Some(x), &ci, &mut issues) else { continue };
let codein = zod::string(c.get("codein"), &child(&ci, "codein"), &mut issues);
let before = zod::nullable_string(c.get("codeGammeBefore"), &child(&ci, "codeGammeBefore"), &mut issues);
let gamme = zod::string(c.get("codeGamme"), &child(&ci, "codeGamme"), &mut issues);
if let (Some(codein), Some(code_gamme_before), Some(code_gamme)) = (codein, before, gamme) {
changes.push(ChangementGamme { codein, code_gamme_before, code_gamme });
}
}
}
autre => issues.invalid_type(&chemin, "array", autre),
}
let traitees = match o.get("gammesAValiderTraitees") {
None => Some(Vec::new()),
v => tableau_de_chaines(v, &p("gammesAValiderTraitees"), &mut issues),
};
if !issues.is_empty() {
return Err(issues);
}
Ok(SaveDraftsInput {
code_fournisseur: code_fournisseur.unwrap_or_default(),
nom_fournisseur: nom_fournisseur.flatten(),
magasin: magasin.unwrap_or_default(),
changes,
gammes_a_valider_traitees: traitees.unwrap_or_default(),
})
}
/// `saveDraftChanges` une fois la session vérifiée : `{ success, saved, error? }`.
pub async fn enregistrer_brouillon(user_raw_id: &str, raw: &Value) -> Value {
let d = match parse_save_drafts(raw) {
Ok(d) => d,
Err(issues) => return json!({ "success": false, "saved": 0, "error": format!("Validation failed: {}", issues.message()) }),
};
let user_id = final_user_id(user_raw_id).map(|v| v as i64);
let res: AppResult<usize> = async {
let saved = enregistrer_gammes(&EnregistrementGammes {
code_fournisseur: d.code_fournisseur.clone(),
nom_fournisseur: d.nom_fournisseur.clone(),
magasin: d.magasin.clone(),
changes: d.changes.clone(),
user_id,
label: None,
})
.await?;
supprimer_gammes_a_valider(&d.code_fournisseur, &d.gammes_a_valider_traitees).await?;
Ok(saved)
}
.await;
match res {
Ok(saved) => json!({ "success": true, "saved": saved }),
Err(e) => json!({ "success": false, "saved": 0, "error": e.message }),
}
}
/// Action `saveDraftChanges(raw)`.
async fn save_draft_changes(ctx: Ctx) -> AppResult<Value> {
let utilisateur = match verifier_session(&ctx).await? {
Ok(u) => u,
Err(message) => return Ok(json!({ "success": false, "saved": 0, "error": message })),
};
Ok(enregistrer_brouillon(&utilisateur.id, &ctx.raw(0)).await)
}
// ---------------------------------------------------------------------------
// Gammes à valider (actions)
// ---------------------------------------------------------------------------
/// Action `getGammesAValider(codeFournisseur)` : gammes proposées par l'API pour
/// un fournisseur, en attente de validation.
async fn get_gammes_a_valider(ctx: Ctx) -> AppResult<Value> {
if let Err(message) = verifier_session(&ctx).await? {
return Ok(json!({ "success": false, "propositions": [], "error": message }));
}
let code = super::js::js_string(ctx.args.first());
match lister_gammes_a_valider(&code).await {
Ok(propositions) => Ok(json!({ "success": true, "propositions": propositions })),
Err(e) => Ok(json!({ "success": false, "propositions": [], "error": e.message })),
}
}
/// `RejetSchema.safeParse(raw)` → `(codeFournisseur, codeins)`.
pub fn parse_rejet(raw: &Value) -> Result<(String, Vec<String>), Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(raw), &[], &mut issues) else { return Err(issues) };
let chemin = vec![Seg::from("codeFournisseur")];
let code = zod::string(o.get("codeFournisseur"), &chemin, &mut issues);
if let Some(c) = &code {
zod::check_len(c, &chemin, Some((1, None)), None, &mut issues);
}
let codeins = tableau_de_chaines(o.get("codeins"), &[Seg::from("codeins")], &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok((code.unwrap_or_default(), codeins.unwrap_or_default()))
}
/// Action `rejeterGammesAValider(raw)` : rejette des propositions de l'API
/// (« Annuler les modifications » dans la Grille).
async fn rejeter_gammes_a_valider(ctx: Ctx) -> AppResult<Value> {
if let Err(message) = verifier_session(&ctx).await? {
return Ok(json!({ "success": false, "error": message }));
}
let (code, codeins) = match parse_rejet(&ctx.raw(0)) {
Ok(v) => v,
Err(issues) => return Ok(json!({ "success": false, "error": format!("Validation failed: {}", issues.message()) })),
};
match supprimer_gammes_a_valider(&code, &codeins).await {
Ok(()) => Ok(json!({ "success": true })),
Err(e) => Ok(json!({ "success": false, "error": e.message })),
}
}
pub fn actions() -> Vec<(&'static str, crate::rpc::Handler)> {
vec![
("saveDraftChanges", crate::handler!(save_draft_changes)),
("getGammesAValider", crate::handler!(get_gammes_a_valider)),
("rejeterGammesAValider", crate::handler!(rejeter_gammes_a_valider)),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validation_brouillon_comme_zod() {
let raw = json!({ "codeFournisseur": "1", "magasin": "T", "changes": [{ "codein": "a" }], "gammesAValiderTraitees": "x" });
let issues = parse_save_drafts(&raw).unwrap_err();
assert_eq!(
serde_json::to_string(&issues.to_json()).unwrap(),
r#"[{"expected":"string","code":"invalid_type","path":["changes",0,"codeGammeBefore"],"message":"Invalid input: expected string, received undefined"},{"expected":"string","code":"invalid_type","path":["changes",0,"codeGamme"],"message":"Invalid input: expected string, received undefined"},{"expected":"array","code":"invalid_type","path":["gammesAValiderTraitees"],"message":"Invalid input: expected array, received string"}]"#
);
let ok = parse_save_drafts(
&json!({ "codeFournisseur": "1", "magasin": "T", "changes": [{ "codein": "a", "codeGammeBefore": null, "codeGamme": "B" }] }),
)
.unwrap();
assert_eq!(ok.changes[0].code_gamme, "B");
assert!(ok.gammes_a_valider_traitees.is_empty());
}
#[test]
fn validation_rejet_comme_zod() {
let issues = parse_rejet(&json!({ "codeFournisseur": "", "codeins": [1] })).unwrap_err();
assert_eq!(
issues.message(),
"[\n {\n \"origin\": \"string\",\n \"code\": \"too_small\",\n \"minimum\": 1,\n \"inclusive\": true,\n \"path\": [\n \"codeFournisseur\"\n ],\n \"message\": \"Too small: expected string to have >=1 characters\"\n },\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"codeins\",\n 0\n ],\n \"message\": \"Invalid input: expected string, received number\"\n }\n]"
);
assert_eq!(parse_rejet(&json!(5)).unwrap_err().0.len(), 1);
}
}
+148
View File
@@ -0,0 +1,148 @@
//! CollectFlow — Enrichissement des lignes servies par `/api/v1`
//! (port de `src/lib/api-enrich.ts`).
//!
//! L'instantané `grid_rows` fige la ligne au moment du calcul. Deux informations
//! doivent pourtant refléter l'état **courant** au moment de l'appel :
//!
//! 1. **Métriques réseau Qlik** — la table `qlik_network_metrics` est mise à jour par
//! les synchronisations et les recherches réseau, indépendamment du calcul de la
//! grille. On les relit donc à la lecture, et on les expose telles quelles quand
//! elles existent (`network: null` sinon).
//!
//! 2. **Gamme serveur** — `codeGamme` peut être surchargée par un snapshot de session.
//! L'API expose la gamme **non modifiée**, celle réellement présente en base,
//! relue à chaque appel.
//!
//! Ce n'est pas un recalcul : des lectures indexées bornées au nombre de lignes.
use super::gammes_a_valider::gammes_a_valider_par_article;
use super::months;
use super::network_trend::{compute_network_trend, trend_label, Direction};
use super::types::ProductRow;
use crate::ff::pg::pg_get_gammes_by_codeins;
use crate::qlik::network_cache::{get_network_metrics_by_code_centrale, NetworkMetricCached};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet, HashMap};
/// Tendance réseau 12 mois, telle qu'affichée dans la colonne « Tendance » de la Grille.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProductTrend {
pub direction: Direction,
/// Variation des 4 derniers mois vs les 4 premiers (0.23 = +23 %). `null` si aucune base.
#[serde(serialize_with = "super::js::opt_num::serialize")]
pub pct: Option<f64>,
/// Libellé prêt à afficher : « Forte hausse », « Nouveau », « Stable »…
pub label: &'static str,
/// `true` = tendance calculée sur la quantité **par magasin vendeur**.
pub sur_qte_par_magasin: bool,
/// Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers.
pub nouveau: bool,
/// Nombre de mois de la série (12 quand elle est complète).
pub months_used: usize,
}
/// Ligne enrichie : la ligne, puis la gamme serveur, la proposition en attente,
/// les métriques réseau et la tendance (ordre des clés du TS).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnrichedProductRow {
#[serde(flatten)]
pub row: ProductRow,
/// Gamme telle qu'elle existe sur le serveur PostgreSQL — non modifiée.
pub code_gamme_serveur: Option<String>,
/// Gamme proposée par l'API et pas encore validée dans la Grille.
pub gamme_a_valider: Option<String>,
/// Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas.
pub network: Option<NetworkMetricCached>,
/// Tendance réseau sur les 12 derniers mois, `null` sans série mensuelle.
pub trend: Option<ProductTrend>,
}
/// Enrichit un lot de lignes. Les lectures sont indépendantes et lancées en
/// parallèle ; un échec de l'une n'empêche pas les autres (l'API reste servie, avec
/// la valeur de l'instantané en repli).
pub async fn enrich_rows(rows: Vec<ProductRow>) -> Vec<EnrichedProductRow> {
if rows.is_empty() {
return vec![];
}
let mut vus = BTreeSet::new();
let codes_centraux: Vec<String> =
rows.iter().filter_map(|r| r.code_centrale.clone()).filter(|c| !c.is_empty() && vus.insert(c.clone())).collect();
let mut vus = BTreeSet::new();
let codeins: Vec<String> = rows.iter().map(|r| r.codein.clone()).filter(|c| !c.is_empty() && vus.insert(c.clone())).collect();
let (network, gammes, a_valider) = tokio::join!(
async {
if codes_centraux.is_empty() {
return HashMap::new();
}
get_network_metrics_by_code_centrale(&codes_centraux).await.unwrap_or_else(|e| {
tracing::error!("[api-enrich] métriques réseau KO: {}", crate::error::short(&e, 160));
HashMap::new()
})
},
async {
if codeins.is_empty() {
return HashMap::new();
}
pg_get_gammes_by_codeins(&codeins).await.unwrap_or_else(|e| {
tracing::error!("[api-enrich] gammes serveur KO: {}", crate::error::short(&e, 160));
HashMap::new()
})
},
async {
gammes_a_valider_par_article(&codeins).await.unwrap_or_else(|e| {
tracing::error!("[api-enrich] gammes à valider KO: {}", crate::error::short(&e.message, 160));
HashMap::new()
})
},
);
let aujourd_hui = months::today();
rows.into_iter()
.map(|mut row| {
let metrics = row.code_centrale.as_deref().filter(|c| !c.is_empty()).and_then(|c| network.get(c)).cloned();
// Gamme absente de la base = pas de gamme, et non « inconnue ».
let gamme_serveur = gammes.get(&row.codein).cloned();
let gamme_a_valider = a_valider.get(&format!("{}|{}", row.code_fournisseur, row.codein)).cloned();
// `codeGammeInit` porte déjà la sémantique « état serveur » dans la Grille :
// on la garde alignée pour que les deux champs ne divergent jamais.
if !gammes.is_empty() {
row.code_gamme_init = gamme_serveur.clone();
}
// Les colonnes réseau de la ligne datent du calcul : on les réaligne sur le
// cache quand il a quelque chose à dire.
if let Some(m) = &metrics {
row.ca_reseau = Some(m.ca_reseau);
row.qte_reseau = Some(m.qte_reseau);
row.nb_magasins_reseau = Some(m.nb_magasins_reseau);
row.ca_par_magasin_reseau = Some(m.ca_par_magasin_reseau);
row.marge_pct_reseau = Some(m.marge_pct_reseau);
row.qte_reseau_by_month = Some(m.qte_by_month.clone());
row.network_fetched_at = m.fetched_at.clone();
}
// Tendance : mêmes entrées que la colonne « Tendance » de la Grille — la
// série des quantités **et** celle du nombre de magasins vendeurs.
let serie = row.qte_reseau_by_month.clone().flatten();
let nb_mag_by_month: Option<BTreeMap<String, f64>> = metrics
.as_ref()
.and_then(|m| m.metrics_by_month.as_ref())
.map(|d| d.iter().map(|(mois, v)| (mois.clone(), v.nb_mag.filter(|n| !n.is_nan()).unwrap_or(0.0))).collect());
let t = compute_network_trend(serie.as_ref(), nb_mag_by_month.as_ref(), aujourd_hui);
let trend = t.has_data.then(|| ProductTrend {
direction: t.direction,
pct: t.pct,
label: trend_label(t.pct, t.nouveau),
sur_qte_par_magasin: t.sur_qte_par_magasin,
nouveau: t.nouveau,
months_used: t.values.len(),
});
EnrichedProductRow { row, code_gamme_serveur: gamme_serveur, gamme_a_valider, network: metrics, trend }
})
.collect()
}
+402
View File
@@ -0,0 +1,402 @@
//! Exports Excel de la Grille (port de `src/app/api/export/excel/route.ts` et
//! `src/app/api/export/modified-gammes/route.ts`, ExcelJS → rust_xlsxwriter).
//!
//! Mêmes feuilles, colonnes, largeurs, styles d'en-tête, formats et noms de
//! fichier. Le découpage du fichier d'import des gammes en lots de 5 000 lignes
//! (`LIGNES_MAX_PAR_FICHIER_GAMMES`) se fait côté interface, qui appelle
//! `/api/export/modified-gammes` une fois par lot : la route ne fait que mettre en
//! forme les lignes reçues.
use super::js;
use super::zod::{self, child, Issues, Seg};
use crate::auth::SessionUser;
use crate::error::{AppError, AppResult};
use axum::body::Bytes;
use axum::http::{header, HeaderValue};
use axum::response::{IntoResponse, Response};
use axum::Json;
use rust_xlsxwriter::{Color, Format, FormatAlign, FormatBorder, FormatPattern, Workbook, Worksheet, XlsxError};
use serde_json::{json, Value};
const CONTENT_TYPE_XLSX: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
/// Largeur ExcelJS (`width: 14`) : valeur écrite telle quelle dans `<col width>`.
/// rust_xlsxwriter ajoute la marge de cellule à une largeur en caractères ; en
/// pixels (7 px par caractère), il écrit exactement `width`.
fn largeur(ws: &mut Worksheet, col: u16, width: u32) -> Result<(), XlsxError> {
ws.set_column_width_pixels(col, width * 7)?;
Ok(())
}
/// Style d'en-tête commun aux deux exports.
fn style_entete() -> Format {
Format::new()
.set_bold()
.set_font_color(Color::RGB(0xFFFFFF))
.set_pattern(FormatPattern::Solid)
.set_background_color(Color::RGB(0x0F172A))
.set_align(FormatAlign::Center)
.set_border_bottom(FormatBorder::Thin)
.set_border_bottom_color(Color::RGB(0x334155))
}
fn xlsx_err(e: XlsxError) -> AppError {
AppError::internal(e.to_string())
}
/// `await req.json()` : un corps illisible lève une exception (500 côté Next).
fn lire_json(body: &Bytes) -> AppResult<Value> {
serde_json::from_slice(body).map_err(|e| AppError::internal(format!("Corps JSON illisible : {e}")))
}
fn payload_invalide() -> Response {
(axum::http::StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid payload" }))).into_response()
}
/// `${prefixe}_${nomFournisseur.replace(/\s+/g, "_")}_${YYYY-MM-DD}.xlsx` (date UTC,
/// comme `toISOString().slice(0, 10)`).
pub fn nom_fichier(prefixe: &str, nom_fournisseur: &str) -> String {
format!("{prefixe}_{}_{}.xlsx", js::replace_whitespace_runs(nom_fournisseur, "_"), chrono::Utc::now().format("%Y-%m-%d"))
}
/// `attachment; filename="…"`. Comme Node, l'en-tête est écrit octet par octet en
/// Latin-1 : un caractère au-delà de U+00FF (ou un caractère de contrôle) est
/// refusé, ce qui faisait échouer la route Next en 500.
pub fn content_disposition(filename: &str) -> AppResult<HeaderValue> {
let mut octets = Vec::with_capacity(filename.len() + 32);
octets.extend_from_slice(b"attachment; filename=\"");
for c in filename.chars() {
let v = c as u32;
if v > 0xFF || (v < 0x20 && v != 0x09) || v == 0x7F {
return Err(AppError::internal(format!("Invalid character in header content [\"Content-Disposition\"] : {c:?}")));
}
octets.push(v as u8);
}
octets.push(b'"');
HeaderValue::from_bytes(&octets).map_err(|e| AppError::internal(e.to_string()))
}
fn reponse_xlsx(buffer: Vec<u8>, filename: &str) -> AppResult<Response> {
let disposition = content_disposition(filename)?;
Ok(([(header::CONTENT_TYPE, HeaderValue::from_static(CONTENT_TYPE_XLSX)), (header::CONTENT_DISPOSITION, disposition)], buffer)
.into_response())
}
// ---------------------------------------------------------------------------
// POST /api/export/excel
// ---------------------------------------------------------------------------
/// Ligne de `ExportSchema`.
#[derive(Debug, Clone, PartialEq)]
pub struct ExportRow {
pub codein: String,
pub libelle1: String,
pub code3: String,
pub libelle3: String,
pub code_gamme: Option<String>,
pub total_quantite: f64,
pub total_ca: f64,
pub taux_marge: f64,
/// Before gamme for delta
pub gamme_avant: Option<String>,
}
/// `ExportSchema.safeParse(body)` → `(nomFournisseur, rows)`.
pub fn parse_export(body: &Value) -> Result<(String, Vec<ExportRow>), Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(body), &[], &mut issues) else { return Err(issues) };
let nom = zod::string(o.get("nomFournisseur"), &[Seg::from("nomFournisseur")], &mut issues);
let chemin = vec![Seg::from("rows")];
let mut rows = Vec::new();
match o.get("rows") {
Some(Value::Array(a)) => {
for (i, x) in a.iter().enumerate() {
let ci = child(&chemin, i);
let Some(r) = zod::object(Some(x), &ci, &mut issues) else { continue };
let s = |k: &str, issues: &mut Issues| zod::string(r.get(k), &child(&ci, k), issues);
let codein = s("codein", &mut issues);
let libelle1 = s("libelle1", &mut issues);
let code3 = s("code3", &mut issues);
let libelle3 = s("libelle3", &mut issues);
let code_gamme = zod::nullable_string(r.get("codeGamme"), &child(&ci, "codeGamme"), &mut issues);
let n = |k: &str, issues: &mut Issues| zod::number(r.get(k), &child(&ci, k), issues);
let total_quantite = n("totalQuantite", &mut issues);
let total_ca = n("totalCa", &mut issues);
let taux_marge = n("tauxMarge", &mut issues);
let gamme_avant = match r.get("gammeAvant") {
None => Some(None),
v => zod::nullable_string(v, &child(&ci, "gammeAvant"), &mut issues),
};
if let (
Some(codein),
Some(libelle1),
Some(code3),
Some(libelle3),
Some(code_gamme),
Some(total_quantite),
Some(total_ca),
Some(taux_marge),
Some(gamme_avant),
) = (codein, libelle1, code3, libelle3, code_gamme, total_quantite, total_ca, taux_marge, gamme_avant)
{
rows.push(ExportRow {
codein,
libelle1,
code3,
libelle3,
code_gamme,
total_quantite,
total_ca,
taux_marge,
gamme_avant,
});
}
}
}
autre => issues.invalid_type(&chemin, "array", autre),
}
if !issues.is_empty() {
return Err(issues);
}
Ok((nom.unwrap_or_default(), rows))
}
/// Gamme colours
fn couleur_gamme(code: &str) -> Option<u32> {
match code {
"A" => Some(0x064E3B),
"B" => Some(0x1E3A5F),
"C" => Some(0x78350F),
"Z" => Some(0x4C0519),
_ => None,
}
}
/// Classeur « Assortiment » (tous les produits, gamme avant/après, chiffres 12 mois).
pub fn classeur_assortiment(rows: &[ExportRow]) -> Result<Vec<u8>, XlsxError> {
let mut workbook = Workbook::new();
workbook.set_properties(&rust_xlsxwriter::DocProperties::new().set_author("CollectFlow"));
let sheet = workbook.add_worksheet();
sheet.set_name("Assortiment")?;
sheet.set_freeze_panes(1, 0)?;
let entete = style_entete();
let colonnes: [(&str, u32); 9] = [
("Code", 14),
("Désignation", 40),
("Nomenclature", 25),
("Gamme Avant", 14),
("Gamme Après", 14),
("Delta", 10),
("Vol. 12m", 12),
("CA 12m (€)", 14),
("Marge (%)", 12),
];
for (i, (titre, w)) in colonnes.iter().enumerate() {
largeur(sheet, i as u16, *w)?;
sheet.write_string_with_format(0, i as u16, *titre, &entete)?;
}
sheet.set_row_height(0, 22)?;
let fmt_ca = Format::new().set_num_format("#,##0.00 \"€\"");
let fmt_marge = Format::new().set_num_format("0.00\"%\"");
let fmt_delta = Format::new().set_bold().set_font_color(Color::RGB(0xFBBF24));
for (i, row) in rows.iter().enumerate() {
let r = (i + 1) as u32;
let delta = if row.gamme_avant != row.code_gamme {
format!("{} → {}", row.gamme_avant.as_deref().unwrap_or("—"), row.code_gamme.as_deref().unwrap_or("—"))
} else {
String::new()
};
sheet.write_string(r, 0, &row.codein)?;
sheet.write_string(r, 1, &row.libelle1)?;
sheet.write_string(r, 2, &row.libelle3)?;
sheet.write_string(r, 3, row.gamme_avant.as_deref().unwrap_or("—"))?;
// Colour the "Gamme Après" cell
let gamme_apres = row.code_gamme.as_deref().unwrap_or("—");
match row.code_gamme.as_deref().and_then(couleur_gamme) {
Some(c) => {
let f = Format::new()
.set_pattern(FormatPattern::Solid)
.set_background_color(Color::RGB(c))
.set_bold()
.set_font_color(Color::RGB(0xFFFFFF));
sheet.write_string_with_format(r, 4, gamme_apres, &f)?;
}
None => {
sheet.write_string(r, 4, gamme_apres)?;
}
}
// Highlight changed rows
if delta.is_empty() {
sheet.write_string(r, 5, &delta)?;
} else {
sheet.write_string_with_format(r, 5, &delta, &fmt_delta)?;
}
sheet.write_number(r, 6, js::js_round(row.total_quantite))?;
// Format numbers
sheet.write_number_with_format(r, 7, js::js_to_fixed2(row.total_ca), &fmt_ca)?;
sheet.write_number_with_format(r, 8, js::js_to_fixed2(row.taux_marge), &fmt_marge)?;
}
workbook.save_to_buffer()
}
/// `POST /api/export/excel`.
pub async fn export_excel(SessionUser(_utilisateur): SessionUser, body: Bytes) -> AppResult<Response> {
let body = lire_json(&body)?;
let Ok((nom_fournisseur, rows)) = parse_export(&body) else { return Ok(payload_invalide()) };
let buffer = classeur_assortiment(&rows).map_err(xlsx_err)?;
reponse_xlsx(buffer, &nom_fichier("Assortiment", &nom_fournisseur))
}
// ---------------------------------------------------------------------------
// POST /api/export/modified-gammes
// ---------------------------------------------------------------------------
/// Ligne de `ExportModifiedGammesSchema`.
#[derive(Debug, Clone, PartialEq)]
pub struct GammeModifiee {
pub codein: String,
pub gtin: String,
pub code_fournisseur: Option<String>,
pub gamme: String,
}
/// `ExportModifiedGammesSchema.safeParse(body)` → `(nomFournisseur, changes)`.
pub fn parse_modified_gammes(body: &Value) -> Result<(String, Vec<GammeModifiee>), Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(body), &[], &mut issues) else { return Err(issues) };
let nom = zod::string(o.get("nomFournisseur"), &[Seg::from("nomFournisseur")], &mut issues);
let chemin = vec![Seg::from("changes")];
let mut changes = Vec::new();
match o.get("changes") {
Some(Value::Array(a)) => {
for (i, x) in a.iter().enumerate() {
let ci = child(&chemin, i);
let Some(r) = zod::object(Some(x), &ci, &mut issues) else { continue };
let codein = zod::string(r.get("codein"), &child(&ci, "codein"), &mut issues);
let gtin = zod::string(r.get("gtin"), &child(&ci, "gtin"), &mut issues);
let code_fournisseur = zod::opt_string(r.get("codeFournisseur"), &child(&ci, "codeFournisseur"), &mut issues);
let gamme = zod::string(r.get("gamme"), &child(&ci, "gamme"), &mut issues);
if let (Some(codein), Some(gtin), Some(code_fournisseur), Some(gamme)) = (codein, gtin, code_fournisseur, gamme) {
changes.push(GammeModifiee { codein, gtin, code_fournisseur, gamme });
}
}
}
autre => issues.invalid_type(&chemin, "array", autre),
}
if !issues.is_empty() {
return Err(issues);
}
Ok((nom.unwrap_or_default(), changes))
}
/// `x || "—"`.
fn ou_tiret(s: &str) -> &str {
if s.is_empty() {
"—"
} else {
s
}
}
/// Classeur « Gammes Modifiées » (CODE FOURNISSEUR, CODEIN, GAMME) : le fichier
/// d'import des gammes.
pub fn classeur_gammes_modifiees(changes: &[GammeModifiee]) -> Result<Vec<u8>, XlsxError> {
let mut workbook = Workbook::new();
workbook.set_properties(&rust_xlsxwriter::DocProperties::new().set_author("CollectFlow"));
let sheet = workbook.add_worksheet();
sheet.set_name("Gammes Modifiées")?;
sheet.set_freeze_panes(1, 0)?;
// Format en texte pour eviter la notation scientifique excel ; centrer la
// colonne Gamme. ExcelJS applique ces styles de colonne à toutes les cellules
// déjà écrites, en-tête compris. « @ » = format intégré n° 49, comme ExcelJS.
let texte = Format::new().set_num_format_index(49);
let centre = Format::new().set_align(FormatAlign::Center);
let entete = style_entete();
let entete_texte = style_entete().set_num_format_index(49);
largeur(sheet, 0, 22)?;
largeur(sheet, 1, 18)?;
largeur(sheet, 2, 12)?;
sheet.set_column_format(0, &texte)?;
sheet.set_column_format(1, &texte)?;
sheet.set_column_format(2, &centre)?;
sheet.write_string_with_format(0, 0, "CODE FOURNISSEUR", &entete_texte)?;
sheet.write_string_with_format(0, 1, "CODEIN", &entete_texte)?;
sheet.write_string_with_format(0, 2, "GAMME", &entete)?;
sheet.set_row_height(0, 22)?;
for (i, row) in changes.iter().enumerate() {
let r = (i + 1) as u32;
sheet.write_string_with_format(r, 0, ou_tiret(row.code_fournisseur.as_deref().unwrap_or("")), &texte)?;
sheet.write_string_with_format(r, 1, ou_tiret(&row.codein), &texte)?;
sheet.write_string_with_format(r, 2, ou_tiret(&row.gamme), &centre)?;
}
workbook.save_to_buffer()
}
/// `POST /api/export/modified-gammes`.
pub async fn export_modified_gammes(SessionUser(_utilisateur): SessionUser, body: Bytes) -> AppResult<Response> {
let body = lire_json(&body)?;
let Ok((nom_fournisseur, changes)) = parse_modified_gammes(&body) else { return Ok(payload_invalide()) };
let buffer = classeur_gammes_modifiees(&changes).map_err(xlsx_err)?;
reponse_xlsx(buffer, &nom_fichier("Modifications_Gammes", &nom_fournisseur))
}
#[cfg(test)]
mod tests {
use super::*;
/// Écrit les deux classeurs d'exemple dans `GRID_EXPORT_DIR` (pour inspection).
#[test]
fn classeurs_d_exemple() {
let (_, rows) = parse_export(&json!({
"nomFournisseur": "Le Fournisseur",
"rows": [
{ "codein": "123456", "libelle1": "Bougie parfumée", "code3": "320211", "libelle3": "Bougies", "codeGamme": "A", "totalQuantite": 12.5, "totalCa": 1234.125, "tauxMarge": 41.2345, "gammeAvant": "B" },
{ "codein": "000789", "libelle1": "Vase", "code3": "320212", "libelle3": "Vases", "codeGamme": null, "totalQuantite": -2.5, "totalCa": 10, "tauxMarge": 0, "gammeAvant": null },
{ "codein": "42", "libelle1": "", "code3": "", "libelle3": "", "codeGamme": "Y", "totalQuantite": 3, "totalCa": 0.125, "tauxMarge": 1.005, "gammeAvant": "Y" }
]
}))
.unwrap();
let a = classeur_assortiment(&rows).unwrap();
let (_, changes) = parse_modified_gammes(&json!({
"nomFournisseur": "X",
"changes": [
{ "codein": "000123", "gtin": "3700", "codeFournisseur": "F001", "gamme": "A" },
{ "codein": "", "gtin": "", "gamme": "" }
]
}))
.unwrap();
let b = classeur_gammes_modifiees(&changes).unwrap();
assert!(a.starts_with(b"PK") && b.starts_with(b"PK"));
if let Ok(dir) = std::env::var("GRID_EXPORT_DIR") {
std::fs::write(format!("{dir}/rust_excel.xlsx"), a).unwrap();
std::fs::write(format!("{dir}/rust_modified.xlsx"), b).unwrap();
}
}
#[test]
fn validation_et_entetes() {
assert!(parse_export(&json!({ "nomFournisseur": "x", "rows": [{ "codein": "a" }] })).is_err());
assert!(parse_export(&json!({ "nomFournisseur": "x", "rows": [] })).is_ok());
assert!(parse_modified_gammes(
&json!({ "nomFournisseur": "x", "changes": [{ "codein": "a", "gtin": "g", "gamme": "A", "codeFournisseur": 3 }] })
)
.is_err());
let nom = nom_fichier("Assortiment", "Le Fournisseur\tÉté");
assert!(nom.starts_with("Assortiment_Le_Fournisseur_Été_") && nom.ends_with(".xlsx"));
let h = content_disposition("Assortiment_Été.xlsx").unwrap();
assert_eq!(h.as_bytes(), b"attachment; filename=\"Assortiment_\xc9t\xe9.xlsx\"");
assert!(content_disposition("Fournisseur_œ.xlsx").is_err());
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Les gammes d'assortiment : code, nom et couleurs — source unique
//! (port de `src/lib/gammes.ts`, utilisé par la validation `/api/v1` et le
//! document OpenAPI).
pub struct Gamme {
pub code: &'static str,
/// Nom court : « Cœur », « Sortie »…
pub nom: &'static str,
/// Phrase d'explication pour les infobulles.
pub description: &'static str,
/// Classes de pastille (fond, bordure, texte) lisibles en clair comme en sombre.
pub classes: &'static str,
}
pub const GAMMES: [Gamme; 5] = [
Gamme {
code: "A",
nom: "Cœur",
description: "Produit permanent, au cœur de l'assortiment.",
classes: "border-emerald-500/40 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
},
Gamme {
code: "B",
nom: "Complémentaire",
description: "Produit qui complète l'offre du rayon.",
classes: "border-blue-500/40 bg-blue-500/10 text-blue-800 dark:text-blue-300",
},
Gamme {
code: "C",
nom: "Saisonnier",
description: "Produit présent selon la saison.",
classes: "border-amber-500/40 bg-amber-500/10 text-amber-800 dark:text-amber-300",
},
Gamme {
code: "Y",
nom: "En veille",
description: "Produit mis en attente : il n'est plus commandé pour l'instant.",
classes: "border-violet-500/40 bg-violet-500/10 text-violet-800 dark:text-violet-300",
},
Gamme {
code: "Z",
nom: "Sortie",
description: "Produit à retirer de l'assortiment.",
classes: "border-rose-500/40 bg-rose-500/10 text-rose-800 dark:text-rose-300",
},
];
/// Pastille d'un produit sans gamme.
pub const CLASSES_SANS_GAMME: &str = "border-slate-400/40 bg-slate-500/10 text-slate-700 dark:text-slate-300";
/// Codes de gamme, dans l'ordre de [`GAMMES`].
pub fn codes_gamme() -> Vec<&'static str> {
GAMMES.iter().map(|g| g.code).collect()
}
pub fn trouver_gamme(code: Option<&str>) -> Option<&'static Gamme> {
let code = code.filter(|c| !c.is_empty())?;
let c = super::js::js_trim(code);
GAMMES.iter().find(|g| g.code == c)
}
#[cfg(test)]
mod tests {
#[test]
fn coherent_avec_le_socle() {
assert_eq!(super::codes_gamme(), crate::domain::CODES_GAMME.to_vec());
assert_eq!(super::trouver_gamme(Some(" Z ")).unwrap().nom, "Sortie");
assert!(super::trouver_gamme(None).is_none());
}
}
+129
View File
@@ -0,0 +1,129 @@
//! CollectFlow — Gammes proposées par l'API, en attente de validation
//! (port de `src/lib/gammes-a-valider.ts`).
//!
//! L'API `/api/v1` ne change plus une gamme directement : elle dépose une
//! proposition, que la Grille affiche comme une modification non enregistrée.
//! L'utilisateur voit ainsi quels produits changent de gamme, puis valide
//! (« Enregistrer ») ou rejette (« Annuler les modifications »).
use crate::db;
use crate::error::AppResult;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder, Row};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PropositionGamme {
pub codein: String,
/// Gamme proposée.
pub gamme: String,
/// Gamme enregistrée au moment de la proposition.
pub gamme_precedente: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GammeAValider {
pub codein: String,
/// Gamme proposée.
pub gamme: String,
/// Gamme enregistrée au moment de la proposition.
pub gamme_precedente: Option<String>,
pub auteur: Option<String>,
/// Date ISO de la proposition.
pub proposee_le: String,
}
/// Dépose des propositions (la dernière l'emporte pour un même article) et retire
/// celles des articles `annules` — redemander la gamme déjà enregistrée annule la
/// proposition en attente.
pub async fn proposer_gammes(code_fournisseur: &str, propositions: &[PropositionGamme], annules: &[String], auteur: &str) -> AppResult<()> {
let pool = db::pool()?;
let mut tx = pool.begin().await?;
if !annules.is_empty() {
sqlx::query("delete from gammes_a_valider where code_fournisseur = $1 and codein = any($2)")
.bind(code_fournisseur)
.bind(annules)
.execute(&mut *tx)
.await?;
}
if !propositions.is_empty() {
let maintenant = chrono::Utc::now().naive_utc();
let auteur: String = auteur.chars().take(100).collect();
let mut qb: QueryBuilder<Postgres> =
QueryBuilder::new("insert into gammes_a_valider (code_fournisseur, codein, gamme, gamme_precedente, auteur, created_at) ");
qb.push_values(propositions, |mut b, p| {
b.push_bind(code_fournisseur.to_string())
.push_bind(p.codein.clone())
.push_bind(p.gamme.clone())
.push_bind(p.gamme_precedente.clone())
.push_bind(auteur.clone())
.push_bind(maintenant);
});
qb.push(
" on conflict (code_fournisseur, codein) do update set gamme = excluded.gamme, \
gamme_precedente = excluded.gamme_precedente, auteur = excluded.auteur, created_at = excluded.created_at",
);
qb.build().execute(&mut *tx).await?;
}
tx.commit().await?;
Ok(())
}
/// Propositions en attente pour un fournisseur.
pub async fn lister_gammes_a_valider(code_fournisseur: &str) -> AppResult<Vec<GammeAValider>> {
let pool = db::pool()?;
let rows = sqlx::query(
"select code_fournisseur, codein, gamme, gamme_precedente, auteur, created_at from gammes_a_valider \
where code_fournisseur = $1",
)
.bind(code_fournisseur)
.fetch_all(&pool)
.await?;
Ok(rows
.iter()
.map(|r| GammeAValider {
codein: r.try_get("codein").unwrap_or_default(),
gamme: r.try_get("gamme").unwrap_or_default(),
gamme_precedente: r.try_get("gamme_precedente").ok().flatten(),
auteur: r.try_get("auteur").ok().flatten(),
proposee_le: r.try_get::<NaiveDateTime, _>("created_at").map(db::iso).unwrap_or_default(),
})
.collect())
}
/// Propositions en attente pour une liste d'articles, indexées par `fournisseur|codein`.
pub async fn gammes_a_valider_par_article(codeins: &[String]) -> AppResult<HashMap<String, String>> {
if codeins.is_empty() {
return Ok(HashMap::new());
}
let pool = db::pool()?;
let rows = sqlx::query("select code_fournisseur, codein, gamme from gammes_a_valider where codein = any($1)")
.bind(codeins)
.fetch_all(&pool)
.await?;
Ok(rows
.iter()
.map(|r| {
let f: String = r.try_get("code_fournisseur").unwrap_or_default();
let c: String = r.try_get("codein").unwrap_or_default();
(format!("{f}|{c}"), r.try_get("gamme").unwrap_or_default())
})
.collect())
}
/// Retire des propositions, une fois validées ou rejetées dans la Grille.
pub async fn supprimer_gammes_a_valider(code_fournisseur: &str, codeins: &[String]) -> AppResult<()> {
if codeins.is_empty() {
return Ok(());
}
let pool = db::pool()?;
sqlx::query("delete from gammes_a_valider where code_fournisseur = $1 and codein = any($2)")
.bind(code_fournisseur)
.bind(codeins)
.execute(&pool)
.await?;
Ok(())
}
+135
View File
@@ -0,0 +1,135 @@
//! Routes internes de la Grille (ports de `src/app/api/grid/rows/route.ts` et
//! `src/app/api/grid/rows/store-patch/route.ts`).
use super::js::JsNum;
use super::product_rows::{get_product_rows, get_store_patch, GetProductRowsInput};
use super::types::ProductRow;
use crate::auth::SessionUser;
use axum::body::{Body, Bytes};
use axum::extract::Query;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use futures::channel::mpsc;
use futures::SinkExt;
use serde::Serialize;
use serde_json::json;
/// `searchParams.get(nom)` : première occurrence.
pub fn premier<'a>(params: &'a [(String, String)], nom: &str) -> Option<&'a str> {
params.iter().find(|(k, _)| k == nom).map(|(_, v)| v.as_str())
}
#[derive(Serialize)]
struct Lot<'a> {
#[serde(rename = "type")]
type_: &'static str,
rows: &'a [ProductRow],
loaded: JsNum,
total: usize,
}
/// Indice de `Array.prototype.slice` (troncature, bornage à la longueur).
fn indice_slice(x: f64, len: usize) -> usize {
if x.is_nan() {
0
} else {
x.trunc().clamp(0.0, len as f64) as usize
}
}
fn ndjson(v: &impl Serialize) -> Bytes {
let mut out = serde_json::to_vec(v).unwrap_or_default();
out.push(b'\n');
Bytes::from(out)
}
/// `GET /api/grid/rows?fournisseur=…&magasin=…&chunkSize=…&refresh=1`
///
/// Flux NDJSON : `{ type: "start" }`, puis des lots `{ type: "chunk", rows, loaded,
/// total }`, puis `{ type: "done" }` — ou `{ type: "error", error }`.
pub async fn get_grid_rows(SessionUser(_utilisateur): SessionUser, Query(params): Query<Vec<(String, String)>>) -> Response {
let Some(code_fournisseur) = premier(&params, "fournisseur").filter(|f| !f.is_empty()).map(str::to_string) else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "Missing fournisseur" }))).into_response();
};
let magasin = premier(&params, "magasin").unwrap_or("TOTAL").to_string();
let chunk_size = {
let brut = premier(&params, "chunkSize").map(super::js::js_number_from_str).unwrap_or(150.0);
// Math.max(50, Math.min(500, x)) : NaN se propage.
if brut.is_nan() {
f64::NAN
} else {
brut.clamp(50.0, 500.0)
}
};
// `code1`/`code2`/`code3` ne sont pas lus : la Grille filtre en local
// (getProductRows ne s'en sert pas).
let force_refresh = premier(&params, "refresh") == Some("1");
// Client parti (changement de fournisseur, page quittée) : on arrête de
// sérialiser et d'envoyer. Le calcul lui-même continue — il est partagé et
// mis en cache, la prochaine ouverture en profitera.
let (mut tx, rx) = mpsc::channel::<Result<Bytes, std::io::Error>>(4);
tokio::spawn(async move {
if tx.send(Ok(ndjson(&json!({ "type": "start", "loaded": 0, "total": null })))).await.is_err() {
return;
}
let input = GetProductRowsInput { code_fournisseur, magasin: Some(magasin), force_refresh };
match get_product_rows(input).await {
Ok(shared) => {
let total = shared.len();
let mut start = 0.0f64;
while start < total as f64 {
let ligne = {
let rows = shared.rows.read();
let len = rows.len();
let (a, b) = (indice_slice(start, len), indice_slice(start + chunk_size, len));
let lot = &rows[a..b.max(a)];
ndjson(&Lot { type_: "chunk", rows: lot, loaded: JsNum((start + lot.len() as f64).min(total as f64)), total })
};
if tx.send(Ok(ligne)).await.is_err() {
return;
}
start += chunk_size;
tokio::task::yield_now().await;
}
let _ = tx.send(Ok(ndjson(&json!({ "type": "done", "loaded": total, "total": total })))).await;
}
Err(e) => {
let _ = tx.send(Ok(ndjson(&json!({ "type": "error", "error": e.to_string() })))).await;
}
}
});
(
[
(header::CONTENT_TYPE, "application/x-ndjson; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"),
(header::HeaderName::from_static("x-accel-buffering"), "no"),
],
Body::from_stream(rx),
)
.into_response()
}
/// `GET /api/grid/rows/store-patch?fournisseur=…&magasin=…`
///
/// Complément de l'API FF pour un magasin (cf. `store_patch`). La Grille charge
/// toujours les lignes « tous magasins » et applique ce complément à son arrivée :
/// le changement de magasin n'attend plus rien.
pub async fn get_grid_store_patch(SessionUser(_utilisateur): SessionUser, Query(params): Query<Vec<(String, String)>>) -> Response {
let code_fournisseur = premier(&params, "fournisseur").unwrap_or("");
let magasin = premier(&params, "magasin").unwrap_or("");
// Un code inconnu ferait de chaque article un candidat : autant d'appels
// inutiles à l'API FF. « Tous magasins » n'a pas de complément.
if code_fournisseur.is_empty() || !crate::domain::MAGASINS.iter().any(|m| m.code == magasin) {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "Paramètres fournisseur et magasin requis." }))).into_response();
}
match get_store_patch(code_fournisseur, magasin).await {
Ok(patch) => ([(header::CACHE_CONTROL, "no-store")], Json(&*patch)).into_response(),
Err(e) => {
tracing::error!("[store-patch] {code_fournisseur}/{magasin} KO: {}", crate::error::short(&e, 200));
(StatusCode::BAD_GATEWAY, Json(json!({ "error": "Complément indisponible." }))).into_response()
}
}
}
+472
View File
@@ -0,0 +1,472 @@
//! Équivalents des conversions JavaScript dont dépend le comportement du code TS
//! d'origine : `Number(…)`, `parseInt(…, 10)`, `String(nombre)`, `Math.round`,
//! `toFixed`, et la sérialisation JSON des nombres (`12` et non `12.0`).
//!
//! Les nombres de `ProductRow` sont des `f64` ; `JSON.stringify` écrit un entier
//! sans décimale et `NaN` en `null`. Les modules de sérialisation ci-dessous
//! reproduisent ces deux règles, et relisent un `null` en `NaN` pour qu'une ligne
//! persistée par la version Node fasse l'aller-retour à l'identique.
use serde::de::Deserializer;
use serde::ser::{SerializeMap, Serializer};
use serde::Deserialize;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use std::sync::LazyLock;
/// Espace blanc au sens de JS (`trim`, `\s`).
pub fn is_js_whitespace(c: char) -> bool {
matches!(
c,
'\u{9}'..='\u{d}'
| ' '
| '\u{a0}'
| '\u{1680}'
| '\u{2000}'..='\u{200a}'
| '\u{2028}'
| '\u{2029}'
| '\u{202f}'
| '\u{205f}'
| '\u{3000}'
| '\u{feff}'
)
}
/// `s.trim()`.
pub fn js_trim(s: &str) -> &str {
s.trim_matches(is_js_whitespace)
}
/// `s.replace(/\s+/g, remplacement)`.
pub fn replace_whitespace_runs(s: &str, remplacement: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut dans_blanc = false;
for c in s.chars() {
if is_js_whitespace(c) {
if !dans_blanc {
out.push_str(remplacement);
dans_blanc = true;
}
} else {
dans_blanc = false;
out.push(c);
}
}
out
}
static DECIMAL: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$").unwrap());
/// `Number(s)` pour une chaîne (coercition de `z.coerce.number()`).
pub fn js_number_from_str(s: &str) -> f64 {
let t = js_trim(s);
if t.is_empty() {
return 0.0;
}
match t {
"Infinity" | "+Infinity" => return f64::INFINITY,
"-Infinity" => return f64::NEG_INFINITY,
_ => {}
}
let base = |chiffres: &str, radix: u32| -> f64 {
if chiffres.is_empty() {
return f64::NAN;
}
let mut v = 0f64;
for c in chiffres.chars() {
match c.to_digit(radix) {
Some(d) => v = v * radix as f64 + d as f64,
None => return f64::NAN,
}
}
v
};
let prefixe = |p: &[&str]| p.iter().find_map(|x| t.strip_prefix(x));
if let Some(r) = prefixe(&["0x", "0X"]) {
return base(r, 16);
}
if let Some(r) = prefixe(&["0o", "0O"]) {
return base(r, 8);
}
if let Some(r) = prefixe(&["0b", "0B"]) {
return base(r, 2);
}
if !DECIMAL.is_match(t) {
return f64::NAN;
}
t.parse::<f64>().unwrap_or(f64::NAN)
}
/// `parseInt(s, 10)` : préfixe numérique, `NaN` s'il n'y en a pas.
pub fn js_parse_int(s: &str) -> f64 {
let t = s.trim_start_matches(is_js_whitespace);
let (negatif, reste) = match t.as_bytes().first() {
Some(b'-') => (true, &t[1..]),
Some(b'+') => (false, &t[1..]),
_ => (false, t),
};
let fin = reste.bytes().take_while(|b| b.is_ascii_digit()).count();
if fin == 0 {
return f64::NAN;
}
let v: f64 = reste[..fin].parse().unwrap_or(f64::NAN);
if negatif {
-v
} else {
v
}
}
/// `String(x)` pour un nombre (`Number.prototype.toString()`).
pub fn js_number_to_string(x: f64) -> String {
if x.is_nan() {
return "NaN".into();
}
if x == 0.0 {
return "0".into();
}
if x.is_infinite() {
return if x > 0.0 { "Infinity".into() } else { "-Infinity".into() };
}
let signe = if x < 0.0 { "-" } else { "" };
// `{:e}` donne les chiffres les plus courts qui relisent la même valeur,
// exactement ceux que retient l'algorithme de JS.
let e = format!("{:e}", x.abs());
let (mantisse, exposant) = e.split_once('e').unwrap_or((&e, "0"));
let chiffres: String = mantisse.chars().filter(|c| *c != '.').collect();
let k = chiffres.len() as i64;
let n = exposant.parse::<i64>().unwrap_or(0) + 1;
let corps = if k <= n && n <= 21 {
format!("{chiffres}{}", "0".repeat((n - k) as usize))
} else if 0 < n && n <= 21 {
format!("{}.{}", &chiffres[..n as usize], &chiffres[n as usize..])
} else if -6 < n && n <= 0 {
format!("0.{}{chiffres}", "0".repeat((-n) as usize))
} else {
let reste = if k > 1 { format!(".{}", &chiffres[1..]) } else { String::new() };
let e = n - 1;
format!("{}{reste}e{}{}", &chiffres[..1], if e < 0 { "-" } else { "+" }, e.abs())
};
format!("{signe}{corps}")
}
/// `String(v)` pour une valeur JSON quelconque (argument d'action non typé).
pub fn js_string(v: Option<&Value>) -> String {
match v {
None => "undefined".into(),
Some(Value::Null) => "null".into(),
Some(Value::Bool(b)) => b.to_string(),
Some(Value::Number(n)) => js_number_to_string(n.as_f64().unwrap_or(f64::NAN)),
Some(Value::String(s)) => s.clone(),
Some(Value::Array(a)) => a
.iter()
.map(|x| match x {
Value::Null => String::new(),
autre => js_string(Some(autre)),
})
.collect::<Vec<_>>()
.join(","),
Some(Value::Object(_)) => "[object Object]".into(),
}
}
/// Valeur « vraie » au sens de JS.
pub fn js_truthy(v: Option<&Value>) -> bool {
match v {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(false),
Some(Value::String(s)) => !s.is_empty(),
Some(_) => true,
}
}
/// `Math.round(x)` : demi arrondi vers +∞ (`Math.round(-2.5) === -2`).
pub fn js_round(x: f64) -> f64 {
if !x.is_finite() {
return x;
}
let f = x.floor();
if x - f >= 0.5 {
f + 1.0
} else {
f
}
}
/// `parseFloat(x.toFixed(2))`.
///
/// `toFixed` arrondit la valeur binaire exacte ; en cas d'égalité parfaite
/// (0,125 → « 0.13 »), il retient le plus grand entier en valeur absolue, là où
/// le formatage de Rust arrondit au pair.
pub fn js_to_fixed2(x: f64) -> f64 {
if !x.is_finite() || x.abs() >= 1e21 {
return x;
}
let a = x.abs();
// Une égalité exacte au millième n'est possible que pour un multiple de 1/8.
let huitiemes = a * 8.0;
let r = if huitiemes.fract() == 0.0 && huitiemes < 9.0e15 {
let centiemes = a * 100.0; // exact : multiple de 12,5
let f = centiemes.floor();
let c = if centiemes - f >= 0.5 { f + 1.0 } else { f };
c / 100.0
} else {
format!("{a:.2}").parse::<f64>().unwrap_or(a)
};
if x < 0.0 {
-r
} else {
r
}
}
/// Clé d'objet qui est un indice de tableau pour JS (`"0"`, `"404"`, `"123456"`) :
/// ces clés sont énumérées avant les autres, par ordre croissant.
pub fn js_array_index(k: &str) -> Option<u64> {
if k.is_empty() || k.len() > 10 || (k.len() > 1 && k.starts_with('0')) || !k.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
k.parse::<u64>().ok().filter(|v| *v < u32::MAX as u64)
}
/// Réordonne un objet comme JS énumère ses clés : indices croissants, puis les
/// autres clés dans l'ordre d'insertion.
pub fn js_object_order(m: Map<String, Value>) -> Map<String, Value> {
if !m.keys().any(|k| js_array_index(k).is_some()) {
return m;
}
let mut indices: Vec<(u64, String, Value)> = Vec::new();
let mut autres: Vec<(String, Value)> = Vec::new();
for (k, v) in m {
match js_array_index(&k) {
Some(i) => indices.push((i, k, v)),
None => autres.push((k, v)),
}
}
indices.sort_by_key(|(i, _, _)| *i);
indices.into_iter().map(|(_, k, v)| (k, v)).chain(autres).collect()
}
/// Texte d'un nombre pour une colonne `numeric` (équivalent de `String(n)`).
pub fn numeric_text(x: f64) -> String {
js_number_to_string(x)
}
// ---------------------------------------------------------------------------
// Sérialisation des nombres façon `JSON.stringify`
// ---------------------------------------------------------------------------
/// Nombre sérialisé comme le ferait `JSON.stringify` : entier sans décimale,
/// `null` pour `NaN`/`Infinity`.
pub struct JsNum(pub f64);
impl serde::Serialize for JsNum {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let v = self.0;
if !v.is_finite() {
s.serialize_unit()
} else if v.fract() == 0.0 && v.abs() < 9.0e15 {
s.serialize_i64(v as i64)
} else {
s.serialize_f64(v)
}
}
}
/// `f64` (un `null` relu vaut `NaN`).
pub mod num {
use super::*;
pub fn serialize<S: Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
serde::Serialize::serialize(&JsNum(*v), s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
Ok(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN))
}
}
/// `Option<f64>` d'un champ facultatif (présent à `null` → `Some(NaN)`).
pub mod opt_num {
use super::*;
pub fn serialize<S: Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(x) => serde::Serialize::serialize(&JsNum(*x), s),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
Ok(Some(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN)))
}
}
fn ser_map<S: Serializer>(m: &BTreeMap<String, f64>, s: S) -> Result<S::Ok, S::Error> {
let mut out = s.serialize_map(Some(m.len()))?;
for (k, v) in m {
out.serialize_entry(k, &JsNum(*v))?;
}
out.end()
}
fn de_map<'de, D: Deserializer<'de>>(d: D) -> Result<BTreeMap<String, f64>, D::Error> {
let brut = Option::<BTreeMap<String, Option<f64>>>::deserialize(d)?.unwrap_or_default();
Ok(brut.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect())
}
/// `Record<string, number>` obligatoire.
pub mod map {
use super::*;
pub fn serialize<S: Serializer>(m: &BTreeMap<String, f64>, s: S) -> Result<S::Ok, S::Error> {
ser_map(m, s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<BTreeMap<String, f64>, D::Error> {
de_map(d)
}
}
/// `Record<string, number>` facultatif.
pub mod opt_map {
use super::*;
pub fn serialize<S: Serializer>(m: &Option<BTreeMap<String, f64>>, s: S) -> Result<S::Ok, S::Error> {
match m {
Some(m) => ser_map(m, s),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<BTreeMap<String, f64>>, D::Error> {
let brut = Option::<BTreeMap<String, Option<f64>>>::deserialize(d)?;
Ok(brut.map(|m| m.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect()))
}
}
/// `Record<string, number> | null` facultatif : absent (`None`), `null`
/// (`Some(None)`) ou renseigné.
pub mod opt_null_map {
use super::*;
pub fn serialize<S: Serializer>(m: &Option<Option<BTreeMap<String, f64>>>, s: S) -> Result<S::Ok, S::Error> {
match m {
Some(Some(m)) => ser_map(m, s),
_ => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<BTreeMap<String, f64>>>, D::Error> {
let brut = Option::<BTreeMap<String, Option<f64>>>::deserialize(d)?;
Ok(Some(brut.map(|m| m.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect())))
}
}
/// `Record<string, Record<string, number>>` facultatif (ventilations par magasin).
pub mod opt_nested_map {
use super::*;
pub type Nested = BTreeMap<String, BTreeMap<String, f64>>;
struct Interne<'a>(&'a BTreeMap<String, f64>);
impl serde::Serialize for Interne<'_> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
ser_map(self.0, s)
}
}
pub fn serialize<S: Serializer>(m: &Option<Nested>, s: S) -> Result<S::Ok, S::Error> {
match m {
Some(m) => {
let mut out = s.serialize_map(Some(m.len()))?;
for (k, v) in m {
out.serialize_entry(k, &Interne(v))?;
}
out.end()
}
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Nested>, D::Error> {
let brut = Option::<BTreeMap<String, Option<BTreeMap<String, Option<f64>>>>>::deserialize(d)?;
Ok(brut.map(|m| {
m.into_iter().map(|(k, v)| (k, v.unwrap_or_default().into_iter().map(|(p, x)| (p, x.unwrap_or(f64::NAN))).collect())).collect()
}))
}
}
/// Chaîne obligatoire : un `null` relu devient `""`.
pub mod string {
use super::*;
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn number_from_str_comme_js() {
assert_eq!(js_number_from_str(""), 0.0);
assert_eq!(js_number_from_str(" "), 0.0);
assert_eq!(js_number_from_str(" 0x2 "), 2.0);
assert_eq!(js_number_from_str("0b11"), 3.0);
assert_eq!(js_number_from_str("0o2"), 2.0);
assert!(js_number_from_str("-0x2").is_nan());
assert!(js_number_from_str("1_000").is_nan());
assert!(js_number_from_str("abc").is_nan());
assert!(js_number_from_str("inf").is_nan());
assert_eq!(js_number_from_str("+3"), 3.0);
assert_eq!(js_number_from_str(".2e1"), 2.0);
assert_eq!(js_number_from_str("1."), 1.0);
assert_eq!(js_number_from_str("\t2\n"), 2.0);
assert_eq!(js_number_from_str("-Infinity"), f64::NEG_INFINITY);
assert_eq!(js_number_from_str("1e400"), f64::INFINITY);
}
#[test]
fn parse_int_comme_js() {
assert_eq!(js_parse_int("12abc"), 12.0);
assert_eq!(js_parse_int(" -5"), -5.0);
assert_eq!(js_parse_int("0"), 0.0);
assert!(js_parse_int("abc").is_nan());
assert!(js_parse_int("").is_nan());
assert_eq!(js_parse_int("0x10"), 0.0);
}
#[test]
fn number_to_string_comme_js() {
assert_eq!(js_number_to_string(123.0), "123");
assert_eq!(js_number_to_string(-1.5), "-1.5");
assert_eq!(js_number_to_string(0.1 + 0.2), "0.30000000000000004");
assert_eq!(js_number_to_string(1e21), "1e+21");
assert_eq!(js_number_to_string(1e20), "100000000000000000000");
assert_eq!(js_number_to_string(1e-7), "1e-7");
assert_eq!(js_number_to_string(0.000001), "0.000001");
assert_eq!(js_number_to_string(1.5e-10), "1.5e-10");
assert_eq!(js_number_to_string(f64::NAN), "NaN");
}
#[test]
fn arrondis_comme_js() {
assert_eq!(js_round(-2.5), -2.0);
assert_eq!(js_round(2.5), 3.0);
assert_eq!(js_round(0.49999999999999994), 0.0);
assert_eq!(js_to_fixed2(0.125), 0.13);
assert_eq!(js_to_fixed2(-0.125), -0.13);
assert_eq!(js_to_fixed2(1.005), 1.0);
assert_eq!(js_to_fixed2(1234.5678), 1234.57);
}
#[test]
fn nombres_json_comme_js() {
assert_eq!(serde_json::to_string(&JsNum(12.0)).unwrap(), "12");
assert_eq!(serde_json::to_string(&JsNum(-0.0)).unwrap(), "0");
assert_eq!(serde_json::to_string(&JsNum(12.5)).unwrap(), "12.5");
assert_eq!(serde_json::to_string(&JsNum(f64::NAN)).unwrap(), "null");
}
#[test]
fn string_et_truthy() {
assert_eq!(js_string(Some(&serde_json::json!(123))), "123");
assert_eq!(js_string(Some(&serde_json::json!(null))), "null");
assert_eq!(js_string(None), "undefined");
assert!(!js_truthy(Some(&serde_json::json!(""))));
assert!(js_truthy(Some(&serde_json::json!("export"))));
assert_eq!(replace_whitespace_runs("Le Fournisseur\tX", "_"), "Le_Fournisseur_X");
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Module `grid` — grille d'assortiment.
//!
//! - [`types`] : `ProductRow` et types associés (JSON identique au TS) ;
//! - [`product_rows`] : calcul des lignes (`getProductRows`), branché sur
//! [`rows_cache`] (cache mémoire 10 min) et [`store`] (instantané `grid_rows`) ;
//! - [`network_trend`], [`months`], [`store_patch`] : logique pure ;
//! - [`gammes_a_valider`], [`enregistrer`], [`snapshots`] : gammes et snapshots ;
//! - [`grid_rows`] : `/api/grid/rows` (NDJSON) et `/api/grid/rows/store-patch` ;
//! - [`exports`] : exports Excel ;
//! - API publique : [`api_schemas`], [`enrich`], [`api_gammes`], [`api_v1`],
//! [`api_v1_grid`], [`openapi`].
pub mod api_gammes;
pub mod api_schemas;
pub mod api_v1;
pub mod api_v1_grid;
pub mod enregistrer;
pub mod enrich;
pub mod exports;
pub mod gammes;
pub mod gammes_a_valider;
pub mod grid_rows;
pub mod js;
pub mod months;
pub mod network_trend;
pub mod openapi;
pub mod product_rows;
pub mod rows_cache;
pub mod session;
pub mod snapshots;
pub mod store;
pub mod store_patch;
pub mod types;
pub mod zod;
#[cfg(test)]
mod tests_db;
#[cfg(test)]
mod tests_ff;
use crate::rpc::Handler;
use axum::routing::{get, post, put};
use axum::Router;
/// Routes HTTP du module.
pub fn router() -> Router {
Router::new()
.route("/api/export/excel", post(exports::export_excel))
.route("/api/export/modified-gammes", post(exports::export_modified_gammes))
.route("/api/v1/nomenclatures", get(api_v1::get_nomenclatures))
.route("/api/v1/openapi.json", get(openapi::get_openapi))
.route("/api/grid/rows", get(grid_rows::get_grid_rows))
.route("/api/grid/rows/store-patch", get(grid_rows::get_grid_store_patch))
.route("/api/v1/grid", get(api_v1_grid::get_grid))
.route("/api/v1/products/search", get(api_v1_grid::get_products_search))
.route("/api/v1/products/{codein}", get(api_v1_grid::get_product))
.route("/api/v1/products/{codein}/gamme", put(api_v1_grid::put_product_gamme))
.route("/api/v1/gammes", post(api_v1_grid::post_gammes))
.route("/api/v1/fournisseurs", get(api_v1_grid::get_fournisseurs))
.route("/api/v1/network/{codeCentrale}", get(api_v1_grid::get_network))
}
/// Actions (`POST /api/actions/{nom}`) du module.
pub fn actions() -> Vec<(&'static str, Handler)> {
[snapshots::actions(), enregistrer::actions()].concat()
}
/// Données de pages (`GET /api/pages/{nom}`) du module.
pub fn pages() -> Vec<(&'static str, Handler)> {
vec![]
}
+97
View File
@@ -0,0 +1,97 @@
//! CollectFlow — Helpers de fenêtre mensuelle (port des fonctions de
//! `src/features/grid/lib/months.ts` utilisées côté serveur).
//!
//! ⚠️ 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 [`ff_month_to_qlik`] / [`qlik_month_to_ff`] pour passer de l'une à l'autre.
use super::types::ProductRow;
use chrono::{Datelike, NaiveDate};
/// Date du jour, heure locale du serveur (`new Date()`).
pub fn today() -> NaiveDate {
chrono::Local::now().date_naive()
}
/// 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).
///
/// Même fenêtre que `last12Periods()` dans `get-product-rows.ts`.
pub fn get_last12_months(now: NaiveDate) -> Vec<String> {
(1..=12)
.rev()
.map(|i: i32| {
let total = now.year() * 12 + now.month0() as i32 - i;
format!("{}{:02}", total.div_euclid(12), total.rem_euclid(12) + 1)
})
.collect()
}
/// Fenêtre de 12 mois **telle que le serveur l'a calculée**, lue dans les clés des
/// séries mensuelles des lignes reçues. `None` si aucune ligne ne porte de série
/// complète (l'appelant retombe alors sur [`get_last12_months`]).
pub fn get_months_from_rows(rows: &[ProductRow]) -> Option<Vec<String>> {
for r in rows {
let mut keys: Vec<&String> = r.sales12m.keys().filter(|k| k.len() == 6 && k.bytes().all(|b| b.is_ascii_digit())).collect();
if keys.len() < 12 {
continue;
}
// Toutes les lignes d'un même calcul partagent la même fenêtre : la
// première ligne complète suffit.
keys.sort();
return Some(keys[keys.len() - 12..].iter().map(|k| k.to_string()).collect());
}
None
}
/// `"202601"` → `"Jan 26"`.
pub fn format_month_label(key: &str) -> String {
const NOMS: [&str; 12] = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
let m: usize = key.get(4..6).and_then(|s| s.parse().ok()).unwrap_or(0);
let nom = m.checked_sub(1).and_then(|i| NOMS.get(i)).copied().unwrap_or("undefined");
format!("{nom} {}", key.get(2..4).unwrap_or(""))
}
/// Clé de mois FF `"202601"` → clé Qlik `"2026-01"`.
pub fn ff_month_to_qlik(key: &str) -> String {
format!("{}-{}", key.get(..4).unwrap_or(key), key.get(4..6).unwrap_or(""))
}
/// Clé de mois Qlik `"2026-01"` → clé FF `"202601"`.
pub fn qlik_month_to_ff(key: &str) -> String {
key.replacen('-', "", 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn douze_mois_hors_mois_en_cours() {
let m = get_last12_months(NaiveDate::from_ymd_opt(2026, 1, 31).unwrap());
assert_eq!(m.first().unwrap(), "202501");
assert_eq!(m.last().unwrap(), "202512");
assert_eq!(m.len(), 12);
}
#[test]
fn conversions_de_cles() {
assert_eq!(ff_month_to_qlik("202601"), "2026-01");
assert_eq!(qlik_month_to_ff("2026-01"), "202601");
assert_eq!(format_month_label("202602"), "Fév 26");
}
#[test]
fn fenetre_depuis_les_lignes() {
let mut r = ProductRow::default();
for k in get_last12_months(NaiveDate::from_ymd_opt(2026, 10, 1).unwrap()) {
r.sales12m.insert(k, 1.0);
}
r.sales12m.insert("202409".into(), 0.0);
let m = get_months_from_rows(&[ProductRow::default(), r]).unwrap();
assert_eq!(m.first().unwrap(), "202510");
assert_eq!(m.last().unwrap(), "202609");
}
}
+350
View File
@@ -0,0 +1,350 @@
//! CollectFlow — Tendance des ventes réseau Qlik sur 12 mois
//! (port de `src/features/grid/lib/network-trend.ts`).
//!
//! Partagé entre la Grille, la fiche produit et l'API `/api/v1` (champ `trend`).
//! Aucun changement de comportement.
use super::js;
use chrono::{Datelike, NaiveDate};
use serde::Serialize;
use std::collections::BTreeMap;
/// 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.
pub const NB_MAGASINS_RESEAU: f64 = 270.0;
/// Nombre de mois comparés à chaque bout de la fenêtre.
///
/// Quatre, et pas un : comparer le dernier mois au premier ferait dépendre toute
/// la tendance de deux points, donc du hasard d'un réassort ou d'une opération.
pub const TREND_WINDOW: usize = 4;
/// Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI.
pub const TREND_STRONG: f64 = 0.25;
/// Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%).
pub const TREND_FLAT: f64 = 0.08;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
Up,
Down,
Flat,
}
impl Direction {
pub fn as_str(self) -> &'static str {
match self {
Direction::Up => "up",
Direction::Down => "down",
Direction::Flat => "flat",
}
}
}
/// Couleur de chaque direction (`TREND_COLOR`).
pub fn trend_color(d: Direction) -> &'static str {
match d {
Direction::Up => "#22c55e",
Direction::Down => "#ef4444",
Direction::Flat => "#94a3b8",
}
}
struct Nums<'a>(&'a [f64]);
impl Serialize for Nums<'_> {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_seq(self.0.iter().map(|v| js::JsNum(*v)))
}
}
fn ser_nums<S: serde::Serializer>(v: &[f64], s: S) -> Result<S::Ok, S::Error> {
Nums(v).serialize(s)
}
fn ser_opt_nums<S: serde::Serializer>(v: &Option<Vec<f64>>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(v) => Nums(v).serialize(s),
None => s.serialize_none(),
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkTrend {
/// quantités mensuelles, ordre chronologique
#[serde(serialize_with = "ser_nums")]
pub values: Vec<f64>,
/// "YYYY-MM"
pub labels: Vec<String>,
/// Quantité moyenne par magasin vendeur, mois par mois. `None` si le nombre
/// de magasins n'est pas connu sur les 12 mois.
#[serde(serialize_with = "ser_opt_nums")]
pub per_store: Option<Vec<f64>>,
/// `true` si la tendance porte sur la qté/magasin, `false` si elle a dû se
/// rabattre sur les quantités brutes faute de nombre de magasins.
pub sur_qte_par_magasin: bool,
pub direction: Direction,
/// Variation des 4 derniers mois vs les 4 premiers. `None` si aucune base.
#[serde(serialize_with = "js::opt_num::serialize")]
pub pct: Option<f64>,
/// Rien sur les 4 premiers mois, des ventes sur les 4 derniers.
pub nouveau: bool,
/// `true` si la fenêtre s'arrête un mois plus tôt que prévu : le cache Qlik
/// n'a pas encore été resynchronisé depuis le changement de mois.
pub en_retard: bool,
pub has_data: bool,
}
/// Premier jour du mois décalé de `delta` mois (`new Date(y, m + delta, 1)`).
fn mois_decale(now: NaiveDate, delta: i32) -> (i32, u32) {
let total = now.year() * 12 + now.month0() as i32 + delta;
(total.div_euclid(12), total.rem_euclid(12) as u32 + 1)
}
/// Les 12 mois complets glissants au format Qlik `"YYYY-MM"`, du plus ancien au
/// plus récent. **Le mois en cours est exclu** : il est partiel, l'intégrer
/// écraserait systématiquement la tendance vers le bas.
///
/// Même fenêtre que `getLast12Months()` (clés FF `"YYYYMM"`) et que
/// `buildGridNetworkQlikDateFilter()` (extraction Qlik) — les trois doivent
/// rester alignés.
pub fn build_rolling12_qlik_months(now: NaiveDate) -> Vec<String> {
(1..=12)
.rev()
.map(|i| {
let (y, m) = mois_decale(now, -i);
format!("{y}-{m:02}")
})
.collect()
}
/// Les 12 clés de la fenêtre sont-elles explicitement présentes et chiffrées ?
fn fenetre_complete(serie: &BTreeMap<String, f64>, labels: &[String]) -> bool {
labels.iter().all(|l| serie.get(l).map(|v| v.is_finite()).unwrap_or(false))
}
/// Les 12 mois réellement disponibles dans une série Qlik : la fenêtre du jour,
/// sinon celle qui s'arrête un mois plus tôt.
///
/// Le repli couvre les jours qui suivent un changement de mois : la fenêtre a
/// glissé, mais le cache Qlik n'est resynchronisé que fournisseur par
/// fournisseur, la nuit. Sans lui, toutes les tendances disparaissaient d'un
/// coup le 1er du mois. Ce sont toujours 12 mois complets et extraits — aucun
/// mois n'est inventé — simplement décalés d'un cran. `None` si aucune des deux
/// fenêtres n'est complète.
pub fn fenetre_disponible(serie: Option<&BTreeMap<String, f64>>, now: NaiveDate) -> Option<(Vec<String>, bool)> {
let serie = serie?;
let labels = build_rolling12_qlik_months(now);
if fenetre_complete(serie, &labels) {
return Some((labels, false));
}
let (y, m) = mois_decale(now, -1);
let precedente = build_rolling12_qlik_months(NaiveDate::from_ymd_opt(y, m, 1)?);
if fenetre_complete(serie, &precedente) {
return Some((precedente, true));
}
None
}
/// Tendance réseau : variation entre les **4 premiers** et les **4 derniers**
/// mois de la fenêtre de 12 mois glissants, mois en cours exclu.
///
/// Une série n'est retenue que si les **12 clés sont explicitement présentes**
/// (tout ou rien) ; la tendance porte sur la **quantité par magasin vendeur**
/// quand le nombre de magasins est connu sur les 12 mois, sinon sur les
/// quantités brutes (`surQteParMagasin = false`). Sans base de comparaison (rien
/// vendu sur les 4 premiers mois), aucun pourcentage n'est inventé : le produit
/// est signalé « nouveau ».
///
/// Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
pub fn compute_network_trend(
qte_by_month: Option<&BTreeMap<String, f64>>,
nb_mag_by_month: Option<&BTreeMap<String, f64>>,
now: NaiveDate,
) -> NetworkTrend {
let empty = NetworkTrend {
values: vec![],
labels: vec![],
per_store: None,
sur_qte_par_magasin: false,
direction: Direction::Flat,
pct: None,
nouveau: false,
en_retard: false,
has_data: false,
};
let (Some(qte), Some((labels, en_retard))) = (qte_by_month, fenetre_disponible(qte_by_month, now)) else {
return empty;
};
let values: Vec<f64> = labels.iter().map(|l| qte.get(l).copied().unwrap_or(f64::NAN)).collect();
let n = values.len();
// Quantité par magasin vendeur : l'indicateur qui isole la performance du
// produit de sa diffusion. Un mois sans magasin vendeur vaut 0 — c'est le
// seul choix cohérent avec une quantité elle-même nulle.
let magasins = compute_stores_series(nb_mag_by_month, Some(&labels));
let per_store: Option<Vec<f64>> =
magasins.map(|(mags, _)| values.iter().zip(mags.iter()).map(|(v, m)| if *m > 0.0 { v / m } else { 0.0 }).collect());
let serie: &[f64] = per_store.as_deref().unwrap_or(&values);
let taille = TREND_WINDOW.min(n / 2);
let moyenne = |xs: &[f64]| if xs.is_empty() { 0.0 } else { xs.iter().sum::<f64>() / xs.len() as f64 };
let debut = moyenne(&serie[..taille]);
let fin = moyenne(&serie[n - taille..]);
let mut pct = None;
let mut nouveau = false;
let mut direction = Direction::Flat;
if debut > 0.0 {
// Plancher naturel à −100 % : `fin` ne peut pas descendre sous zéro sans
// retours massifs, et la division par une base positive garde le sens.
let p = (fin - debut) / debut;
direction = if p > TREND_FLAT {
Direction::Up
} else if p < -TREND_FLAT {
Direction::Down
} else {
Direction::Flat
};
pct = Some(p);
} else if fin > 0.0 {
// Rien au départ, des ventes à l'arrivée : la variation relative n'existe
// pas (division par zéro). On le dit, au lieu d'afficher un nombre inventé.
nouveau = true;
direction = Direction::Up;
}
let sur_qte_par_magasin = per_store.is_some();
NetworkTrend { values, labels, per_store, sur_qte_par_magasin, direction, pct, nouveau, en_retard, has_data: true }
}
/// Série « nombre de magasins vendeurs » sur la MÊME fenêtre que
/// [`compute_network_trend`], pour la superposer à la courbe des quantités.
///
/// Même règle du tout ou rien que la courbe des quantités : les 12 clés doivent
/// être explicitement présentes, sinon `None` — un mois non extrait ne doit pas
/// se lire comme « zéro magasin », ce qui simulerait un arrêt de diffusion.
/// Renvoie `(values, labels)`.
pub fn compute_stores_series(
nb_mag_by_month: Option<&BTreeMap<String, f64>>,
labels: Option<&[String]>,
) -> Option<(Vec<f64>, Vec<String>)> {
let serie = nb_mag_by_month?;
let labels = labels.filter(|l| !l.is_empty())?;
if !fenetre_complete(serie, labels) {
return None;
}
Some((labels.iter().map(|l| serie[l]).collect(), labels.to_vec()))
}
/// Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »…
pub fn trend_label(pct: Option<f64>, nouveau: bool) -> &'static str {
// Un produit sans base de comparaison n'est pas « stable » : il est nouveau.
if nouveau {
return "Nouveau";
}
let Some(p) = pct else { return "Stable" };
if p > TREND_STRONG {
"Forte hausse"
} else if p > TREND_FLAT {
"Hausse"
} else if p < -TREND_STRONG {
"Forte baisse"
} else if p < -TREND_FLAT {
"Baisse"
} else {
"Stable"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, j: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, j).unwrap()
}
fn serie(labels: &[String], f: impl Fn(usize) -> f64) -> BTreeMap<String, f64> {
labels.iter().enumerate().map(|(i, l)| (l.clone(), f(i))).collect()
}
#[test]
fn fenetre_glissante_hors_mois_en_cours() {
let m = build_rolling12_qlik_months(d(2026, 1, 15));
assert_eq!(m.len(), 12);
assert_eq!(m.first().unwrap(), "2025-01");
assert_eq!(m.last().unwrap(), "2025-12");
let m = build_rolling12_qlik_months(d(2026, 10, 1));
assert_eq!(m.first().unwrap(), "2025-10");
assert_eq!(m.last().unwrap(), "2026-09");
}
#[test]
fn tendance_hausse_par_magasin() {
let now = d(2026, 10, 12);
let labels = build_rolling12_qlik_months(now);
let qte = serie(&labels, |i| {
if i < 4 {
10.0
} else if i >= 8 {
20.0
} else {
15.0
}
});
let nb = serie(&labels, |_| 5.0);
let t = compute_network_trend(Some(&qte), Some(&nb), now);
assert!(t.has_data && t.sur_qte_par_magasin && !t.en_retard);
assert_eq!(t.direction, Direction::Up);
assert!((t.pct.unwrap() - 1.0).abs() < 1e-12);
assert_eq!(trend_label(t.pct, t.nouveau), "Forte hausse");
// Même quantité, mais diffusée dans deux fois plus de magasins : stable.
let nb2 = serie(&labels, |i| {
if i < 4 {
5.0
} else if i >= 8 {
10.0
} else {
7.5
}
});
let t2 = compute_network_trend(Some(&qte), Some(&nb2), now);
assert_eq!(t2.direction, Direction::Flat);
assert_eq!(trend_label(t2.pct, t2.nouveau), "Stable");
}
#[test]
fn tendance_nouveau_retard_et_incomplet() {
let now = d(2026, 10, 2);
// Cache pas encore resynchronisé : fenêtre d'un mois plus tôt.
let anciens = build_rolling12_qlik_months(d(2026, 9, 1));
let qte = serie(&anciens, |i| if i < 4 { 0.0 } else { 3.0 });
let t = compute_network_trend(Some(&qte), None, now);
assert!(t.has_data && t.en_retard && t.nouveau && !t.sur_qte_par_magasin);
assert_eq!(t.pct, None);
assert_eq!(t.direction, Direction::Up);
assert_eq!(trend_label(t.pct, t.nouveau), "Nouveau");
// Un mois manquant : aucune tendance.
let mut trou = qte.clone();
trou.remove(&anciens[5]);
let t = compute_network_trend(Some(&trou), None, now);
assert!(!t.has_data);
assert_eq!(
serde_json::to_string(&t).unwrap(),
r#"{"values":[],"labels":[],"perStore":null,"surQteParMagasin":false,"direction":"flat","pct":null,"nouveau":false,"enRetard":false,"hasData":false}"#
);
}
#[test]
fn libelles() {
assert_eq!(trend_label(None, false), "Stable");
assert_eq!(trend_label(Some(0.1), false), "Hausse");
assert_eq!(trend_label(Some(-0.1), false), "Baisse");
assert_eq!(trend_label(Some(-0.3), false), "Forte baisse");
assert_eq!(trend_label(Some(0.08), false), "Stable");
}
}
File diff suppressed because it is too large. Load diff
+879
View File
@@ -0,0 +1,879 @@
//! Calcul des lignes de la Grille (port de `src/features/grid/api/get-product-rows.ts`).
//!
//! Sept requêtes SQL en parallèle sur la base miroir FF, jointure avec le cache
//! réseau Qlik, report des gammes du dernier snapshot. Le résultat est gardé
//! 10 minutes en mémoire ([`super::rows_cache`]) et persisté dans `grid_rows`
//! (magasin « TOTAL » seulement), d'où il est resservi tant qu'il date du mois
//! en cours (`serveFromSnapshot`).
use super::months;
use super::network_trend::NB_MAGASINS_RESEAU;
use super::rows_cache::{self, GridRows, RowsFuture, SharedRows, StorePatchFuture};
use super::store::{read_grid_snapshot, upsert_grid_rows_shared};
use super::store_patch::{apply_store_patch_in_place, build_store_patch_entries, store_patch_candidates};
use super::types::{ProductRow, StorePatch, StorePatchEntry};
use crate::ff::api::{build_last12_months_range, get_mensuel_by_articles, FfArticle};
use crate::ff::pg::{self, PgStockRow};
use crate::qlik::network_cache::{get_network_metrics_by_code_centrale, NetworkMetricCached};
use futures::future::{BoxFuture, FutureExt};
use serde_json::{Map, Value};
use sqlx::Row;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
use std::time::Duration;
/// Paramètres de `getProductRows`. (`filters` n'est pas repris : le TS ne s'en
/// servait pas, la Grille filtre en local.)
#[derive(Debug, Clone, Default)]
pub struct GetProductRowsInput {
pub code_fournisseur: String,
/// `None` = « TOTAL ».
pub magasin: Option<String>,
pub force_refresh: bool,
}
impl GetProductRowsInput {
pub fn total(code_fournisseur: &str, force_refresh: bool) -> Self {
Self { code_fournisseur: code_fournisseur.to_string(), magasin: Some("TOTAL".into()), force_refresh }
}
fn magasin(&self) -> &str {
self.magasin.as_deref().unwrap_or("TOTAL")
}
}
/// Au-delà, l'instantané est servi mais un recalcul est relancé en arrière-plan.
const SNAPSHOT_REFRESH_AFTER: Duration = Duration::from_secs(20 * 60 * 60);
fn court(e: &dyn std::fmt::Display) -> String {
crate::error::short(e, 200)
}
/// `Number(x) || 0` sur une valeur déjà numérique.
fn or0(v: f64) -> f64 {
if v.is_nan() {
0.0
} else {
v
}
}
/// 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 fn refresh_gamme_init(rows: &GridRows, code_fournisseur: &str) {
match pg::pg_get_gammes_by_fournisseur(code_fournisseur).await {
Ok(gamme_map) => appliquer_gammes_init(rows, &gamme_map),
Err(e) => tracing::error!("[getProductRows] refreshGammeInit error: {}", court(&e)),
}
}
fn appliquer_gammes_init(rows: &GridRows, gamme_map: &HashMap<String, String>) {
if gamme_map.is_empty() {
return;
}
for row in rows.rows.write().iter_mut() {
if let Some(live) = gamme_map.get(&row.codein) {
row.code_gamme_init = Some(live.clone());
}
}
}
/// Écrit l'instantané de grille lu par /api/v1 (table `grid_rows`).
///
/// Seul `magasin = TOTAL` est persisté : `ProductRow` embarque déjà les ventilations
/// par magasin (sales12mByStore, caByStore…), inutile de stocker trois variantes.
/// Toute erreur est avalée : la persistance ne doit jamais casser l'affichage.
async fn persist_grid_snapshot(input: &GetProductRowsInput, rows: &GridRows) {
if input.magasin() != "TOTAL" {
return;
}
match upsert_grid_rows_shared(&input.code_fournisseur, rows).await {
Ok(n) => tracing::info!("[getProductRows] instantané persisté: {n} lignes pour {}", input.code_fournisseur),
Err(e) => tracing::error!("[getProductRows] persistance grid_rows KO: {}", court(&e.message)),
}
}
/// Lignes de la Grille d'un fournisseur (cache 10 min, instantané, sinon calcul).
pub fn get_product_rows(input: GetProductRowsInput) -> BoxFuture<'static, anyhow::Result<SharedRows>> {
async move {
let cache_key = rows_cache::grid_rows_cache_key(&input.code_fournisseur, input.magasin.as_deref());
if !input.force_refresh {
if let Some(cached) = rows_cache::get_fresh(&cache_key) {
// 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).
refresh_gamme_init(&cached, &input.code_fournisseur).await;
return Ok(cached);
}
}
// Un calcul déjà en cours est réutilisé même en rechargement forcé : il part
// de données fraîches, et en lancer un second en parallèle doublait la charge
// (double clic, onglet rouvert…) sans rien apporter.
if let Some(pending) = rows_cache::pending_get(&cache_key) {
return pending.await.map_err(|e| anyhow::anyhow!(e));
}
// Instantané persisté (calcul de la nuit ou de la dernière ouverture) : servi
// en une lecture au lieu d'un recalcul de 10 à 40 s. Jamais en rechargement
// forcé — c'est précisément ce que l'utilisateur demande d'éviter.
if !input.force_refresh {
if let Some(servi) = serve_from_snapshot(&input, &cache_key).await {
return Ok(servi);
}
}
let version_au_depart = rows_cache::gamme_save_version(&input.code_fournisseur);
// Le calcul est lancé à part, comme une promesse JS : il continue même si
// l'appelant s'en va (client déconnecté), et il est partagé entre appelants.
let (tx, rx) = tokio::sync::oneshot::channel::<Result<SharedRows, String>>();
let partage: RowsFuture =
async move { rx.await.unwrap_or_else(|_| Err("calcul de la grille interrompu".to_string())) }.boxed().shared();
rows_cache::pending_insert(&cache_key, partage.clone());
let entree = input.clone();
let cle = cache_key.clone();
tokio::spawn(async move {
let resultat = async {
let mut rows = build_product_rows(&entree).await?;
if rows_cache::gamme_save_version(&entree.code_fournisseur) != version_au_depart {
// Gammes enregistrées pendant le calcul : on reprend le dernier snapshot.
apply_snapshot_changes(&mut rows, load_latest_snapshot_changes(&entree.code_fournisseur).await.as_ref());
}
let shared = GridRows::new(rows);
rows_cache::cache_grid_rows(&cle, shared.clone());
rows_cache::pending_remove(&cle);
// Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on
// arrête simplement de jeter le résultat. Volontairement NON bloquant —
// l'affichage de la Grille ne doit pas attendre l'écriture.
let a_persister = shared.clone();
let code = entree.code_fournisseur.clone();
let ecriture: Persistance =
tokio::spawn(async move { persist_grid_snapshot(&entree, &a_persister).await }).map(|_| ()).boxed().shared();
PERSISTANCES.lock().insert(code.clone(), ecriture.clone());
tokio::spawn(async move {
ecriture.clone().await;
let mut p = PERSISTANCES.lock();
if p.get(&code).map(|e| e.ptr_eq(&ecriture)).unwrap_or(false) {
p.remove(&code);
}
});
anyhow::Ok(shared)
}
.await;
let resultat = resultat.map_err(|e| {
rows_cache::pending_remove(&cle);
format!("{e:#}")
});
let _ = tx.send(resultat);
});
partage.await.map_err(|e| anyhow::anyhow!(e))
}
.boxed()
}
/// Écriture de l'instantané en cours, par fournisseur.
type Persistance = futures::future::Shared<BoxFuture<'static, ()>>;
static PERSISTANCES: std::sync::LazyLock<parking_lot::Mutex<HashMap<String, Persistance>>> =
std::sync::LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
/// Attend la fin de l'écriture de l'instantané lancée par le dernier calcul du
/// fournisseur (s'il y en a une en cours).
///
/// Écart volontaire avec le TS : `/api/v1` relisait `grid_rows` juste après un
/// calcul à la demande alors que la persistance, non attendue, pouvait ne pas
/// être finie — d'où un faux « aucun article ». Les routes attendent ici l'écriture.
pub async fn attendre_persistance(code_fournisseur: &str) {
let en_cours = PERSISTANCES.lock().get(code_fournisseur).cloned();
if let Some(e) = en_cours {
e.await;
}
}
/// `getProductRows({ codeFournisseur, magasin: "TOTAL", forceRefresh: true })` :
/// recalcul complet (synchro nocturne, préchauffage). Renvoie le nombre de lignes.
pub async fn get_product_rows_for_sync(code_fournisseur: &str) -> anyhow::Result<usize> {
let rows = get_product_rows(GetProductRowsInput::total(code_fournisseur, true)).await?;
Ok(rows.len())
}
/// Sert la Grille depuis `grid_rows` quand c'est possible : mêmes lignes que le
/// calcul en direct (elles en sont issues), avec la gamme FF du moment (INIT) et
/// les dernières gammes enregistrées reportées dessus. Hors « tous magasins »,
/// le rattrapage du magasin choisi est appliqué comme après un calcul.
///
/// `None` = pas d'instantané utilisable (absent, ancien format, autre mois) :
/// l'appelant recalcule.
async fn serve_from_snapshot(input: &GetProductRowsInput, cache_key: &str) -> Option<SharedRows> {
let code = input.code_fournisseur.as_str();
let magasin = input.magasin();
let snapshot = match read_grid_snapshot(code).await {
Ok(s) => s,
Err(e) => {
tracing::error!("[getProductRows] lecture grid_rows KO: {}", court(&e.message));
return None;
}
};
let snapshot = snapshot.filter(|s| !s.rows.is_empty())?;
let computed_at = snapshot.computed_at;
let mut rows = snapshot.rows;
// Les colonnes réseau de l'instantané datent de son calcul : on les relit dans
// le cache Qlik, comme le calcul en direct. Sans cela, une synchro Qlik faite
// depuis (au changement de mois notamment) restait invisible jusqu'au
// prochain recalcul, et la tendance réseau disparaissait.
let codes: Vec<String> = rows.iter().map(|r| r.code_centrale.clone().unwrap_or_default()).collect();
let (changes, network, gammes) =
tokio::join!(load_latest_snapshot_changes(code), fetch_network_metrics(&codes), pg::pg_get_gammes_by_fournisseur(code));
match gammes {
Ok(g) if !g.is_empty() => {
for row in rows.iter_mut() {
if let Some(live) = g.get(&row.codein) {
row.code_gamme_init = Some(live.clone());
}
}
}
Ok(_) => {}
Err(e) => tracing::error!("[getProductRows] refreshGammeInit error: {}", court(&e)),
}
apply_snapshot_changes(&mut rows, changes.as_ref());
enrich_with_network_metrics(&mut rows, network.as_ref());
if magasin != "TOTAL" {
let range = build_last12_months_range();
reconcile_selected_store_from_mensuel_api(&mut rows, magasin, &range.date_debut, &range.date_fin, &last12_periods()).await;
}
let shared = GridRows::new(rows);
rows_cache::cache_grid_rows(cache_key, shared.clone());
// Instantané qui n'a pas été recalculé par la nuit : on le rafraîchit sans
// faire attendre l'utilisateur (la prochaine ouverture en profitera).
// Toujours le calcul « tous magasins » : c'est lui qui réécrit l'instantané.
let age = chrono::Utc::now().naive_utc() - computed_at;
if age.to_std().map(|d| d > SNAPSHOT_REFRESH_AFTER).unwrap_or(false)
&& !rows_cache::pending_contains(&rows_cache::grid_rows_cache_key(code, Some("TOTAL")))
{
let code = code.to_string();
tokio::spawn(async move {
if let Err(e) = get_product_rows(GetProductRowsInput::total(&code, true)).await {
tracing::error!("[getProductRows] rafraîchissement en arrière-plan KO: {}", court(&e));
}
});
}
Some(shared)
}
/// Les 12 derniers mois complets, clés « YYYYMM » triées (même fenêtre que le calcul).
fn last12_periods() -> Vec<String> {
months::get_last12_months(months::today())
}
#[derive(Default, Clone, Copy)]
struct PeriodData {
qty: f64,
ca: f64,
marge: f64,
stock: f64,
receptions: f64,
}
/// `Math.max(...valeurs)` : `NaN` si l'une l'est.
fn js_max(valeurs: impl Iterator<Item = f64>) -> f64 {
valeurs.fold(f64::NEG_INFINITY, |m, v| if m.is_nan() || v.is_nan() { f64::NAN } else { m.max(v) })
}
/// Le calcul complet (`buildProductRows`).
async fn build_product_rows(input: &GetProductRowsInput) -> anyhow::Result<Vec<ProductRow>> {
let code_fournisseur = input.code_fournisseur.as_str();
let magasin = input.magasin();
tracing::info!("\n>>> [getProductRows] supplier: {code_fournisseur}, magasin: {magasin}");
let range = build_last12_months_range();
let (date_debut, date_fin) = (range.date_debut.as_str(), range.date_fin.as_str());
// ─── Phase 1 : 7 requêtes SQL en parallèle ────────────────────────────
// Phases 8 et 9 lancées dès maintenant : elles n'attendent que la liste des
// articles (codes centraux) ou le seul code fournisseur.
let articles_et_reseau = async {
let articles = pg::pg_get_articles_by_fournisseur(code_fournisseur).await.unwrap_or_else(|e| {
tracing::error!("[getProductRows] pgGetArticlesByFournisseur ERROR: {e:#}");
vec![]
});
let codes: Vec<String> = articles
.iter()
.map(|a| a.code_centrale.as_deref().filter(|c| !c.is_empty()).map(|c| c.trim().to_string()).unwrap_or_default())
.collect();
let network = fetch_network_metrics(&codes).await;
(articles, network)
};
let ou_vide = |nom: &'static str| move |e: &anyhow::Error| tracing::error!("[getProductRows] {nom} ERROR: {e:#}");
let ((articles, network), mensuel_rows, gamme_map, nom_map, stock_map, commandes_map, prix_vente_map, snapshot_changes) = tokio::join!(
articles_et_reseau,
async {
pg::pg_get_mensuel_by_fournisseur(code_fournisseur, date_debut, date_fin)
.await
.inspect_err(ou_vide("pgGetMensuelByFournisseur"))
.unwrap_or_default()
},
async {
pg::pg_get_gammes_by_fournisseur(code_fournisseur).await.inspect_err(ou_vide("pgGetGammesByFournisseur")).unwrap_or_default()
},
async {
pg::pg_get_nomenclature_by_fournisseur(code_fournisseur)
.await
.inspect_err(ou_vide("pgGetNomenclatureByFournisseur"))
.unwrap_or_default()
},
async {
pg::pg_get_stock_by_fournisseur(code_fournisseur).await.inspect_err(ou_vide("pgGetStockByFournisseur")).unwrap_or_default()
},
async {
pg::pg_get_commandes_by_fournisseur(code_fournisseur)
.await
.inspect_err(ou_vide("pgGetCommandesByFournisseur"))
.unwrap_or_default()
},
async {
pg::pg_get_prix_vente_by_fournisseur(code_fournisseur)
.await
.inspect_err(ou_vide("pgGetPrixVenteByFournisseur"))
.unwrap_or_default()
},
load_latest_snapshot_changes(code_fournisseur),
);
tracing::info!("[getProductRows] {} articles, {} mensuel rows, {} gammes", articles.len(), mensuel_rows.len(), gamme_map.len());
// ─── Phase 2 : Fenêtre temporelle 12 mois complets ───────────────────
let sorted_periods = last12_periods();
// ─── Phase 3 : Seed productMap depuis articles ────────────────────────
// Ordre d'insertion conservé (Map JS) : index par codein.
let mut products: Vec<ProductRow> = Vec::with_capacity(articles.len());
let mut index: HashMap<String, usize> = HashMap::with_capacity(articles.len());
for art in &articles {
if art.codein.is_empty() || index.contains_key(&art.codein) {
continue;
}
index.insert(art.codein.clone(), products.len());
let non_nul = |v: Option<f64>| v.filter(|x| *x != 0.0 && !x.is_nan());
products.push(ProductRow {
codein: art.codein.clone(),
code_fournisseur: art.codefou.clone(),
nom_fournisseur: art.nomfou.clone().unwrap_or_default(),
fournisseur_principal_code: art.codefou_principal.clone(),
fournisseur_principal_nom: art.nomfou_principal.clone(),
libelle1: art.libelle1.clone().unwrap_or_default(),
gtin: art.gtin.clone().unwrap_or_default(),
reference: art.reference.clone().unwrap_or_default(),
code_centrale: art.code_centrale.as_deref().filter(|c| !c.is_empty()).map(|c| c.trim().to_string()),
code_gamme: None,
code_gamme_init: None,
code_gamme_draft: None,
total_quantite: 0.0,
total_ca: 0.0,
total_marge: 0.0,
taux_marge: 0.0,
working_stores: vec![],
noid: (art.no_id != 0).then_some(art.no_id as f64),
pcb: non_nul(art.pcb),
prix_vente: non_nul(art.pv_central),
..Default::default()
});
}
// ─── Phase 4 : Agréger mensuelRows → byPeriod par codein ─────────────
// SQL a déjà fait l'agrégation par (codein, site, mois).
// On somme les 2 sites (TOTAL) ET on accumule par site pour le switch client-side.
let allowed: BTreeSet<&str> = sorted_periods.iter().map(String::as_str).collect();
let mut mensuel_by_codein: Vec<(String, HashMap<String, PeriodData>)> = Vec::new();
let mut mensuel_index: HashMap<String, usize> = HashMap::new();
let mut mensuel_by_site: HashMap<String, BTreeMap<String, HashMap<String, PeriodData>>> = HashMap::new();
let mut store_months: HashMap<String, BTreeSet<String>> = HashMap::new();
for row in &mensuel_rows {
let periode = row.mois.replacen('-', "", 1); // "2026-02" → "202602"
if !allowed.contains(periode.as_str()) {
continue;
}
let stock = or0(row.stock_fin_mois.unwrap_or(f64::NAN));
// Accumulation TOTAL (toujours tous sites)
let i = *mensuel_index.entry(row.codein.clone()).or_insert_with(|| {
mensuel_by_codein.push((row.codein.clone(), HashMap::new()));
mensuel_by_codein.len() - 1
});
let p = mensuel_by_codein[i].1.entry(periode.clone()).or_default();
p.stock += stock;
p.qty += or0(row.qte_vendue);
p.ca += or0(row.ca_ht);
p.marge += or0(row.marge);
p.receptions += or0(row.qte_recue);
// Accumulation par site (pour switch client-side)
let sp = mensuel_by_site.entry(row.codein.clone()).or_default().entry(row.site.clone()).or_default().entry(periode).or_default();
sp.qty += or0(row.qte_vendue);
sp.ca += or0(row.ca_ht);
sp.marge += or0(row.marge);
sp.stock = stock; // SQL donne déjà le dernier mouvement
sp.receptions += or0(row.qte_recue);
// Suivi magasins actifs (pour workingStores) : 1 vente suffit
if row.qte_vendue > 0.0 {
store_months.entry(row.codein.clone()).or_default().insert(row.site.clone());
}
}
// ─── Phase 5 : Remplir ProductRow depuis mensuelByCodein ─────────────
for (codein, by_period) in &mensuel_by_codein {
let Some(&pi) = index.get(codein) else { continue };
let product = &mut products[pi];
let mut last_stock = 0.0;
for periode in &sorted_periods {
match by_period.get(periode) {
Some(p) => {
product.sales12m.insert(periode.clone(), p.qty);
product.stock12m.insert(periode.clone(), p.stock);
product.receptions12m.insert(periode.clone(), p.receptions);
product.total_quantite += p.qty;
product.total_ca += p.ca;
product.total_marge += p.marge;
last_stock = p.stock;
}
None => {
product.sales12m.insert(periode.clone(), 0.0);
product.stock12m.insert(periode.clone(), last_stock); // carry-forward
product.receptions12m.insert(periode.clone(), 0.0);
}
}
}
product.taux_marge = if product.total_ca > 0.0 { (product.total_marge / product.total_ca) * 100.0 } else { 0.0 };
// Breakdowns par site pour le switch client-side
if let Some(site_map) = mensuel_by_site.get(codein) {
let mut sales_by_store = BTreeMap::new();
let mut stock_by_store = BTreeMap::new();
let mut receptions_by_store = BTreeMap::new();
let mut ca_by_store = BTreeMap::new();
let mut quantite_by_store = BTreeMap::new();
let mut marge_by_store = BTreeMap::new();
for (site, site_by_period) in site_map {
let (mut sales, mut stock, mut receptions) = (BTreeMap::new(), BTreeMap::new(), BTreeMap::new());
let (mut s_qty, mut s_ca, mut s_marge) = (0.0, 0.0, 0.0);
let mut last_site_stock = 0.0;
for periode in &sorted_periods {
let sp = site_by_period.get(periode);
sales.insert(periode.clone(), sp.map(|p| p.qty).unwrap_or(0.0));
receptions.insert(periode.clone(), sp.map(|p| p.receptions).unwrap_or(0.0));
match sp {
Some(p) => {
stock.insert(periode.clone(), p.stock);
last_site_stock = p.stock;
}
None => {
stock.insert(periode.clone(), last_site_stock); // carry-forward
}
}
s_qty += sp.map(|p| p.qty).unwrap_or(0.0);
s_ca += sp.map(|p| p.ca).unwrap_or(0.0);
s_marge += sp.map(|p| p.marge).unwrap_or(0.0);
}
sales_by_store.insert(site.clone(), sales);
stock_by_store.insert(site.clone(), stock);
receptions_by_store.insert(site.clone(), receptions);
quantite_by_store.insert(site.clone(), s_qty);
ca_by_store.insert(site.clone(), s_ca);
marge_by_store.insert(site.clone(), s_marge);
}
// Le stock TOTAL se recalcule ici, à partir des séries par site.
//
// Il était sommé plus haut depuis les lignes mensuelles brutes, ce
// qui perdait tout magasin SANS MOUVEMENT dans le mois : le stock
// est un niveau, pas un flux — un magasin qui n'a rien vendu ni reçu
// détient toujours sa marchandise. Les séries par site, elles, sont
// reportées d'un mois sur l'autre : leur somme est la bonne.
if !stock_by_store.is_empty() {
for periode in &sorted_periods {
let total: f64 = stock_by_store.values().map(|s: &BTreeMap<String, f64>| s.get(periode).copied().unwrap_or(0.0)).sum();
product.stock12m.insert(periode.clone(), total);
}
}
product.sales12m_by_store = Some(sales_by_store);
product.stock12m_by_store = Some(stock_by_store);
product.receptions12m_by_store = Some(receptions_by_store);
product.ca_by_store = Some(ca_by_store);
product.quantite_by_store = Some(quantite_by_store);
product.marge_by_store = Some(marge_by_store);
}
// workingStores : sites avec au moins 1 vente sur les 12 derniers mois
if let Some(sites) = store_months.get(codein) {
product.working_stores = sites.iter().cloned().collect();
}
// Commandes en cours
if let Some(&cmd) = commandes_map.get(codein) {
if cmd != 0.0 && !cmd.is_nan() {
product.commandes_en_cours = Some(cmd);
}
}
}
// ─── Phase 6 : Gammes (saison active) ────────────────────────────────
for (codein, gamme) in &gamme_map {
let Some(&pi) = index.get(codein) else { continue };
products[pi].code_gamme_init = Some(gamme.clone());
products[pi].code_gamme = Some(gamme.clone());
}
// ─── Phase 6b : Nomenclature (famille / sous-famille) ────────────────
for (codein, nom) in &nom_map {
let Some(&pi) = index.get(codein) else { continue };
let p = &mut products[pi];
p.code3 = nom.code3.clone();
p.libelle3 = nom.libelle3.clone();
p.code2 = nom.code2.clone().unwrap_or_default();
p.libelle_niveau2 = nom.libelle2.clone().unwrap_or_default();
p.code1 = nom.code1.clone().unwrap_or_default();
p.libelle_niveau1 = nom.libelle1nom.clone().unwrap_or_default();
}
// ─── Phase 7 : Stock temps réel (cube_stock) ─────────────────────────
for (codein, stocks) in &stock_map {
let Some(&pi) = index.get(codein) else { continue };
let product = &mut products[pi];
let somme = |f: fn(&PgStockRow) -> Option<f64>| stocks.iter().map(|r| or0(f(r).unwrap_or(f64::NAN))).sum::<f64>();
product.stock_actuel = Some(somme(|r| r.stockdispo));
product.stock_total = Some(somme(|r| r.qte));
product.stock_valeur = Some(somme(|r| r.valstock));
if let Some(prmp) = stocks.iter().filter_map(|r| r.prmp).find(|p| *p > 0.0) {
product.pa = Some(prmp);
product.prix_achat = Some(prmp);
}
let plus_recente = |dates: Vec<&String>| -> String {
dates.into_iter().fold(String::new(), |best, d| if !d.is_empty() && *d > best { d.clone() } else { best })
};
let dern_vente = plus_recente(stocks.iter().filter_map(|r| r.dernierevente.as_ref()).collect());
if !dern_vente.is_empty() {
product.derniere_vente = Some(dern_vente);
}
let dern_liv = plus_recente(stocks.iter().filter_map(|r| r.dernierereception.as_ref()).collect());
if !dern_liv.is_empty() {
product.derniere_livraison = Some(dern_liv);
}
// Map store-specific delivery dates
let mut par_site = BTreeMap::new();
for r in stocks {
if let Some(d) = r.dernierereception.as_ref().filter(|d| !d.is_empty() && !r.site.is_empty()) {
par_site.insert(r.site.clone(), d.clone());
}
}
product.derniere_livraison_by_store = Some(par_site);
}
// ─── Phase 7b : Prix de vente (cube_pv, sinon le PV du cube de stock) ─
//
// Le vrai prix vit dans `cube_pv`, par article ET par site. On garde le
// détail par magasin — deux magasins peuvent ne pas pratiquer le même prix,
// et l'afficher vaut mieux que le moyenner.
for product in products.iter_mut() {
match prix_vente_map.get(&product.codein).filter(|m| !m.is_empty()) {
Some(par_site) => product.prix_vente_by_store = Some(par_site.clone()),
None => {
// Filet de secours : le cube de stock porte le même PV, mais
// seulement pour les articles qui ont une ligne de stock.
let mut depuis_stock = BTreeMap::new();
for s in stock_map.get(&product.codein).map(|v| v.as_slice()).unwrap_or_default() {
let pv = s.pv.unwrap_or(f64::NAN);
let site = s.site.trim();
if !site.is_empty() && pv.is_finite() && pv > 0.0 {
depuis_stock.insert(site.to_string(), pv);
}
}
if !depuis_stock.is_empty() {
product.prix_vente_by_store = Some(depuis_stock);
}
}
}
// Le plus élevé quand les magasins divergent : une moyenne inventerait
// un prix qu'aucune caisse ne pratique. L'écart est signalé à l'écran.
if let Some(prix) = product.prix_vente_by_store.as_ref().filter(|m| !m.is_empty()) {
product.prix_vente = Some(js_max(prix.values().copied()));
}
}
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
enrich_with_network_metrics(&mut products, network.as_ref());
// ─── 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.
apply_snapshot_changes(&mut products, snapshot_changes.as_ref());
// ─── Phase 10 : Filtrer gamme Y sans ventes ──────────────────────────
let tous = products.len();
let mut rows: Vec<ProductRow> =
products.into_iter().filter(|p| p.code_gamme.as_deref() != Some("Y") || p.total_quantite > 0.0).collect();
reconcile_selected_store_from_mensuel_api(&mut rows, magasin, date_debut, date_fin, &sorted_periods).await;
let exclus_y = tous - rows.len();
tracing::info!(
"[getProductRows] {} produits ({exclus_y} gamme Y sans ventes exclus), {} avec ventes",
rows.len(),
mensuel_by_codein.len()
);
Ok(rows)
}
/// Articles complétés par l'API FF, au plus, lors d'un changement de magasin
/// (`GRID_STORE_RECONCILE_MAX`, 300 par défaut).
fn plafond_complement() -> f64 {
match std::env::var("GRID_STORE_RECONCILE_MAX") {
Ok(v) => super::js::js_number_from_str(&v),
Err(_) => 300.0,
}
}
/// `candidates.slice(0, plafond)`.
fn js_slice_end(len: usize, fin: f64) -> usize {
if fin.is_nan() {
return 0;
}
let fin = fin.trunc();
if fin < 0.0 {
(len as f64 + fin).max(0.0) as usize
} else {
fin.min(len as f64) as usize
}
}
/// Article candidat au complément, copié hors verrou.
struct Candidat {
codein: String,
libelle1: String,
codefou: String,
noid: Option<f64>,
}
fn candidats(rows: &[ProductRow], magasin: &str) -> Vec<Candidat> {
store_patch_candidates(rows, magasin)
.into_iter()
.map(|r| Candidat { codein: r.codein.clone(), libelle1: r.libelle1.clone(), codefou: r.code_fournisseur.clone(), noid: r.noid })
.collect()
}
/// Interroge l'API FF pour les articles du magasin à compléter (cf. store-patch).
async fn fetch_store_patch_entries(
candidates: Vec<Candidat>,
magasin: &str,
date_debut: &str,
date_fin: &str,
periods: &[String],
) -> Vec<StorePatchEntry> {
if candidates.is_empty() {
return vec![];
}
// Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur,
// les candidats se comptent par milliers et le changement de magasin se fige
// plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté
// plutôt que de tronquer en silence.
let nb = candidates.len();
let retenus = &candidates[..js_slice_end(nb, plafond_complement())];
if nb > retenus.len() {
tracing::warn!(
"[getProductRows] rattrapage magasin {magasin} borné à {} articles sur {nb} : les autres gardent la valeur SQL.",
retenus.len()
);
}
let articles: Vec<FfArticle> = retenus
.iter()
.map(|c| FfArticle {
codein: c.codein.clone(),
libelle1: c.libelle1.clone(),
codefou: c.codefou.clone(),
noid: c.noid.filter(|n| n.is_finite()).map(|n| n as i64),
..Default::default()
})
.collect();
let mensuel_map = get_mensuel_by_articles(&articles, date_debut, date_fin, 25).await;
let codes: Vec<String> = retenus.iter().map(|c| c.codein.clone()).collect();
build_store_patch_entries(&codes, magasin, periods, &mensuel_map)
}
async fn reconcile_selected_store_from_mensuel_api(
rows: &mut [ProductRow],
magasin: &str,
date_debut: &str,
date_fin: &str,
sorted_periods: &[String],
) {
if magasin == "TOTAL" {
return;
}
let entries = fetch_store_patch_entries(candidats(rows, magasin), magasin, date_debut, date_fin, sorted_periods).await;
let fixed = apply_store_patch_in_place(rows, magasin, sorted_periods, &entries);
if fixed > 0 {
tracing::info!("[getProductRows] {fixed} produits corrigés via API mensuelle pour magasin {magasin}");
}
}
/// Complément de l'API FF pour un magasin, calculé sur les lignes « tous
/// magasins » (jamais modifiées ici). La Grille l'applique à son arrivée, sans
/// recharger les lignes. Les demandes simultanées partagent le même calcul, et
/// un échec n'est jamais gardé.
pub async fn get_store_patch(code_fournisseur: &str, magasin: &str) -> anyhow::Result<Arc<StorePatch>> {
let rows = match rows_cache::peek_fresh(&rows_cache::grid_rows_cache_key(code_fournisseur, Some("TOTAL"))) {
Some(r) => r,
None => get_product_rows(GetProductRowsInput::total(code_fournisseur, false)).await?,
};
let patch: StorePatchFuture = {
let mut table = rows.store_patches.lock();
match table.get(magasin) {
Some(p) => p.clone(),
None => {
let periods = last12_periods();
let range = build_last12_months_range();
let candidates = candidats(&rows.rows.read(), magasin);
let magasin_s = magasin.to_string();
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let entries = fetch_store_patch_entries(candidates, &magasin_s, &range.date_debut, &range.date_fin, &periods).await;
let _ = tx.send(Ok(Arc::new(StorePatch { magasin: magasin_s, periods, entries })));
});
let en_cours: StorePatchFuture =
async move { rx.await.unwrap_or_else(|_| Err("complément interrompu".to_string())) }.boxed().shared();
table.insert(magasin.to_string(), en_cours.clone());
en_cours
}
}
};
match patch.clone().await {
Ok(p) => Ok(p),
Err(e) => {
let mut table = rows.store_patches.lock();
if table.get(magasin).map(|p| p.ptr_eq(&patch)).unwrap_or(false) {
table.remove(magasin);
}
Err(anyhow::anyhow!(e))
}
}
}
/// Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
///
/// Un mois PRÉSENT dans le détail est un mois extrait : s'il n'a pas de `nbMag`,
/// c'est qu'aucun magasin n'a vendu, donc zéro. Renvoie `None` plutôt qu'un objet
/// vide pour que l'UI distingue « pas de donnée » de « zéro magasin ».
fn nb_mag_par_mois(m: &NetworkMetricCached) -> Option<BTreeMap<String, f64>> {
let detail = m.metrics_by_month.as_ref()?;
let par_mois: BTreeMap<String, f64> =
detail.iter().map(|(mois, v)| (mois.clone(), v.nb_mag.filter(|n| n.is_finite()).unwrap_or(0.0))).collect();
(!par_mois.is_empty()).then_some(par_mois)
}
/// Phase 9 : la colonne Gamme (codeGamme) reprend la valeur du snapshot ;
/// codeGammeInit reste figé (état serveur).
pub fn apply_snapshot_changes(rows: &mut [ProductRow], changes: Option<&Map<String, Value>>) {
let Some(changes) = changes else { return };
for row in rows.iter_mut() {
let after = changes.get(&row.codein).and_then(|c| c.get("after"));
if super::js::js_truthy(after) {
row.code_gamme = Some(super::js::js_string(after));
}
}
}
/// Dernières gammes enregistrées pour ce fournisseur (Phase 9), ou `None`.
pub async fn load_latest_snapshot_changes(code_fournisseur: &str) -> Option<Map<String, Value>> {
let res = async {
let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?;
let row = sqlx::query("select changes from session_snapshots where code_fournisseur = $1 order by created_at desc limit 1")
.bind(code_fournisseur)
.fetch_optional(&pool)
.await?;
anyhow::Ok(row.map(|r| r.try_get::<Value, _>("changes")).transpose()?)
}
.await;
match res {
Ok(Some(Value::Object(m))) => Some(m),
Ok(_) => None,
Err(e) => {
tracing::error!("[getProductRows] Snapshot restore error: {e:#}");
None
}
}
}
/// Lit les métriques réseau Qlik (cache qlik_network_metrics) des codes centraux
/// donnés. `None` en cas d'erreur : la grille s'affiche alors sans données réseau.
async fn fetch_network_metrics(codes_centrale: &[String]) -> Option<HashMap<String, NetworkMetricCached>> {
let mut vus = BTreeSet::new();
let codes: Vec<String> = codes_centrale.iter().filter(|c| !c.is_empty() && vus.insert(c.as_str())).cloned().collect();
if codes.is_empty() {
return None;
}
match get_network_metrics_by_code_centrale(&codes).await {
Ok(m) => Some(m),
Err(e) => {
tracing::error!("[getProductRows] enrichWithNetworkMetrics error: {e:#}");
None
}
}
}
/// Enrichit les produits avec les métriques réseau Qlik, jointes par code centrale.
/// Dégradation propre si la sync Qlik n'a jamais tourné ou si le code centrale
/// n'est pas encore disponible.
pub fn enrich_with_network_metrics(products: &mut [ProductRow], metrics: Option<&HashMap<String, NetworkMetricCached>>) {
let Some(metrics) = metrics.filter(|m| !m.is_empty()) else { return };
for product in products.iter_mut() {
let Some(m) = product.code_centrale.as_deref().filter(|c| !c.is_empty()).and_then(|c| metrics.get(c)) else { continue };
product.ca_reseau = Some(m.ca_reseau);
product.qte_reseau = Some(m.qte_reseau);
product.nb_magasins_reseau = Some(m.nb_magasins_reseau);
product.ca_par_magasin_reseau = Some(m.ca_par_magasin_reseau);
product.marge_pct_reseau = Some(m.marge_pct_reseau);
product.taux_presence_reseau = Some(m.nb_magasins_reseau / NB_MAGASINS_RESEAU);
product.qte_reseau_by_month = Some(m.qte_by_month.clone());
product.nb_mag_reseau_by_month = Some(nb_mag_par_mois(m));
product.network_fetched_at = m.fetched_at.clone();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slice_et_max_comme_js() {
assert_eq!(js_slice_end(10, 300.0), 10);
assert_eq!(js_slice_end(10, 3.7), 3);
assert_eq!(js_slice_end(10, -2.0), 8);
assert_eq!(js_slice_end(10, f64::NAN), 0);
assert_eq!(js_max([1.0, 3.0, 2.0].into_iter()), 3.0);
assert!(js_max([1.0, f64::NAN].into_iter()).is_nan());
}
#[test]
fn report_des_gammes_du_snapshot() {
let mut rows = vec![
ProductRow { codein: "1".into(), code_gamme: Some("B".into()), ..Default::default() },
ProductRow { codein: "2".into(), code_gamme: Some("B".into()), ..Default::default() },
ProductRow { codein: "3".into(), code_gamme: Some("B".into()), ..Default::default() },
];
let changes = serde_json::json!({ "1": { "before": "B", "after": "Z" }, "2": { "before": "B", "after": "" } });
apply_snapshot_changes(&mut rows, changes.as_object());
let g: Vec<_> = rows.iter().map(|r| r.code_gamme.as_deref().unwrap()).collect();
assert_eq!(g, vec!["Z", "B", "B"]);
}
}
+273
View File
@@ -0,0 +1,273 @@
//! Cache mémoire des lignes de grille calculées (partie « cache » de
//! `src/features/grid/api/get-product-rows.ts`).
//!
//! Clé : `"{codeFournisseur}:{magasin ?? "TOTAL"}"`. Durée de vie : 10 minutes.
//! Le calcul lui-même (`getProductRows`, `buildProductRows`, `serveFromSnapshot`,
//! `getStorePatch`) viendra en phase 2 et s'appuie sur les briques ci-dessous :
//!
//! - [`get_fresh`] : lecture d'une entrée de moins de 10 min, remontée en tête de
//! l'ordre d'usage (éviction LRU) ; [`peek_fresh`] la lit sans la remonter
//! (`getStorePatch`) ;
//! - [`cache_grid_rows`] : insertion avec éviction (`cacheGridRows`) ;
//! - [`pending_get`] / [`pending_insert`] / [`pending_remove`] : calculs en cours,
//! partagés entre appels simultanés (`gridRowsPending`) ;
//! - [`gamme_save_version`] : compteur d'enregistrements de gammes par fournisseur,
//! qu'un calcul compare avant/après pour relire le dernier snapshot ;
//! - [`invalidate_grid_rows_cache`], [`patch_grid_rows_cache`].
//!
//! Les lignes d'une entrée sont partagées ([`SharedRows`]) et modifiées sur place,
//! comme le tableau JS : `patchGridRowsCache` et `refreshGammeInit` écrivent dans
//! les lignes déjà servies. Les compléments par magasin (`storePatches`, une
//! `WeakMap` indexée par le tableau en TS) sont rattachés au même objet : ils
//! vivent et meurent avec lui.
use super::types::{GammeCode, ProductRow, StorePatch};
use futures::future::{BoxFuture, Shared};
use parking_lot::{Mutex, RwLock};
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
pub const GRID_ROWS_CACHE_TTL: Duration = Duration::from_secs(10 * 60);
/// Bornes du cache mémoire. La synchro nocturne et le préchauffage passent par
/// `getProductRows()` pour CHAQUE fournisseur : sans plafond, le processus finissait
/// par garder toutes les grilles en mémoire (plusieurs centaines de Mo). Ordre
/// d'insertion = ordre d'usage : on évince les entrées les moins récemment servies.
///
/// `Number(process.env.X ?? défaut)` : une valeur illisible donne `NaN`, et toute
/// comparaison avec `NaN` est fausse — comportement conservé.
fn borne(nom: &str, defaut: f64) -> f64 {
match std::env::var(nom) {
Ok(v) => super::js::js_number_from_str(&v),
Err(_) => defaut,
}
}
fn max_entries() -> f64 {
borne("GRID_ROWS_CACHE_MAX_ENTRIES", 30.0)
}
fn max_rows() -> f64 {
borne("GRID_ROWS_CACHE_MAX_ROWS", 150_000.0)
}
/// Complément par magasin en cours de calcul ou calculé (partagé entre appelants).
pub type StorePatchFuture = Shared<BoxFuture<'static, Result<Arc<StorePatch>, String>>>;
/// Lignes d'une entrée du cache, partagées et modifiables sur place.
#[derive(Default)]
pub struct GridRows {
pub rows: RwLock<Vec<ProductRow>>,
/// `storePatches` : magasin → complément, rattaché à ce tableau de lignes.
pub store_patches: Mutex<HashMap<String, StorePatchFuture>>,
}
impl GridRows {
pub fn new(rows: Vec<ProductRow>) -> SharedRows {
Arc::new(GridRows { rows: RwLock::new(rows), store_patches: Mutex::new(HashMap::new()) })
}
pub fn len(&self) -> usize {
self.rows.read().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Copie des lignes (pour les sérialiser hors verrou).
pub fn snapshot(&self) -> Vec<ProductRow> {
self.rows.read().clone()
}
}
pub type SharedRows = Arc<GridRows>;
/// Calcul en cours, partagé : l'erreur est un message (le calcul TS relance l'erreur
/// à chaque appelant).
pub type RowsFuture = Shared<BoxFuture<'static, Result<SharedRows, String>>>;
struct Entree {
rows: SharedRows,
created_at: Instant,
}
#[derive(Default)]
struct Etat {
/// Entrées dans l'ordre d'usage (la plus ancienne en tête).
cache: Vec<(String, Entree)>,
pending: HashMap<String, RowsFuture>,
/// Nombre d'enregistrements de gammes par fournisseur (cf. [`patch_grid_rows_cache`]).
gamme_save_version: HashMap<String, u64>,
}
static ETAT: LazyLock<Mutex<Etat>> = LazyLock::new(|| Mutex::new(Etat::default()));
/// `gridRowsCacheKey` : `"{codeFournisseur}:{magasin ?? "TOTAL"}"`.
pub fn grid_rows_cache_key(code_fournisseur: &str, magasin: Option<&str>) -> String {
format!("{code_fournisseur}:{}", magasin.unwrap_or("TOTAL"))
}
fn prefixe(code_fournisseur: &str) -> String {
format!("{code_fournisseur}:")
}
/// `cacheGridRows` : (ré)insère l'entrée en fin d'ordre d'usage, puis évince les
/// plus anciennes tant que les bornes sont dépassées (jamais la dernière).
pub fn cache_grid_rows(key: &str, rows: SharedRows) {
let (max_e, max_r) = (max_entries(), max_rows());
let mut etat = ETAT.lock();
etat.cache.retain(|(k, _)| k != key);
etat.cache.push((key.to_string(), Entree { rows, created_at: Instant::now() }));
let mut total_rows: f64 = etat.cache.iter().map(|(_, e)| e.rows.len() as f64).sum();
while etat.cache.len() > 1 {
let taille = etat.cache.len() as f64;
if taille <= max_e && total_rows <= max_r {
break;
}
let (_, e) = etat.cache.remove(0);
total_rows -= e.rows.len() as f64;
}
}
/// Entrée de moins de 10 min, remontée en tête de l'ordre d'usage (hit de
/// `getProductRows`). `None` si absente ou expirée.
pub fn get_fresh(key: &str) -> Option<SharedRows> {
let mut etat = ETAT.lock();
let pos = etat.cache.iter().position(|(k, e)| k == key && e.created_at.elapsed() < GRID_ROWS_CACHE_TTL)?;
// Remonte l'entrée en tête de l'ordre d'usage (éviction LRU), sans changer
// sa date de création.
let entree = etat.cache.remove(pos);
let rows = entree.1.rows.clone();
etat.cache.push(entree);
Some(rows)
}
/// Entrée de moins de 10 min, sans toucher à l'ordre d'usage (`getStorePatch`).
pub fn peek_fresh(key: &str) -> Option<SharedRows> {
let etat = ETAT.lock();
etat.cache.iter().find(|(k, e)| k == key && e.created_at.elapsed() < GRID_ROWS_CACHE_TTL).map(|(_, e)| e.rows.clone())
}
/// Calcul en cours pour cette clé.
pub fn pending_get(key: &str) -> Option<RowsFuture> {
ETAT.lock().pending.get(key).cloned()
}
pub fn pending_contains(key: &str) -> bool {
ETAT.lock().pending.contains_key(key)
}
pub fn pending_insert(key: &str, fut: RowsFuture) {
ETAT.lock().pending.insert(key.to_string(), fut);
}
pub fn pending_remove(key: &str) {
ETAT.lock().pending.remove(key);
}
/// Compteur d'enregistrements de gammes du fournisseur.
pub fn gamme_save_version(code_fournisseur: &str) -> u64 {
ETAT.lock().gamme_save_version.get(code_fournisseur).copied().unwrap_or(0)
}
/// `invalidateGridRowsCache` : un fournisseur (toutes ses vues magasin), ou tout.
pub fn invalidate_grid_rows_cache(code_fournisseur: Option<&str>) {
let mut etat = ETAT.lock();
match code_fournisseur.filter(|c| !c.is_empty()) {
Some(code) => {
let p = prefixe(code);
etat.cache.retain(|(k, _)| !k.starts_with(&p));
etat.pending.retain(|k, _| !k.starts_with(&p));
}
None => {
etat.cache.clear();
etat.pending.clear();
}
}
}
/// Reporte des gammes enregistrées sur les lignes déjà en cache, au lieu de jeter
/// le cache : l'invalidation forçait un recalcul complet (jusqu'à ~40 s sur un gros
/// fournisseur) à la réouverture suivante. Le résultat est celui qu'aurait donné
/// ce recalcul : la colonne Gamme reprend la valeur du dernier snapshot (Phase 9),
/// la colonne INIT reste l'état serveur.
pub fn patch_grid_rows_cache<'a>(code_fournisseur: &str, changes: impl IntoIterator<Item = (&'a str, &'a str)>) {
let by_codein: HashMap<&str, GammeCode> = changes.into_iter().map(|(c, g)| (c, g.to_string())).collect();
if by_codein.is_empty() {
return;
}
let entrees: Vec<SharedRows> = {
let mut etat = ETAT.lock();
// Un calcul en cours pour ce fournisseur a lu les gammes avant cet
// enregistrement : le compteur lui signale de les relire avant d'être caché.
*etat.gamme_save_version.entry(code_fournisseur.to_string()).or_insert(0) += 1;
let p = prefixe(code_fournisseur);
etat.cache.iter().filter(|(k, _)| k.starts_with(&p)).map(|(_, e)| e.rows.clone()).collect()
};
for entree in entrees {
for row in entree.rows.write().iter_mut() {
if let Some(g) = by_codein.get(row.codein.as_str()) {
row.code_gamme = Some(g.clone());
}
}
}
}
/// Nombre d'entrées en cache (diagnostic).
pub fn cache_len() -> usize {
ETAT.lock().cache.len()
}
/// Sérialise les tests qui touchent à l'état global du cache.
#[cfg(test)]
pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(());
#[cfg(test)]
pub(crate) fn reset_for_tests() {
*ETAT.lock() = Etat::default();
}
#[cfg(test)]
mod tests {
use super::*;
fn lignes(n: usize) -> SharedRows {
GridRows::new((0..n).map(|i| ProductRow { codein: format!("C{i}"), ..Default::default() }).collect())
}
/// Un seul test : l'état est global au processus.
#[test]
fn cache_patch_invalidation_eviction() {
let _verrou = TEST_LOCK.lock();
reset_for_tests();
assert_eq!(grid_rows_cache_key("F1", None), "F1:TOTAL");
assert_eq!(grid_rows_cache_key("F1", Some("292")), "F1:292");
let total = lignes(3);
cache_grid_rows("F1:TOTAL", total.clone());
cache_grid_rows("F1:292", lignes(3));
cache_grid_rows("F10:TOTAL", lignes(3));
assert!(get_fresh("F1:TOTAL").is_some());
assert!(peek_fresh("absent").is_none());
// Patch : seules les entrées du fournisseur F1 (pas F10) reçoivent la gamme.
patch_grid_rows_cache("F1", [("C1", "Z")]);
assert_eq!(gamme_save_version("F1"), 1);
assert_eq!(gamme_save_version("F10"), 0);
assert_eq!(total.rows.read()[1].code_gamme.as_deref(), Some("Z"));
assert_eq!(get_fresh("F1:292").unwrap().rows.read()[1].code_gamme.as_deref(), Some("Z"));
assert_eq!(get_fresh("F10:TOTAL").unwrap().rows.read()[1].code_gamme, None);
// Invalidation d'un fournisseur : « F1: » ne touche pas « F10: ».
invalidate_grid_rows_cache(Some("F1"));
assert!(get_fresh("F1:TOTAL").is_none() && get_fresh("F1:292").is_none());
assert!(get_fresh("F10:TOTAL").is_some());
// Éviction LRU par nombre de lignes (borne 150 000 par défaut).
cache_grid_rows("GROS:TOTAL", lignes(150_000));
assert!(get_fresh("F10:TOTAL").is_none(), "l'entrée la moins récente est évincée");
assert!(get_fresh("GROS:TOTAL").is_some());
// Jamais la dernière, même au-delà des bornes.
cache_grid_rows("ENORME:TOTAL", lignes(150_001));
assert_eq!(cache_len(), 1);
invalidate_grid_rows_cache(None);
assert_eq!(cache_len(), 0);
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Accès et identifiant utilisateur, tels que les actions TS les manipulent.
use super::js;
use crate::auth::UtilisateurCourant;
use crate::error::AppResult;
use crate::rpc::Ctx;
use axum::http::StatusCode;
use serde_json::{Map, Value};
/// `verifierSession()` : `Ok(Ok(u))` si connecté, `Ok(Err(message))` pour un
/// refus d'accès (401/403) que l'action renvoie dans `{ success: false, error }`.
/// Toute autre erreur est propagée, comme le `throw e` du TS.
pub async fn verifier_session(ctx: &Ctx) -> AppResult<Result<UtilisateurCourant, String>> {
match ctx.session().await {
Ok(u) => Ok(Ok(u)),
Err(e) if e.status == StatusCode::UNAUTHORIZED || e.status == StatusCode::FORBIDDEN => Ok(Err(e.message)),
Err(e) => Err(e),
}
}
/// `rawUserId ? parseInt(rawUserId, 10) : null` — `None` pour `null`, sinon le
/// nombre (éventuellement `NaN`).
pub fn parse_user_id(raw: &str) -> Option<f64> {
if raw.is_empty() {
None
} else {
Some(js::js_parse_int(raw))
}
}
/// `userId && !isNaN(userId) ? userId : null` : le compte de secours « 0 » et les
/// identifiants non numériques valent `null` (snapshots « anonymes »).
pub fn final_user_id(raw: &str) -> Option<f64> {
parse_user_id(raw).filter(|v| *v != 0.0 && !v.is_nan())
}
/// Représentation de `${userId}` dans les journaux.
pub fn user_id_log(raw: &str) -> String {
match parse_user_id(raw) {
None => "null".into(),
Some(v) => js::js_number_to_string(v),
}
}
/// Valeur liée à une colonne `integer` : hors bornes, PostgreSQL refuse la
/// requête (« value out of range for type integer ») — même issue qu'en TS.
pub fn as_pg_integer(v: f64) -> Result<i32, String> {
if v.fract() == 0.0 && v >= i32::MIN as f64 && v <= i32::MAX as f64 {
Ok(v as i32)
} else {
Err(format!("value \"{}\" is out of range for type integer", js::js_number_to_string(v)))
}
}
/// Clés d'un objet dans l'ordre où JS les énumère : indices entiers croissants
/// d'abord (« 123456 » est un indice), puis les autres dans l'ordre d'insertion.
pub fn js_key_order(m: &Map<String, Value>) -> Vec<(&String, &Value)> {
let indice = js::js_array_index;
let mut entiers: Vec<(u64, &String, &Value)> = Vec::new();
let mut autres = Vec::new();
for (k, v) in m {
match indice(k) {
Some(i) => entiers.push((i, k, v)),
None => autres.push((k, v)),
}
}
entiers.sort_by_key(|(i, _, _)| *i);
entiers.into_iter().map(|(_, k, v)| (k, v)).chain(autres).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identifiants_comme_en_ts() {
assert_eq!(final_user_id("12"), Some(12.0));
assert_eq!(final_user_id("12abc"), Some(12.0));
assert_eq!(final_user_id("0"), None);
assert_eq!(final_user_id("abc"), None);
assert_eq!(final_user_id(""), None);
assert_eq!(user_id_log("abc"), "NaN");
assert_eq!(user_id_log(""), "null");
assert_eq!(user_id_log("0"), "0");
assert!(as_pg_integer(99_999_999_999.0).is_err());
assert_eq!(as_pg_integer(-5.0), Ok(-5));
}
#[test]
fn ordre_des_cles_js() {
let v: Value = serde_json::from_str(r#"{"b":1,"20":2,"a":3,"3":4,"007":5}"#).unwrap();
let ordre: Vec<&str> = js_key_order(v.as_object().unwrap()).into_iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(ordre, vec!["3", "20", "b", "a", "007"]);
}
}
+407
View File
@@ -0,0 +1,407 @@
//! Snapshots de la Grille (port de `src/features/snapshots/api/*.ts`) :
//! actions `getSnapshots`, `deleteSnapshot`, `saveSnapshot`.
//!
//! `session_snapshots.user_id` est un entier : l'identifiant de session est lu
//! comme le faisait le TS (`parseInt`), et le compte de secours « 0 » ou un
//! identifiant non numérique travaillent sur les snapshots « anonymes »
//! (`user_id IS NULL`). Les `revalidatePath` de Next.js n'ont plus d'objet.
use super::session::{as_pg_integer, final_user_id, js_key_order, user_id_log, verifier_session};
use super::zod::{self, child, Issues, Seg};
use crate::db;
use crate::error::{AppError, AppResult};
use crate::rpc::{json, Ctx};
use chrono::NaiveDateTime;
use serde::Serialize;
use serde_json::{json as j, Map, Value};
use sqlx::{PgPool, Row};
/// Ligne de `session_snapshots`, telle que `db.select().from(sessionSnapshots)`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSnapshot {
pub id: i32,
pub user_id: Option<i32>,
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub changes: Value,
pub summary_json: Option<Value>,
pub label: Option<String>,
#[serde(rename = "type")]
pub type_: Option<String>,
pub created_at: Option<String>,
}
fn ligne_snapshot(r: &sqlx::postgres::PgRow) -> SessionSnapshot {
SessionSnapshot {
id: r.try_get("id").unwrap_or_default(),
user_id: r.try_get("user_id").ok().flatten(),
code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
magasin: r.try_get("magasin").unwrap_or_default(),
changes: r.try_get("changes").unwrap_or(Value::Null),
summary_json: r.try_get("summary_json").ok().flatten(),
label: r.try_get("label").ok().flatten(),
type_: r.try_get("type").ok().flatten(),
created_at: r.try_get::<Option<NaiveDateTime>, _>("created_at").ok().flatten().map(db::iso),
}
}
// ---------------------------------------------------------------------------
// getSnapshots
// ---------------------------------------------------------------------------
/// Snapshots de l'utilisateur connecté (ou anonymes), du plus récent au plus ancien.
pub async fn lister_snapshots(user_raw_id: &str, type_: Option<&str>) -> Result<Vec<SessionSnapshot>, String> {
let pool = db::pool().map_err(|e| e.message)?;
// Si on a un userId, on filtre par celui-ci.
// Sinon, on filtre par userId IS NULL (mode anonyme/local)
let mut sql = String::from(
"select id, user_id, code_fournisseur, nom_fournisseur, magasin, changes, summary_json, label, type, created_at \
from session_snapshots where ",
);
let user_id = match final_user_id(user_raw_id) {
Some(v) => Some(as_pg_integer(v)?),
None => None,
};
sql.push_str(if user_id.is_some() { "user_id = $1" } else { "user_id is null" });
if type_.is_some() {
sql.push_str(if user_id.is_some() { " and type = $2" } else { " and type = $1" });
}
sql.push_str(" order by created_at desc");
let mut q = sqlx::query(&sql);
if let Some(id) = user_id {
q = q.bind(id);
}
if let Some(t) = type_ {
q = q.bind(t);
}
let rows = q.fetch_all(&pool).await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(ligne_snapshot).collect())
}
/// Action `getSnapshots(type?)`.
async fn get_snapshots(ctx: Ctx) -> AppResult<Value> {
let utilisateur = ctx.session().await?;
let arg = ctx.args.first();
// `if (type)` : seule une valeur « vraie » filtre.
let type_ = super::js::js_truthy(arg).then(|| super::js::js_string(arg));
match lister_snapshots(&utilisateur.id, type_.as_deref()).await {
Ok(rows) => json(rows),
Err(msg) => {
let premiere_ligne = msg.lines().next().unwrap_or_default().to_string();
tracing::error!("[getSnapshots] ERROR (type={}): {premiere_ligne}", type_.as_deref().unwrap_or("undefined"));
// Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot »
// au lieu de signaler que le chargement a échoué.
Err(AppError::internal("Chargement de l'historique impossible."))
}
}
}
// ---------------------------------------------------------------------------
// deleteSnapshot
// ---------------------------------------------------------------------------
/// Supprime un snapshot de l'utilisateur (ou anonyme). Résultat `{ success, error? }`.
pub async fn supprimer_snapshot(user_raw_id: &str, id: &Value) -> Value {
tracing::info!("[deleteSnapshot] Request for ID: {}, Session UserId: {}", super::js::js_string(Some(id)), user_id_log(user_raw_id));
let res: Result<u64, String> = async {
let pool = db::pool().map_err(|e| e.message)?;
// Condition de sécurité : match ID ET match le propriétaire (ou anonyme)
let user_id = match final_user_id(user_raw_id) {
Some(v) => Some(as_pg_integer(v)?),
None => None,
};
let id_sql: Option<i32> = match id {
Value::Null => None,
Value::Number(n) => Some(as_pg_integer(n.as_f64().unwrap_or(f64::NAN))?),
Value::String(s) => Some(s.trim().parse::<i32>().map_err(|_| format!("invalid input syntax for type integer: \"{s}\""))?),
autre => return Err(format!("invalid input syntax for type integer: {autre}")),
};
let sql = if user_id.is_some() {
"delete from session_snapshots where (id = $1 and user_id = $2) returning id"
} else {
"delete from session_snapshots where (id = $1 and user_id is null) returning id"
};
let mut q = sqlx::query(sql).bind(id_sql);
if let Some(u) = user_id {
q = q.bind(u);
}
let rows = q.fetch_all(&pool).await.map_err(|e| e.to_string())?;
Ok(rows.len() as u64)
}
.await;
match res {
Ok(0) => {
tracing::warn!(
"[deleteSnapshot] No rows deleted for ID {}. Snapshot may not exist or belongs to another user.",
super::js::js_string(Some(id))
);
j!({ "success": false, "error": "Snapshot non trouvé ou non autorisé" })
}
Ok(_) => {
tracing::info!("[deleteSnapshot] Successfully deleted ID: {}", super::js::js_string(Some(id)));
j!({ "success": true })
}
Err(e) => {
// Le détail technique reste dans les journaux du serveur.
tracing::error!("[deleteSnapshot] DB Error: {e}");
j!({ "success": false, "error": "erreur technique sur le serveur" })
}
}
}
/// Action `deleteSnapshot(id)`.
async fn delete_snapshot(ctx: Ctx) -> AppResult<Value> {
let utilisateur = match verifier_session(&ctx).await? {
Ok(u) => u,
Err(message) => return Ok(j!({ "success": false, "error": message })),
};
Ok(supprimer_snapshot(&utilisateur.id, &ctx.raw(0)).await)
}
// ---------------------------------------------------------------------------
// saveSnapshot
// ---------------------------------------------------------------------------
/// Données validées de `SaveSnapshotSchema` (clés inconnues retirées, comme zod).
#[derive(Debug, Clone)]
pub struct SaveSnapshotInput {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub label: Option<String>,
/// codein → `{ before, after }`.
pub changes: Map<String, Value>,
pub type_: Option<String>,
/// `{ totalRows, totalCa, totalMarge, tauxMargeGlobal }`.
pub summary: Option<Value>,
}
/// `SaveSnapshotSchema.safeParse(raw)`.
pub fn parse_save_snapshot(raw: &Value) -> Result<SaveSnapshotInput, Issues> {
let mut issues = Issues::default();
let racine: Vec<Seg> = vec![];
let Some(o) = zod::object(Some(raw), &racine, &mut issues) else { return Err(issues) };
let p = |k: &str| vec![Seg::from(k)];
let code_fournisseur = zod::string(o.get("codeFournisseur"), &p("codeFournisseur"), &mut issues);
let nom_fournisseur = zod::opt_string(o.get("nomFournisseur"), &p("nomFournisseur"), &mut issues);
let magasin = zod::string(o.get("magasin"), &p("magasin"), &mut issues);
let label = zod::opt_string(o.get("label"), &p("label"), &mut issues);
let mut changes = Map::new();
let chemin_changes = p("changes");
match o.get("changes") {
Some(Value::Object(m)) => {
for (k, v) in js_key_order(m) {
let chemin = child(&chemin_changes, k.as_str());
let Some(entree) = zod::object(Some(v), &chemin, &mut issues) else { continue };
let before = zod::nullable_string(entree.get("before"), &child(&chemin, "before"), &mut issues);
let after = zod::string(entree.get("after"), &child(&chemin, "after"), &mut issues);
if let (Some(before), Some(after)) = (before, after) {
changes.insert(k.clone(), j!({ "before": before, "after": after }));
}
}
}
autre => issues.invalid_type(&chemin_changes, "record", autre),
}
let type_ = match o.get("type") {
None => Some(None),
v => zod::enum_value(v, &["snapshot", "export"], &p("type"), None, &mut issues).map(Some),
};
let summary = match o.get("summary") {
None => Some(None),
v => {
let chemin = p("summary");
match zod::object(v, &chemin, &mut issues) {
Some(s) => {
let mut out = Map::new();
let mut complet = true;
for k in ["totalRows", "totalCa", "totalMarge", "tauxMargeGlobal"] {
match zod::number(s.get(k), &child(&chemin, k), &mut issues) {
Some(_) => {
out.insert(k.to_string(), s[k].clone());
}
None => complet = false,
}
}
complet.then_some(Some(Value::Object(out)))
}
None => None,
}
}
};
if !issues.is_empty() {
return Err(issues);
}
Ok(SaveSnapshotInput {
code_fournisseur: code_fournisseur.unwrap_or_default(),
nom_fournisseur: nom_fournisseur.flatten(),
magasin: magasin.unwrap_or_default(),
label: label.flatten(),
changes,
type_: type_.flatten(),
summary: summary.flatten(),
})
}
/// Date du jour façon `toLocaleDateString("fr-FR")`.
pub fn date_fr() -> String {
chrono::Local::now().format("%d/%m/%Y").to_string()
}
async fn inserer_snapshot(pool: &PgPool, user_id: Option<f64>, d: &SaveSnapshotInput) -> Result<i32, sqlx::Error> {
let label = d
.label
.clone()
.unwrap_or_else(|| format!("{} — {}", if d.type_.as_deref() == Some("export") { "Export" } else { "Snapshot" }, date_fr()));
// L'identifiant est lié en entier 64 bits : hors bornes de la colonne
// `integer`, PostgreSQL refuse l'insertion, comme pour la version Node.
let row = sqlx::query(
"insert into session_snapshots (user_id, code_fournisseur, nom_fournisseur, magasin, label, changes, summary_json, type) \
values ($1, $2, $3, $4, $5, $6, $7, $8) returning id",
)
.bind(user_id.map(|v| v as i64))
.bind(&d.code_fournisseur)
.bind(&d.nom_fournisseur)
.bind(&d.magasin)
.bind(label)
.bind(Value::Object(d.changes.clone()))
.bind(d.summary.clone())
.bind(d.type_.clone().unwrap_or_else(|| "snapshot".into()))
.fetch_one(pool)
.await?;
row.try_get("id")
}
/// Enregistre un snapshot pour l'utilisateur. Résultat `{ success, snapshotId?, error? }`.
pub async fn enregistrer_snapshot(user_raw_id: &str, raw: &Value) -> Value {
let d = match parse_save_snapshot(raw) {
Ok(d) => d,
Err(issues) => {
tracing::error!("Snapshot validation failed: {}", serde_json::to_string(&issues.to_json()).unwrap_or_default());
return j!({ "success": false, "error": format!("Validation failed: {}", issues.messages_joined()) });
}
};
tracing::info!(
"[saveSnapshot] User: {} (raw: {user_raw_id}), Supplier: {}, Type: {}",
user_id_log(user_raw_id),
d.code_fournisseur,
d.type_.as_deref().unwrap_or("undefined")
);
let final_id = final_user_id(user_raw_id);
let pool = match db::pool() {
Ok(p) => p,
Err(e) => {
tracing::error!("Snapshot auto-repair/retry failed: {}", e.message);
return j!({ "success": false, "error": "erreur technique sur le serveur" });
}
};
match inserer_snapshot(&pool, final_id, &d).await {
Ok(id) => j!({ "success": true, "snapshotId": id }),
Err(err) => {
tracing::error!("Initial snapshot save failed, attempting auto-repair... {err}");
let reparation = async {
// Tentative systématique de création de table (no-op si déjà là)
sqlx::raw_sql(
"CREATE TABLE IF NOT EXISTS session_snapshots (
id SERIAL PRIMARY KEY,
user_id INTEGER,
code_fournisseur VARCHAR(20) NOT NULL,
nom_fournisseur VARCHAR(255),
magasin VARCHAR(20) NOT NULL,
changes JSONB NOT NULL,
summary_json JSONB,
label TEXT,
type VARCHAR(20) DEFAULT 'snapshot',
created_at TIMESTAMP DEFAULT NOW()
);",
)
.execute(&pool)
.await?;
// Si la table existait déjà mais sans la colonne 'type' ou 'user_id', on les ajoute
let alter = async {
sqlx::raw_sql("ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'snapshot'")
.execute(&pool)
.await?;
sqlx::raw_sql("ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS user_id INTEGER").execute(&pool).await
};
let _ = alter.await; // Ignore
// Deuxième tentative d'insertion
inserer_snapshot(&pool, final_id, &d).await
};
match reparation.await {
Ok(id) => j!({ "success": true, "snapshotId": id }),
Err(e) => {
// Le détail technique (les deux erreurs) reste dans les journaux du serveur.
tracing::error!("Snapshot auto-repair/retry failed: {e}");
j!({ "success": false, "error": "erreur technique sur le serveur" })
}
}
}
}
}
/// Action `saveSnapshot(raw)`.
async fn save_snapshot(ctx: Ctx) -> AppResult<Value> {
let utilisateur = match verifier_session(&ctx).await? {
Ok(u) => u,
Err(message) => return Ok(j!({ "success": false, "error": message })),
};
Ok(enregistrer_snapshot(&utilisateur.id, &ctx.raw(0)).await)
}
pub fn actions() -> Vec<(&'static str, crate::rpc::Handler)> {
vec![
("getSnapshots", crate::handler!(get_snapshots)),
("deleteSnapshot", crate::handler!(delete_snapshot)),
("saveSnapshot", crate::handler!(save_snapshot)),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validation_comme_zod() {
let raw: Value = serde_json::from_str(
r#"{"codeFournisseur":"1","magasin":"T","changes":{"a":{"before":1},"b":3},"type":"x","summary":{"totalRows":"1"}}"#,
)
.unwrap();
let issues = parse_save_snapshot(&raw).unwrap_err();
assert_eq!(
serde_json::to_string(&issues.to_json()).unwrap(),
r#"[{"expected":"string","code":"invalid_type","path":["changes","a","before"],"message":"Invalid input: expected string, received number"},{"expected":"string","code":"invalid_type","path":["changes","a","after"],"message":"Invalid input: expected string, received undefined"},{"expected":"object","code":"invalid_type","path":["changes","b"],"message":"Invalid input: expected object, received number"},{"code":"invalid_value","values":["snapshot","export"],"path":["type"],"message":"Invalid option: expected one of \"snapshot\"|\"export\""},{"expected":"number","code":"invalid_type","path":["summary","totalRows"],"message":"Invalid input: expected number, received string"},{"expected":"number","code":"invalid_type","path":["summary","totalCa"],"message":"Invalid input: expected number, received undefined"},{"expected":"number","code":"invalid_type","path":["summary","totalMarge"],"message":"Invalid input: expected number, received undefined"},{"expected":"number","code":"invalid_type","path":["summary","tauxMargeGlobal"],"message":"Invalid input: expected number, received undefined"}]"#
);
let vide = parse_save_snapshot(&j!({})).unwrap_err();
assert_eq!(
vide.messages_joined(),
"Invalid input: expected string, received undefined, Invalid input: expected string, received undefined, Invalid input: expected record, received undefined"
);
assert!(parse_save_snapshot(&j!({ "codeFournisseur": "1", "magasin": "T", "changes": [] })).is_err());
}
#[test]
fn donnees_nettoyees() {
let raw = j!({
"codeFournisseur": "F1", "magasin": "TOTAL", "inconnu": true,
"changes": { "200": { "before": null, "after": "A", "x": 1 }, "100": { "before": "B", "after": "Z" } },
"summary": { "totalRows": 2, "totalCa": 10.5, "totalMarge": 3, "tauxMargeGlobal": 28.5, "y": 0 }
});
let d = parse_save_snapshot(&raw).unwrap();
assert_eq!(serde_json::to_string(&d.changes).unwrap(), r#"{"100":{"before":"B","after":"Z"},"200":{"before":null,"after":"A"}}"#);
assert_eq!(d.summary.unwrap(), j!({ "totalRows": 2, "totalCa": 10.5, "totalMarge": 3, "tauxMargeGlobal": 28.5 }));
assert_eq!(d.type_, None);
assert_eq!(d.label, None);
}
}
+723
View File
@@ -0,0 +1,723 @@
//! CollectFlow — Instantané persisté de la grille (table `grid_rows`),
//! port de `src/lib/grid-store.ts`.
//!
//! `getProductRows()` reconstruit la grille en direct (6 requêtes SQL sur la base
//! miroir FF + jointure Qlik) et ne la garde qu'en mémoire 10 minutes. L'API `/api/v1`
//! ne doit **jamais** déclencher ce calcul : elle lit cette table, remplie en effet de
//! bord quand quelqu'un ouvre la Grille.
//!
//! On n'introduit donc aucun calcul supplémentaire — on arrête de jeter un résultat
//! qui existe déjà, et il survit désormais aux redémarrages.
use super::js;
use super::types::ProductRow;
use crate::db;
use crate::error::{AppError, AppResult};
use chrono::{Datelike, NaiveDateTime, TimeZone};
use serde::Serialize;
use serde_json::Value;
use sqlx::{Postgres, QueryBuilder, Row};
use std::collections::HashMap;
/// Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GridSortKey {
Libelle1,
Codein,
CodeFournisseur,
CodeGamme,
TotalCa,
TotalQuantite,
TotalMarge,
TauxMarge,
StockActuel,
CaReseau,
QteReseau,
NbMagasinsReseau,
CaParMagasinReseau,
ComputedAt,
}
/// `SORTABLE` : clé exposée → colonne SQL.
const SORTABLE: [(GridSortKey, &str, &str); 14] = [
(GridSortKey::Libelle1, "libelle1", "libelle1"),
(GridSortKey::Codein, "codein", "codein"),
(GridSortKey::CodeFournisseur, "codeFournisseur", "code_fournisseur"),
(GridSortKey::CodeGamme, "codeGamme", "code_gamme"),
(GridSortKey::TotalCa, "totalCa", "total_ca"),
(GridSortKey::TotalQuantite, "totalQuantite", "total_quantite"),
(GridSortKey::TotalMarge, "totalMarge", "total_marge"),
(GridSortKey::TauxMarge, "tauxMarge", "taux_marge"),
(GridSortKey::StockActuel, "stockActuel", "stock_actuel"),
(GridSortKey::CaReseau, "caReseau", "ca_reseau"),
(GridSortKey::QteReseau, "qteReseau", "qte_reseau"),
(GridSortKey::NbMagasinsReseau, "nbMagasinsReseau", "nb_magasins_reseau"),
(GridSortKey::CaParMagasinReseau, "caParMagasinReseau", "ca_par_magasin_reseau"),
(GridSortKey::ComputedAt, "computedAt", "computed_at"),
];
/// `GRID_SORT_KEYS`, dans l'ordre de `SORTABLE`.
pub const GRID_SORT_KEYS: [&str; 14] = [
"libelle1",
"codein",
"codeFournisseur",
"codeGamme",
"totalCa",
"totalQuantite",
"totalMarge",
"tauxMarge",
"stockActuel",
"caReseau",
"qteReseau",
"nbMagasinsReseau",
"caParMagasinReseau",
"computedAt",
];
impl GridSortKey {
/// Clé de tri depuis son nom exposé ; `None` hors liste blanche.
pub fn from_key(key: &str) -> Option<Self> {
SORTABLE.iter().find(|(_, k, _)| *k == key).map(|(s, _, _)| *s)
}
pub fn as_str(self) -> &'static str {
SORTABLE.iter().find(|(s, _, _)| *s == self).map(|(_, k, _)| *k).unwrap_or("totalCa")
}
/// Colonne SQL (constante du code, jamais l'entrée utilisateur).
fn column(self) -> &'static str {
SORTABLE.iter().find(|(s, _, _)| *s == self).map(|(_, _, c)| *c).unwrap_or("total_ca")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortOrder {
Asc,
#[default]
Desc,
}
#[derive(Debug, Clone, Default)]
pub struct GridQuery {
pub code_fournisseur: Option<String>,
pub gamme: Option<String>,
pub code1: Option<String>,
pub code2: Option<String>,
pub code3: Option<String>,
/// Préfixe de nomenclature (« 32 », « 3202 », « 320211 ») — filtre sur code3.
pub nomenclature: Option<String>,
/// Recherche libre : libellé, codein, GTIN, référence, code centrale.
pub search: Option<String>,
pub sort: Option<GridSortKey>,
pub order: Option<SortOrder>,
pub page: Option<i64>,
pub limit: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridQueryResult {
pub rows: Vec<ProductRow>,
pub total: i64,
/// Calcul le plus ancien parmi les lignes renvoyées — fraîcheur de la donnée.
pub computed_at: Option<String>,
}
/// Version du format des lignes persistées. À incrémenter dès qu'un champ de
/// `ProductRow` est ajouté ou change de sens : les instantanés plus anciens ne
/// seront plus servis à la Grille (recalcul), mais restent lisibles par /api/v1.
pub const GRID_PAYLOAD_VERSION: f64 = 1.0;
/// Lots d'écriture : payload jsonb volumineux, lots plus petits que pour des scalaires.
const CHUNK_UPSERT: usize = 200;
/// Bien en deçà des 65 535 paramètres liés de PostgreSQL.
const CHUNK_CODEINS: usize = 5000;
/// Chaîne non vide (`if (q.x)` côté TS).
fn renseigne(s: &Option<String>) -> Option<&str> {
s.as_deref().filter(|v| !v.is_empty())
}
/// Horodatage façon `new Date()` stocké par drizzle (UTC, à la milliseconde).
fn maintenant_utc_ms() -> NaiveDateTime {
use chrono::Timelike;
let now = chrono::Utc::now().naive_utc();
now.with_nanosecond(now.nanosecond() / 1_000_000 * 1_000_000).unwrap_or(now)
}
/// Colonne `numeric` : le TS passe `String(n)` ; on fait de même (texte casté).
fn push_numeric(b: &mut sqlx::query_builder::Separated<'_, '_, Postgres, &'static str>, v: Option<f64>) {
b.push("CAST(");
b.push_bind_unseparated(v.map(js::numeric_text));
b.push_unseparated(" AS numeric)");
}
/// Persiste les lignes calculées pour un fournisseur.
///
/// Stratégie : toutes les lignes du lot portent le même `computedAt`, puis on supprime
/// celles du fournisseur restées sur un `computedAt` antérieur. Cela purge les articles
/// disparus du catalogue sans avoir à passer des centaines de codein en paramètres.
pub async fn upsert_grid_rows(code_fournisseur: &str, rows: &[ProductRow]) -> AppResult<usize> {
if code_fournisseur.is_empty() || rows.is_empty() {
return Ok(0);
}
let values: Vec<&ProductRow> = rows.iter().filter(|r| !r.codein.is_empty()).collect();
upsert_lots(code_fournisseur, values.len(), |plage| values[plage].iter().map(|r| (*r).clone()).collect()).await
}
/// Même écriture depuis les lignes partagées du cache mémoire : chaque lot est
/// copié sous verrou, puis écrit sans le tenir (pas de copie intégrale d'un
/// fournisseur de 130 000 articles).
pub async fn upsert_grid_rows_shared(code_fournisseur: &str, rows: &super::rows_cache::GridRows) -> AppResult<usize> {
let indices: Vec<usize> = {
let lignes = rows.rows.read();
if code_fournisseur.is_empty() || lignes.is_empty() {
return Ok(0);
}
lignes.iter().enumerate().filter(|(_, r)| !r.codein.is_empty()).map(|(i, _)| i).collect()
};
upsert_lots(code_fournisseur, indices.len(), |plage| {
let lignes = rows.rows.read();
indices[plage].iter().filter_map(|i| lignes.get(*i).cloned()).collect()
})
.await
}
/// Écrit `nb` lignes par lots de [`CHUNK_UPSERT`] (`lot(plage)` fournit les lignes
/// d'une plage), puis purge les lignes du fournisseur restées sur un calcul antérieur.
async fn upsert_lots(code_fournisseur: &str, nb: usize, lot: impl Fn(std::ops::Range<usize>) -> Vec<ProductRow>) -> AppResult<usize> {
if nb == 0 {
return Ok(0);
}
let pool = db::pool()?;
let computed_at = maintenant_utc_ms();
let mut written = 0usize;
for debut in (0..nb).step_by(CHUNK_UPSERT) {
let batch = lot(debut..(debut + CHUNK_UPSERT).min(nb));
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new(
"insert into grid_rows (code_fournisseur, codein, nom_fournisseur, libelle1, gtin, reference, \
code_centrale, code1, code2, code3, code_gamme, code_gamme_init, total_ca, total_quantite, \
total_marge, taux_marge, stock_actuel, ca_reseau, qte_reseau, nb_magasins_reseau, \
ca_par_magasin_reseau, marge_pct_reseau, payload, computed_at) ",
);
let mut erreur_payload = None;
qb.push_values(batch.iter(), |mut b, r| {
let mut payload = r.clone();
payload.payload_version = Some(GRID_PAYLOAD_VERSION);
let payload = serde_json::to_value(&payload).unwrap_or_else(|e| {
erreur_payload = Some(e.to_string());
Value::Null
});
b.push_bind(code_fournisseur.to_string())
.push_bind(r.codein.clone())
.push_bind(r.nom_fournisseur.clone())
.push_bind(r.libelle1.clone())
.push_bind(r.gtin.clone())
.push_bind(r.reference.clone())
.push_bind(r.code_centrale.clone())
.push_bind(r.code1.clone())
.push_bind(r.code2.clone())
.push_bind(r.code3.clone())
.push_bind(r.code_gamme.clone())
.push_bind(r.code_gamme_init.clone());
push_numeric(&mut b, Some(r.total_ca));
push_numeric(&mut b, Some(r.total_quantite));
push_numeric(&mut b, Some(r.total_marge));
push_numeric(&mut b, Some(r.taux_marge));
push_numeric(&mut b, r.stock_actuel);
push_numeric(&mut b, r.ca_reseau);
push_numeric(&mut b, r.qte_reseau);
// Colonne integer : le TS passe le nombre tel quel.
b.push("CAST(");
b.push_bind_unseparated(r.nb_magasins_reseau.map(js::numeric_text));
b.push_unseparated(" AS integer)");
push_numeric(&mut b, r.ca_par_magasin_reseau);
push_numeric(&mut b, r.marge_pct_reseau);
b.push_bind(payload).push_bind(computed_at);
});
if let Some(e) = erreur_payload {
return Err(AppError::internal(e));
}
qb.push(
" on conflict (code_fournisseur, codein) do update set \
nom_fournisseur = excluded.nom_fournisseur, libelle1 = excluded.libelle1, gtin = excluded.gtin, \
reference = excluded.reference, code_centrale = excluded.code_centrale, code1 = excluded.code1, \
code2 = excluded.code2, code3 = excluded.code3, code_gamme = excluded.code_gamme, \
code_gamme_init = excluded.code_gamme_init, total_ca = excluded.total_ca, \
total_quantite = excluded.total_quantite, total_marge = excluded.total_marge, \
taux_marge = excluded.taux_marge, stock_actuel = excluded.stock_actuel, ca_reseau = excluded.ca_reseau, \
qte_reseau = excluded.qte_reseau, nb_magasins_reseau = excluded.nb_magasins_reseau, \
ca_par_magasin_reseau = excluded.ca_par_magasin_reseau, marge_pct_reseau = excluded.marge_pct_reseau, \
payload = excluded.payload, computed_at = excluded.computed_at",
);
qb.build().execute(&pool).await?;
written += batch.len();
}
// Purge des articles qui ne font plus partie du catalogue du fournisseur.
sqlx::query("delete from grid_rows where code_fournisseur = $1 and computed_at < $2")
.bind(code_fournisseur)
.bind(computed_at)
.execute(&pool)
.await?;
Ok(written)
}
/// Instantané complet d'un fournisseur, tel que persisté par le dernier calcul.
pub struct GridSnapshot {
pub rows: Vec<ProductRow>,
/// Calcul le plus ancien des lignes (UTC).
pub computed_at: NaiveDateTime,
}
/// Instantané complet d'un fournisseur, tel que persisté par le dernier calcul.
///
/// Renvoie `None` (→ recalcul) si rien n'est persisté, si une ligne est d'une
/// version de format antérieure, ou si le calcul date d'un autre mois que le mois
/// en cours : la fenêtre des 12 mois glissants aurait changé entre-temps.
/// Les lignes sont rendues dans l'ordre du calcul en direct (identifiant article).
pub async fn read_grid_snapshot(code_fournisseur: &str) -> AppResult<Option<GridSnapshot>> {
let pool = db::pool()?;
let found = sqlx::query("select payload, computed_at from grid_rows where code_fournisseur = $1")
.bind(code_fournisseur)
.fetch_all(&pool)
.await?;
if found.is_empty() {
return Ok(None);
}
let mut oldest: Option<NaiveDateTime> = None;
let mut rows = Vec::with_capacity(found.len());
for r in &found {
let payload: Value = r.try_get("payload")?;
if payload.get("payloadVersion").and_then(Value::as_f64) != Some(GRID_PAYLOAD_VERSION) {
return Ok(None);
}
let row: ProductRow = match serde_json::from_value(payload) {
Ok(row) => row,
Err(e) => {
tracing::warn!("[grid-store] payload illisible pour {code_fournisseur} : {}", crate::error::short(&e, 200));
return Ok(None);
}
};
let computed_at: NaiveDateTime = r.try_get("computed_at")?;
if oldest.map(|o| computed_at < o).unwrap_or(true) {
oldest = Some(computed_at);
}
rows.push(row);
}
let Some(oldest) = oldest else { return Ok(None) };
// Comparaison en heure locale, comme `getFullYear()` / `getMonth()`.
let local = chrono::Local.from_utc_datetime(&oldest);
let now = chrono::Local::now();
if local.year() != now.year() || local.month() != now.month() {
return Ok(None);
}
rows.sort_by(|a, b| {
let (x, y) = (a.noid.unwrap_or(0.0), b.noid.unwrap_or(0.0));
(x - y).partial_cmp(&0.0).unwrap_or(std::cmp::Ordering::Equal)
});
Ok(Some(GridSnapshot { rows, computed_at: oldest }))
}
/// Reporte des gammes enregistrées dans l'instantané persisté (colonne et payload),
/// pour que /api/v1 les voie sans attendre un recalcul complet de la grille.
pub async fn update_grid_rows_gamme<'a>(code_fournisseur: &str, changes: impl IntoIterator<Item = (&'a str, &'a str)>) -> AppResult<()> {
// Map TS : ordre de première apparition de chaque gamme.
let mut par_gamme: Vec<(String, Vec<String>)> = Vec::new();
for (codein, gamme) in changes {
match par_gamme.iter_mut().find(|(g, _)| g == gamme) {
Some((_, codeins)) => codeins.push(codein.to_string()),
None => par_gamme.push((gamme.to_string(), vec![codein.to_string()])),
}
}
if par_gamme.is_empty() {
return Ok(());
}
let pool = db::pool()?;
for (gamme, codeins) in &par_gamme {
for lot in codeins.chunks(CHUNK_CODEINS) {
sqlx::query(
"update grid_rows set code_gamme = $1, payload = jsonb_set(payload, '{codeGamme}', to_jsonb($2::text)) \
where code_fournisseur = $3 and codein = any($4)",
)
.bind(gamme)
.bind(gamme)
.bind(code_fournisseur)
.bind(lot)
.execute(&pool)
.await?;
}
}
Ok(())
}
/// Construit la clause WHERE commune à la grille et à la recherche transversale.
fn push_where(qb: &mut QueryBuilder<'_, Postgres>, q: &GridQuery) {
let mut premiere = true;
let mut et = |qb: &mut QueryBuilder<'_, Postgres>| {
qb.push(if premiere { " where (" } else { " and " });
premiere = false;
};
if let Some(v) = renseigne(&q.code_fournisseur) {
et(qb);
qb.push("code_fournisseur = ").push_bind(v.to_string());
}
if let Some(v) = renseigne(&q.gamme) {
et(qb);
qb.push("code_gamme = ").push_bind(v.to_string());
}
if let Some(v) = renseigne(&q.code1) {
et(qb);
qb.push("code1 = ").push_bind(v.to_string());
}
if let Some(v) = renseigne(&q.code2) {
et(qb);
qb.push("code2 = ").push_bind(v.to_string());
}
if let Some(v) = renseigne(&q.code3) {
et(qb);
qb.push("code3 = ").push_bind(v.to_string());
}
// Filtre par **préfixe** de nomenclature : « 32 », « 3202 » ou « 320211 ».
// C'est celui à utiliser avec /api/v1/nomenclatures, car il ne dépend pas de
// code1/code2 — parfois vides quand la hiérarchie n'a pas pu être remontée.
if let Some(v) = renseigne(&q.nomenclature) {
et(qb);
qb.push("code3 like ").push_bind(format!("{v}%"));
}
if let Some(search) = q.search.as_deref().map(js::js_trim).filter(|s| !s.is_empty()) {
let pattern = format!("%{search}%");
et(qb);
qb.push("(libelle1 ilike ")
.push_bind(pattern.clone())
.push(" or codein ilike ")
.push_bind(pattern.clone())
.push(" or gtin ilike ")
.push_bind(pattern.clone())
.push(" or reference ilike ")
.push_bind(pattern.clone())
.push(" or code_centrale ilike ")
.push_bind(pattern)
.push(")");
}
if !premiere {
qb.push(")");
}
}
/// Ligne exposée par l'API : la version de format est un détail interne.
fn vers_ligne_api(payload: Value) -> AppResult<ProductRow> {
let mut ligne: ProductRow =
serde_json::from_value(payload).map_err(|e| AppError::internal(format!("payload grid_rows illisible : {e}")))?;
ligne.payload_version = None;
Ok(ligne)
}
/// Lit les lignes de grille persistées : filtre, tri et pagination faits en SQL.
/// Ne déclenche aucun recalcul et ne contacte jamais Qlik.
pub async fn query_grid_rows(q: &GridQuery) -> AppResult<GridQueryResult> {
let pool = db::pool()?;
let page = q.page.unwrap_or(1).max(1);
// `limit` absent = aucune limite : on renvoie toutes les lignes du filtre.
let limit = q.limit.map(|l| l.max(1));
let sort_col = q.sort.unwrap_or(GridSortKey::TotalCa).column();
let sens = if q.order == Some(SortOrder::Asc) { "asc" } else { "desc" };
let mut selection: QueryBuilder<Postgres> = QueryBuilder::new("select payload, computed_at from grid_rows");
push_where(&mut selection, q);
// Mêmes ORDER BY que drizzle (`desc(col)`, `asc(codein)`).
selection.push(format!(" order by {sort_col} {sens}, codein asc"));
if let Some(l) = limit {
selection.push(" limit ").push_bind(l).push(" offset ").push_bind((page - 1) * l);
}
let mut comptage: QueryBuilder<Postgres> = QueryBuilder::new("select count(*)::int as total from grid_rows");
push_where(&mut comptage, q);
// Comptage et page en parallèle : deux connexions du pool, un seul temps d'attente.
let (count_row, found) = tokio::try_join!(comptage.build().fetch_one(&pool), selection.build().fetch_all(&pool))?;
let mut oldest: Option<NaiveDateTime> = None;
let mut rows = Vec::with_capacity(found.len());
for r in &found {
if let Ok(Some(c)) = r.try_get::<Option<NaiveDateTime>, _>("computed_at") {
if oldest.map(|o| c < o).unwrap_or(true) {
oldest = Some(c);
}
}
rows.push(vers_ligne_api(r.try_get("payload")?)?);
}
Ok(GridQueryResult { rows, total: count_row.try_get::<i32, _>("total").map(i64::from).unwrap_or(0), computed_at: oldest.map(db::iso) })
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridRowFound {
pub row: ProductRow,
pub computed_at: Option<String>,
}
/// Fiche complète d'un produit. `code_fournisseur` lève l'ambiguïté d'un article multi-fournisseurs.
pub async fn get_grid_row_by_codein(codein: &str, code_fournisseur: Option<&str>) -> AppResult<Option<GridRowFound>> {
let pool = db::pool()?;
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new("select payload, computed_at from grid_rows where (codein = ");
qb.push_bind(codein.to_string());
if let Some(f) = code_fournisseur.filter(|f| !f.is_empty()) {
qb.push(" and code_fournisseur = ").push_bind(f.to_string());
}
qb.push(") order by computed_at desc limit 1");
let Some(found) = qb.build().fetch_optional(&pool).await? else {
return Ok(None);
};
let computed_at: Option<NaiveDateTime> = found.try_get("computed_at").ok().flatten();
Ok(Some(GridRowFound { row: vers_ligne_api(found.try_get("payload")?)?, computed_at: computed_at.map(db::iso) }))
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridSupplierRef {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
}
/// Fournisseurs chez lesquels un article figure dans l'instantané.
pub async fn list_grid_suppliers_for_codein(codein: &str) -> AppResult<Vec<GridSupplierRef>> {
let pool = db::pool()?;
let rows = sqlx::query("select code_fournisseur, nom_fournisseur from grid_rows where codein = $1 order by code_fournisseur asc")
.bind(codein)
.fetch_all(&pool)
.await?;
Ok(rows
.iter()
.map(|r| GridSupplierRef {
code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
})
.collect())
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GammeInstantane {
/// Gamme courante, modifications enregistrées comprises.
pub code_gamme: Option<String>,
/// Gamme serveur au moment du calcul de la grille.
pub code_gamme_init: Option<String>,
pub nom_fournisseur: Option<String>,
}
/// Gammes d'articles d'un fournisseur dans l'instantané. Un codein absent de la
/// carte n'appartient pas à ce fournisseur (ou n'a pas encore été calculé).
pub async fn get_grid_rows_gammes(code_fournisseur: &str, codeins: &[String]) -> AppResult<HashMap<String, GammeInstantane>> {
let mut out = HashMap::new();
if codeins.is_empty() {
return Ok(out);
}
let pool = db::pool()?;
for lot in codeins.chunks(CHUNK_CODEINS) {
let found = sqlx::query(
"select codein, code_gamme, code_gamme_init, nom_fournisseur from grid_rows \
where code_fournisseur = $1 and codein = any($2)",
)
.bind(code_fournisseur)
.bind(lot)
.fetch_all(&pool)
.await?;
for r in &found {
out.insert(
r.try_get::<String, _>("codein").unwrap_or_default(),
GammeInstantane {
code_gamme: r.try_get("code_gamme").ok().flatten(),
code_gamme_init: r.try_get("code_gamme_init").ok().flatten(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
},
);
}
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridFreshness {
pub row_count: i64,
pub computed_at: Option<String>,
}
/// Indique si un fournisseur a déjà été calculé au moins une fois.
/// Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long.
pub async fn get_grid_freshness(code_fournisseur: &str) -> AppResult<Option<GridFreshness>> {
let pool = db::pool()?;
let found =
sqlx::query("select count(*)::int as row_count, max(computed_at) as computed_at from grid_rows where code_fournisseur = $1")
.bind(code_fournisseur)
.fetch_one(&pool)
.await?;
let row_count = found.try_get::<i32, _>("row_count").map(i64::from).unwrap_or(0);
if row_count == 0 {
return Ok(None);
}
let computed_at: Option<NaiveDateTime> = found.try_get("computed_at").ok().flatten();
Ok(Some(GridFreshness { row_count, computed_at: computed_at.map(db::iso) }))
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridSupplier {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub row_count: i64,
pub computed_at: Option<String>,
}
/// Fournisseurs présents dans l'instantané, avec leur fraîcheur.
pub async fn list_grid_suppliers() -> AppResult<Vec<GridSupplier>> {
let pool = db::pool()?;
let found = sqlx::query(
"select code_fournisseur, max(nom_fournisseur) as nom_fournisseur, count(*)::int as row_count, \
max(computed_at) as computed_at from grid_rows group by code_fournisseur order by code_fournisseur asc",
)
.fetch_all(&pool)
.await?;
Ok(found
.iter()
.map(|r| GridSupplier {
code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
row_count: r.try_get::<i32, _>("row_count").map(i64::from).unwrap_or(0),
computed_at: r.try_get::<Option<NaiveDateTime>, _>("computed_at").ok().flatten().map(db::iso),
})
.collect())
}
// ---------------------------------------------------------------------------
// Inventaire des nomenclatures d'un fournisseur
// ---------------------------------------------------------------------------
/// Un poste de nomenclature, avec son poids — de quoi décider par où découper.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridNomenclature {
pub code: String,
/// Libellé du poste, lu dans le payload (il n'a pas de colonne dédiée).
pub libelle: Option<String>,
pub nb_articles: i64,
#[serde(serialize_with = "js::num::serialize")]
pub total_ca: f64,
#[serde(serialize_with = "js::num::serialize")]
pub total_quantite: f64,
}
/// Longueur du code pour chaque niveau : « 32 » → « 3202 » → « 320211 ».
pub const NOMENCLATURE_LONGUEURS: [(u8, i32); 3] = [(1, 2), (2, 4), (3, 6)];
/// Longueur du code d'un niveau (1, 2 ou 3).
pub fn nomenclature_longueur(niveau: u8) -> i32 {
NOMENCLATURE_LONGUEURS.iter().find(|(n, _)| *n == niveau).map(|(_, l)| *l).unwrap_or(2)
}
/// Répartition des articles d'un fournisseur par niveau de nomenclature.
///
/// Sans cela, un appelant qui veut découper un gros fournisseur par nomenclature
/// est coincé : le filtre existe sur `/grid`, mais rien ne lui dit **quelles**
/// valeurs existent — il devrait tout télécharger pour les découvrir, ce qui
/// annule le bénéfice du découpage.
///
/// Les niveaux sont dérivés par **préfixe de `code3`** (codes hiérarchiques à 6
/// chiffres, de 31xxxx à 40xxxx), et non des colonnes `code1`/`code2`. Celles-ci
/// ne sont renseignées que si `pgGetNomenclatureByFournisseur` a su remonter la
/// hiérarchie ; quand elle échoue, seul `code3` est rempli. Le préfixe, lui, est
/// toujours disponible.
///
/// Tout est agrégé en SQL : la réponse fait quelques dizaines de lignes, même pour
/// un fournisseur de 130 000 articles.
///
/// Écart volontaire avec le TS : drizzle répétait `left(code3, $n)` dans le
/// GROUP BY avec un **autre** paramètre, ce que PostgreSQL refuse (« must appear
/// in the GROUP BY clause ») — la requête échouait systématiquement. On groupe
/// ici par position (`group by 1`), même résultat attendu.
pub async fn list_grid_nomenclatures(code_fournisseur: &str, niveau: u8, parent: Option<&str>) -> AppResult<Vec<GridNomenclature>> {
let pool = db::pool()?;
let longueur = nomenclature_longueur(niveau);
// Les libellés n'ont pas de colonne dédiée : on les lit dans le payload.
// Aux niveaux 1 et 2 ils peuvent être absents (même cause que ci-dessus) ;
// le code et les compteurs, eux, restent justes.
let libelle_key = match niveau {
1 => "libelleNiveau1",
2 => "libelleNiveau2",
_ => "libelle3",
};
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new("select left(code3, ");
qb.push_bind(longueur)
.push(") as code, max(payload ->> ")
.push_bind(libelle_key)
.push(") as libelle, count(*)::int as nb_articles, coalesce(sum(total_ca), 0)::float as total_ca, ")
.push("coalesce(sum(total_quantite), 0)::float as total_quantite from grid_rows where (code_fournisseur = ")
.push_bind(code_fournisseur.to_string())
.push(" and code3 is not null and length(code3) >= ")
.push_bind(longueur);
// `parent` est lui aussi un préfixe : « 32 » pour descendre au niveau 2.
if let Some(p) = parent.filter(|p| !p.is_empty()) {
qb.push(" and code3 like ").push_bind(format!("{p}%"));
}
qb.push(") group by 1 order by coalesce(sum(total_ca), 0) desc");
let rows = qb.build().fetch_all(&pool).await?;
Ok(rows
.iter()
.map(|r| GridNomenclature {
code: db::string_or_empty(r, "code"),
libelle: r.try_get("libelle").ok().flatten(),
nb_articles: db::f64_or0(r, "nb_articles") as i64,
total_ca: db::f64_or0(r, "total_ca"),
total_quantite: db::f64_or0(r, "total_quantite"),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn liste_blanche_du_tri() {
assert_eq!(GRID_SORT_KEYS.len(), SORTABLE.len());
for (i, (_, k, _)) in SORTABLE.iter().enumerate() {
assert_eq!(GRID_SORT_KEYS[i], *k);
assert_eq!(GridSortKey::from_key(k).unwrap().as_str(), *k);
}
assert_eq!(GridSortKey::from_key("total_ca; drop table x"), None);
assert_eq!(GridSortKey::TotalCa.column(), "total_ca");
}
#[test]
fn clause_where() {
let q = GridQuery {
code_fournisseur: Some("F1".into()),
gamme: Some(String::new()),
nomenclature: Some("32".into()),
search: Some(" bougie ".into()),
..Default::default()
};
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new("select 1 from grid_rows");
push_where(&mut qb, &q);
assert_eq!(
qb.sql(),
"select 1 from grid_rows where (code_fournisseur = $1 and code3 like $2 and (libelle1 ilike $3 or codein ilike $4 or gtin ilike $5 or reference ilike $6 or code_centrale ilike $7))"
);
let mut vide: QueryBuilder<Postgres> = QueryBuilder::new("select 1 from grid_rows");
push_where(&mut vide, &GridQuery::default());
assert_eq!(vide.sql(), "select 1 from grid_rows");
}
#[test]
fn longueurs_de_nomenclature() {
assert_eq!(nomenclature_longueur(1), 2);
assert_eq!(nomenclature_longueur(2), 4);
assert_eq!(nomenclature_longueur(3), 6);
}
}
+198
View File
@@ -0,0 +1,198 @@
//! Complément des chiffres d'un magasin par l'API FF (ventes mensuelles) — partie
//! pure de `src/features/grid/lib/store-patch.ts`.
//!
//! Le calcul SQL de la Grille peut manquer les ventes d'un magasin pour certains
//! articles (quantité à 0 pour ce magasin). L'API FF les connaît : on en tire,
//! pour ces articles seulement, les séries et totaux du magasin.
use super::types::{ProductRow, StorePatchEntry};
use crate::ff::api::FfMensuelEntry;
use crate::ff::js::js_number_or0;
use std::collections::HashMap;
/// Chiffres du magasin tirés de l'API, pour les articles où elle montre des ventes.
///
/// `retenus` : codes article des candidats retenus (cf. [`store_patch_candidates`]).
pub fn build_store_patch_entries(
retenus: &[String],
magasin: &str,
periods: &[String],
mensuel: &HashMap<String, Vec<FfMensuelEntry>>,
) -> Vec<StorePatchEntry> {
struct Valeurs {
qty: f64,
ca: f64,
marge: f64,
stock: f64,
receptions: f64,
}
let mut resultat = Vec::new();
for codein in retenus {
let entries: Vec<&FfMensuelEntry> =
mensuel.get(codein).map(|v| v.iter().filter(|e| e.site == magasin).collect()).unwrap_or_default();
if entries.is_empty() {
continue;
}
let mut by_period: HashMap<String, Valeurs> = HashMap::new();
for entry in entries {
let period = entry.mois.replacen('-', "", 1);
if !periods.contains(&period) {
continue;
}
let ventes = entry.ventes.as_ref();
// 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.
let qty = -ventes.map(|v| js_number_or0(&v.qte_vendue)).unwrap_or(0.0);
let ca = -ventes.map(|v| js_number_or0(&v.ca_ht)).unwrap_or(0.0);
let marge = ventes.map(|v| js_number_or0(&v.marge)).unwrap_or(0.0);
let stock = js_number_or0(&entry.stock_fin_mois);
let receptions = entry.receptions.as_ref().map(|r| js_number_or0(&r.qte_recue)).unwrap_or(0.0);
by_period.insert(period, Valeurs { qty, ca, marge, stock, receptions });
}
let api_qty: f64 = by_period.values().map(|v| v.qty).sum();
if api_qty == 0.0 {
continue;
}
let mut e = StorePatchEntry { codein: codein.clone(), ..Default::default() };
let mut last_stock = 0.0;
for period in periods {
let value = by_period.get(period);
e.sales.insert(period.clone(), value.map(|v| v.qty).unwrap_or(0.0));
e.receptions.insert(period.clone(), value.map(|v| v.receptions).unwrap_or(0.0));
if let Some(v) = value {
last_stock = v.stock;
}
e.stock.insert(period.clone(), last_stock);
e.quantite += value.map(|v| v.qty).unwrap_or(0.0);
e.ca += value.map(|v| v.ca).unwrap_or(0.0);
e.marge += value.map(|v| v.marge).unwrap_or(0.0);
}
resultat.push(e);
}
resultat
}
/// Articles à compléter : connus de FF (noid) et sans aucune vente SQL dans ce magasin.
pub fn store_patch_candidates<'a>(rows: &'a [ProductRow], magasin: &str) -> Vec<&'a ProductRow> {
rows.iter()
.filter(|row| {
row.noid.map(|n| n != 0.0 && !n.is_nan()).unwrap_or(false)
&& row.quantite_by_store.as_ref().and_then(|m| m.get(magasin)).copied().unwrap_or(0.0) == 0.0
})
.collect()
}
/// Application historique, côté serveur (`/api/grid/rows?magasin=…`) : corrige le
/// magasin ET reporte les écarts sur les totaux « tous magasins » de la ligne.
/// Renvoie le nombre de lignes corrigées.
pub fn apply_store_patch_in_place(rows: &mut [ProductRow], magasin: &str, periods: &[String], entries: &[StorePatchEntry]) -> usize {
let par_code: HashMap<&str, &StorePatchEntry> = entries.iter().map(|e| (e.codein.as_str(), e)).collect();
let mut corrigees = 0;
for row in rows.iter_mut() {
let Some(e) = par_code.get(row.codein.as_str()) else { continue };
let sales = row.sales12m_by_store.get_or_insert_with(Default::default).entry(magasin.to_string()).or_default();
let mut deltas_ventes = Vec::with_capacity(periods.len());
for period in periods {
let current = sales.get(period).copied().unwrap_or(0.0);
let next = e.sales.get(period).copied().unwrap_or(0.0);
sales.insert(period.clone(), next);
deltas_ventes.push((period, next - current));
}
let receptions = row.receptions12m_by_store.get_or_insert_with(Default::default).entry(magasin.to_string()).or_default();
for period in periods {
receptions.insert(period.clone(), e.receptions.get(period).copied().unwrap_or(0.0));
}
let stock = row.stock12m_by_store.get_or_insert_with(Default::default).entry(magasin.to_string()).or_default();
for period in periods {
stock.insert(period.clone(), e.stock.get(period).copied().unwrap_or(0.0));
}
for (period, delta) in deltas_ventes {
let v = row.sales12m.get(period).copied().unwrap_or(0.0);
row.sales12m.insert(period.clone(), v + delta);
}
let ca_by_store = row.ca_by_store.get_or_insert_with(Default::default);
let quantite_by_store = row.quantite_by_store.get_or_insert_with(Default::default);
let marge_by_store = row.marge_by_store.get_or_insert_with(Default::default);
let delta_qty = e.quantite - quantite_by_store.get(magasin).copied().unwrap_or(0.0);
let delta_ca = e.ca - ca_by_store.get(magasin).copied().unwrap_or(0.0);
let delta_marge = e.marge - marge_by_store.get(magasin).copied().unwrap_or(0.0);
quantite_by_store.insert(magasin.to_string(), e.quantite);
ca_by_store.insert(magasin.to_string(), e.ca);
marge_by_store.insert(magasin.to_string(), e.marge);
row.total_quantite += delta_qty;
row.total_ca += delta_ca;
row.total_marge += delta_marge;
row.taux_marge = if row.total_ca > 0.0 { (row.total_marge / row.total_ca) * 100.0 } else { 0.0 };
if !row.working_stores.iter().any(|s| s == magasin) {
row.working_stores.push(magasin.to_string());
}
corrigees += 1;
}
corrigees
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn complement_depuis_l_api() {
let periods = vec!["202601".to_string(), "202602".to_string(), "202603".to_string()];
let entree = |mois: &str, site: &str, qte: &str| -> FfMensuelEntry {
serde_json::from_value(serde_json::json!({
"mois": mois, "site": site, "stock_fin_mois": "5.000",
"ventes": { "qte_vendue": qte, "ca_ht": "-26.66", "marge": "12.76" }, "receptions": null
}))
.unwrap()
};
let mut mensuel = HashMap::new();
mensuel.insert(
"A1".to_string(),
vec![entree("2026-01", "292", "-2.000"), entree("2026-02", "579", "-9"), entree("2025-01", "292", "-4")],
);
mensuel.insert("A2".to_string(), vec![entree("2026-01", "292", "0")]);
let e = build_store_patch_entries(&["A1".into(), "A2".into(), "A3".into()], "292", &periods, &mensuel);
assert_eq!(e.len(), 1, "A2 sans vente, A3 sans donnée");
assert_eq!(e[0].quantite, 2.0);
assert!((e[0].ca - 26.66).abs() < 1e-9);
assert_eq!(e[0].sales["202602"], 0.0);
assert_eq!(e[0].stock["202603"], 5.0, "stock reporté");
assert_eq!(serde_json::to_value(&e[0]).unwrap()["receptions"], serde_json::json!({ "202601": 0, "202602": 0, "202603": 0 }));
}
#[test]
fn complement_reporte_sur_les_totaux() {
let periods = vec!["202601".to_string(), "202602".to_string()];
let mut row = ProductRow { codein: "A1".into(), noid: Some(7.0), ..Default::default() };
row.sales12m.insert("202601".into(), 4.0);
row.total_quantite = 4.0;
row.total_ca = 40.0;
row.total_marge = 10.0;
row.working_stores = vec!["292".into()];
let mut q = std::collections::BTreeMap::new();
q.insert("292".to_string(), 4.0);
row.quantite_by_store = Some(q);
assert_eq!(store_patch_candidates(std::slice::from_ref(&row), "579").len(), 1);
assert_eq!(store_patch_candidates(std::slice::from_ref(&row), "292").len(), 0);
let mut e = StorePatchEntry { codein: "A1".into(), quantite: 3.0, ca: 30.0, marge: 6.0, ..Default::default() };
e.sales.insert("202602".into(), 3.0);
e.stock.insert("202601".into(), 5.0);
let mut rows = vec![row];
assert_eq!(apply_store_patch_in_place(&mut rows, "579", &periods, &[e]), 1);
let r = &rows[0];
assert_eq!(r.total_quantite, 7.0);
assert_eq!(r.total_ca, 70.0);
assert!((r.taux_marge - 16.0 / 70.0 * 100.0).abs() < 1e-9);
assert_eq!(r.sales12m["202602"], 3.0);
assert_eq!(r.sales12m_by_store.as_ref().unwrap()["579"]["202601"], 0.0);
assert_eq!(r.stock12m_by_store.as_ref().unwrap()["579"]["202601"], 5.0);
assert_eq!(r.working_stores, vec!["292".to_string(), "579".to_string()]);
}
}
+300
View File
@@ -0,0 +1,300 @@
//! Test d'intégration contre une vraie base PostgreSQL : `grid_rows`,
//! `session_snapshots`, `gammes_a_valider`.
//!
//! Désactivé par défaut. Pour le lancer :
//!
//! ```sh
//! GRID_TEST_DATABASE_URL=postgres://postgres:postgres@localhost/collectflow cargo test grid::tests_db
//! ```
//!
//! Les données de test portent des codes fournisseur `TSTGRID…`, supprimés avant
//! et après le passage.
use super::api_schemas::query_map;
use super::enregistrer::{enregistrer_brouillon, enregistrer_gammes, ChangementGamme, EnregistrementGammes};
use super::gammes_a_valider::*;
use super::rows_cache;
use super::snapshots::{enregistrer_snapshot, lister_snapshots, supprimer_snapshot};
use super::store::*;
use super::types::ProductRow;
use serde_json::{json, Value};
use std::time::Duration;
const F1: &str = "TSTGRID1";
const F2: &str = "TSTGRID2";
fn ligne(codein: &str, noid: f64, libelle: &str, code3: &str, ca: f64) -> ProductRow {
let mut r: ProductRow = serde_json::from_value(json!({
"codein": codein, "codeFournisseur": F1, "nomFournisseur": "FOURNISSEUR TEST",
"libelle1": libelle, "gtin": format!("370{codein}"), "reference": format!("REF-{codein}"),
"codeCentrale": format!("10000{codein}"),
"code1": &code3[..2], "libelleNiveau1": "Univers", "code2": &code3[..4], "libelleNiveau2": "Famille",
"code3": code3, "libelle3": format!("Sous-famille {code3}"),
"codeGamme": "B", "codeGammeInit": "B", "codeGammeDraft": null,
"sales12m": { "202509": 1, "202510": 2 }, "stock12m": { "202509": 5, "202510": 4 },
"receptions12m": { "202509": 0, "202510": 0 },
"totalQuantite": 3, "totalCa": ca, "totalMarge": ca / 4.0, "tauxMarge": 25,
"workingStores": ["292"], "noid": noid, "stockActuel": 4.5, "nbMagasinsReseau": 120,
"qteReseauByMonth": null, "champInconnu": { "garde": true }
}))
.unwrap();
r.libelle1 = libelle.to_string();
r
}
async fn nettoyer(pool: &sqlx::PgPool) {
for t in ["grid_rows", "session_snapshots", "gammes_a_valider"] {
sqlx::query(&format!("delete from {t} where code_fournisseur like 'TSTGRID%'")).execute(pool).await.unwrap();
}
}
#[tokio::test]
async fn grid_rows_snapshots_gammes_a_valider() {
let Ok(url) = std::env::var("GRID_TEST_DATABASE_URL") else {
eprintln!("GRID_TEST_DATABASE_URL absent : test d'intégration ignoré.");
return;
};
let _verrou = rows_cache::TEST_LOCK.lock();
// La configuration lue par `db::pool()` : pas de fichier de réglages, l'URL de test.
let dir = std::env::temp_dir().join(format!("grid-tests-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("DATA_DIR", &dir);
std::env::set_var("DATABASE_URL", &url);
// Une autre base a pu servir à un test précédent du même processus.
crate::db::refresh();
let pool = crate::db::pool().unwrap();
sqlx::raw_sql(include_str!("../../sql/init.sql")).execute(&pool).await.unwrap();
nettoyer(&pool).await;
// ── upsert / lecture de l'instantané ────────────────────────────────────
let rows = vec![
ligne("900002", 20.0, "Vase en verre", "320212", 50.0),
ligne("900001", 10.0, "Bougie parfumée", "320211", 150.0),
ProductRow { codein: String::new(), ..Default::default() },
ligne("900003", 5.0, "Cadre photo", "330101", 0.125),
];
assert_eq!(upsert_grid_rows(F1, &rows).await.unwrap(), 3);
assert_eq!(upsert_grid_rows("", &rows).await.unwrap(), 0);
let snap = read_grid_snapshot(F1).await.unwrap().expect("instantané");
let codes: Vec<&str> = snap.rows.iter().map(|r| r.codein.as_str()).collect();
assert_eq!(codes, vec!["900003", "900001", "900002"], "tri par noid");
assert_eq!(snap.rows[1].payload_version, Some(GRID_PAYLOAD_VERSION));
let mut relu = serde_json::to_value(&snap.rows[1]).unwrap();
relu.as_object_mut().unwrap().remove("payloadVersion");
assert_eq!(relu, serde_json::to_value(&rows[1]).unwrap(), "aller-retour du payload");
assert!(read_grid_snapshot("TSTGRID-ABSENT").await.unwrap().is_none());
// Colonnes scalaires (numeric arrondi par PostgreSQL).
let (ca, nb): (f64, Option<i32>) =
sqlx::query_as("select total_ca::float8, nb_magasins_reseau from grid_rows where code_fournisseur = $1 and codein = '900003'")
.bind(F1)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!((ca, nb), (0.13, Some(120)));
// Une ligne d'un ancien format n'est pas servie à la Grille.
sqlx::query("update grid_rows set payload = payload - 'payloadVersion' where code_fournisseur = $1 and codein = '900002'")
.bind(F1)
.execute(&pool)
.await
.unwrap();
assert!(read_grid_snapshot(F1).await.unwrap().is_none());
assert_eq!(upsert_grid_rows(F1, &rows).await.unwrap(), 3);
// ── requêtes filtrées / triées / paginées ──────────────────────────────
let tout = query_grid_rows(&GridQuery { code_fournisseur: Some(F1.into()), ..Default::default() }).await.unwrap();
assert_eq!(tout.total, 3);
assert_eq!(tout.rows.iter().map(|r| r.codein.as_str()).collect::<Vec<_>>(), vec!["900001", "900002", "900003"]);
assert!(tout.rows.iter().all(|r| r.payload_version.is_none()));
assert_eq!(tout.rows[0].extra.get("champInconnu"), Some(&json!({ "garde": true })));
assert!(tout.computed_at.as_deref().unwrap().ends_with('Z'));
let page2 = query_grid_rows(&GridQuery {
code_fournisseur: Some(F1.into()),
sort: Some(GridSortKey::Codein),
order: Some(SortOrder::Asc),
page: Some(2),
limit: Some(1),
..Default::default()
})
.await
.unwrap();
assert_eq!((page2.total, page2.rows.len(), page2.rows[0].codein.as_str()), (3, 1, "900002"));
let recherche =
query_grid_rows(&GridQuery { search: Some(" BOUGIE ".into()), code_fournisseur: Some(F1.into()), ..Default::default() })
.await
.unwrap();
assert_eq!(recherche.total, 1);
let par_centrale = query_grid_rows(&GridQuery { search: Some("10000900003".into()), ..Default::default() }).await.unwrap();
assert_eq!(par_centrale.rows[0].codein, "900003");
let nomenclature =
query_grid_rows(&GridQuery { code_fournisseur: Some(F1.into()), nomenclature: Some("3202".into()), ..Default::default() })
.await
.unwrap();
assert_eq!(nomenclature.total, 2);
// Une valeur hostile reste une valeur liée.
let hostile = query_grid_rows(&GridQuery { code_fournisseur: Some("x' or '1'='1".into()), ..Default::default() }).await.unwrap();
assert_eq!(hostile.total, 0);
// ── gammes, fiches, fournisseurs, fraîcheur, nomenclatures ─────────────
update_grid_rows_gamme(F1, [("900001", "A"), ("900002", "Z"), ("900003", "A")]).await.unwrap();
let gammes = get_grid_rows_gammes(F1, &["900001".into(), "900002".into(), "inconnu".into()]).await.unwrap();
assert_eq!(gammes.len(), 2);
assert_eq!(gammes["900001"].code_gamme.as_deref(), Some("A"));
assert_eq!(gammes["900001"].code_gamme_init.as_deref(), Some("B"));
let fiche = get_grid_row_by_codein("900002", Some(F1)).await.unwrap().unwrap();
assert_eq!(fiche.row.code_gamme.as_deref(), Some("Z"), "payload mis à jour");
assert!(get_grid_row_by_codein("900002", Some("TSTGRID-ABSENT")).await.unwrap().is_none());
let mut chez_f2 = ligne("900001", 1.0, "Bougie parfumée", "320211", 1.0);
chez_f2.code_fournisseur = F2.into();
upsert_grid_rows(F2, &[chez_f2]).await.unwrap();
let fournisseurs: Vec<String> =
list_grid_suppliers_for_codein("900001").await.unwrap().into_iter().map(|f| f.code_fournisseur).collect();
assert!(fournisseurs.contains(&F1.to_string()) && fournisseurs.contains(&F2.to_string()));
let tous = list_grid_suppliers().await.unwrap();
let f1 = tous.iter().find(|s| s.code_fournisseur == F1).unwrap();
assert_eq!((f1.row_count, f1.nom_fournisseur.as_deref()), (3, Some("FOURNISSEUR TEST")));
let fraicheur = get_grid_freshness(F1).await.unwrap().unwrap();
assert_eq!(fraicheur.row_count, 3);
assert!(get_grid_freshness("TSTGRID-ABSENT").await.unwrap().is_none());
let n1 = list_grid_nomenclatures(F1, 1, None).await.unwrap();
assert_eq!(n1.iter().map(|n| (n.code.as_str(), n.nb_articles)).collect::<Vec<_>>(), vec![("32", 2), ("33", 1)]);
assert_eq!(n1[0].libelle.as_deref(), Some("Univers"));
assert_eq!(n1[0].total_ca, 200.0);
let n3 = list_grid_nomenclatures(F1, 3, Some("3202")).await.unwrap();
assert_eq!(n3.iter().map(|n| n.code.as_str()).collect::<Vec<_>>(), vec!["320211", "320212"]);
assert_eq!(n3[0].libelle.as_deref(), Some("Sous-famille 320211"));
// Purge : un nouveau calcul sans l'article 900003 le retire.
tokio::time::sleep(Duration::from_millis(5)).await;
assert_eq!(upsert_grid_rows(F1, &rows[..2]).await.unwrap(), 2);
assert_eq!(get_grid_freshness(F1).await.unwrap().unwrap().row_count, 2);
// ── snapshots (actions) ────────────────────────────────────────────────
let brut = json!({ "codeFournisseur": F1, "magasin": "TOTAL", "changes": { "900001": { "before": "B", "after": "A" } } });
let anonyme = enregistrer_snapshot("0", &brut).await;
assert_eq!(anonyme["success"], true, "{anonyme}");
let id_anonyme = anonyme["snapshotId"].as_i64().unwrap();
let a_moi = enregistrer_snapshot("987654", &json!({ "codeFournisseur": F1, "magasin": "292", "type": "export", "label": "Mon export", "changes": {}, "summary": { "totalRows": 1, "totalCa": 2.5, "totalMarge": 1, "tauxMargeGlobal": 40 } })).await;
assert_eq!(a_moi["success"], true, "{a_moi}");
let id_a_moi = a_moi["snapshotId"].as_i64().unwrap();
let ids = |v: Vec<super::snapshots::SessionSnapshot>| -> Vec<i64> {
v.into_iter().filter(|s| s.code_fournisseur == F1).map(|s| s.id as i64).collect()
};
assert_eq!(ids(lister_snapshots("0", None).await.unwrap()), vec![id_anonyme]);
assert_eq!(ids(lister_snapshots("abc", None).await.unwrap()), vec![id_anonyme], "identifiant non numérique = anonyme");
assert_eq!(ids(lister_snapshots("987654", Some("export")).await.unwrap()), vec![id_a_moi]);
assert!(ids(lister_snapshots("987654", Some("snapshot")).await.unwrap()).is_empty());
let detail = lister_snapshots("987654", None).await.unwrap().into_iter().find(|s| s.id as i64 == id_a_moi).unwrap();
let detail = serde_json::to_value(detail).unwrap();
assert_eq!(detail["label"], "Mon export");
assert_eq!(detail["userId"], 987654);
assert_eq!(detail["summaryJson"], json!({ "totalRows": 1, "totalCa": 2.5, "totalMarge": 1, "tauxMargeGlobal": 40 }));
assert!(detail["createdAt"].as_str().unwrap().ends_with('Z'));
assert!(lister_snapshots("99999999999", None).await.is_err(), "hors bornes integer : erreur SQL comme en TS");
let label_defaut: (String,) =
sqlx::query_as("select label from session_snapshots where id = $1").bind(id_anonyme as i32).fetch_one(&pool).await.unwrap();
assert!(label_defaut.0.starts_with("Snapshot — "));
let invalide = enregistrer_snapshot("0", &json!({ "codeFournisseur": F1 })).await;
assert_eq!(
invalide,
json!({ "success": false, "error": "Validation failed: Invalid input: expected string, received undefined, Invalid input: expected record, received undefined" })
);
let trop_grand = enregistrer_snapshot("99999999999", &brut).await;
assert_eq!(trop_grand, json!({ "success": false, "error": "erreur technique sur le serveur" }));
assert_eq!(
supprimer_snapshot("0", &json!(id_a_moi)).await,
json!({ "success": false, "error": "Snapshot non trouvé ou non autorisé" })
);
assert_eq!(supprimer_snapshot("987654", &json!(id_a_moi)).await, json!({ "success": true }));
assert_eq!(supprimer_snapshot("0", &json!("abc")).await, json!({ "success": false, "error": "erreur technique sur le serveur" }));
// ── enregistrerGammes / saveDraftChanges ───────────────────────────────
rows_cache::invalidate_grid_rows_cache(None);
let en_cache = rows_cache::GridRows::new(rows[..2].to_vec());
rows_cache::cache_grid_rows(&rows_cache::grid_rows_cache_key(F1, None), en_cache.clone());
let n = enregistrer_gammes(&EnregistrementGammes {
code_fournisseur: F1.into(),
nom_fournisseur: Some("FOURNISSEUR TEST".into()),
magasin: "TOTAL".into(),
changes: vec![ChangementGamme { codein: "900002".into(), code_gamme_before: Some("B".into()), code_gamme: "C".into() }],
user_id: None,
label: None,
})
.await
.unwrap();
assert_eq!(n, 1);
let dernier: (Value, Option<String>) =
sqlx::query_as("select changes, label from session_snapshots where code_fournisseur = $1 order by created_at desc limit 1")
.bind(F1)
.fetch_one(&pool)
.await
.unwrap();
// Fusion par-dessus le dernier snapshot du fournisseur (celui de 987654 a été supprimé).
assert_eq!(dernier.0, json!({ "900001": { "before": "B", "after": "A" }, "900002": { "before": "B", "after": "C" } }));
assert!(dernier.1.unwrap().starts_with("Draft — "));
assert_eq!(en_cache.rows.read()[0].code_gamme.as_deref(), Some("C"), "cache patché (900002)");
// Le report dans grid_rows est asynchrone.
let mut vu = false;
for _ in 0..50 {
let g = get_grid_rows_gammes(F1, &["900002".into()]).await.unwrap();
if g["900002"].code_gamme.as_deref() == Some("C") {
vu = true;
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(vu, "grid_rows mis à jour en arrière-plan");
// ── gammes à valider ───────────────────────────────────────────────────
let p = |c: &str, g: &str| PropositionGamme { codein: c.into(), gamme: g.into(), gamme_precedente: Some("B".into()) };
proposer_gammes(F1, &[p("900001", "Z"), p("900002", "A")], &[], &"x".repeat(150)).await.unwrap();
proposer_gammes(F1, &[p("900002", "Y")], &["900001".into()], "api-key").await.unwrap();
let liste = lister_gammes_a_valider(F1).await.unwrap();
assert_eq!(liste.len(), 1);
assert_eq!((liste[0].codein.as_str(), liste[0].gamme.as_str(), liste[0].auteur.as_deref()), ("900002", "Y", Some("api-key")));
assert!(liste[0].proposee_le.ends_with('Z'));
let par_article = gammes_a_valider_par_article(&["900002".into(), "900009".into()]).await.unwrap();
assert_eq!(par_article.get(&format!("{F1}|900002")).map(String::as_str), Some("Y"));
proposer_gammes(F1, &[p("900001", "Z")], &[], &"x".repeat(150)).await.unwrap();
let auteur: (String,) = sqlx::query_as("select auteur from gammes_a_valider where code_fournisseur = $1 and codein = '900001'")
.bind(F1)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(auteur.0.len(), 100);
// saveDraftChanges : les propositions traitées quittent la file.
let res = enregistrer_brouillon(
"987654",
&json!({ "codeFournisseur": F1, "magasin": "TOTAL", "changes": [{ "codein": "900002", "codeGammeBefore": "C", "codeGamme": "Y" }], "gammesAValiderTraitees": ["900002"] }),
)
.await;
assert_eq!(res, json!({ "success": true, "saved": 1 }));
let restantes: Vec<String> = lister_gammes_a_valider(F1).await.unwrap().into_iter().map(|g| g.codein).collect();
assert_eq!(restantes, vec!["900001".to_string()]);
supprimer_gammes_a_valider(F1, &["900001".into()]).await.unwrap();
assert!(lister_gammes_a_valider(F1).await.unwrap().is_empty());
let invalide = enregistrer_brouillon("0", &json!({ "codeFournisseur": F1 })).await;
assert_eq!(invalide["success"], false);
assert!(invalide["error"].as_str().unwrap().starts_with("Validation failed: [\n {\n \"expected\": \"string\""));
// ── route /api/v1/nomenclatures (sans HTTP : schéma + requêtes) ────────
let params = query_map(vec![("fournisseur".into(), F1.into()), ("niveau".into(), "2".into())]);
let parsed = super::api_schemas::parse_nomenclatures_query(&params).unwrap();
let postes = list_grid_nomenclatures(&parsed.fournisseur, parsed.niveau, None).await.unwrap();
assert_eq!(postes.iter().map(|p| p.code.as_str()).collect::<Vec<_>>(), vec!["3202"]);
nettoyer(&pool).await;
rows_cache::invalidate_grid_rows_cache(None);
}
+302
View File
@@ -0,0 +1,302 @@
//! Test d'intégration du calcul de la grille (phase 2) sur une base de test
//! contenant les tables FF (`ff_fixture`), avec une fausse API FF Nancy.
//!
//! ```sh
//! GRID_FF_TEST_DATABASE_URL=postgres://postgres:postgres@localhost/ff_fixture cargo test grid::tests_ff -- --nocapture
//! ```
//!
//! Écrit dans `grid_rows`, `session_snapshots`, `gammes_a_valider`, `users` et
//! `api_keys` de la base de test (nettoyés à la fin).
use super::enrich::enrich_rows;
use super::months;
use super::product_rows::*;
use super::rows_cache;
use super::store::*;
use crate::auth::Claims;
use axum::body::Body;
use axum::extract::Path;
use axum::http::Request;
use axum::routing::get;
use axum::{Json, Router};
use serde_json::{json, Value};
use tower::ServiceExt;
const CLE_API: &str = "cf_cle-de-test-grille-phase-2";
/// Fausse API FF : seul l'historique mensuel sert au complément par magasin.
async fn fausse_api(mois: String) -> String {
let app = Router::new().route(
"/api/articles/{id}/mensuel",
get(move |Path(id): Path<String>| {
let mois = mois.clone();
async move {
Json(json!({ "data": [
{ "mois": mois, "site": "579", "stock_fin_mois": "7.000",
"ventes": { "qte_vendue": "-3.000", "ca_ht": "-30.00", "marge": "9.5" },
"receptions": { "qte_recue": "2" }, "id": id },
{ "mois": mois, "site": "292", "stock_fin_mois": "1",
"ventes": { "qte_vendue": "-100", "ca_ht": "-1", "marge": "0" } }
] }))
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}
async fn appel(req: Request<Body>) -> (u16, Vec<u8>) {
let res = super::router().oneshot(req).await.unwrap();
let status = res.status().as_u16();
(status, axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec())
}
fn get_cle(uri: &str) -> Request<Body> {
Request::builder().uri(uri).header("x-api-key", CLE_API).body(Body::empty()).unwrap()
}
fn json_de(b: &[u8]) -> Value {
serde_json::from_slice(b).unwrap_or_else(|e| panic!("JSON illisible ({e}) : {}", String::from_utf8_lossy(b)))
}
async fn nettoyer(pool: &sqlx::PgPool) {
for sql in [
"delete from grid_rows where code_fournisseur in ('F001', 'F002', 'F404')",
"delete from session_snapshots where code_fournisseur in ('F001', 'F002')",
"delete from gammes_a_valider where code_fournisseur in ('F001', 'F002')",
"delete from api_keys where name = 'test-grille'",
"delete from users where username = 'test-grille'",
] {
sqlx::query(sql).execute(pool).await.unwrap();
}
}
#[tokio::test]
async fn calcul_de_la_grille_et_routes() {
let Ok(url) = std::env::var("GRID_FF_TEST_DATABASE_URL") else {
eprintln!("GRID_FF_TEST_DATABASE_URL absent : test d'intégration du calcul ignoré.");
return;
};
let _verrou = rows_cache::TEST_LOCK.lock();
let dir = std::env::temp_dir().join(format!("grid-ff-tests-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("DATA_DIR", &dir);
std::env::set_var("DATABASE_URL", &url);
std::env::set_var("AUTH_SECRET", "secret-de-test");
crate::db::refresh();
let periods = months::get_last12_months(months::today());
let dernier_mois = months::ff_month_to_qlik(periods.last().unwrap());
std::env::set_var("FF_API_BASE_URL", fausse_api(dernier_mois).await);
crate::ff::api::reset_ff_api_base_cache();
let pool = crate::db::pool().unwrap();
sqlx::raw_sql(include_str!("../../sql/init.sql")).execute(&pool).await.unwrap();
nettoyer(&pool).await;
rows_cache::invalidate_grid_rows_cache(None);
// ── calcul complet ─────────────────────────────────────────────────────
let rows = get_product_rows(GetProductRowsInput::total("F001", false)).await.unwrap();
let lignes = rows.snapshot();
println!("── lignes calculées\n{}", serde_json::to_string_pretty(&lignes).unwrap());
assert!(!lignes.is_empty());
// `articles.codein` est un char(10) dans la base de test : codes complétés d'espaces.
let tapis = lignes.iter().find(|r| r.codein.trim() == "100001").expect("article 100001");
let code_tapis = tapis.codein.clone();
let tapis_url = urlencoding::encode(&code_tapis).into_owned();
assert_eq!(tapis.sales12m.len(), 12);
assert_eq!(tapis.code_centrale.as_deref(), Some("10000111111"));
assert_eq!(tapis.ca_reseau, Some(50000.0), "métriques réseau du cache Qlik");
assert_eq!(tapis.taux_presence_reseau, Some(1.0));
assert!(tapis.sales12m_by_store.as_ref().unwrap().contains_key("292"));
assert!(tapis.working_stores.contains(&"292".to_string()));
assert_eq!(tapis.stock_actuel, Some(4.0), "5 + -1 sur les deux sites");
assert_eq!(tapis.prix_vente, Some(12.9), "le PV le plus élevé des deux magasins");
assert!(tapis.total_quantite > 0.0 && tapis.total_ca > 0.0);
// Persistance (lancée en arrière-plan) puis instantané.
attendre_persistance("F001").await;
let fraicheur = get_grid_freshness("F001").await.unwrap().expect("instantané écrit");
assert_eq!(fraicheur.row_count as usize, lignes.len());
// Cache : même tableau partagé.
let encore = get_product_rows(GetProductRowsInput::total("F001", false)).await.unwrap();
assert!(std::sync::Arc::ptr_eq(&rows, &encore));
// Instantané resservi après invalidation du cache, gammes du snapshot reportées.
sqlx::query("insert into session_snapshots (code_fournisseur, magasin, changes, type) values ('F001', 'TOTAL', $1, 'snapshot')")
.bind(json!({ code_tapis.clone(): { "before": null, "after": "Z" } }))
.execute(&pool)
.await
.unwrap();
rows_cache::invalidate_grid_rows_cache(Some("F001"));
let servies = get_product_rows(GetProductRowsInput::total("F001", false)).await.unwrap();
assert!(!std::sync::Arc::ptr_eq(&rows, &servies));
assert_eq!(servies.len(), lignes.len());
let s = servies.snapshot();
assert_eq!(s.iter().find(|r| r.codein == code_tapis).unwrap().code_gamme.as_deref(), Some("Z"));
assert!(s.iter().all(|r| r.payload_version == Some(GRID_PAYLOAD_VERSION)), "lignes issues de grid_rows");
// Synchro : recalcul forcé, nombre de lignes.
assert_eq!(get_product_rows_for_sync("F001").await.unwrap(), lignes.len());
// ── complément par magasin (fausse API FF) ─────────────────────────────
let patch = get_store_patch("F001", "579").await.unwrap();
println!("── complément 579\n{}", serde_json::to_string_pretty(&*patch).unwrap());
assert_eq!(patch.magasin, "579");
assert_eq!(patch.periods, periods);
assert!(!patch.entries.is_empty());
assert!(patch.entries.iter().all(|e| e.quantite == 3.0 && e.ca == 30.0));
let meme = get_store_patch("F001", "579").await.unwrap();
assert!(std::sync::Arc::ptr_eq(&patch, &meme), "complément partagé");
// Vue d'un magasin : rattrapage appliqué aux lignes.
let vue_579 =
get_product_rows(GetProductRowsInput { code_fournisseur: "F001".into(), magasin: Some("579".into()), force_refresh: false })
.await
.unwrap();
assert!(vue_579.snapshot().iter().any(|r| r.quantite_by_store.as_ref().and_then(|q| q.get("579")).copied() == Some(3.0)));
// ── enrichissement /api/v1 ─────────────────────────────────────────────
let enrichies =
enrich_rows(query_grid_rows(&GridQuery { code_fournisseur: Some("F001".into()), ..Default::default() }).await.unwrap().rows).await;
let e = enrichies.iter().find(|r| r.row.codein == code_tapis).unwrap();
assert_eq!(e.network.as_ref().map(|n| n.ca_reseau), Some(50000.0));
let v = serde_json::to_value(e).unwrap();
assert!(v.get("codeGammeServeur").is_some() && v.get("gammeAValider").is_some() && v.get("trend").is_some());
assert_eq!(v["codein"], json!(code_tapis));
// ── routes ─────────────────────────────────────────────────────────────
let (id_user,): (i32,) =
sqlx::query_as("insert into users (username, password_hash, role) values ('test-grille', 'x', 'admin') returning id")
.fetch_one(&pool)
.await
.unwrap();
sqlx::query("insert into api_keys (name, key_prefix, key_hash, role) values ('test-grille', 'cf_cle', $1, 'user')")
.bind(crate::auth::api_key::hash_api_key(CLE_API))
.execute(&pool)
.await
.unwrap();
let claims =
Claims { id: id_user.to_string(), name: "test-grille".into(), role: "admin".into(), exp: chrono::Utc::now().timestamp() + 3600 };
let cookie = format!("{}={}", crate::auth::session::COOKIE_NAME, crate::auth::session::encode(&claims));
let get_session = |uri: &str| Request::builder().uri(uri).header("cookie", cookie.clone()).body(Body::empty()).unwrap();
// /api/grid/rows (NDJSON)
let (st, corps) = appel(get_session("/api/grid/rows?fournisseur=F001&magasin=TOTAL&chunkSize=1")).await;
assert_eq!(st, 200);
let messages: Vec<Value> = String::from_utf8(corps).unwrap().lines().map(|l| json_de(l.as_bytes())).collect();
assert_eq!(messages[0], json!({ "type": "start", "loaded": 0, "total": null }));
let chunks: Vec<&Value> = messages.iter().filter(|m| m["type"] == "chunk").collect();
// chunkSize=1 est relevé au minimum de 50 : un seul lot ici.
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0]["rows"].as_array().unwrap().len(), lignes.len());
assert_eq!(chunks[0]["loaded"], lignes.len());
assert_eq!(messages.last().unwrap(), &json!({ "type": "done", "loaded": lignes.len(), "total": lignes.len() }));
let (st, _) = appel(get_session("/api/grid/rows")).await;
assert_eq!(st, 400);
let (st, _) = appel(Request::builder().uri("/api/grid/rows?fournisseur=F001").body(Body::empty()).unwrap()).await;
assert_eq!(st, 401);
// /api/grid/rows/store-patch
let (st, corps) = appel(get_session("/api/grid/rows/store-patch?fournisseur=F001&magasin=579")).await;
assert_eq!(st, 200);
assert_eq!(json_de(&corps)["magasin"], "579");
let (st, corps) = appel(get_session("/api/grid/rows/store-patch?fournisseur=F001&magasin=TOTAL")).await;
assert_eq!((st, json_de(&corps)), (400, json!({ "error": "Paramètres fournisseur et magasin requis." })));
// /api/v1/grid : diffusion complète, puis page
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F001")).await;
assert_eq!(st, 200);
let tout = json_de(&corps);
assert_eq!(tout["data"].as_array().unwrap().len(), lignes.len());
assert_eq!(tout["meta"]["diffuse"], true);
assert_eq!(tout["meta"]["complet"], true);
assert_eq!(tout["pagination"]["total"], lignes.len());
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F001&limit=1&fields=totalCa&sort=codein&order=asc")).await;
assert_eq!(st, 200);
let page = json_de(&corps);
assert_eq!(page["data"][0].as_object().unwrap().keys().collect::<Vec<_>>(), vec!["totalCa", "codein"]);
assert_eq!(page["meta"]["complet"], json!(lignes.len() <= 1));
if lignes.len() > 1 {
assert!(page["meta"]["avertissement"].as_str().unwrap().contains("page=2"));
}
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F002&compute=0")).await;
assert_eq!((st, json_de(&corps)["error"]["code"].clone()), (202, json!("not_ready")));
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F002")).await;
let f002 = json_de(&corps);
assert_eq!(st, 200, "{f002}");
assert_eq!(f002["meta"]["computedOnDemand"], true);
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F404")).await;
assert_eq!((st, json_de(&corps)["error"]["code"].clone()), (404, json!("not_found")));
// /api/v1/products/*
let (st, corps) = appel(get_cle("/api/v1/products/search?q=tapis&fields=libelle1")).await;
assert_eq!(st, 200);
let rech = json_de(&corps);
assert_eq!(rech["data"][0]["codein"], json!(code_tapis));
assert_eq!(rech["meta"]["scope"], "tous fournisseurs");
let (st, corps) = appel(get_cle(&format!("/api/v1/products/{tapis_url}?fournisseur=F001"))).await;
assert_eq!(st, 200);
let fiche = json_de(&corps);
assert_eq!(fiche["meta"]["hasNetwork"], true);
assert_eq!(fiche["data"]["network"]["caReseau"], 50000);
let (st, _) = appel(get_cle("/api/v1/products/999999")).await;
assert_eq!(st, 404);
// Gammes : proposition à valider, puis lot avec un inconnu (tout ou rien).
let put = Request::builder()
.method("PUT")
.uri(format!("/api/v1/products/{tapis_url}/gamme"))
.header("x-api-key", CLE_API)
.header("content-type", "application/json")
.body(Body::from(r#"{"gamme":"A","fournisseur":"F001"}"#))
.unwrap();
let (st, corps) = appel(put).await;
let rep = json_de(&corps);
assert_eq!(st, 200, "{rep}");
assert_eq!(rep["data"]["statut"], "a_valider");
assert_eq!(rep["data"]["codeFournisseur"], "F001");
let a_valider = super::gammes_a_valider::lister_gammes_a_valider("F001").await.unwrap();
assert_eq!((a_valider[0].codein.as_str(), a_valider[0].auteur.as_deref()), (code_tapis.as_str(), Some("test-grille")));
let post = |corps: &str| {
Request::builder().method("POST").uri("/api/v1/gammes").header("x-api-key", CLE_API).body(Body::from(corps.to_string())).unwrap()
};
let (st, corps) = appel(post(
&json!({ "fournisseur": "F001", "changes": [{ "codein": code_tapis, "gamme": "B" }, { "codein": "XXX", "gamme": "A" }] })
.to_string(),
))
.await;
let rep = json_de(&corps);
assert_eq!(st, 404);
assert_eq!(rep["error"]["details"]["inconnus"], json!(["XXX"]));
let (st, corps) = appel(post("pas du json")).await;
assert_eq!(
(st, json_de(&corps)["error"]["message"].clone()),
(400, json!("Corps de requête JSON attendu (Content-Type: application/json)."))
);
let (st, corps) = appel(post(&json!({ "fournisseur": "F001", "changes": [{ "codein": code_tapis, "gamme": "Z" }] }).to_string())).await;
let rep = json_de(&corps);
assert_eq!(st, 200, "{rep}");
assert_eq!(rep["meta"]["demandes"], 1);
// /api/v1/fournisseurs, /api/v1/network
let (st, corps) = appel(get_cle("/api/v1/fournisseurs")).await;
assert_eq!(st, 200);
let fous = json_de(&corps);
assert!(fous["data"].as_array().unwrap().iter().any(|f| f["code"] == "F001" && f["hasSnapshot"] == true));
let (st, corps) = appel(get_cle("/api/v1/fournisseurs?withData=1&search=f00")).await;
assert_eq!(st, 200);
assert_eq!(json_de(&corps)["meta"]["scope"], "instantané uniquement");
let (st, corps) = appel(get_cle("/api/v1/network/10000111111")).await;
assert_eq!(st, 200);
let net = json_de(&corps);
assert_eq!(net["data"].as_object().unwrap().keys().next().unwrap(), "codeCentrale");
assert_eq!(net["meta"]["source"], "cache");
let (st, _) = appel(get_cle("/api/v1/network/inconnu")).await;
assert_eq!(st, 404);
nettoyer(&pool).await;
rows_cache::invalidate_grid_rows_cache(None);
}
+354
View File
@@ -0,0 +1,354 @@
//! CollectFlow - Core Grid Types (port de `src/types/grid.ts`).
//!
//! `ProductRow` circule tel quel en JSON : vers la Grille, dans `grid_rows.payload`
//! (jsonb) et dans l'API `/api/v1`. Les attributs serde reproduisent
//! `JSON.stringify` : noms camelCase, champs facultatifs omis quand ils sont
//! `undefined`, `null` conservé pour les champs `| null`, entiers sans décimale.
//!
//! Lecture tolérante : un payload écrit par la version Node se relit sans perte —
//! champ manquant → valeur par défaut, `null` numérique → `NaN` (réécrit `null`),
//! champ inconnu conservé dans `extra` et réécrit à l'identique.
use super::js;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
/// A/B/C/Z status codes for product categories, but can be any string from DB (like 'Aucune')
pub type GammeCode = String;
/// Série mensuelle `{ "YYYYMM": valeur }` (ou `"YYYY-MM"` pour le réseau Qlik).
pub type Serie = BTreeMap<String, f64>;
/// Ventilation par magasin puis par mois.
pub type SerieParMagasin = BTreeMap<String, Serie>;
/// A product row with its 12-month sales history and computed KPIs.
///
/// Ordre des champs = ordre de construction dans `getProductRows` (donc celui du
/// JSON produit par le calcul en direct).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ProductRow {
#[serde(deserialize_with = "js::string::deserialize")]
pub codein: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub code_fournisseur: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub nom_fournisseur: String,
/// Dernier fournisseur enregistré pour cet article (artfou1.no_id DESC). Peut différer du fournisseur consulté.
#[serde(skip_serializing_if = "Option::is_none")]
pub fournisseur_principal_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fournisseur_principal_nom: Option<String>,
#[serde(deserialize_with = "js::string::deserialize")]
pub libelle1: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub gtin: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub reference: String,
/// Code centrale (clé jointure Qlik, format 10000XXXXXX)
#[serde(skip_serializing_if = "Option::is_none")]
pub code_centrale: Option<String>,
#[serde(deserialize_with = "js::string::deserialize")]
pub code1: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub libelle_niveau1: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub code2: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub libelle_niveau2: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub code3: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub libelle3: String,
pub code_gamme: Option<GammeCode>,
pub code_gamme_init: Option<GammeCode>,
/// Draft gamme value before saving
pub code_gamme_draft: Option<GammeCode>,
/// 12-month sales quantities, indexed by YYYYMM key
#[serde(with = "js::map")]
pub sales12m: Serie,
/// End-of-month stock levels, indexed by YYYYMM key
#[serde(with = "js::map")]
pub stock12m: Serie,
/// Monthly reception quantities, indexed by YYYYMM key
#[serde(with = "js::map")]
pub receptions12m: Serie,
#[serde(with = "js::num")]
pub total_quantite: f64,
#[serde(with = "js::num")]
pub total_ca: f64,
#[serde(with = "js::num")]
pub total_marge: f64,
#[serde(with = "js::num")]
pub taux_marge: f64,
pub working_stores: Vec<String>,
/// SQL Server internal ID — nécessaire pour /api/articles/:noid/mensuel
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub noid: Option<f64>,
/// Conditionnement (pack size)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub pcb: Option<f64>,
/// Prix de vente pratiqué, en euros (le plus élevé quand les magasins divergent :
/// `prixVenteByStore` porte le détail).
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub prix_vente: Option<f64>,
/// Per-store breakdown (site → YYYYMM → qty) — always present even in TOTAL mode
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_nested_map")]
pub sales12m_by_store: Option<SerieParMagasin>,
/// Per-store stock breakdown (site → YYYYMM → stock fin de mois)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_nested_map")]
pub stock12m_by_store: Option<SerieParMagasin>,
/// Per-store reception breakdown (site → YYYYMM → qté reçue)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_nested_map")]
pub receptions12m_by_store: Option<SerieParMagasin>,
/// Per-store CA totals
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")]
pub ca_by_store: Option<Serie>,
/// Per-store quantity totals
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")]
pub quantite_by_store: Option<Serie>,
/// Per-store margin totals
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")]
pub marge_by_store: Option<Serie>,
/// Qté en commande fournisseur
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub commandes_en_cours: Option<f64>,
/// Stock dispo agrégé tous sites
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub stock_actuel: Option<f64>,
/// Qte en stock agrégé
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub stock_total: Option<f64>,
/// Valeur stock agrégée
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub stock_valeur: Option<f64>,
/// PRMP (alias prixAchat)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub pa: Option<f64>,
/// PA / PRMP
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub prix_achat: Option<f64>,
/// ISO date de dernière vente
#[serde(skip_serializing_if = "Option::is_none")]
pub derniere_vente: Option<String>,
/// ISO date de dernière réception
#[serde(skip_serializing_if = "Option::is_none")]
pub derniere_livraison: Option<String>,
/// Per-store last delivery/reception date (site → ISO date string)
#[serde(skip_serializing_if = "Option::is_none")]
pub derniere_livraison_by_store: Option<BTreeMap<String, String>>,
/// Prix de vente par magasin (site → PV) — `cube_pv`.
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")]
pub prix_vente_by_store: Option<Serie>,
/// Données réseau Qlik (~270 magasins)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub ca_reseau: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub qte_reseau: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub nb_magasins_reseau: Option<f64>,
/// CA moyen par magasin réseau (productivité normalisée présence)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub ca_par_magasin_reseau: Option<f64>,
/// Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub marge_pct_reseau: Option<f64>,
/// Taux de présence réseau = nbMagasinsReseau / 270
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub taux_presence_reseau: Option<f64>,
/// Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal).
/// `None` = absent, `Some(None)` = `null`.
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_null_map")]
pub qte_reseau_by_month: Option<Option<Serie>>,
/// Nombre de magasins vendeurs par mois : { "YYYY-MM": nb } (deuxième courbe de
/// la carte tendance). `None` = absent, `Some(None)` = `null`.
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_null_map")]
pub nb_mag_reseau_by_month: Option<Option<Serie>>,
/// Fraîcheur des données réseau (ISO)
#[serde(skip_serializing_if = "Option::is_none")]
pub network_fetched_at: Option<String>,
/// Rotation normalisée : unités vendues / magasin / mois
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub units_per_store_per_month: Option<f64>,
/// CA normalisé : € / magasin / an
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub ca_per_store_per_year: Option<f64>,
/// Produit récent (≤ 3 mois de données)
#[serde(skip_serializing_if = "Option::is_none")]
pub is_recent: Option<bool>,
/// Seul produit du fournisseur dans le lot
#[serde(skip_serializing_if = "Option::is_none")]
pub is_last_product: Option<bool>,
/// Top 30% CA du lot fournisseur
#[serde(skip_serializing_if = "Option::is_none")]
pub is_top30_supplier: Option<bool>,
/// Moyennes de comparaison pour l'IA
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub avg_qty_fournisseur: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub avg_qty_rayon: Option<f64>,
/// Poids relatifs (%) du produit chez le fournisseur
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub share_ca: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub share_marge: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub share_qty: Option<f64>,
/// Référentiels globaux
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub total_fournisseur_ca: Option<f64>,
/// Version du format de la ligne, posée à l'écriture dans `grid_rows`. Une
/// ligne d'une version antérieure n'est pas servie telle quelle à la Grille
/// (des champs récents lui manqueraient) : elle est recalculée.
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub payload_version: Option<f64>,
/// Champs inconnus de ce type (payload écrit par une autre version) : conservés
/// et réécrits tels quels.
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// Summary bar totals for the currently visible/filtered rows
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct GridSummary {
#[serde(with = "js::num")]
pub total_products: f64,
#[serde(with = "js::num")]
pub total_rows: f64,
#[serde(with = "js::num")]
pub total_quantite: f64,
#[serde(with = "js::num")]
pub total_ca: f64,
#[serde(with = "js::num")]
pub total_marge: f64,
#[serde(with = "js::num")]
pub taux_marge_global: f64,
}
/// Filters applied to the grid
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct GridFilters {
pub magasin: Option<String>,
pub code_fournisseur: Option<String>,
pub code1: Option<String>,
pub code2: Option<String>,
/// Nomenclatures retenues (codes `code3`).
/// `None` = aucun filtre (tout s'affiche) ; `Some(vec![])` = rien de coché.
pub code3: Option<Vec<String>>,
pub code_gamme: Option<GammeCode>,
pub search: String,
}
/// One row in the data grid, representing a product at a store for one period.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct VenteProduit {
#[serde(with = "js::num")]
pub id: f64,
pub codein: String,
pub code_fournisseur: Option<String>,
pub nom_fournisseur: Option<String>,
pub libelle1: Option<String>,
pub gtin: Option<String>,
pub reference: Option<String>,
pub code_gamme: Option<GammeCode>,
pub code_gamme_init: Option<GammeCode>,
pub code3: Option<String>,
pub libelle3: Option<String>,
pub magasin: String,
pub code_magasin: Option<String>,
pub annee: Option<f64>,
pub mois: Option<f64>,
pub periode: String,
pub quantite: Option<f64>,
pub montant_mvt: Option<f64>,
pub marge_mvt: Option<f64>,
}
// ---------------------------------------------------------------------------
// Complément par magasin (`src/features/grid/lib/store-patch.ts`)
// ---------------------------------------------------------------------------
/// Chiffres d'un article pour le magasin complété.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct StorePatchEntry {
pub codein: String,
/// Ventes par mois (« YYYYMM »).
#[serde(with = "js::map")]
pub sales: Serie,
/// Stock de fin de mois, reporté sur les mois sans donnée.
#[serde(with = "js::map")]
pub stock: Serie,
#[serde(with = "js::map")]
pub receptions: Serie,
#[serde(with = "js::num")]
pub quantite: f64,
#[serde(with = "js::num")]
pub ca: f64,
#[serde(with = "js::num")]
pub marge: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct StorePatch {
pub magasin: String,
/// Fenêtre des 12 mois du calcul : un complément d'un autre mois est ignoré.
pub periods: Vec<String>,
pub entries: Vec<StorePatchEntry>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// Ligne telle que l'écrit la version Node (ordre jsonb, null, champ inconnu).
fn payload_node() -> Value {
json!({
"pa": 2.5, "pcb": 6, "gtin": "3700000000001", "noid": 4242,
"code1": "32", "code2": "3202", "code3": "320211",
"codein": "123456", "libelle1": "Bougie parfumée", "libelle3": "Bougies",
"sales12m": { "202510": 3, "202511": 0 }, "stock12m": { "202510": 10, "202511": 10 },
"totalCa": 37.5, "caByStore": { "292": 37.5 }, "codeGamme": "A",
"reference": "REF-1", "tauxMarge": 41.2, "prixAchat": 2.5, "totalMarge": 15.45,
"codeCentrale": "10000123456", "codeGammeInit": "B", "codeGammeDraft": null,
"nomFournisseur": "FOURNISSEUR TEST", "receptions12m": { "202510": 0, "202511": 0 },
"totalQuantite": 3, "workingStores": ["292"], "libelleNiveau1": "Déco", "libelleNiveau2": "Senteurs",
"codeFournisseur": "F001", "payloadVersion": 1, "sales12mByStore": { "292": { "202510": 3 }, "579": {} },
"qteReseauByMonth": null, "margePctReseau": null, "champFutur": { "x": [1, 2] },
"derniereLivraisonByStore": { "292": "2025-10-03" }
})
}
#[test]
fn aller_retour_payload_node() {
let brut = payload_node();
let row: ProductRow = serde_json::from_value(brut.clone()).unwrap();
assert_eq!(row.codein, "123456");
assert_eq!(row.noid, Some(4242.0));
assert_eq!(row.code_gamme_draft, None);
assert_eq!(row.qte_reseau_by_month, Some(None));
assert!(row.marge_pct_reseau.unwrap().is_nan());
assert!(row.nb_mag_reseau_by_month.is_none());
assert_eq!(row.extra.get("champFutur"), Some(&json!({ "x": [1, 2] })));
// Même contenu JSON après relecture et réécriture.
let reecrit = serde_json::to_value(&row).unwrap();
assert_eq!(reecrit, brut);
}
#[test]
fn champs_facultatifs_omis_et_entiers_sans_decimale() {
let row = ProductRow { codein: "1".into(), total_quantite: 12.0, total_ca: 10.5, ..Default::default() };
let s = serde_json::to_string(&row).unwrap();
assert!(s.starts_with(r#"{"codein":"1","codeFournisseur":"","nomFournisseur":"","libelle1":"""#));
assert!(s.contains(r#""codeGamme":null,"codeGammeInit":null,"codeGammeDraft":null"#));
assert!(s.contains(r#""totalQuantite":12,"totalCa":10.5"#));
assert!(!s.contains("noid"));
assert!(!s.contains("payloadVersion"));
}
}
+362
View File
@@ -0,0 +1,362 @@
//! Validation façon zod (v4) : mêmes contrôles, mêmes « issues ».
//!
//! Les routes `/api/v1` renvoient `parsed.error.issues` dans `error.details`, et
//! certaines actions recopient `parsed.error.message` (le JSON indenté des issues)
//! dans leur message d'erreur. Pour que l'appelant reçoive exactement le même
//! détail, on reproduit la forme des issues de zod 4 : clés dans le même ordre,
//! mêmes messages par défaut, même ordre de parcours (celui du schéma).
use super::js;
use serde_json::{json, Map, Value};
/// Plus grand entier « sûr » de JS (`Number.MAX_SAFE_INTEGER`).
pub const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
/// Segment de chemin d'une issue : clé d'objet ou indice de tableau.
#[derive(Debug, Clone)]
pub enum Seg {
K(String),
I(usize),
}
impl From<&str> for Seg {
fn from(s: &str) -> Self {
Seg::K(s.to_string())
}
}
impl From<usize> for Seg {
fn from(i: usize) -> Self {
Seg::I(i)
}
}
pub type Path = Vec<Seg>;
pub fn path_json(path: &[Seg]) -> Value {
Value::Array(
path.iter()
.map(|s| match s {
Seg::K(k) => Value::String(k.clone()),
Seg::I(i) => Value::from(*i),
})
.collect(),
)
}
pub fn child(path: &[Seg], seg: impl Into<Seg>) -> Path {
let mut p = path.to_vec();
p.push(seg.into());
p
}
/// Type reçu, tel que zod le nomme dans ses messages.
pub fn received(v: Option<&Value>) -> &'static str {
match v {
None => "undefined",
Some(Value::Null) => "null",
Some(Value::Bool(_)) => "boolean",
Some(Value::Number(_)) => "number",
Some(Value::String(_)) => "string",
Some(Value::Array(_)) => "array",
Some(Value::Object(_)) => "object",
}
}
/// Nombre affiché dans un message (`String(n)`).
fn n(x: f64) -> String {
js::js_number_to_string(x)
}
fn num_json(x: f64) -> Value {
if x.fract() == 0.0 && x.abs() <= MAX_SAFE_INTEGER {
Value::from(x as i64)
} else {
serde_json::Number::from_f64(x).map(Value::Number).unwrap_or(Value::Null)
}
}
/// Liste d'issues, dans l'ordre où zod les produit.
#[derive(Debug, Default, Clone)]
pub struct Issues(pub Vec<Value>);
impl Issues {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
fn push(&mut self, mut issue: Map<String, Value>, path: &[Seg], message: String) {
issue.insert("path".into(), path_json(path));
issue.insert("message".into(), Value::String(message));
self.0.push(Value::Object(issue));
}
fn obj(v: Value) -> Map<String, Value> {
match v {
Value::Object(m) => m,
_ => Map::new(),
}
}
/// `invalid_type` : type attendu absent ou différent.
pub fn invalid_type(&mut self, path: &[Seg], expected: &str, got: Option<&Value>) {
let issue = Self::obj(json!({ "expected": expected, "code": "invalid_type" }));
self.push(issue, path, format!("Invalid input: expected {expected}, received {}", received(got)));
}
/// `invalid_type` d'un nombre non fini (`NaN`, `Infinity`) après coercition.
pub fn invalid_number(&mut self, path: &[Seg], x: f64) {
let recu = if x.is_nan() { "NaN" } else { "Infinity" };
let issue = Self::obj(json!({ "expected": "number", "code": "invalid_type", "received": recu }));
let msg = if x.is_nan() { "received NaN" } else { "received number" };
self.push(issue, path, format!("Invalid input: expected number, {msg}"));
}
/// `.int()` sur un nombre à décimales.
pub fn not_int(&mut self, path: &[Seg]) {
let issue = Self::obj(json!({ "expected": "int", "format": "safeint", "code": "invalid_type" }));
self.push(issue, path, "Invalid input: expected int, received number".into());
}
/// `.int()` hors de l'intervalle des entiers sûrs.
pub fn unsafe_int(&mut self, path: &[Seg], trop_grand: bool) {
let (code, cle, borne, sens, op) = if trop_grand {
("too_big", "maximum", MAX_SAFE_INTEGER, "Too big", "<=")
} else {
("too_small", "minimum", -MAX_SAFE_INTEGER, "Too small", ">=")
};
let mut issue = Map::new();
issue.insert("code".into(), json!(code));
issue.insert(cle.into(), num_json(borne));
issue.insert("note".into(), json!("Integers must be within the safe integer range."));
issue.insert("origin".into(), json!("int"));
issue.insert("inclusive".into(), json!(true));
self.push(issue, path, format!("{sens}: expected int to be {op}{}", n(borne)));
}
/// `too_small` (`origin` : `string`, `number` ou `array`).
pub fn too_small(&mut self, path: &[Seg], origin: &str, minimum: f64, message: Option<&str>) {
let mut issue = Map::new();
issue.insert("origin".into(), json!(origin));
issue.insert("code".into(), json!("too_small"));
issue.insert("minimum".into(), num_json(minimum));
issue.insert("inclusive".into(), json!(true));
let defaut = match origin {
"string" => format!("Too small: expected string to have >={} characters", n(minimum)),
"array" => format!("Too small: expected array to have >={} items", n(minimum)),
_ => format!("Too small: expected {origin} to be >={}", n(minimum)),
};
self.push(issue, path, message.map(str::to_string).unwrap_or(defaut));
}
/// `too_big` (`origin` : `string`, `number` ou `array`).
pub fn too_big(&mut self, path: &[Seg], origin: &str, maximum: f64, message: Option<&str>) {
let mut issue = Map::new();
issue.insert("origin".into(), json!(origin));
issue.insert("code".into(), json!("too_big"));
issue.insert("maximum".into(), num_json(maximum));
issue.insert("inclusive".into(), json!(true));
let defaut = match origin {
"string" => format!("Too big: expected string to have <={} characters", n(maximum)),
"array" => format!("Too big: expected array to have <={} items", n(maximum)),
_ => format!("Too big: expected {origin} to be <={}", n(maximum)),
};
self.push(issue, path, message.map(str::to_string).unwrap_or(defaut));
}
/// `invalid_value` d'un `z.enum([...])`.
pub fn invalid_value(&mut self, path: &[Seg], values: &[&str], message: Option<&str>) {
let issue = Self::obj(json!({ "code": "invalid_value", "values": values }));
let defaut = format!("Invalid option: expected one of {}", values.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join("|"));
self.push(issue, path, message.map(str::to_string).unwrap_or(defaut));
}
/// `invalid_format` d'un `.regex(...)`.
pub fn invalid_regex(&mut self, path: &[Seg], pattern: &str, message: &str) {
let issue = Self::obj(json!({ "origin": "string", "code": "invalid_format", "format": "regex", "pattern": pattern }));
self.push(issue, path, message.to_string());
}
/// `custom` d'un `.refine(...)`.
pub fn custom(&mut self, path: &[Seg], message: &str) {
let issue = Self::obj(json!({ "code": "custom" }));
self.push(issue, path, message.to_string());
}
/// Les issues, telles que `parsed.error.issues`.
pub fn to_json(&self) -> Value {
Value::Array(self.0.clone())
}
/// `parsed.error.message` : `JSON.stringify(issues, null, 2)`.
pub fn message(&self) -> String {
serde_json::to_string_pretty(&self.0).unwrap_or_default()
}
/// `issues.map((i) => i.message).join(", ")`.
pub fn messages_joined(&self) -> String {
self.0.iter().map(|i| i.get("message").and_then(Value::as_str).unwrap_or_default().to_string()).collect::<Vec<_>>().join(", ")
}
}
// ---------------------------------------------------------------------------
// Briques de schéma
// ---------------------------------------------------------------------------
/// `z.object(...)` : la racine doit être un objet. `None` (issue posée) sinon.
pub fn object<'a>(v: Option<&'a Value>, path: &[Seg], issues: &mut Issues) -> Option<&'a Map<String, Value>> {
match v {
Some(Value::Object(m)) => Some(m),
autre => {
issues.invalid_type(path, "object", autre);
None
}
}
}
/// `z.string()` obligatoire.
pub fn string(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<String> {
match v {
Some(Value::String(s)) => Some(s.clone()),
autre => {
issues.invalid_type(path, "string", autre);
None
}
}
}
/// `z.string().optional()` : `Ok(None)` si absent.
pub fn opt_string(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<Option<String>> {
match v {
None => Some(None),
autre => string(autre, path, issues).map(Some),
}
}
/// `z.string().nullable()`.
pub fn nullable_string(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<Option<String>> {
match v {
Some(Value::Null) => Some(None),
autre => string(autre, path, issues).map(Some),
}
}
/// Bornes de longueur d'une chaîne (`.min(n)`, `.max(n)`), en unités UTF-16 comme JS.
pub fn check_len(
s: &str,
path: &[Seg],
min: Option<(usize, Option<&str>)>,
max: Option<(usize, Option<&str>)>,
issues: &mut Issues,
) -> bool {
let len = s.encode_utf16().count();
let mut ok = true;
if let Some((m, msg)) = min {
if len < m {
issues.too_small(path, "string", m as f64, msg);
ok = false;
}
}
if let Some((m, msg)) = max {
if len > m {
issues.too_big(path, "string", m as f64, msg);
ok = false;
}
}
ok
}
/// `z.number()` (pas de coercition) : nombre fini.
pub fn number(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<f64> {
match v {
Some(Value::Number(x)) => x.as_f64(),
autre => {
issues.invalid_type(path, "number", autre);
None
}
}
}
/// `z.boolean()`.
pub fn boolean(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<bool> {
match v {
Some(Value::Bool(b)) => Some(*b),
autre => {
issues.invalid_type(path, "boolean", autre);
None
}
}
}
/// `z.enum([...])` sur une valeur quelconque.
pub fn enum_value(v: Option<&Value>, values: &[&str], path: &[Seg], message: Option<&str>, issues: &mut Issues) -> Option<String> {
match v.and_then(Value::as_str) {
Some(s) if values.contains(&s) => Some(s.to_string()),
_ => {
issues.invalid_value(path, values, message);
None
}
}
}
/// `z.coerce.number().int().min(min)[.max(max)]` sur un paramètre d'URL.
pub fn coerce_int(s: &str, path: &[Seg], min: f64, max: Option<f64>, issues: &mut Issues) -> Option<i64> {
let x = js::js_number_from_str(s);
if !x.is_finite() {
issues.invalid_number(path, x);
return None;
}
if x.fract() != 0.0 {
issues.not_int(path);
return None;
}
let mut ok = true;
if x > MAX_SAFE_INTEGER {
issues.unsafe_int(path, true);
ok = false;
} else if x < -MAX_SAFE_INTEGER {
issues.unsafe_int(path, false);
ok = false;
}
if x < min {
issues.too_small(path, "number", min, None);
ok = false;
}
if let Some(m) = max {
if x > m {
issues.too_big(path, "number", m, None);
ok = false;
}
}
// `-0` reste un entier valide pour zod ; il ne peut de toute façon pas
// franchir un minimum de 1.
ok.then_some(x as i64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forme_des_issues() {
let mut i = Issues::default();
let p = vec![Seg::from("niveau")];
assert!(coerce_int("abc", &p, 1.0, Some(3.0), &mut i).is_none());
assert!(coerce_int("1e20", &p, 1.0, Some(3.0), &mut i).is_none());
assert!(coerce_int("1.5", &p, 1.0, Some(3.0), &mut i).is_none());
assert_eq!(coerce_int(" 0x2 ", &p, 1.0, Some(3.0), &mut i), Some(2));
assert_eq!(
serde_json::to_string(&i.to_json()).unwrap(),
r#"[{"expected":"number","code":"invalid_type","received":"NaN","path":["niveau"],"message":"Invalid input: expected number, received NaN"},{"code":"too_big","maximum":9007199254740991,"note":"Integers must be within the safe integer range.","origin":"int","inclusive":true,"path":["niveau"],"message":"Too big: expected int to be <=9007199254740991"},{"origin":"number","code":"too_big","maximum":3,"inclusive":true,"path":["niveau"],"message":"Too big: expected number to be <=3"},{"expected":"int","format":"safeint","code":"invalid_type","path":["niveau"],"message":"Invalid input: expected int, received number"}]"#
);
}
#[test]
fn message_indente_comme_json_stringify() {
let mut i = Issues::default();
i.invalid_type(&[], "object", Some(&Value::Null));
assert_eq!(
i.message(),
"[\n {\n \"expected\": \"object\",\n \"code\": \"invalid_type\",\n \"path\": [],\n \"message\": \"Invalid input: expected object, received null\"\n }\n]"
);
}
}
+225
View File
@@ -0,0 +1,225 @@
//! Capture des journaux serveur d'un job, pour téléchargement.
//!
//! Les extractions Qlik produisent des milliers de lignes ; la sortie standard
//! du conteneur les tronque. Tant qu'au moins une capture est ouverte, chaque
//! ligne journalisée (par n'importe quel code du processus) y est recopiée, puis
//! écrite sur disque au fil de l'eau (toutes les 2 s au plus).
//!
//! ⚠️ Deux jobs simultanés partagent les journaux du processus : chaque capture
//! ouverte reçoit TOUTES les lignes de la période.
use chrono::Utc;
use parking_lot::Mutex;
use serde::Serialize;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::Write as _;
use std::path::PathBuf;
use std::sync::LazyLock;
use std::time::{Instant, SystemTime};
use tracing::field::{Field, Visit};
use tracing::{Event, Subscriber};
use tracing_subscriber::layer::Context;
use tracing_subscriber::Layer;
const MAX_LIGNES: usize = 200_000;
const MAX_LONGUEUR_LIGNE: usize = 8_000;
const MAX_FICHIERS: usize = 20;
const FLUSH_MS: u128 = 2_000;
struct Capture {
lignes: Vec<String>,
tronque: bool,
debut: Instant,
dernier_flush: Option<Instant>,
ecrites: usize,
}
static CAPTURES: LazyLock<Mutex<HashMap<String, Capture>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
fn log_dir() -> PathBuf {
std::env::temp_dir().join("collectflow-logs")
}
fn nom_sur(id: &str) -> String {
id.chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect()
}
fn fichier_pour(id: &str) -> PathBuf {
log_dir().join(format!("{}.log", nom_sur(id)))
}
fn horodatage() -> String {
Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
/// Écrit sur disque les lignes pas encore écrites (synchrone, appelé sous verrou).
fn flush(id: &str, c: &mut Capture) {
let _ = std::fs::create_dir_all(log_dir());
let total = c.lignes.len();
if c.ecrites == 0 || total > c.ecrites {
let nouvelles = &c.lignes[c.ecrites..total];
let mut texte = nouvelles.join("\n");
texte.push('\n');
let res = if c.ecrites == 0 {
std::fs::write(fichier_pour(id), texte)
} else {
std::fs::OpenOptions::new()
.append(true)
.open(fichier_pour(id))
.and_then(|mut f| f.write_all(texte.as_bytes()))
};
if res.is_ok() {
c.ecrites = total;
}
}
c.dernier_flush = Some(Instant::now());
}
/// Ajoute une ligne à toutes les captures ouvertes.
pub fn ajouter(ligne: &str) {
let mut captures = CAPTURES.lock();
if captures.is_empty() {
return;
}
let texte = if ligne.len() > MAX_LONGUEUR_LIGNE {
let mut cut = MAX_LONGUEUR_LIGNE;
while !ligne.is_char_boundary(cut) {
cut -= 1;
}
format!("{} …[ligne tronquée]", &ligne[..cut])
} else {
ligne.to_string()
};
let prefixe = format!("[{}] ", horodatage());
for (id, c) in captures.iter_mut() {
if c.lignes.len() >= MAX_LIGNES {
if !c.tronque {
c.tronque = true;
c.lignes.push(format!("{prefixe}[log-capture] limite de {MAX_LIGNES} lignes atteinte — suite non enregistrée"));
}
continue;
}
c.lignes.push(format!("{prefixe}{texte}"));
if c.dernier_flush.map(|t| t.elapsed().as_millis() >= FLUSH_MS).unwrap_or(true) {
flush(id, c);
}
}
}
fn purger() {
let Ok(rd) = std::fs::read_dir(log_dir()) else { return };
let mut fichiers: Vec<(PathBuf, SystemTime)> = rd
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".log"))
.map(|e| (e.path(), e.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH)))
.collect();
if fichiers.len() <= MAX_FICHIERS {
return;
}
fichiers.sort_by(|a, b| b.1.cmp(&a.1));
for (p, _) in fichiers.into_iter().skip(MAX_FICHIERS) {
let _ = std::fs::remove_file(p);
}
}
/// Ouvre une capture : toutes les lignes journalisées à partir de maintenant y
/// sont recopiées jusqu'à [`stop_capture`].
pub fn start_capture(id: &str, entete: Option<&str>) {
let mut c = Capture { lignes: Vec::new(), tronque: false, debut: Instant::now(), dernier_flush: None, ecrites: 0 };
c.lignes.push(format!("[{}] [log-capture] début de capture {id}", horodatage()));
if let Some(e) = entete {
c.lignes.push(format!("[{}] [log-capture] {e}", horodatage()));
}
flush(id, &mut c);
CAPTURES.lock().insert(id.to_string(), c);
purger();
}
/// Ferme la capture et écrit son état final sur disque.
pub fn stop_capture(id: &str, pied: Option<&str>) {
let Some(mut c) = CAPTURES.lock().remove(id) else { return };
let duree = c.debut.elapsed().as_secs();
let n = c.lignes.len();
c.lignes.push(format!("[{}] [log-capture] fin de capture {id} — {n} lignes, {duree} s", horodatage()));
if let Some(p) = pied {
c.lignes.push(format!("[{}] [log-capture] {p}", horodatage()));
}
flush(id, &mut c);
}
/// Contenu d'un journal : la capture en cours, sinon le fichier sur disque.
pub fn read_capture(id: &str) -> Option<String> {
if let Some(c) = CAPTURES.lock().get(id) {
return Some(c.lignes.join("\n") + "\n");
}
std::fs::read_to_string(fichier_pour(id)).ok()
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CaptureInfo {
pub id: String,
pub octets: u64,
pub modifie_le: String,
pub en_cours: bool,
}
/// Journaux disponibles, du plus récent au plus ancien.
pub fn list_captures() -> Vec<CaptureInfo> {
let en_cours: Vec<String> = CAPTURES.lock().keys().cloned().collect();
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(log_dir()) {
for e in rd.filter_map(|e| e.ok()) {
let nom = e.file_name().to_string_lossy().to_string();
let Some(id) = nom.strip_suffix(".log") else { continue };
let Ok(meta) = e.metadata() else { continue };
let modifie: chrono::DateTime<Utc> = meta.modified().map(Into::into).unwrap_or_else(|_| Utc::now());
out.push(CaptureInfo {
id: id.to_string(),
octets: meta.len(),
modifie_le: modifie.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
en_cours: en_cours.iter().any(|x| nom_sur(x) == id),
});
}
}
out.sort_by(|a, b| b.modifie_le.cmp(&a.modifie_le));
out
}
// ---------------------------------------------------------------------------
// Couche `tracing` qui alimente les captures
// ---------------------------------------------------------------------------
pub struct CaptureLayer;
struct MessageVisitor(String);
impl Visit for MessageVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
let _ = write!(self.0, "{value:?}");
} else {
let _ = write!(self.0, " {}={value:?}", field.name());
}
}
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "message" {
self.0.push_str(value);
} else {
let _ = write!(self.0, " {}={value}", field.name());
}
}
}
impl<S: Subscriber> Layer<S> for CaptureLayer {
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
// Aucune capture ouverte (le cas courant) : on ne formate rien.
if CAPTURES.lock().is_empty() {
return;
}
let mut v = MessageVisitor(String::new());
event.record(&mut v);
ajouter(&v.0);
}
}
+111
View File
@@ -0,0 +1,111 @@
// Les modules portés gardent quelques utilitaires et types de l'ancienne
// version sans appelant (déjà inutilisés côté TypeScript) : on ne les signale pas.
#![allow(dead_code)]
//! CollectFlow — serveur.
//!
//! Un binaire unique : API JSON (interface + `/api/v1` publique), accès à la
//! base PostgreSQL (tables de l'application et recopie FF Nancy), extraction
//! Qlik Sense, synchronisation nocturne, et service de l'interface compilée.
mod admin;
mod api_v1_response;
mod auth;
mod config;
mod db;
mod domain;
mod error;
mod ff;
mod ff_cache;
mod grid;
mod logcapture;
mod migrations;
mod produits;
mod qlik;
mod routes;
mod rpc;
mod spa;
use axum::extract::DefaultBodyLimit;
use axum::Router;
use rpc::Handler;
use tower_http::compression::CompressionLayer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, Layer};
/// Toutes les actions déclarées par les modules.
pub fn modules_actions() -> Vec<(&'static str, Handler)> {
[ff::actions(), qlik::actions(), grid::actions(), produits::actions(), admin::actions(), routes::actions()].concat()
}
/// Toutes les pages déclarées par les modules.
pub fn modules_pages() -> Vec<(&'static str, Handler)> {
[ff::pages(), qlik::pages(), grid::pages(), produits::pages(), admin::pages(), routes::pages()].concat()
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,chromiumoxide=warn,tungstenite=warn,hyper=warn"));
let fmt = tracing_subscriber::fmt::layer().with_target(false).with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout())).with_filter(filter);
tracing_subscriber::registry().with(fmt).with(logcapture::CaptureLayer).init();
}
fn app() -> Router {
Router::new()
.merge(auth::router())
.merge(rpc::router())
.merge(ff::router())
.merge(qlik::router())
.merge(grid::router())
.merge(produits::router())
.merge(admin::router())
.merge(routes::router())
.merge(spa::router())
// Les instantanés de la Grille peuvent peser plusieurs Mo.
.layer(DefaultBodyLimit::max(64 * 1024 * 1024))
.layer(CompressionLayer::new())
}
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
s.recv().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("Arrêt demandé.");
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_tracing();
tracing::info!("CollectFlow {} — démarrage", env!("CARGO_PKG_VERSION"));
let _ = std::fs::create_dir_all(config::data_dir());
migrations::run().await;
tokio::spawn(async {
auth::ensure_admin_exists().await;
});
admin::start_background_tasks();
let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(5643);
let host = std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()).unwrap_or_else(|| "0.0.0.0".into());
let addr = format!("{host}:{port}");
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l,
// HOSTNAME est parfois le nom du conteneur : on retombe sur toutes les interfaces.
Err(_) => tokio::net::TcpListener::bind(("0.0.0.0", port)).await?,
};
tracing::info!("Écoute sur http://{}", listener.local_addr()?);
axum::serve(listener, app()).with_graceful_shutdown(shutdown_signal()).await?;
Ok(())
}
+19
View File
@@ -0,0 +1,19 @@
//! Schéma de la base, joué à chaque démarrage (idempotent).
const INIT_SQL: &str = include_str!("../sql/init.sql");
pub async fn run() {
tracing::info!("[DB Init] Starting database migration & initialization...");
let pool = match crate::db::pool() {
Ok(p) => p,
Err(_) => {
tracing::warn!("[DB Init] No DATABASE_URL found in env or config. Skipping.");
return;
}
};
match sqlx::raw_sql(INIT_SQL).execute(&pool).await {
Ok(_) => tracing::info!("[DB Init] Initialization successful."),
// L'application reste utilisable (comptes de secours, Paramètres).
Err(e) => tracing::error!("[DB Init] Initialization failed: {e}"),
}
}
+42
View File
@@ -0,0 +1,42 @@
//! CollectFlow — Comparatif local vs réseau pour un produit — partie serveur de
//! `src/features/produits/lib/compare-reseau.ts`.
//!
//! Le calcul du comparatif lui-même (`computeComparatif`, `indiceVerdict`) reste
//! côté interface : seul `normalizeMargePct` sert au serveur (recherche produit).
/// 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.
pub fn normalize_marge_pct(raw: Option<f64>) -> Option<f64> {
let v = raw?;
if !v.is_finite() || v == 0.0 {
return None;
}
Some(if v.abs() <= 1.0 { v * 100.0 } else { v })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ratio_brut_converti_en_points() {
assert_eq!(normalize_marge_pct(Some(0.32)), Some(32.0));
assert_eq!(normalize_marge_pct(Some(-0.5)), Some(-50.0));
assert_eq!(normalize_marge_pct(Some(1.0)), Some(100.0));
}
#[test]
fn points_de_pourcentage_conserves() {
assert_eq!(normalize_marge_pct(Some(32.5)), Some(32.5));
assert_eq!(normalize_marge_pct(Some(-12.0)), Some(-12.0));
}
#[test]
fn absent_nul_ou_non_fini() {
assert_eq!(normalize_marge_pct(None), None);
assert_eq!(normalize_marge_pct(Some(0.0)), None);
assert_eq!(normalize_marge_pct(Some(f64::NAN)), None);
assert_eq!(normalize_marge_pct(Some(f64::INFINITY)), None);
}
}
+329
View File
@@ -0,0 +1,329 @@
//! CollectFlow — Fiche produit — port de
//! `src/features/produits/api/get-produit-fiche.ts`
//! (`getProduitFiche`, `getProduitFicheByCodeCentrale`).
use super::types::{ProduitFiche, ProduitMois, ProduitTotaux, SerieMensuelle};
use crate::ff::api::build_last12_months_range;
use crate::ff::pg::{
pg_get_codein_by_code_centrale, pg_get_commandes_by_codein, pg_get_fournisseurs_by_codein, pg_get_gamme_history_by_codein,
pg_get_mensuel_by_codein, pg_get_produit_detail, pg_get_stock_by_codein, PgMensuelRow,
};
use crate::grid::js::js_trim;
use crate::grid::months::{get_last12_months, today};
use crate::qlik::get_network_metrics_by_code_centrale;
use std::collections::{BTreeMap, HashMap, HashSet};
/// Les 12 clés de mois "YYYYMM" glissantes, mois courant exclu.
fn build_month_keys() -> Vec<String> {
get_last12_months(today())
}
fn empty_mois(mois: &str) -> ProduitMois {
ProduitMois { mois: mois.to_string(), qte: 0.0, ca: 0.0, marge: 0.0, stock_fin_mois: 0.0, qte_recue: 0.0 }
}
fn totaux_from<'a>(rows: impl Iterator<Item = &'a ProduitMois>) -> ProduitTotaux {
let (mut qte, mut ca, mut marge) = (0.0, 0.0, 0.0);
for r in rows {
qte += r.qte;
ca += r.ca;
marge += r.marge;
}
ProduitTotaux { qte, ca, marge, taux_marge: if ca > 0.0 { (marge / ca) * 100.0 } else { 0.0 } }
}
/// Totaux d'une série sur les mois de la fenêtre, dans l'ordre de `months`.
fn totaux_serie(months: &[String], serie: &SerieMensuelle) -> ProduitTotaux {
totaux_from(months.iter().filter_map(|m| serie.get(m)))
}
/// `Number(x) || 0`.
fn ou_zero(x: f64) -> f64 {
if x.is_nan() {
0.0
} else {
x
}
}
/// Partie mensuelle de la fiche, calculée à partir des lignes `mvtart` agrégées.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Mensuel {
pub mensuel_total: SerieMensuelle,
pub mensuel_par_site: BTreeMap<String, SerieMensuelle>,
pub totaux: ProduitTotaux,
pub totaux_par_site: BTreeMap<String, ProduitTotaux>,
pub sites_actifs: Vec<String>,
}
/// Agrège les lignes mensuelles (total et par site) sur la fenêtre `months`,
/// avec report du stock de fin de mois sur les mois sans mouvement.
pub fn assembler_mensuel(months: &[String], mensuel_rows: &[PgMensuelRow]) -> Mensuel {
let allowed: HashSet<&str> = months.iter().map(|m| m.as_str()).collect();
let mut mensuel_total: SerieMensuelle = months.iter().map(|m| (m.clone(), empty_mois(m))).collect();
let mut mensuel_par_site: BTreeMap<String, SerieMensuelle> = BTreeMap::new();
// 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.
let mut mois_avec_donnees: HashSet<String> = HashSet::new();
let mut mois_avec_donnees_par_site: HashMap<String, HashSet<String>> = HashMap::new();
for row in mensuel_rows {
// SQL renvoie "YYYY-MM", la timeline de l'app utilise "YYYYMM".
let mois = row.mois.replacen('-', "", 1);
if !allowed.contains(mois.as_str()) {
continue;
}
let site = js_trim(&row.site).to_string();
let qte = ou_zero(row.qte_vendue);
let ca = ou_zero(row.ca_ht);
let marge = ou_zero(row.marge);
let stock_fin_mois = ou_zero(row.stock_fin_mois.unwrap_or(0.0));
let qte_recue = ou_zero(row.qte_recue);
mois_avec_donnees.insert(mois.clone());
if let Some(tot) = mensuel_total.get_mut(&mois) {
tot.qte += qte;
tot.ca += ca;
tot.marge += marge;
tot.stock_fin_mois += stock_fin_mois;
tot.qte_recue += qte_recue;
}
let par_site =
mensuel_par_site.entry(site.clone()).or_insert_with(|| months.iter().map(|m| (m.clone(), empty_mois(m))).collect());
mois_avec_donnees_par_site.entry(site).or_default().insert(mois.clone());
if let Some(cell) = par_site.get_mut(&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.stock_fin_mois = stock_fin_mois;
cell.qte_recue += qte_recue;
}
}
// 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.
let carry_forward = |serie: &mut SerieMensuelle, avec_donnees: Option<&HashSet<String>>| {
let mut last = 0.0;
for m in months {
let Some(cell) = serie.get_mut(m) else { continue };
if avec_donnees.is_some_and(|s| s.contains(m)) {
last = cell.stock_fin_mois;
} else {
cell.stock_fin_mois = last;
}
}
};
carry_forward(&mut mensuel_total, Some(&mois_avec_donnees));
for (site, by_site) in mensuel_par_site.iter_mut() {
carry_forward(by_site, mois_avec_donnees_par_site.get(site));
}
let totaux = totaux_serie(months, &mensuel_total);
let totaux_par_site: BTreeMap<String, ProduitTotaux> =
mensuel_par_site.iter().map(|(site, by_site)| (site.clone(), totaux_serie(months, by_site))).collect();
let mut sites_actifs: Vec<String> = totaux_par_site.iter().filter(|(_, t)| t.qte > 0.0).map(|(s, _)| s.clone()).collect();
sites_actifs.sort();
Mensuel { mensuel_total, mensuel_par_site, totaux, totaux_par_site, sites_actifs }
}
/// Assemble la fiche d'un produit **à partir de son code centrale** : c'est la
/// clé du réseau Qlik, seule identité disponible pour un produit que le réseau
/// travaille mais que Nancy ne référence pas.
///
/// Si le catalogue Nancy connaît ce code centrale, on rattache toute la partie
/// locale (identité, fournisseurs, stock, gammes, 12 mois glissants de ventes).
/// Sinon la fiche reste purement réseau, ce qui est un cas parfaitement normal
/// depuis la recherche Qlik.
///
/// Renvoie `None` uniquement si ni Qlik ni le catalogue ne connaissent le code.
pub async fn get_produit_fiche_by_code_centrale(code_centrale: &str) -> anyhow::Result<Option<ProduitFiche>> {
let cleaned = js_trim(code_centrale);
if cleaned.is_empty() {
return Ok(None);
}
if let Some(codein) = pg_get_codein_by_code_centrale(cleaned).await {
return get_produit_fiche(&codein).await;
}
// Produit réseau pur : aucune donnée locale à assembler.
let mut map = get_network_metrics_by_code_centrale(&[cleaned.to_string()]).await?;
let Some(reseau) = map.remove(cleaned) else {
return Ok(None);
};
let periode = build_last12_months_range();
let months = build_month_keys();
let mensuel_total: SerieMensuelle = months.iter().map(|m| (m.clone(), empty_mois(m))).collect();
let totaux = totaux_serie(&months, &mensuel_total);
Ok(Some(ProduitFiche {
detail: None,
code_centrale: cleaned.to_string(),
libelle_reseau: reseau.libelle_reseau.clone().unwrap_or_default(),
fournisseur_reseau: reseau.fournisseur_reseau.clone().unwrap_or_default(),
fournisseurs: vec![],
stock: vec![],
commandes_en_cours: 0.0,
gammes: vec![],
months,
mensuel_total,
mensuel_par_site: BTreeMap::new(),
totaux,
totaux_par_site: BTreeMap::new(),
sites_actifs: vec![],
reseau: Some(reseau),
periode,
}))
}
/// 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 `None` si le codein est inconnu du catalogue FF.
pub async fn get_produit_fiche(codein: &str) -> anyhow::Result<Option<ProduitFiche>> {
let cleaned = js_trim(codein);
if cleaned.is_empty() {
return Ok(None);
}
let periode = build_last12_months_range();
// Tout part en même temps : l'identité du produit était lue seule d'abord,
// puis les cinq autres lectures, puis les métriques réseau — trois attentes
// successives. Les métriques réseau n'attendent que l'identité (code centrale).
let detail_et_reseau = async {
let detail = pg_get_produit_detail(cleaned).await;
let code = detail.as_ref().map(|d| d.code_centrale.clone()).filter(|c| !c.is_empty());
let reseau = match code {
Some(code) => get_network_metrics_by_code_centrale(std::slice::from_ref(&code)).await?.remove(&code),
None => None,
};
anyhow::Ok((detail, reseau))
};
let (detail_et_reseau, fournisseurs, stock, commandes_en_cours, gammes, mensuel_rows) = tokio::join!(
detail_et_reseau,
pg_get_fournisseurs_by_codein(cleaned),
pg_get_stock_by_codein(cleaned),
pg_get_commandes_by_codein(cleaned),
pg_get_gamme_history_by_codein(cleaned),
pg_get_mensuel_by_codein(cleaned, &periode.date_debut, &periode.date_fin),
);
let (detail, reseau) = detail_et_reseau?;
let Some(detail) = detail else {
return Ok(None);
};
let months = build_month_keys();
let Mensuel { mensuel_total, mensuel_par_site, totaux, totaux_par_site, sites_actifs } = assembler_mensuel(&months, &mensuel_rows);
Ok(Some(ProduitFiche {
code_centrale: detail.code_centrale.clone(),
detail: Some(detail),
libelle_reseau: reseau.as_ref().and_then(|r| r.libelle_reseau.clone()).unwrap_or_default(),
fournisseur_reseau: reseau.as_ref().and_then(|r| r.fournisseur_reseau.clone()).unwrap_or_default(),
fournisseurs,
stock,
commandes_en_cours,
gammes,
months,
mensuel_total,
mensuel_par_site,
totaux,
totaux_par_site,
sites_actifs,
reseau,
periode,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn ligne(site: &str, mois: &str, qte: f64, ca: f64, marge: f64, stock: Option<f64>, recu: f64) -> PgMensuelRow {
PgMensuelRow {
codein: "123".into(),
site: site.into(),
mois: mois.into(),
qte_vendue: qte,
ca_ht: ca,
marge,
stock_fin_mois: stock,
qte_recue: recu,
}
}
fn mois() -> Vec<String> {
get_last12_months(chrono::NaiveDate::from_ymd_opt(2026, 3, 15).unwrap())
}
#[test]
fn fenetre_12_mois_hors_mois_courant() {
let m = mois();
assert_eq!(m.len(), 12);
assert_eq!(m.first().unwrap(), "202503");
assert_eq!(m.last().unwrap(), "202602");
}
#[test]
fn agrege_total_et_sites_avec_report_de_stock() {
let m = mois();
let rows = vec![
ligne("292", "2025-04", 2.0, 20.0, 5.0, Some(10.0), 0.0),
ligne(" 579 ", "2025-04", 1.0, 10.0, 2.0, Some(4.0), 3.0),
// Mois avec mouvements et stock nul : reste à 0 (pas de report).
ligne("292", "2025-06", 0.0, 0.0, 0.0, Some(0.0), 0.0),
// Hors fenêtre : ignoré.
ligne("292", "2024-01", 99.0, 99.0, 99.0, Some(99.0), 99.0),
];
let r = assembler_mensuel(&m, &rows);
assert_eq!(r.mensuel_total.len(), 12);
let avril = &r.mensuel_total["202504"];
assert_eq!((avril.qte, avril.ca, avril.marge, avril.stock_fin_mois, avril.qte_recue), (3.0, 30.0, 7.0, 14.0, 3.0));
// Mars : avant tout mouvement → 0 ; mai : report d'avril ; juin : 0 réel ; juillet : report de juin.
assert_eq!(r.mensuel_total["202503"].stock_fin_mois, 0.0);
assert_eq!(r.mensuel_total["202505"].stock_fin_mois, 14.0);
assert_eq!(r.mensuel_total["202506"].stock_fin_mois, 0.0);
assert_eq!(r.mensuel_total["202602"].stock_fin_mois, 0.0);
// Le site est « trimé » ; chaque site a ses 12 mois.
assert_eq!(r.mensuel_par_site.keys().cloned().collect::<Vec<_>>(), vec!["292", "579"]);
assert_eq!(r.mensuel_par_site["579"].len(), 12);
assert_eq!(r.mensuel_par_site["579"]["202602"].stock_fin_mois, 4.0);
assert_eq!(r.mensuel_par_site["292"]["202505"].stock_fin_mois, 10.0);
assert_eq!(r.mensuel_par_site["292"]["202507"].stock_fin_mois, 0.0);
assert_eq!(r.totaux, ProduitTotaux { qte: 3.0, ca: 30.0, marge: 7.0, taux_marge: 7.0 / 30.0 * 100.0 });
assert_eq!(r.totaux_par_site["292"].qte, 2.0);
assert_eq!(r.sites_actifs, vec!["292", "579"]);
}
#[test]
fn site_sans_vente_non_actif_et_taux_nul_sans_ca() {
let m = mois();
let rows = vec![ligne("579", "2025-05", 0.0, 0.0, 0.0, None, 6.0)];
let r = assembler_mensuel(&m, &rows);
assert!(r.sites_actifs.is_empty());
assert_eq!(r.totaux.taux_marge, 0.0);
assert_eq!(r.mensuel_total["202505"].qte_recue, 6.0);
assert_eq!(r.mensuel_total["202505"].stock_fin_mois, 0.0);
}
#[test]
fn mois_vide_serialise_comme_le_ts() {
let v = serde_json::to_value(empty_mois("202501")).unwrap();
assert_eq!(v, serde_json::json!({ "mois": "202501", "qte": 0, "ca": 0, "marge": 0, "stockFinMois": 0, "qteRecue": 0 }));
}
}
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff. Show more