mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 02:37:55 +02:00
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:
286 files changed
+32316
-26347
No files matched your search
+14
-38
@@ -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*
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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).
|
||||
@@ -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();
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
Generated
+998
-8852
File diff suppressed because it is too large.
Load diff
+16
-20
@@ -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 +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 +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 +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 +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 +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 |
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
Generated
+3477
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
);
|
||||
@@ -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." }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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() })))
|
||||
}
|
||||
@@ -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() }))))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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." })),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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?))
|
||||
}
|
||||
}
|
||||
@@ -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?))
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(), ¶ms, &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"));
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
]
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
.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
File diff suppressed because it is too large.
Load diff
@@ -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(¶ms).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()
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms) {
|
||||
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))
|
||||
}
|
||||
@@ -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"))]))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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, ¢re)?;
|
||||
|
||||
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), ¢re)?;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(¶ms, "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(¶ms, "magasin").unwrap_or("TOTAL").to_string();
|
||||
let chunk_size = {
|
||||
let brut = premier(¶ms, "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(¶ms, "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(¶ms, "fournisseur").unwrap_or("");
|
||||
let magasin = premier(¶ms, "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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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![]
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms).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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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]"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user