mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-21 22:18:17 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0afbecffa | ||
|
|
4ed129bd74 | ||
|
|
c869d7aa08 | ||
|
|
97d63b2097 | ||
|
|
af6a854400 | ||
|
|
bffdca1297 | ||
|
|
600627c632 | ||
|
|
6760f43e87 | ||
|
|
b964e137bf | ||
|
|
bd3bb5e64a | ||
|
|
6f1a39e4b2 | ||
|
|
2274c9bb4a |
No files matched your search
+8
-1
@@ -93,4 +93,11 @@ ssl/
|
|||||||
|
|
||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.backup
|
||||||
|
# Compilation Rust locale (le binaire est construit dans l'image)
|
||||||
|
server-rs/target/
|
||||||
|
|
||||||
|
# Serveur Node de référence du banc (scripts/bench/reference-node.sh)
|
||||||
|
.bench-reference/
|
||||||
|
# Interface construite en local : reconstruite dans l'image
|
||||||
|
dist/
|
||||||
@@ -30,3 +30,9 @@ ENCRYPTION_KEY=
|
|||||||
# Route de secours POST /api/emergency-admin-reset : désactivée si vide.
|
# Route de secours POST /api/emergency-admin-reset : désactivée si vide.
|
||||||
# Ne définir qu'en cas de besoin, puis retirer.
|
# Ne définir qu'en cas de besoin, puis retirer.
|
||||||
EMERGENCY_SECRET=
|
EMERGENCY_SECRET=
|
||||||
|
|
||||||
|
# API externe de rapprochement BL / factures (docs/API-RAPPROCHEMENT.md).
|
||||||
|
# Les clés se créent de préférence dans Paramètres > API externe. Optionnel :
|
||||||
|
# une ou plusieurs clés supplémentaires ici, séparées par des virgules.
|
||||||
|
# Générer une clé : node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
EXTERNAL_API_KEYS=
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Décision R6 (docs/PLAN-MIGRATION-RUST.md) : le serveur Rust, le banc qui le
|
||||||
|
# vérifie et les fichiers dont il dépend sont relus par leur mainteneur.
|
||||||
|
/server-rs/ @R0m1k3
|
||||||
|
/scripts/bench/ @R0m1k3
|
||||||
|
/scripts/gen-rust-schema.ts @R0m1k3
|
||||||
|
/shared/schema.ts @R0m1k3
|
||||||
|
/shared/permissions.json @R0m1k3
|
||||||
|
/Dockerfile @R0m1k3
|
||||||
|
/docker-compose.yml @R0m1k3
|
||||||
|
/.github/workflows/ @R0m1k3
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
name: Serveur Rust
|
||||||
|
|
||||||
|
# Contrôles du serveur Rust, contrôle de type et construction de l'interface, et
|
||||||
|
# banc de comparaison avec le serveur Node de référence (scripts/bench/README.md)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: server-rs
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: server-rs
|
||||||
|
- name: Mise en forme
|
||||||
|
run: cargo fmt --check
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --all-targets --locked -- -D warnings
|
||||||
|
- name: Tests
|
||||||
|
run: cargo test --locked
|
||||||
|
|
||||||
|
client:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
- name: Dépendances
|
||||||
|
run: npm ci
|
||||||
|
- name: Contrôle de type
|
||||||
|
run: npm run check
|
||||||
|
- name: Construction de l'interface
|
||||||
|
run: npx vite build
|
||||||
|
- name: Fichier docker-compose valide
|
||||||
|
run: docker compose -f docker-compose.yml config -q
|
||||||
|
|
||||||
|
bench:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: logiflow_admin
|
||||||
|
POSTGRES_PASSWORD: bench
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U logiflow_admin"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
env:
|
||||||
|
BENCH_PG_URL: postgres://logiflow_admin:bench@localhost:5432
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Le serveur Node de référence est construit depuis un commit passé
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: server-rs
|
||||||
|
- name: Dépendances
|
||||||
|
run: npm ci
|
||||||
|
- name: Serveur Node de référence (REFERENCE_NODE_COMMIT)
|
||||||
|
run: scripts/bench/reference-node.sh
|
||||||
|
- name: Serveur Rust
|
||||||
|
run: cargo build --release --locked --manifest-path server-rs/Cargo.toml
|
||||||
|
- name: Base modèle du banc
|
||||||
|
run: scripts/bench/setup.sh
|
||||||
|
- name: Banc Node de référence contre Rust
|
||||||
|
run: npx tsx scripts/bench/compare.ts
|
||||||
+3
-1
@@ -1,7 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.DS_Store
|
.DS_Store
|
||||||
server/public
|
|
||||||
vite.config.ts.*
|
vite.config.ts.*
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
@@ -13,3 +12,6 @@ cookie*.txt
|
|||||||
# Artefacts de debug locaux
|
# Artefacts de debug locaux
|
||||||
*.log
|
*.log
|
||||||
scratch/
|
scratch/
|
||||||
|
|
||||||
|
# Serveur Node de référence du banc (scripts/bench/reference-node.sh)
|
||||||
|
.bench-reference/
|
||||||
+34
-107
@@ -1,122 +1,49 @@
|
|||||||
# Multi-stage build for optimized production image
|
# Image de production : interface construite par Vite + serveur Rust (server-rs/).
|
||||||
FROM node:20-alpine AS build
|
# Node ne sert qu'à construire l'interface ; il n'est pas dans l'image finale.
|
||||||
|
|
||||||
# Set working directory
|
# Interface (dist/public)
|
||||||
|
FROM node:20-alpine AS client-build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install all dependencies (including dev dependencies needed for build)
|
|
||||||
RUN npm ci && npm cache clean --force
|
RUN npm ci && npm cache clean --force
|
||||||
|
COPY tsconfig.json vite.config.ts tailwind.config.ts postcss.config.js components.json ./
|
||||||
# Copy source code (excluding .env to avoid conflicts)
|
|
||||||
COPY package*.json ./
|
|
||||||
COPY tsconfig.json ./
|
|
||||||
COPY drizzle.config.ts ./
|
|
||||||
COPY vite.config.ts ./
|
|
||||||
COPY tailwind.config.ts ./
|
|
||||||
COPY postcss.config.js ./
|
|
||||||
COPY components.json ./
|
|
||||||
COPY client/ client/
|
COPY client/ client/
|
||||||
COPY server/ server/
|
|
||||||
COPY shared/ shared/
|
COPY shared/ shared/
|
||||||
|
RUN NODE_ENV=production npx vite build && test -f dist/public/index.html
|
||||||
|
|
||||||
# Vérifier la structure
|
# Serveur : binaire statique (musl) pour l'image Alpine
|
||||||
RUN ls -la server/
|
FROM rust:1.97-alpine AS rust-build
|
||||||
|
RUN apk add --no-cache musl-dev
|
||||||
|
WORKDIR /build
|
||||||
|
# Dépendances d'abord : couche en cache tant que Cargo.lock ne change pas
|
||||||
|
COPY server-rs/Cargo.toml server-rs/Cargo.lock ./server-rs/
|
||||||
|
RUN mkdir -p server-rs/src && echo "fn main() {}" > server-rs/src/main.rs && \
|
||||||
|
cargo build --release --locked --manifest-path server-rs/Cargo.toml && \
|
||||||
|
rm -rf server-rs/src
|
||||||
|
COPY server-rs/build.rs ./server-rs/build.rs
|
||||||
|
COPY server-rs/src ./server-rs/src
|
||||||
|
COPY shared/permissions.json ./shared/permissions.json
|
||||||
|
RUN touch server-rs/src/main.rs && \
|
||||||
|
cargo build --release --locked --manifest-path server-rs/Cargo.toml
|
||||||
|
|
||||||
# Build the application
|
FROM alpine:3.20 AS production
|
||||||
# Build frontend first
|
# pg_dump/psql pour les sauvegardes, wget pour la sonde de santé
|
||||||
RUN NODE_ENV=production npx vite build
|
RUN apk add --no-cache postgresql-client wget tzdata ca-certificates && \
|
||||||
|
addgroup -g 1001 -S logiflow && adduser -S logiflow -u 1001 -G logiflow
|
||||||
# Vérifier que les fichiers sont construits
|
|
||||||
RUN echo "=== BUILD VERIFICATION ===" && \
|
|
||||||
ls -la dist/ && \
|
|
||||||
echo "Frontend files in dist/public:" && \
|
|
||||||
ls -la dist/public/ && \
|
|
||||||
echo "index.html exists:" && \
|
|
||||||
ls -la dist/public/index.html
|
|
||||||
|
|
||||||
# Build backend avec tous les modules externes
|
|
||||||
RUN npx esbuild server/index.production.ts \
|
|
||||||
--platform=node \
|
|
||||||
--bundle \
|
|
||||||
--format=esm \
|
|
||||||
--outfile=dist/index.js \
|
|
||||||
--external:vite \
|
|
||||||
--external:@vitejs/* \
|
|
||||||
--external:@replit/* \
|
|
||||||
--external:tsx \
|
|
||||||
--external:openid-client \
|
|
||||||
--external:@neondatabase/serverless \
|
|
||||||
--external:ws \
|
|
||||||
--external:drizzle-orm \
|
|
||||||
--external:pg \
|
|
||||||
--external:express \
|
|
||||||
--external:connect-pg-simple \
|
|
||||||
--external:passport \
|
|
||||||
--external:passport-local \
|
|
||||||
--external:express-session \
|
|
||||||
--external:zod \
|
|
||||||
--external:express-rate-limit \
|
|
||||||
--external:memoizee \
|
|
||||||
--external:nanoid \
|
|
||||||
--external:date-fns \
|
|
||||||
--external:nodemailer \
|
|
||||||
--external:compression
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM node:20-alpine AS production
|
|
||||||
|
|
||||||
# Create non-root user for security
|
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
|
||||||
adduser -S nextjs -u 1001
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY --from=rust-build /build/server-rs/target/release/logiflow /app/bin/logiflow
|
||||||
|
COPY --from=client-build /app/dist/public /app/dist/public
|
||||||
|
COPY migrations/ /app/migrations/
|
||||||
|
RUN mkdir -p /app/backups && chown -R logiflow:logiflow /app/backups
|
||||||
|
USER logiflow
|
||||||
|
|
||||||
# Install production dependencies only
|
ENV PORT=3000 \
|
||||||
COPY --from=build /app/package*.json ./
|
STATIC_DIR=/app/dist/public \
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
BACKUP_DIR=/app/backups \
|
||||||
|
NODE_ENV=production
|
||||||
# Copy built application from build stage
|
|
||||||
COPY --from=build --chown=nextjs:nodejs /app/dist ./dist
|
|
||||||
COPY --from=build --chown=nextjs:nodejs /app/shared ./shared
|
|
||||||
|
|
||||||
# Copy migration scripts and files
|
|
||||||
COPY --chown=nextjs:nodejs scripts/ ./scripts/
|
|
||||||
COPY --chown=nextjs:nodejs migrations/ ./migrations/
|
|
||||||
COPY --chown=nextjs:nodejs scripts/docker-entrypoint.sh ./scripts/docker-entrypoint.sh
|
|
||||||
|
|
||||||
# Create symlink for public files to be accessible from dist
|
|
||||||
RUN ln -sf /app/dist/public /app/dist/public
|
|
||||||
|
|
||||||
# Create uploads and backups directories with proper permissions
|
|
||||||
RUN mkdir -p /app/uploads /app/backups && chown -R nextjs:nodejs /app/uploads /app/backups
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
|
||||||
# Fixé ici car db.ts lit NODE_ENV dès l'import, avant le forçage de index.production.ts
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
|
|
||||||
# Install wget for health check and postgresql-client for pg_dump
|
|
||||||
USER root
|
|
||||||
RUN apk add --no-cache wget postgresql-client
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
# Make scripts executable
|
CMD ["/app/bin/logiflow"]
|
||||||
RUN chmod +x /app/scripts/auto-migrate-production.sh && \
|
|
||||||
chmod +x /app/scripts/docker-entrypoint.sh && \
|
|
||||||
ls -la /app/scripts/ && \
|
|
||||||
test -f /app/scripts/docker-entrypoint.sh || echo "ERREUR: docker-entrypoint.sh manquant"
|
|
||||||
|
|
||||||
# Apply DLC migration directly and start application
|
|
||||||
CMD ["sh", "-c", "echo 'Applying DLC migration...'; if [ -n \"$DATABASE_URL\" ]; then psql \"$DATABASE_URL\" -c 'ALTER TABLE dlc_products ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL, ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255), ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp; CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise);' || echo 'Migration already applied or failed'; fi; node dist/index.js"]
|
|
||||||
@@ -44,8 +44,8 @@ Une plateforme d'entreprise moderne pour la gestion logistique, le suivi des liv
|
|||||||
## 🚀 Installation et Configuration
|
## 🚀 Installation et Configuration
|
||||||
|
|
||||||
### Prérequis
|
### Prérequis
|
||||||
- Node.js 18+
|
- Rust stable (serveur) et Node.js 20+ (construction de l'interface uniquement)
|
||||||
- PostgreSQL 13+
|
- PostgreSQL 13+ et `pg_dump` (sauvegardes)
|
||||||
- Clé API Visual Crossing (gratuite sur [visualcrossing.com](https://www.visualcrossing.com/))
|
- Clé API Visual Crossing (gratuite sur [visualcrossing.com](https://www.visualcrossing.com/))
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
@@ -61,13 +61,18 @@ npm install
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Éditer .env avec vos paramètres de base de données
|
# Éditer .env avec vos paramètres de base de données
|
||||||
|
|
||||||
# Initialiser la base de données
|
# Démarrer le serveur (API, migrations au démarrage) sur le port 3000
|
||||||
npm run db:push
|
npm run dev:server
|
||||||
|
|
||||||
# Démarrer l'application
|
# Dans un autre terminal : l'interface avec rechargement à chaud (Vite),
|
||||||
|
# qui relaie /api vers le serveur
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
En production : `npm run build` (interface dans `dist/public`, binaire dans
|
||||||
|
`server-rs/target/release/logiflow`) puis `npm start`, ou l'image Docker
|
||||||
|
(`docker compose up -d`).
|
||||||
|
|
||||||
### Configuration de l'API Météo
|
### Configuration de l'API Météo
|
||||||
1. Créez un compte gratuit sur [Visual Crossing](https://www.visualcrossing.com/)
|
1. Créez un compte gratuit sur [Visual Crossing](https://www.visualcrossing.com/)
|
||||||
2. Obtenez votre clé API
|
2. Obtenez votre clé API
|
||||||
@@ -122,11 +127,9 @@ npm run dev
|
|||||||
- **Wouter** : Routage léger et performant
|
- **Wouter** : Routage léger et performant
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- **Node.js** : Runtime JavaScript haute performance
|
- **Rust** (axum, tokio, sqlx) : serveur unique, binaire statique (voir `server-rs/README.md`)
|
||||||
- **Express** : Framework web minimaliste
|
- **Schéma** : `shared/schema.ts` (Drizzle) reste la source, d'où est généré `server-rs/src/schema.rs`
|
||||||
- **TypeScript** : Développement sécurisé côté serveur
|
- **Zod** : validation côté interface ; mêmes règles et mêmes messages côté serveur
|
||||||
- **Drizzle ORM** : ORM moderne et type-safe
|
|
||||||
- **Zod** : Validation de schémas robuste
|
|
||||||
|
|
||||||
### Base de Données
|
### Base de Données
|
||||||
- **PostgreSQL** : Base de données relationnelle performante
|
- **PostgreSQL** : Base de données relationnelle performante
|
||||||
@@ -146,13 +149,16 @@ npm run dev
|
|||||||
│ │ ├── pages/ # Pages de l'application
|
│ │ ├── pages/ # Pages de l'application
|
||||||
│ │ ├── lib/ # Utilitaires et configuration
|
│ │ ├── lib/ # Utilitaires et configuration
|
||||||
│ │ └── hooks/ # Hooks React personnalisés
|
│ │ └── hooks/ # Hooks React personnalisés
|
||||||
├── server/ # Backend Express
|
├── server-rs/ # Serveur Rust (API, interface, migrations, tâches de fond)
|
||||||
│ ├── routes.ts # Définition des routes API
|
│ └── src/
|
||||||
│ ├── storage.ts # Interface de stockage
|
│ ├── router.rs # Table des adresses de l'API
|
||||||
│ ├── weatherService.ts # Service météorologique
|
│ ├── modules/ # Une famille de routes par fichier
|
||||||
│ └── index.ts # Point d'entrée du serveur
|
│ ├── startup.rs # Migrations au démarrage
|
||||||
|
│ └── jobs.rs # Sauvegardes automatiques, purge des caches
|
||||||
├── shared/ # Code partagé
|
├── shared/ # Code partagé
|
||||||
│ └── schema.ts # Schémas de base de données
|
│ ├── schema.ts # Schémas de base de données (source de schema.rs)
|
||||||
|
│ └── permissions.json # Matrice des droits (interface et serveur)
|
||||||
|
├── scripts/bench/ # Banc de comparaison avec l'ancien serveur Node
|
||||||
├── migrations/ # Migrations de base de données
|
├── migrations/ # Migrations de base de données
|
||||||
└── backups/ # Dossier des sauvegardes
|
└── backups/ # Dossier des sauvegardes
|
||||||
```
|
```
|
||||||
@@ -168,8 +174,13 @@ DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
|||||||
VISUAL_CROSSING_API_KEY=votre_clé_api_ici
|
VISUAL_CROSSING_API_KEY=votre_clé_api_ici
|
||||||
|
|
||||||
# Application
|
# Application
|
||||||
NODE_ENV=production
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
SESSION_SECRET=… # signe le cookie de session
|
||||||
|
ENCRYPTION_KEY=… # chiffre les secrets en base (repli : SESSION_SECRET)
|
||||||
|
STATIC_DIR=dist/public # interface construite par Vite
|
||||||
|
BACKUP_DIR=/app/backups
|
||||||
|
ALLOWED_ORIGINS= # autres origines acceptées pour les écritures (facultatif)
|
||||||
|
RUST_LOG=info
|
||||||
```
|
```
|
||||||
|
|
||||||
### Personnalisation
|
### Personnalisation
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Sidebar from "./Sidebar";
|
|||||||
import ErrorBoundary from "./ErrorBoundary";
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import WeatherWidget from "./WeatherWidget";
|
import WeatherWidget from "./WeatherWidget";
|
||||||
import DateWidget from "./DateWidget";
|
import DateWidget from "./DateWidget";
|
||||||
|
import { getNavItemForPath } from "@/lib/navigation";
|
||||||
import type { Group } from "@shared/schema";
|
import type { Group } from "@shared/schema";
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +64,12 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
// Titre de l'onglet du navigateur tiré du menu : « Livraisons — LogiFlow »
|
||||||
|
useEffect(() => {
|
||||||
|
const navItem = getNavItemForPath(location);
|
||||||
|
document.title = navItem ? `${navItem.label} — LogiFlow` : "LogiFlow";
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
const { data: stores = EMPTY_STORES } = useQuery<Group[]>({
|
const { data: stores = EMPTY_STORES } = useQuery<Group[]>({
|
||||||
queryKey: ['/api/groups'],
|
queryKey: ['/api/groups'],
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
import { Home, Package, Truck, ClipboardList, MoreHorizontal } from "lucide-react"
|
|
||||||
import { Link, useLocation } from "wouter"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetTrigger,
|
|
||||||
} from "@/components/ui/sheet"
|
|
||||||
import type { User } from "@shared/schema"
|
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
path: string
|
|
||||||
label: string
|
|
||||||
icon: any
|
|
||||||
primary?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MoreMenuItem {
|
|
||||||
path: string
|
|
||||||
label: string
|
|
||||||
icon: any
|
|
||||||
roles?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryNavItems: NavItem[] = [
|
|
||||||
{ path: "/", label: "Accueil", icon: Home, primary: true },
|
|
||||||
{ path: "/orders", label: "Commandes", icon: Package, primary: true },
|
|
||||||
{ path: "/deliveries", label: "Livraisons", icon: Truck, primary: true },
|
|
||||||
{ path: "/tasks", label: "Tâches", icon: ClipboardList, primary: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
const moreMenuItems: MoreMenuItem[] = [
|
|
||||||
{ path: "/bl-reconciliation", label: "Rapprochement", icon: ClipboardList, roles: ["admin", "directeur", "manager"] },
|
|
||||||
{ path: "/publicities", label: "Publicités", icon: Package, roles: ["admin", "directeur", "manager"] },
|
|
||||||
{ path: "/customer-orders", label: "Commandes Clients", icon: Package, roles: ["admin", "directeur", "manager"] },
|
|
||||||
{ path: "/avoirs", label: "Avoirs", icon: Package, roles: ["admin", "directeur", "manager"] },
|
|
||||||
{ path: "/dlc", label: "DLC", icon: Package, roles: ["admin", "directeur", "manager"] },
|
|
||||||
{ path: "/sav", label: "SAV", icon: ClipboardList, roles: ["admin", "directeur", "manager"] },
|
|
||||||
{ path: "/calendar", label: "Calendrier", icon: ClipboardList },
|
|
||||||
]
|
|
||||||
|
|
||||||
interface PhoneBottomNavProps {
|
|
||||||
user: User
|
|
||||||
location: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PhoneBottomNav({ user, location }: PhoneBottomNavProps) {
|
|
||||||
const [moreSheetOpen, setMoreSheetOpen] = useState(false)
|
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
|
||||||
if (path === "/") return location === "/"
|
|
||||||
return location.startsWith(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasPermission = (roles?: string[]) => {
|
|
||||||
if (!roles || !user) return true
|
|
||||||
return roles.includes(user.role)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredMoreItems = moreMenuItems.filter(item => hasPermission(item.roles))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50 safe-area-pb bottom-nav">
|
|
||||||
<div className="flex items-center justify-around py-2 px-1">
|
|
||||||
{/* Navigation principale */}
|
|
||||||
{primaryNavItems.map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
const active = isActive(item.path)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link key={item.path} href={item.path}>
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-center justify-center min-w-[60px] py-2 px-1 rounded-lg transition-all duration-200 ${
|
|
||||||
active
|
|
||||||
? 'bg-blue-50 text-blue-600'
|
|
||||||
: 'text-gray-600 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
data-testid={`nav-${item.path.replace('/', 'home')}`}
|
|
||||||
>
|
|
||||||
<Icon className={`w-5 h-5 ${active ? 'text-blue-600' : 'text-gray-600'}`} />
|
|
||||||
<span className={`text-xs mt-1 font-medium ${
|
|
||||||
active ? 'text-blue-600' : 'text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Menu "Plus" */}
|
|
||||||
<Sheet open={moreSheetOpen} onOpenChange={setMoreSheetOpen}>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<div
|
|
||||||
className="flex flex-col items-center justify-center min-w-[60px] py-2 px-1 rounded-lg transition-all duration-200 text-gray-600 hover:bg-gray-50 cursor-pointer"
|
|
||||||
data-testid="nav-more"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="w-5 h-5 text-gray-600" />
|
|
||||||
<span className="text-xs mt-1 font-medium text-gray-600">Plus</span>
|
|
||||||
</div>
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent side="bottom" className="h-auto max-h-[60vh] rounded-t-lg">
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle>Navigation</SheetTitle>
|
|
||||||
<SheetDescription>
|
|
||||||
Accédez aux autres sections de l'application
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
<div className="grid grid-cols-3 gap-4 mt-6 pb-4">
|
|
||||||
{filteredMoreItems.map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
const active = isActive(item.path)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
href={item.path}
|
|
||||||
onClick={() => setMoreSheetOpen(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-center justify-center p-4 rounded-lg transition-all duration-200 min-h-[80px] ${
|
|
||||||
active
|
|
||||||
? 'bg-blue-50 text-blue-600 border-2 border-blue-200'
|
|
||||||
: 'bg-gray-50 text-gray-700 hover:bg-gray-100 border-2 border-transparent'
|
|
||||||
}`}
|
|
||||||
data-testid={`more-nav-${item.path.replace('/', '')}`}
|
|
||||||
>
|
|
||||||
<Icon className={`w-6 h-6 ${active ? 'text-blue-600' : 'text-gray-600'}`} />
|
|
||||||
<span className={`text-sm mt-2 font-medium text-center ${
|
|
||||||
active ? 'text-blue-600' : 'text-gray-700'
|
|
||||||
}`}>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, useEffect } from "react";
|
import { lazy, Suspense, useEffect, type ComponentType } from "react";
|
||||||
import { Switch, Route, Redirect, useLocation } from "wouter";
|
import { Switch, Route, Redirect, useLocation } from "wouter";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { useScreenSize } from "@/hooks/use-screen-size";
|
import { useScreenSize } from "@/hooks/use-screen-size";
|
||||||
@@ -10,6 +10,7 @@ import Layout from "@/components/Layout";
|
|||||||
// Mobile : coque et page d'accueil chargées immédiatement
|
// Mobile : coque et page d'accueil chargées immédiatement
|
||||||
import MobileApp from "@/pages/mobile/MobileApp";
|
import MobileApp from "@/pages/mobile/MobileApp";
|
||||||
import MobileDashboardPage from "@/pages/mobile/DashboardPage";
|
import MobileDashboardPage from "@/pages/mobile/DashboardPage";
|
||||||
|
import MobileLayout from "@/pages/mobile/MobileLayout";
|
||||||
|
|
||||||
// Les autres pages sont chargées à la demande (un fichier JS par page) : l'écran
|
// Les autres pages sont chargées à la demande (un fichier JS par page) : l'écran
|
||||||
// de connexion et le tableau de bord n'attendent plus le code de toutes les pages
|
// de connexion et le tableau de bord n'attendent plus le code de toutes les pages
|
||||||
@@ -44,6 +45,36 @@ const MobileDlcPage = lazy(() => import("@/pages/mobile/DlcPage"));
|
|||||||
const MobileSavPage = lazy(() => import("@/pages/mobile/SavPage"));
|
const MobileSavPage = lazy(() => import("@/pages/mobile/SavPage"));
|
||||||
const MobileAvoirsPage = lazy(() => import("@/pages/mobile/AvoirsPage"));
|
const MobileAvoirsPage = lazy(() => import("@/pages/mobile/AvoirsPage"));
|
||||||
|
|
||||||
|
// Pages sans version mobile : affichées dans le cadre téléphone (en-tête, barre
|
||||||
|
// du bas et menu) pour ne jamais laisser une page sans navigation
|
||||||
|
function withMobileFrame(Page: ComponentType) {
|
||||||
|
return function MobileFramedPage() {
|
||||||
|
return (
|
||||||
|
<MobileLayout>
|
||||||
|
<div className="p-3">
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<Page />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</MobileLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobileFramedContacts = withMobileFrame(Contacts);
|
||||||
|
const MobileFramedSuppliers = withMobileFrame(Suppliers);
|
||||||
|
const MobileFramedGroups = withMobileFrame(Groups);
|
||||||
|
const MobileFramedUsers = withMobileFrame(Users);
|
||||||
|
const MobileFramedBLReconciliation = withMobileFrame(BLReconciliation);
|
||||||
|
const MobileFramedUtilities = withMobileFrame(Utilities);
|
||||||
|
const MobileFramedAnalytics = withMobileFrame(Analytics);
|
||||||
|
const MobileFramedPaymentSchedule = withMobileFrame(PaymentSchedulePage);
|
||||||
|
const MobileFramedNotFound = withMobileFrame(NotFound);
|
||||||
|
|
||||||
// Helper component to handle redirection
|
// Helper component to handle redirection
|
||||||
const RedirectToAuth = () => {
|
const RedirectToAuth = () => {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
@@ -92,20 +123,20 @@ function RouterProduction() {
|
|||||||
<Route path="/deliveries" component={MobileDeliveriesPage} />
|
<Route path="/deliveries" component={MobileDeliveriesPage} />
|
||||||
<Route path="/tasks" component={MobileTasksPage} />
|
<Route path="/tasks" component={MobileTasksPage} />
|
||||||
|
|
||||||
{/* Pages sans version mobile - utiliser version desktop pour l'instant */}
|
{/* Pages sans version mobile : version PC dans le cadre téléphone */}
|
||||||
<Route path="/contacts" component={Contacts} />
|
<Route path="/contacts" component={MobileFramedContacts} />
|
||||||
<Route path="/suppliers" component={Suppliers} />
|
<Route path="/suppliers" component={MobileFramedSuppliers} />
|
||||||
<Route path="/groups" component={Groups} />
|
<Route path="/groups" component={MobileFramedGroups} />
|
||||||
<Route path="/users" component={Users} />
|
<Route path="/users" component={MobileFramedUsers} />
|
||||||
<Route path="/bl-reconciliation" component={BLReconciliation} />
|
<Route path="/bl-reconciliation" component={MobileFramedBLReconciliation} />
|
||||||
<Route path="/publicities" component={MobilePublicitiesPage} />
|
<Route path="/publicities" component={MobilePublicitiesPage} />
|
||||||
<Route path="/customer-orders" component={MobileCustomerOrdersPage} />
|
<Route path="/customer-orders" component={MobileCustomerOrdersPage} />
|
||||||
<Route path="/dlc" component={MobileDlcPage} />
|
<Route path="/dlc" component={MobileDlcPage} />
|
||||||
<Route path="/utilities" component={Utilities} />
|
<Route path="/utilities" component={MobileFramedUtilities} />
|
||||||
<Route path="/sav" component={MobileSavPage} />
|
<Route path="/sav" component={MobileSavPage} />
|
||||||
<Route path="/avoirs" component={MobileAvoirsPage} />
|
<Route path="/avoirs" component={MobileAvoirsPage} />
|
||||||
<Route path="/analytics" component={Analytics} />
|
<Route path="/analytics" component={MobileFramedAnalytics} />
|
||||||
<Route path="/payment-schedule" component={PaymentSchedulePage} />
|
<Route path="/payment-schedule" component={MobileFramedPaymentSchedule} />
|
||||||
|
|
||||||
{/* Redirection /auth vers dashboard (navigation interne, sans rechargement) */}
|
{/* Redirection /auth vers dashboard (navigation interne, sans rechargement) */}
|
||||||
<Route path="/auth">
|
<Route path="/auth">
|
||||||
@@ -113,7 +144,7 @@ function RouterProduction() {
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/" component={MobileDashboardPage} />
|
<Route path="/" component={MobileDashboardPage} />
|
||||||
<Route component={NotFound} />
|
<Route component={MobileFramedNotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</MobileApp>
|
</MobileApp>
|
||||||
);
|
);
|
||||||
|
|||||||
+157
-854
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,314 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Clock, FileUp, Send } from "lucide-react";
|
||||||
|
|
||||||
|
// URL utilisée quand aucun webhook BAP n'est configuré dans les paramètres
|
||||||
|
const DEFAULT_BAP_WEBHOOK_URL = "https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d";
|
||||||
|
|
||||||
|
interface BapUploadModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoi d'un bon à payer (PDF) au workflow configuré, avec fenêtre d'attente
|
||||||
|
export default function BapUploadModal({ open, onOpenChange }: BapUploadModalProps) {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [selectedRecipient, setSelectedRecipient] = useState<string>('');
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [showWaitingModal, setShowWaitingModal] = useState(false);
|
||||||
|
const [processingSeconds, setProcessingSeconds] = useState(0);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const stopProcessingTimer = () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => stopProcessingTimer, []);
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file && file.type === 'application/pdf') {
|
||||||
|
setSelectedFile(file);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Veuillez sélectionner un fichier PDF",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startProcessingTimer = () => {
|
||||||
|
stopProcessingTimer();
|
||||||
|
setProcessingSeconds(0);
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setProcessingSeconds(prev => {
|
||||||
|
if (prev >= 60) {
|
||||||
|
stopProcessingTimer();
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
return prev + 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseWaitingModal = () => {
|
||||||
|
setShowWaitingModal(false);
|
||||||
|
setProcessingSeconds(0);
|
||||||
|
stopProcessingTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelectedFile = () => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendBap = async () => {
|
||||||
|
if (!selectedFile || !selectedRecipient) {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Veuillez sélectionner un fichier PDF et un destinataire",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer l'URL webhook configurée
|
||||||
|
let webhookUrl = '';
|
||||||
|
try {
|
||||||
|
const configResponse = await fetch('/api/webhook-bap-config', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (configResponse.ok) {
|
||||||
|
const config = await configResponse.json();
|
||||||
|
webhookUrl = config?.webhookUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Impossible de récupérer la config webhook, utilisation URL par défaut');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webhookUrl) {
|
||||||
|
webhookUrl = DEFAULT_BAP_WEBHOOK_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermer la fenêtre de sélection et ouvrir celle d'attente
|
||||||
|
onOpenChange(false);
|
||||||
|
setShowWaitingModal(true);
|
||||||
|
setIsUploading(true);
|
||||||
|
startProcessingTimer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
formData.append('recipient', selectedRecipient);
|
||||||
|
formData.append('type', 'BAP');
|
||||||
|
formData.append('fileName', selectedFile.name);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 secondes
|
||||||
|
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseWaitingModal();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Succès",
|
||||||
|
description: "Fichier BAP envoyé avec succès",
|
||||||
|
});
|
||||||
|
|
||||||
|
clearSelectedFile();
|
||||||
|
setSelectedRecipient('');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
handleCloseWaitingModal();
|
||||||
|
|
||||||
|
let errorMessage = "Impossible d'envoyer le fichier BAP";
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
errorMessage = "Le traitement a pris trop de temps (timeout de 60 secondes)";
|
||||||
|
} else if (error.message) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: errorMessage,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Réouvrir la fenêtre de sélection en cas d'erreur
|
||||||
|
onOpenChange(true);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileUp className="h-5 w-5 text-blue-600" />
|
||||||
|
Envoyer un bon à payer (BAP)
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold text-gray-800">Fichier PDF</Label>
|
||||||
|
|
||||||
|
{!selectedFile ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full border-2 border-dashed border-blue-300 rounded-xl p-8 bg-blue-50 hover:bg-blue-100 transition-colors group"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="mx-auto h-16 w-16 bg-blue-600 rounded-full flex items-center justify-center group-hover:bg-blue-700 transition-colors">
|
||||||
|
<FileUp className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-gray-800 mb-2">
|
||||||
|
Choisir un fichier PDF
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Cliquez pour parcourir vos fichiers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="border-2 border-green-300 rounded-xl p-6 bg-green-50">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="h-12 w-12 bg-green-600 rounded-full flex items-center justify-center">
|
||||||
|
<FileUp className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-green-800 truncate">{selectedFile.name}</p>
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
{(selectedFile.size / 1024 / 1024).toFixed(2)} Mo • PDF
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearSelectedFile}
|
||||||
|
className="border-green-300 text-green-700 hover:bg-green-100"
|
||||||
|
>
|
||||||
|
Changer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold text-gray-800">Envoyer à</Label>
|
||||||
|
<Select value={selectedRecipient} onValueChange={setSelectedRecipient}>
|
||||||
|
<SelectTrigger className="h-12 border-2 border-gray-200 rounded-lg hover:border-blue-300 transition-colors">
|
||||||
|
<SelectValue placeholder="Choisir le destinataire" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Laurie" className="py-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-purple-600">L</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">Laurie</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Jeremy" className="py-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="h-8 w-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-blue-600">J</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">Jeremy</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="flex-1 h-12 border-2 border-gray-200 hover:border-gray-300"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendBap}
|
||||||
|
disabled={!selectedFile || !selectedRecipient || isUploading}
|
||||||
|
className="flex-1 h-12 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-medium"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full mr-2"></div>
|
||||||
|
Envoi en cours...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="h-4 w-4 mr-2" />
|
||||||
|
Envoyer le BAP
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Fenêtre d'attente pendant l'envoi */}
|
||||||
|
<Dialog open={showWaitingModal} onOpenChange={() => { }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5" />
|
||||||
|
Traitement en cours...
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col items-center space-y-4 text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Envoi du fichier vers le workflow...
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{processingSeconds < 60
|
||||||
|
? `${processingSeconds}s écoulées`
|
||||||
|
: '60s+ écoulées'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
House,
|
||||||
|
ListTodo,
|
||||||
|
CalendarClock,
|
||||||
|
ShoppingCart,
|
||||||
|
Wrench,
|
||||||
|
Package,
|
||||||
|
Truck,
|
||||||
|
FileCheck,
|
||||||
|
Receipt,
|
||||||
|
CreditCard,
|
||||||
|
CalendarDays,
|
||||||
|
Megaphone,
|
||||||
|
BookUser,
|
||||||
|
ChartLine,
|
||||||
|
UserCog,
|
||||||
|
Store,
|
||||||
|
Building2,
|
||||||
|
Settings,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// Configuration unique du menu de navigation (docs/PLAN-OPTIMISATION-WEBUI.md, P1 § 2.1).
|
||||||
|
// Chaque icône n'apparaît qu'une fois ; les adresses sont inchangées.
|
||||||
|
|
||||||
|
export type Role = "admin" | "directeur" | "manager" | "employee";
|
||||||
|
|
||||||
|
const ALL_ROLES: Role[] = ["admin", "directeur", "manager", "employee"];
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
roles: Role[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavSection {
|
||||||
|
// Pas de titre pour les entrées Accueil et Calendrier, affichées en tête
|
||||||
|
title?: string;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAV_SECTIONS: NavSection[] = [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{ path: "/", label: "Accueil", icon: House, roles: ALL_ROLES },
|
||||||
|
{ path: "/calendar", label: "Calendrier", icon: CalendarDays, roles: ALL_ROLES },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Au quotidien",
|
||||||
|
items: [
|
||||||
|
{ path: "/tasks", label: "Tâches", icon: ListTodo, roles: ALL_ROLES },
|
||||||
|
{ path: "/dlc", label: "DLC", icon: CalendarClock, roles: ALL_ROLES },
|
||||||
|
{ path: "/customer-orders", label: "Commandes clients", icon: ShoppingCart, roles: ALL_ROLES },
|
||||||
|
{ path: "/sav", label: "SAV", icon: Wrench, roles: ALL_ROLES },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fournisseurs",
|
||||||
|
items: [
|
||||||
|
{ path: "/orders", label: "Commandes fournisseurs", icon: Package, roles: ["admin", "directeur", "manager"] },
|
||||||
|
{ path: "/deliveries", label: "Livraisons", icon: Truck, roles: ["admin", "directeur", "manager"] },
|
||||||
|
{ path: "/bl-reconciliation", label: "Rapprochement BL / factures", icon: FileCheck, roles: ["admin", "directeur"] },
|
||||||
|
{ path: "/avoirs", label: "Avoirs", icon: Receipt, roles: ["admin", "directeur", "manager"] },
|
||||||
|
{ path: "/payment-schedule", label: "Échéancier", icon: CreditCard, roles: ["admin", "directeur"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Planning et infos",
|
||||||
|
items: [
|
||||||
|
{ path: "/publicities", label: "Publicités", icon: Megaphone, roles: ALL_ROLES },
|
||||||
|
{ path: "/contacts", label: "Contacts", icon: BookUser, roles: ALL_ROLES },
|
||||||
|
{ path: "/analytics", label: "Statistiques", icon: ChartLine, roles: ["admin", "directeur", "manager"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Administration",
|
||||||
|
items: [
|
||||||
|
{ path: "/users", label: "Utilisateurs", icon: UserCog, roles: ["admin"] },
|
||||||
|
{ path: "/groups", label: "Magasins", icon: Store, roles: ["admin"] },
|
||||||
|
{ path: "/suppliers", label: "Fiches fournisseurs", icon: Building2, roles: ["admin"] },
|
||||||
|
{ path: "/utilities", label: "Paramètres", icon: Settings, roles: ["admin"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sections visibles pour un rôle, sans les sections devenues vides
|
||||||
|
export function getNavSections(role?: string): NavSection[] {
|
||||||
|
if (!role) return [];
|
||||||
|
return NAV_SECTIONS
|
||||||
|
.map((section) => ({
|
||||||
|
...section,
|
||||||
|
items: section.items.filter((item) => item.roles.includes(role as Role)),
|
||||||
|
}))
|
||||||
|
.filter((section) => section.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Barre du bas sur téléphone : les 4 premières entrées autorisées de cette liste,
|
||||||
|
// le reste est dans « Menu »
|
||||||
|
const MOBILE_SHORTCUT_PATHS = ["/", "/deliveries", "/tasks", "/dlc", "/customer-orders"];
|
||||||
|
const MOBILE_SHORTCUT_COUNT = 4;
|
||||||
|
|
||||||
|
export function getMobileShortcuts(role?: string): NavItem[] {
|
||||||
|
const allowed = getNavSections(role).flatMap((section) => section.items);
|
||||||
|
return MOBILE_SHORTCUT_PATHS
|
||||||
|
.map((path) => allowed.find((item) => item.path === path))
|
||||||
|
.filter((item): item is NavItem => Boolean(item))
|
||||||
|
.slice(0, MOBILE_SHORTCUT_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// « / » et « /dashboard » activent tous deux Accueil ; les autres entrées
|
||||||
|
// restent actives sur leurs sous-adresses
|
||||||
|
export function isNavItemActive(itemPath: string, location: string): boolean {
|
||||||
|
if (itemPath === "/") return location === "/" || location === "/dashboard";
|
||||||
|
return location === itemPath || location.startsWith(`${itemPath}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNavItemForPath(location: string): NavItem | undefined {
|
||||||
|
for (const section of NAV_SECTIONS) {
|
||||||
|
const item = section.items.find((navItem) => isNavItemActive(navItem.path, location));
|
||||||
|
if (item) return item;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoleLabel(role?: string): string {
|
||||||
|
switch (role) {
|
||||||
|
case "admin": return "Administrateur";
|
||||||
|
case "directeur": return "Directeur";
|
||||||
|
case "manager": return "Manager";
|
||||||
|
default: return "Employé";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,13 +12,14 @@ import { useStore } from "@/contexts/StoreContext";
|
|||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { usePermissions } from "@shared/permissions";
|
import { usePermissions } from "@shared/permissions";
|
||||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||||
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock, MessageSquare, Mail } from "lucide-react";
|
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock, MessageSquare, Mail, FileUp } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import ReconciliationComments from "@/components/ReconciliationComments";
|
import ReconciliationComments from "@/components/ReconciliationComments";
|
||||||
import ReconciliationModal from "@/components/modals/ReconciliationModal";
|
import ReconciliationModal from "@/components/modals/ReconciliationModal";
|
||||||
|
import BapUploadModal from "@/components/modals/BapUploadModal";
|
||||||
|
|
||||||
// Tableau vide stable tant que la liste n'est pas chargée (évite de relancer
|
// Tableau vide stable tant que la liste n'est pas chargée (évite de relancer
|
||||||
// effets et calculs mémoïsés à chaque rendu)
|
// effets et calculs mémoïsés à chaque rendu)
|
||||||
@@ -128,6 +129,7 @@ export default function BLReconciliation() {
|
|||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [selectedDelivery, setSelectedDelivery] = useState<any>(null);
|
const [selectedDelivery, setSelectedDelivery] = useState<any>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [showBapModal, setShowBapModal] = useState(false);
|
||||||
|
|
||||||
// État pour le modal d'envoi de facture
|
// État pour le modal d'envoi de facture
|
||||||
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
|
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
|
||||||
@@ -497,8 +499,11 @@ export default function BLReconciliation() {
|
|||||||
return isManual && isNotValidated;
|
return isManual && isNotValidated;
|
||||||
}), [deliveriesWithBL]);
|
}), [deliveriesWithBL]);
|
||||||
|
|
||||||
|
// Fournisseurs en rapprochement automatique : toujours considérés validés,
|
||||||
|
// même si reconciled n'a pas été posé (livraisons antérieures au passage en mode auto)
|
||||||
const allValidatedDeliveries = useMemo(() => deliveriesWithBL.filter((delivery: any) => {
|
const allValidatedDeliveries = useMemo(() => deliveriesWithBL.filter((delivery: any) => {
|
||||||
return delivery.reconciled === true || delivery.reconciled === 1;
|
const isAutomatic = delivery.supplier?.automaticReconciliation === true;
|
||||||
|
return isAutomatic || delivery.reconciled === true || delivery.reconciled === 1;
|
||||||
}), [deliveriesWithBL]);
|
}), [deliveriesWithBL]);
|
||||||
|
|
||||||
// Fonctions de gestion
|
// Fonctions de gestion
|
||||||
@@ -914,23 +919,41 @@ export default function BLReconciliation() {
|
|||||||
<Search className="w-4 h-4 mr-1" />
|
<Search className="w-4 h-4 mr-1" />
|
||||||
Vérifier toutes les factures
|
Vérifier toutes les factures
|
||||||
</Button>
|
</Button>
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowBapModal(true)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<FileUp className="w-4 h-4 mr-1" />
|
||||||
|
Envoyer un BAP
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Onglets */}
|
{/* Onglets */}
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="manual" className="flex items-center space-x-2">
|
{/* Libellés courts sur téléphone : les libellés complets débordaient */}
|
||||||
<Edit className="w-4 h-4" />
|
<TabsTrigger value="manual" className="flex items-center gap-1.5 sm:gap-2 min-w-0 px-2">
|
||||||
<span>Rapprochement Manuel</span>
|
<Edit className="hidden sm:block w-4 h-4 shrink-0" />
|
||||||
<Badge variant="secondary" className="ml-2">
|
<span className="truncate">
|
||||||
|
<span className="sm:hidden">À rapprocher</span>
|
||||||
|
<span className="hidden sm:inline">Rapprochement Manuel</span>
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
{manualNotValidatedDeliveries.length}
|
{manualNotValidatedDeliveries.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="validated" className="flex items-center space-x-2">
|
<TabsTrigger value="validated" className="flex items-center gap-1.5 sm:gap-2 min-w-0 px-2">
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="hidden sm:block w-4 h-4 shrink-0" />
|
||||||
<span>Livraisons Validées</span>
|
<span className="truncate">
|
||||||
<Badge variant="secondary" className="ml-2">
|
<span className="sm:hidden">Validées</span>
|
||||||
|
<span className="hidden sm:inline">Livraisons Validées</span>
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
{allValidatedDeliveries.length}
|
{allValidatedDeliveries.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -1735,6 +1758,10 @@ export default function BLReconciliation() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<BapUploadModal open={showBapModal} onOpenChange={setShowBapModal} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { AlertTriangle, Check, Copy, KeyRound, Plug, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface ApiKey {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
keyPrefix: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
revokedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiKeysResponse {
|
||||||
|
basePath: string;
|
||||||
|
envKeyCount: number;
|
||||||
|
keys: ApiKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUERY_KEY = ["/api/external-api/keys"];
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
return new Date(value).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message d'erreur lisible depuis « 400: {"error":"..."} » renvoyé par apiRequest
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
const raw = error instanceof Error ? error.message : String(error);
|
||||||
|
const json = raw.slice(raw.indexOf(":") + 1).trim();
|
||||||
|
try {
|
||||||
|
return JSON.parse(json).error || raw;
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyButton({ value, label }: { value: string; label: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Copie impossible", description: "Sélectionnez le texte et copiez-le à la main.", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={copy} aria-label={label} className="shrink-0">
|
||||||
|
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
|
||||||
|
<span className="ml-1.5">{copied ? "Copié" : "Copier"}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExternalApiSettings() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
|
const [createdKey, setCreatedKey] = useState<{ name: string; key: string } | null>(null);
|
||||||
|
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery<ApiKeysResponse>({ queryKey: QUERY_KEY });
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => apiRequest("/api/external-api/keys", "POST", { name }),
|
||||||
|
onSuccess: (result: { key: string; apiKey: ApiKey }) => {
|
||||||
|
setCreatedKey({ name: result.apiKey.name, key: result.key });
|
||||||
|
setNewKeyName("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: "Création impossible", description: errorMessage(err), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => apiRequest(`/api/external-api/keys/${id}`, "DELETE"),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Clé révoquée", description: "Les outils qui l'utilisaient n'ont plus accès à l'API." });
|
||||||
|
setKeyToRevoke(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: "Révocation impossible", description: errorMessage(err), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiUrl = `${window.location.origin}${data?.basePath ?? "/api/ext/v1"}`;
|
||||||
|
const activeKeys = data?.keys.filter((k) => !k.revokedAt) ?? [];
|
||||||
|
const revokedKeys = data?.keys.filter((k) => k.revokedAt) ?? [];
|
||||||
|
const apiEnabled = activeKeys.length > 0 || (data?.envKeyCount ?? 0) > 0;
|
||||||
|
|
||||||
|
const handleCreate = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = newKeyName.trim();
|
||||||
|
if (name) createMutation.mutate(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto space-y-6">
|
||||||
|
{/* Présentation et adresse */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Plug className="w-5 h-5 text-blue-600" />
|
||||||
|
API externe de rapprochement
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Permet à un outil externe (n8n, comptabilité, script…) de lire les fournisseurs et les numéros de BL
|
||||||
|
des livraisons, puis d'écrire le numéro de facture, les montants et l'échéance.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700">État :</span>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="text-sm text-gray-600">Chargement…</span>
|
||||||
|
) : apiEnabled ? (
|
||||||
|
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">Active</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="border-orange-300 text-orange-800">Désactivée : aucune clé active</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="api-url">Adresse de l'API</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id="api-url" value={apiUrl} readOnly className="font-mono text-sm" />
|
||||||
|
<CopyButton value={apiUrl} label="Copier l'adresse de l'API" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
La clé s'envoie dans l'en-tête <code className="rounded bg-gray-100 px-1">X-API-Key</code>{" "}
|
||||||
|
(ou <code className="rounded bg-gray-100 px-1">Authorization: Bearer …</code>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm text-gray-700 space-y-1">
|
||||||
|
<p className="font-medium text-gray-900">Principales routes</p>
|
||||||
|
<ul className="space-y-0.5 font-mono text-xs sm:text-sm">
|
||||||
|
<li>GET /stores — magasins</li>
|
||||||
|
<li>GET /suppliers — fournisseurs</li>
|
||||||
|
<li>GET /deliveries?supplierId=…&hasInvoice=false — livraisons et n° de BL</li>
|
||||||
|
<li>PATCH /deliveries/:id — n° de facture, montants HT/TTC, échéance</li>
|
||||||
|
</ul>
|
||||||
|
<p className="pt-1">Documentation complète : <code className="rounded bg-white px-1">docs/API-RAPPROCHEMENT.md</code></p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Clés */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<KeyRound className="w-5 h-5 text-blue-600" />
|
||||||
|
Clés d'API
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Créez une clé par outil, pour pouvoir en révoquer une sans couper les autres.
|
||||||
|
La clé n'est affichée qu'une seule fois, à sa création.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-2 sm:items-end">
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Label htmlFor="api-key-name">Nom de la nouvelle clé</Label>
|
||||||
|
<Input
|
||||||
|
id="api-key-name"
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
|
placeholder="Ex. : n8n comptabilité"
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={!newKeyName.trim() || createMutation.isPending}>
|
||||||
|
<Plus className="w-4 h-4 mr-1.5" />
|
||||||
|
{createMutation.isPending ? "Création…" : "Créer une clé"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-red-700">Impossible de charger les clés : {errorMessage(error)}</p>
|
||||||
|
) : isLoading ? (
|
||||||
|
<p className="text-sm text-gray-600">Chargement…</p>
|
||||||
|
) : activeKeys.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-600">Aucune clé active.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200">
|
||||||
|
{activeKeys.map((apiKey) => (
|
||||||
|
<li key={apiKey.id} className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-gray-900 truncate">{apiKey.name}</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
<code className="rounded bg-gray-100 px-1">{apiKey.keyPrefix}…</code>
|
||||||
|
{" · "}créée le {formatDateTime(apiKey.createdAt)}
|
||||||
|
{" · "}
|
||||||
|
{apiKey.lastUsedAt ? `dernière utilisation le ${formatDateTime(apiKey.lastUsedAt)}` : "jamais utilisée"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="self-start sm:self-auto text-red-700 border-red-200 hover:bg-red-50"
|
||||||
|
onClick={() => setKeyToRevoke(apiKey)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||||
|
Révoquer
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(data?.envKeyCount ?? 0) > 0 && (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{data!.envKeyCount === 1 ? "1 clé est aussi définie" : `${data!.envKeyCount} clés sont aussi définies`} dans
|
||||||
|
la variable d'environnement <code className="rounded bg-gray-100 px-1">EXTERNAL_API_KEYS</code> du serveur
|
||||||
|
(non modifiables ici).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{revokedKeys.length > 0 && (
|
||||||
|
<details className="text-sm text-gray-600">
|
||||||
|
<summary className="cursor-pointer">Clés révoquées ({revokedKeys.length})</summary>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{revokedKeys.map((apiKey) => (
|
||||||
|
<li key={apiKey.id}>
|
||||||
|
{apiKey.name} · <code className="rounded bg-gray-100 px-1">{apiKey.keyPrefix}…</code> · révoquée le {formatDateTime(apiKey.revokedAt)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Clé créée : affichée une seule fois */}
|
||||||
|
<Dialog open={!!createdKey} onOpenChange={(open) => { if (!open) setCreatedKey(null); }}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Clé « {createdKey?.name} » créée</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Copiez-la maintenant et collez-la dans votre outil : elle ne sera plus jamais affichée.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input value={createdKey?.key ?? ""} readOnly className="font-mono text-sm" onFocus={(e) => e.currentTarget.select()} aria-label="Clé d'API" />
|
||||||
|
{createdKey && <CopyButton value={createdKey.key} label="Copier la clé d'API" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm text-amber-900">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<p>Cette clé donne accès aux livraisons de tous les magasins. Ne la partagez pas ; en cas de doute, révoquez-la.</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setCreatedKey(null)}>J'ai copié la clé</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Confirmation de révocation */}
|
||||||
|
<Dialog open={!!keyToRevoke} onOpenChange={(open) => { if (!open) setKeyToRevoke(null); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Révoquer la clé « {keyToRevoke?.name} » ?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Les outils qui l'utilisent perdront immédiatement l'accès à l'API. Cette action est définitive.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setKeyToRevoke(null)}>Annuler</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={revokeMutation.isPending}
|
||||||
|
onClick={() => keyToRevoke && revokeMutation.mutate(keyToRevoke.id)}
|
||||||
|
>
|
||||||
|
{revokeMutation.isPending ? "Révocation…" : "Révoquer"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Cloud,
|
Cloud,
|
||||||
Send,
|
Send,
|
||||||
Code,
|
Code,
|
||||||
|
Plug,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -21,6 +22,7 @@ import DatabaseDebug from "./DatabaseDebug";
|
|||||||
import WeatherSettings from "./WeatherSettings";
|
import WeatherSettings from "./WeatherSettings";
|
||||||
import WebhookBAPConfig from "./WebhookBAPConfig";
|
import WebhookBAPConfig from "./WebhookBAPConfig";
|
||||||
import SQLExecutor from "./SQLExecutor";
|
import SQLExecutor from "./SQLExecutor";
|
||||||
|
import ExternalApiSettings from "./ExternalApiSettings";
|
||||||
|
|
||||||
export default function Utilities() {
|
export default function Utilities() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
@@ -31,6 +33,7 @@ export default function Utilities() {
|
|||||||
if (path === '/nocodb-config') return 'nocodb';
|
if (path === '/nocodb-config') return 'nocodb';
|
||||||
if (path === '/database-debug') return 'debug';
|
if (path === '/database-debug') return 'debug';
|
||||||
if (path === '/weather-settings') return 'weather';
|
if (path === '/weather-settings') return 'weather';
|
||||||
|
if (new URLSearchParams(window.location.search).get('tab') === 'api') return 'api';
|
||||||
return 'backups';
|
return 'backups';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,6 +44,7 @@ export default function Utilities() {
|
|||||||
const canManageWeather = user?.role === 'admin';
|
const canManageWeather = user?.role === 'admin';
|
||||||
const canManageWebhookBAP = user?.role === 'admin';
|
const canManageWebhookBAP = user?.role === 'admin';
|
||||||
const canExecuteSQL = user?.role === 'admin';
|
const canExecuteSQL = user?.role === 'admin';
|
||||||
|
const canManageExternalApi = user?.role === 'admin';
|
||||||
// Si l'utilisateur n'a aucune permission, afficher le message d'accès refusé
|
// Si l'utilisateur n'a aucune permission, afficher le message d'accès refusé
|
||||||
if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL) {
|
if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL) {
|
||||||
return (
|
return (
|
||||||
@@ -78,7 +82,7 @@ export default function Utilities() {
|
|||||||
{/* Content avec onglets */}
|
{/* Content avec onglets */}
|
||||||
<div className="flex-1 p-6 overflow-hidden">
|
<div className="flex-1 p-6 overflow-hidden">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
|
||||||
<TabsList className="grid w-full grid-cols-6 mb-6">
|
<TabsList className="flex flex-wrap h-auto w-full justify-start gap-1 mb-6">
|
||||||
{canManageBackups && (
|
{canManageBackups && (
|
||||||
<TabsTrigger value="backups" className="flex items-center gap-2">
|
<TabsTrigger value="backups" className="flex items-center gap-2">
|
||||||
<Database className="w-4 h-4" />
|
<Database className="w-4 h-4" />
|
||||||
@@ -109,6 +113,12 @@ export default function Utilities() {
|
|||||||
Configuration BAP
|
Configuration BAP
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{canManageExternalApi && (
|
||||||
|
<TabsTrigger value="api" className="flex items-center gap-2">
|
||||||
|
<Plug className="w-4 h-4" />
|
||||||
|
API externe
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{canExecuteSQL && (
|
{canExecuteSQL && (
|
||||||
<TabsTrigger value="sql" className="flex items-center gap-2">
|
<TabsTrigger value="sql" className="flex items-center gap-2">
|
||||||
<Code className="w-4 h-4" />
|
<Code className="w-4 h-4" />
|
||||||
@@ -158,6 +168,14 @@ export default function Utilities() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canManageExternalApi && (
|
||||||
|
<TabsContent value="api" className="flex-1 overflow-hidden">
|
||||||
|
<div className="h-full bg-gray-50 -m-6 p-6">
|
||||||
|
<ExternalApiSettings />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{canExecuteSQL && (
|
{canExecuteSQL && (
|
||||||
<TabsContent value="sql" className="flex-1 overflow-hidden">
|
<TabsContent value="sql" className="flex-1 overflow-hidden">
|
||||||
<div className="h-full bg-gray-50 -m-6 p-6">
|
<div className="h-full bg-gray-50 -m-6 p-6">
|
||||||
|
|||||||
@@ -1,135 +1,63 @@
|
|||||||
/**
|
/**
|
||||||
* MobileBottomNav - Navigation bottom fixe pour l'application mobile
|
* MobileBottomNav - Barre de navigation fixe en bas de l'écran (téléphone)
|
||||||
* 5 boutons: Accueil, Commandes, Livraisons, Tâches, Plus
|
* 4 raccourcis tirés de la configuration du menu (selon le rôle) + « Menu »
|
||||||
*/
|
*/
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { useState } from "react";
|
import { Menu } from "lucide-react";
|
||||||
import {
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
Home,
|
import { getMobileShortcuts, isNavItemActive } from "@/lib/navigation";
|
||||||
Package,
|
|
||||||
Truck,
|
|
||||||
CheckSquare,
|
|
||||||
MoreHorizontal,
|
|
||||||
Calendar,
|
|
||||||
Megaphone,
|
|
||||||
ShoppingCart,
|
|
||||||
Clock,
|
|
||||||
Wrench,
|
|
||||||
Receipt
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
|
||||||
|
|
||||||
interface NavItem {
|
interface MobileBottomNavProps {
|
||||||
path: string;
|
menuOpen: boolean;
|
||||||
label: string;
|
onOpenMenu: () => void;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryItems: NavItem[] = [
|
export default function MobileBottomNav({ menuOpen, onOpenMenu }: MobileBottomNavProps) {
|
||||||
{ path: "/dashboard", label: "Accueil", icon: Home },
|
|
||||||
{ path: "/orders", label: "Commandes", icon: Package },
|
|
||||||
{ path: "/deliveries", label: "Livraisons", icon: Truck },
|
|
||||||
{ path: "/tasks", label: "Tâches", icon: CheckSquare },
|
|
||||||
];
|
|
||||||
|
|
||||||
const moreItems: NavItem[] = [
|
|
||||||
{ path: "/calendar", label: "Calendrier", icon: Calendar },
|
|
||||||
{ path: "/publicities", label: "Publicités", icon: Megaphone },
|
|
||||||
{ path: "/customer-orders", label: "Cmd Client", icon: ShoppingCart },
|
|
||||||
{ path: "/dlc", label: "DLC", icon: Clock },
|
|
||||||
{ path: "/sav", label: "SAV", icon: Wrench },
|
|
||||||
{ path: "/avoirs", label: "Avoirs", icon: Receipt },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function MobileBottomNav() {
|
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const { user } = useAuthUnified();
|
||||||
|
const shortcuts = getMobileShortcuts(user?.role);
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
// « Menu » est mis en avant quand la page affichée n'est pas un raccourci
|
||||||
if (path === "/dashboard" && (location === "/" || location === "/dashboard")) return true;
|
const menuActive = menuOpen || !shortcuts.some((item) => isNavItemActive(item.path, location));
|
||||||
return location.startsWith(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if any "more" item is active
|
const itemClasses = (active: boolean) =>
|
||||||
const isMoreActive = moreItems.some(item => isActive(item.path));
|
`flex flex-1 flex-col items-center justify-center gap-0.5 min-w-0 h-full px-1 text-xs font-medium leading-tight text-center ${active ? 'text-blue-700' : 'text-gray-600'}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<nav
|
||||||
{/* Navigation bottom fixe */}
|
className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-40"
|
||||||
<nav
|
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||||
className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50"
|
aria-label="Navigation principale"
|
||||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
>
|
||||||
>
|
<div className="flex items-stretch h-16">
|
||||||
<div className="flex items-center justify-around h-16">
|
{shortcuts.map((item) => {
|
||||||
{/* Items principaux */}
|
const Icon = item.icon;
|
||||||
{primaryItems.map((item) => {
|
const active = isNavItemActive(item.path, location);
|
||||||
const Icon = item.icon;
|
|
||||||
const active = isActive(item.path);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={item.path} href={item.path}>
|
<Link
|
||||||
<div className={`flex flex-col items-center justify-center min-w-[60px] py-1 ${active ? 'text-blue-600' : 'text-gray-500'
|
key={item.path}
|
||||||
}`}>
|
href={item.path}
|
||||||
<Icon className={`h-5 w-5 ${active ? 'text-blue-600' : 'text-gray-500'}`} />
|
aria-current={active ? "page" : undefined}
|
||||||
<span className={`text-[10px] mt-0.5 font-medium ${active ? 'text-blue-600' : 'text-gray-500'
|
className={itemClasses(active)}
|
||||||
}`}>
|
>
|
||||||
{item.label}
|
<Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||||
</span>
|
<span className="line-clamp-2">{item.label}</span>
|
||||||
</div>
|
</Link>
|
||||||
</Link>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Bouton "Plus" */}
|
<button
|
||||||
<div
|
type="button"
|
||||||
className={`flex flex-col items-center justify-center min-w-[60px] py-1 cursor-pointer ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
|
onClick={onOpenMenu}
|
||||||
}`}
|
aria-haspopup="dialog"
|
||||||
onClick={() => setMoreOpen(true)}
|
aria-expanded={menuOpen}
|
||||||
>
|
className={itemClasses(menuActive)}
|
||||||
<MoreHorizontal className={`h-5 w-5 ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
|
>
|
||||||
}`} />
|
<Menu className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||||
<span className={`text-[10px] mt-0.5 font-medium ${isMoreActive || moreOpen ? 'text-blue-600' : 'text-gray-500'
|
<span>Menu</span>
|
||||||
}`}>
|
</button>
|
||||||
Plus
|
</div>
|
||||||
</span>
|
</nav>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Sheet "Plus" */}
|
|
||||||
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
|
|
||||||
<SheetContent side="bottom" className="h-auto max-h-[50vh] rounded-t-xl">
|
|
||||||
<SheetHeader className="pb-4">
|
|
||||||
<SheetTitle>Autres pages</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3 pb-6">
|
|
||||||
{moreItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const active = isActive(item.path);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
href={item.path}
|
|
||||||
onClick={() => setMoreOpen(false)}
|
|
||||||
>
|
|
||||||
<div className={`flex flex-col items-center justify-center p-4 rounded-xl min-h-[80px] ${active
|
|
||||||
? 'bg-blue-100 text-blue-700'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}>
|
|
||||||
<Icon className={`h-6 w-6 ${active ? 'text-blue-600' : 'text-gray-600'}`} />
|
|
||||||
<span className={`text-xs mt-2 font-medium text-center ${active ? 'text-blue-700' : 'text-gray-700'
|
|
||||||
}`}>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* MobileLayout - Layout dédié pour l'application mobile
|
* MobileLayout - Cadre de l'application sur téléphone
|
||||||
* Affiche le magasin sélectionné de manière visible et une navigation bottom
|
* En-tête (titre de la page + magasin), contenu défilant, barre du bas et
|
||||||
|
* menu complet, tous tirés de la même configuration que le menu PC
|
||||||
* Note: Le StoreProvider est fourni par MobileApp au niveau supérieur
|
* Note: Le StoreProvider est fourni par MobileApp au niveau supérieur
|
||||||
*/
|
*/
|
||||||
import { ReactNode, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import { Menu, Store, ChevronDown, LogOut } from "lucide-react";
|
import { Link, useLocation } from "wouter";
|
||||||
|
import { Boxes, ChevronDown, LogOut } from "lucide-react";
|
||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { useStore } from "@/contexts/StoreContext";
|
import { useStore } from "@/contexts/StoreContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { getNavItemForPath, getNavSections, getRoleLabel, isNavItemActive } from "@/lib/navigation";
|
||||||
import MobileBottomNav from "./MobileBottomNav";
|
import MobileBottomNav from "./MobileBottomNav";
|
||||||
import type { Group } from "@shared/schema";
|
import type { Group } from "@shared/schema";
|
||||||
|
|
||||||
@@ -20,10 +23,16 @@ interface MobileLayoutProps {
|
|||||||
|
|
||||||
export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
|
const [location] = useLocation();
|
||||||
const { selectedStoreId, setSelectedStoreId, stores } = useStore();
|
const { selectedStoreId, setSelectedStoreId, stores } = useStore();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
// Get selected store name
|
// Titre de l'onglet du navigateur tiré du menu, comme sur PC
|
||||||
|
useEffect(() => {
|
||||||
|
const navItem = getNavItemForPath(location);
|
||||||
|
document.title = navItem ? `${navItem.label} — LogiFlow` : "LogiFlow";
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
const selectedStore = stores?.find((s: Group) => s.id === selectedStoreId);
|
const selectedStore = stores?.find((s: Group) => s.id === selectedStoreId);
|
||||||
const storeName = selectedStore?.name || "Tous les magasins";
|
const storeName = selectedStore?.name || "Tous les magasins";
|
||||||
|
|
||||||
@@ -47,6 +56,11 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
|||||||
setSelectedStoreId(newStoreId);
|
setSelectedStoreId(newStoreId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sections = getNavSections(user?.role);
|
||||||
|
const displayName = user && (user.firstName || user.lastName
|
||||||
|
? `${user.firstName || ''} ${user.lastName || ''}`.trim()
|
||||||
|
: user.username);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-gray-50 flex flex-col"
|
className="bg-gray-50 flex flex-col"
|
||||||
@@ -62,46 +76,38 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
|||||||
bottom: 0
|
bottom: 0
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header fixe */}
|
{/* En-tête : logo + magasin actif (le titre est porté par chaque page) */}
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
|
||||||
<div className="flex items-center justify-between h-14 px-3">
|
<div className="flex items-center justify-between gap-3 h-14 px-4">
|
||||||
{/* Menu hamburger */}
|
<Link href="/" className="flex items-center gap-2 shrink-0">
|
||||||
<Button
|
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-600">
|
||||||
variant="ghost"
|
<Boxes className="h-4 w-4 text-white" aria-hidden="true" />
|
||||||
size="sm"
|
</span>
|
||||||
onClick={() => setMenuOpen(true)}
|
|
||||||
className="h-10 w-10 p-0"
|
|
||||||
>
|
|
||||||
<Menu className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Store className="h-5 w-5 text-blue-600" />
|
|
||||||
<span className="font-semibold text-gray-900">LogiFlow</span>
|
<span className="font-semibold text-gray-900">LogiFlow</span>
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
{/* Magasin sélectionné - bien visible */}
|
<button
|
||||||
<div
|
type="button"
|
||||||
className="flex items-center gap-1 bg-blue-50 px-2 py-1 rounded-lg cursor-pointer"
|
className="flex items-center gap-1 bg-blue-50 px-2.5 py-1.5 rounded-lg min-w-0 shrink"
|
||||||
onClick={() => setMenuOpen(true)}
|
onClick={() => setMenuOpen(true)}
|
||||||
|
aria-label={`Magasin : ${storeName}. Ouvrir le menu`}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-blue-700 truncate max-w-[80px]">
|
<span className="text-sm font-medium text-blue-700 truncate max-w-[140px]">
|
||||||
{storeName}
|
{storeName}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-3 w-3 text-blue-600" />
|
<ChevronDown className="h-4 w-4 text-blue-600 shrink-0" aria-hidden="true" />
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Titre de page optionnel */}
|
{/* Titre de page optionnel */}
|
||||||
{title && (
|
{title && (
|
||||||
<div className="px-3 pb-2">
|
<div className="px-4 pb-2">
|
||||||
<h1 className="text-lg font-bold text-gray-900">{title}</h1>
|
<h1 className="text-lg font-bold text-gray-900">{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Contenu principal - scrollable avec espace pour navbar */}
|
{/* Contenu principal - défilant, avec la place de la barre du bas */}
|
||||||
<main
|
<main
|
||||||
className="flex-1 overflow-x-hidden"
|
className="flex-1 overflow-x-hidden"
|
||||||
style={{
|
style={{
|
||||||
@@ -113,35 +119,50 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
|||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Navigation bottom */}
|
<MobileBottomNav menuOpen={menuOpen} onOpenMenu={() => setMenuOpen(true)} />
|
||||||
<MobileBottomNav />
|
|
||||||
|
|
||||||
{/* Menu latéral (Sheet) */}
|
{/* Menu complet : tous les modules autorisés, magasin, profil */}
|
||||||
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
<SheetContent side="left" className="w-[280px] p-0">
|
<SheetContent side="bottom" className="max-h-[85vh] overflow-y-auto rounded-t-xl p-0">
|
||||||
<SheetHeader className="p-4 border-b">
|
<SheetHeader className="px-4 pt-4 pb-2 text-left">
|
||||||
<SheetTitle className="flex items-center gap-2">
|
<SheetTitle>Menu</SheetTitle>
|
||||||
<Store className="h-5 w-5 text-blue-600" />
|
|
||||||
LogiFlow
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="p-4 space-y-6">
|
<nav className="px-4 space-y-4" aria-label="Menu principal">
|
||||||
{/* Utilisateur */}
|
{sections.map((section) => (
|
||||||
{user && (
|
<div key={section.title ?? 'accueil'}>
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
{section.title && (
|
||||||
<p className="font-medium text-gray-900">
|
<h2 className="mb-2 text-xs font-semibold text-gray-500">{section.title}</h2>
|
||||||
{user.firstName} {user.lastName}
|
)}
|
||||||
</p>
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<p className="text-sm text-gray-500 capitalize">{user.role}</p>
|
{section.items.map((item) => {
|
||||||
</div>
|
const Icon = item.icon;
|
||||||
)}
|
const active = isNavItemActive(item.path, location);
|
||||||
|
|
||||||
{/* Sélecteur de magasin */}
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
href={item.path}
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={`flex flex-col items-center justify-center gap-1.5 rounded-xl p-2 min-h-[76px] text-center text-xs font-medium leading-tight ${active
|
||||||
|
? 'bg-blue-50 text-blue-700 ring-1 ring-blue-200'
|
||||||
|
: 'bg-gray-100 text-gray-800 active:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-6 w-6 ${active ? 'text-blue-700' : 'text-gray-600'}`} aria-hidden="true" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-gray-200 p-4 space-y-4" style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-gray-700">
|
<label className="text-sm font-medium text-gray-700">Magasin</label>
|
||||||
Magasin
|
|
||||||
</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={selectedStoreId?.toString() || (user?.role === 'admin' ? "all" : "")}
|
value={selectedStoreId?.toString() || (user?.role === 'admin' ? "all" : "")}
|
||||||
onValueChange={handleStoreChange}
|
onValueChange={handleStoreChange}
|
||||||
@@ -163,7 +184,7 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: store.color || '#gray' }}
|
style={{ backgroundColor: store.color || '#9ca3af' }}
|
||||||
/>
|
/>
|
||||||
{store.name}
|
{store.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -173,15 +194,22 @@ export default function MobileLayout({ children, title }: MobileLayoutProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Déconnexion */}
|
{user && (
|
||||||
<Button
|
<div className="flex items-center justify-between gap-3">
|
||||||
variant="outline"
|
<div className="min-w-0">
|
||||||
className="w-full h-12 text-red-600 border-red-200 hover:bg-red-50"
|
<p className="font-medium text-gray-900 truncate">{displayName}</p>
|
||||||
onClick={handleLogout}
|
<p className="text-sm text-gray-600">{getRoleLabel(user.role)}</p>
|
||||||
>
|
</div>
|
||||||
<LogOut className="h-4 w-4 mr-2" />
|
<Button
|
||||||
Déconnexion
|
variant="outline"
|
||||||
</Button>
|
className="h-11 shrink-0 text-red-600 border-red-200 hover:bg-red-50"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Déconnexion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# Fichier de configuration Docker Compose pour le développement et la production
|
|
||||||
# Utilise les variables d'environnement pour la configuration
|
|
||||||
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
volumes:
|
|
||||||
# Monter les scripts de migration
|
|
||||||
- ./scripts:/app/scripts:ro
|
|
||||||
environment:
|
|
||||||
# Forcer l'exécution des migrations au démarrage
|
|
||||||
- AUTO_MIGRATE=true
|
|
||||||
# Variables de debug
|
|
||||||
- DEBUG_MIGRATIONS=true
|
|
||||||
+21
-11
@@ -1,5 +1,22 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
|
# logiflow : serveur Rust (server-rs/), qui sert l'API et l'interface, applique
|
||||||
|
# les migrations au démarrage et lance les tâches de fond (sauvegardes, purge).
|
||||||
|
|
||||||
|
x-logiflow-env: &logiflow-env
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: postgresql://logiflow_admin:LogiFlow2025!@logiflow-db:5432/logiflow_db
|
||||||
|
SESSION_SECRET: LogiFlow_Super_Secret_Session_Key_2025_Production
|
||||||
|
USE_LOCAL_AUTH: "true"
|
||||||
|
POSTGRES_USER: logiflow_admin
|
||||||
|
POSTGRES_PASSWORD: LogiFlow2025!
|
||||||
|
POSTGRES_DB: logiflow_db
|
||||||
|
BACKUP_DIR: /app/backups
|
||||||
|
# Clé(s) de l'API externe de rapprochement, lues depuis le .env de l'hôte (vide = API désactivée)
|
||||||
|
EXTERNAL_API_KEYS: ${EXTERNAL_API_KEYS:-}
|
||||||
|
# Origines acceptées en plus de l'adresse du site pour les écritures (décision R4)
|
||||||
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
logiflow:
|
logiflow:
|
||||||
build:
|
build:
|
||||||
@@ -8,15 +25,9 @@ services:
|
|||||||
target: production
|
target: production
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
<<: *logiflow-env
|
||||||
DATABASE_URL: postgresql://logiflow_admin:LogiFlow2025!@logiflow-db:5432/logiflow_db
|
|
||||||
SESSION_SECRET: LogiFlow_Super_Secret_Session_Key_2025_Production
|
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
USE_LOCAL_AUTH: "true"
|
RUST_LOG: info
|
||||||
POSTGRES_USER: logiflow_admin
|
|
||||||
POSTGRES_PASSWORD: LogiFlow2025!
|
|
||||||
POSTGRES_DB: logiflow_db
|
|
||||||
BACKUP_DIR: /app/backups
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
networks:
|
networks:
|
||||||
@@ -25,14 +36,13 @@ services:
|
|||||||
logiflow-db:
|
logiflow-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./migrations:/app/migrations
|
|
||||||
- backup_data:/app/backups
|
- backup_data:/app/backups
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 10s
|
||||||
|
|
||||||
logiflow-db:
|
logiflow-db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
@@ -64,4 +74,4 @@ volumes:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
nginx_default:
|
nginx_default:
|
||||||
external: true
|
external: true
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# API externe — Rapprochement BL / factures
|
||||||
|
|
||||||
|
Permet à un outil tiers (logiciel comptable, n8n, script…) de lire les magasins,
|
||||||
|
les fournisseurs et les livraisons livrées avec leur numéro de BL, puis d'écrire
|
||||||
|
la référence et le montant de la facture, comme dans la page **Rapprochement**.
|
||||||
|
|
||||||
|
Code : `server/externalApi.ts`. Préfixe : `/api/ext/v1`.
|
||||||
|
|
||||||
|
## Activation et authentification
|
||||||
|
|
||||||
|
**Depuis LogiFlow (recommandé)** : *Paramètres > API externe* (admin). On y
|
||||||
|
crée une clé par outil (nommée, ex. « n8n comptabilité »), affichée une seule
|
||||||
|
fois à sa création, avec sa date de dernière utilisation ; une clé se révoque
|
||||||
|
d'un clic, sans redémarrage. Seule l'empreinte SHA-256 de la clé est stockée
|
||||||
|
(table `external_api_keys`). L'onglet s'ouvre directement par
|
||||||
|
`/utilities?tab=api`.
|
||||||
|
|
||||||
|
**Par l'environnement (optionnel)** : des clés peuvent aussi être définies,
|
||||||
|
séparées par des virgules, puis le serveur redémarré :
|
||||||
|
|
||||||
|
```
|
||||||
|
EXTERNAL_API_KEYS=3f9c…a1,7b20…e4
|
||||||
|
```
|
||||||
|
|
||||||
|
Générer une clé :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune clé active (ni dans Paramètres, ni dans l'environnement) : l'API répond
|
||||||
|
`503` (désactivée).
|
||||||
|
|
||||||
|
Chaque requête envoie la clé dans l'un de ces en-têtes :
|
||||||
|
|
||||||
|
```
|
||||||
|
X-API-Key: <clé>
|
||||||
|
Authorization: Bearer <clé>
|
||||||
|
```
|
||||||
|
|
||||||
|
Clé absente ou fausse : `401`. Une clé donne accès à **tous les magasins** ;
|
||||||
|
pour révoquer un outil, retirer sa clé de la liste et redémarrer.
|
||||||
|
Exposer l'API uniquement en HTTPS (la clé circule dans chaque requête).
|
||||||
|
|
||||||
|
## Points d'accès
|
||||||
|
|
||||||
|
### `GET /stores` — magasins
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "id": 1, "name": "Magasin Nancy" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /suppliers` — fournisseurs
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "id": 4, "name": "Lactalis", "code": "LAC01", "paymentMethod": "Virement", "automaticReconciliation": false }]
|
||||||
|
```
|
||||||
|
|
||||||
|
`code` = code fournisseur (`codefou`).
|
||||||
|
|
||||||
|
### `GET /deliveries` — livraisons à rapprocher
|
||||||
|
|
||||||
|
Seules les livraisons **livrées** sont renvoyées (même périmètre que la page
|
||||||
|
Rapprochement), de la plus récente à la plus ancienne.
|
||||||
|
|
||||||
|
| Paramètre | Exemple | Effet |
|
||||||
|
|--------------|---------------|------------------------------------------------------------|
|
||||||
|
| `storeId` | `1` | Un seul magasin |
|
||||||
|
| `supplierId` | `4` | Un seul fournisseur |
|
||||||
|
| `blNumber` | `BL-4567` | N° de BL exact (casse et espaces de bord ignorés) |
|
||||||
|
| `reconciled` | `false` | `true` = rapprochées, `false` = en attente |
|
||||||
|
| `hasBl` | `true` | Avec / sans numéro de BL |
|
||||||
|
| `hasInvoice` | `false` | Avec / sans référence facture |
|
||||||
|
| `from`, `to` | `2026-10-01` | Bornes incluses sur la date de livraison (sinon date prévue) |
|
||||||
|
| `limit` | `100` | Taille de page, 100 par défaut, 500 maximum |
|
||||||
|
| `offset` | `0` | Décalage de pagination |
|
||||||
|
|
||||||
|
Paramètre mal formé : `400` avec la liste des paramètres fautifs.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 1,
|
||||||
|
"limit": 100,
|
||||||
|
"offset": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 812,
|
||||||
|
"storeId": 1,
|
||||||
|
"storeName": "Magasin Nancy",
|
||||||
|
"supplierId": 4,
|
||||||
|
"supplierName": "Lactalis",
|
||||||
|
"supplierCode": "LAC01",
|
||||||
|
"automaticReconciliation": false,
|
||||||
|
"status": "delivered",
|
||||||
|
"scheduledDate": "2026-10-08",
|
||||||
|
"deliveredDate": "2026-10-08T07:42:00.000Z",
|
||||||
|
"blNumber": "BL-4567",
|
||||||
|
"blAmount": 1234.5,
|
||||||
|
"invoiceReference": null,
|
||||||
|
"invoiceAmount": null,
|
||||||
|
"invoiceAmountTTC": null,
|
||||||
|
"dueDate": null,
|
||||||
|
"reconciled": false,
|
||||||
|
"validatedAt": null,
|
||||||
|
"updatedAt": "2026-10-08T07:42:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Montants en nombres (euros), dates `YYYY-MM-DD`, horodatages ISO 8601 UTC.
|
||||||
|
|
||||||
|
### `GET /deliveries/:id` — une livraison
|
||||||
|
|
||||||
|
Même objet qu'un élément de `items`. Inconnue : `404`.
|
||||||
|
|
||||||
|
### `PATCH /deliveries/:id` — écrire la facture
|
||||||
|
|
||||||
|
Tous les champs sont facultatifs, au moins un est requis ; tout autre champ est refusé.
|
||||||
|
|
||||||
|
| Champ | Type | Effet |
|
||||||
|
|--------------------|-------------------------|------------------------------------------------------------------|
|
||||||
|
| `invoiceReference` | texte (100 car.) / null | Référence facture ; `null` ou `""` l'efface |
|
||||||
|
| `invoiceAmount` | nombre / texte / null | Montant HT. Accepte `1234.5`, `"1234,50"`, `"1 234,50"` |
|
||||||
|
| `invoiceAmountTTC` | nombre / texte / null | Montant TTC, mêmes formats |
|
||||||
|
| `dueDate` | `YYYY-MM-DD` / null | Échéance. Si absente et que la référence change, elle est reprise de NocoDB comme dans le webUI |
|
||||||
|
| `reconciled` | booléen | `true` valide le rapprochement, `false` le dévalide |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH https://logiflow.example/api/ext/v1/deliveries/812 \
|
||||||
|
-H "X-API-Key: $LOGIFLOW_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"invoiceReference":"FAC-2026-0042","invoiceAmount":"1234,50"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Réponse : la livraison mise à jour.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
|
||||||
|
- livraison non livrée : `409` ;
|
||||||
|
- livraison déjà rapprochée : sa facture est figée (`409`). Envoyer
|
||||||
|
`"reconciled": false` dans la même requête pour la dévalider et la modifier ;
|
||||||
|
- exception, fournisseur en **rapprochement automatique**
|
||||||
|
(`automaticReconciliation: true`) : la livraison est validée d'office dès la
|
||||||
|
saisie du BL, sa facture (référence, montants, échéance) se complète ensuite
|
||||||
|
directement, sans la dévalider ; elle reste validée ;
|
||||||
|
- corps invalide : `400` avec le détail par champ.
|
||||||
|
|
||||||
|
## Exemple : rapprocher une facture reçue
|
||||||
|
|
||||||
|
1. `GET /suppliers` → retrouver l'`id` du fournisseur via son `code`.
|
||||||
|
2. `GET /deliveries?supplierId=4&blNumber=BL-4567` → récupérer l'`id` de la livraison.
|
||||||
|
3. `PATCH /deliveries/812` avec `invoiceReference` et `invoiceAmount`
|
||||||
|
(ajouter `"reconciled": true` pour valider directement).
|
||||||
|
|
||||||
|
## Exemple : compléter la facture d'un fournisseur automatique
|
||||||
|
|
||||||
|
Les livraisons d'un fournisseur en rapprochement automatique sont déjà
|
||||||
|
validées (`reconciled: true`) ; la facture s'y ajoute sans autre champ :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH https://logiflow.example/api/ext/v1/deliveries/815 \
|
||||||
|
-H "X-API-Key: $LOGIFLOW_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"invoiceReference":"FAC-2026-0057","invoiceAmount":"842,10","invoiceAmountTTC":"1010,52","dueDate":"2026-11-30"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour les retrouver : `GET /deliveries?supplierId=<id auto>&hasInvoice=false`.
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
# Plan de migration du serveur LogiFlow vers Rust
|
||||||
|
|
||||||
|
*Plan rédigé le 15 octobre 2026. **Migration faite** : voir [Où en est la migration](#où-en-est-la-migration) ; la suite du document est le plan d'origine, gardé tel quel.*
|
||||||
|
|
||||||
|
**Sommaire** : [Où en est la migration](#où-en-est-la-migration) · [Résumé](#résumé) · [Ce qui change, ce qui ne change pas](#ce-qui-change-ce-qui-ne-change-pas) · [Le serveur actuel](#le-serveur-actuel) · [Architecture cible](#architecture-cible) · [Stratégie : une adresse à la fois](#stratégie--une-adresse-à-la-fois) · [Feuille de route](#feuille-de-route) · [Compatibilité : les pièges](#compatibilité--les-pièges) · [Ce qu'on ne reporte pas](#ce-quon-ne-reporte-pas) · [Vérification](#vérification) · [Articulation avec le plan du webUI](#articulation-avec-le-plan-du-webui) · [Risques](#risques) · [Décisions à prendre](#décisions-à-prendre)
|
||||||
|
|
||||||
|
## Où en est la migration
|
||||||
|
|
||||||
|
**Le serveur Node a quitté le dépôt.** Le serveur Rust (`server-rs/`) est le seul serveur : il sert les 148 adresses de l'API et l'interface, applique les migrations au démarrage et lance les tâches de fond (sauvegarde automatique, purge des caches, compte `admin` par défaut). L'image Docker ne contient plus Node : un binaire statique, l'interface construite et `pg_dump`. Node ne sert plus qu'à construire l'interface (Vite) et à faire tourner le banc.
|
||||||
|
|
||||||
|
**Vérification.** Le banc rejoue 2 025 requêtes (148 adresses, 5 comptes, sans session, clé d'API ; lectures et scénarios d'écriture avec l'état des tables et les appels aux services simulés) sur le serveur Node de référence — le dernier commit qui le contient, `REFERENCE_NODE_COMMIT` — et sur Rust seul : **0 écart**, hors les écarts déclarés ci-dessous. Il tourne dans la CI (`.github/workflows/rust.yml`), avec `cargo fmt`, `clippy -D warnings` et les tests unitaires.
|
||||||
|
|
||||||
|
**Écarts voulus** (déclarés au banc) :
|
||||||
|
|
||||||
|
| Code | Changement |
|
||||||
|
|---|---|
|
||||||
|
| USERS-02 | L'empreinte du mot de passe n'est plus renvoyée après création ou modification d'un compte. |
|
||||||
|
| RAPPRO-21 | L'envoi de facture n'appelle plus une adresse de webhook quelconque : seulement celle d'un magasin ou du webhook BAP actif (400 sinon). |
|
||||||
|
| R4 | Requête d'écriture venue d'un autre site refusée (403). |
|
||||||
|
| R5 | « Aujourd'hui » à l'heure de Paris : différent de Node seulement entre minuit et 1 h (2 h l'été), heure de Paris ; le banc le signale quand il tourne dans cette fenêtre. |
|
||||||
|
|
||||||
|
Tout le reste, défauts compris, est reproduit à l'identique et signalé dans le code par l'identifiant de l'audit : c'est le point de départ des corrections, une par une, chacune déclarée au banc.
|
||||||
|
|
||||||
|
**Ce qui a différé du plan** :
|
||||||
|
|
||||||
|
- **Ordre** : sur demande, le serveur Node a été retiré dès la fin du portage, sans les deux semaines de production avec Node en secours prévues au Lot 5. La première mise en production du serveur Rust seul est donc la bascule : garder l'image précédente sous la main pour revenir en arrière (même base, mêmes sessions, aucune migration de données).
|
||||||
|
- **Requêtes SQL** vérifiées à l'exécution (`sqlx::query_as`) et non à la compilation : les vérifier à la compilation demande une base au moment du build. Le banc couvre ce que la compilation aurait vérifié.
|
||||||
|
- **Schéma** : `shared/schema.ts` reste la source ; `server-rs/src/schema.rs` en est **généré** (`npm run gen:rust-schema`), l'inverse de ce que prévoyait le Lot 5. L'interface garde ses types et ses schémas zod sans changement.
|
||||||
|
- **Migrations** : celles du démarrage de Node, reprises telles quelles dans `src/startup.rs` (idempotentes), plutôt qu'une table `_sqlx_migrations`.
|
||||||
|
- **Sessions** : table `sessions` (celle de `shared/schema.ts`), cookie `connect.sid` inchangé : les sessions ouvertes avant la bascule restent valides.
|
||||||
|
|
||||||
|
**Décisions** :
|
||||||
|
|
||||||
|
| | Statut |
|
||||||
|
|---|---|
|
||||||
|
| R1 | Prise : le serveur seul, l'interface React reste. |
|
||||||
|
| R2 | Sans objet : le portage s'est fait d'un bloc, sans gel prolongé. |
|
||||||
|
| R3 | Rien n'a été retiré : toutes les adresses, y compris l'exécution de SQL et la réinitialisation d'urgence, sont portées à l'identique. La liste [Ce qu'on ne reporte pas](#ce-quon-ne-reporte-pas) reste à trancher, adresse par adresse. |
|
||||||
|
| R4 | **Faite.** Cookie `SameSite=Lax`, et toute requête `/api` qui n'est pas une lecture est refusée (403) si le navigateur indique qu'elle vient d'un autre site : `Sec-Fetch-Site`, à défaut `Origin` comparé à l'hôte de la requête (`Host` ou `X-Forwarded-Host`). Sans en-tête (n8n, curl) elle passe ; `/api/ext/` (clé d'API) n'est pas concerné ; `ALLOWED_ORIGINS` ajoute des origines. Code : `server-rs/src/origin.rs`. |
|
||||||
|
| R5 | **Faite.** « Aujourd'hui » se calcule à Paris (`server-rs/src/clock.rs`) : produits DLC expirés ou proches, tâches à venir, météo du jour et de l'an dernier, année et mois par défaut des statistiques, numéro de ticket SAV, nom de l'export CSV, libellé des sauvegardes, et sauvegarde automatique à partir de 2 h, heure de Paris. Les instants enregistrés restent en UTC. |
|
||||||
|
| R6 | **Faite.** Mainteneur et relecteur : @R0m1k3, propriétaire du dépôt (`.github/CODEOWNERS` : `server-rs/`, banc, schéma, droits, Docker, CI). Toute modification passe par une PR avec la CI verte (mise en forme, clippy, tests, banc). À activer dans les réglages GitHub : protection de `main` avec relecture des CODEOWNERS et vérifications obligatoires. Un second relecteur connaissant Rust reste souhaitable. |
|
||||||
|
|
||||||
|
## Résumé
|
||||||
|
|
||||||
|
- **Quoi.** Réécrire le serveur (Express + TypeScript + Drizzle, environ 18 800 lignes dans `server/` et `shared/`, 148 adresses d'API) en Rust. **L'interface React reste telle quelle** : elle ne parle au serveur qu'en JSON, sous les mêmes adresses.
|
||||||
|
- **Comment.** Progressivement, jamais d'un bloc. Un serveur Rust se place devant l'actuel : il sert l'application et les adresses déjà réécrites, et transmet toutes les autres au serveur Node. Module par module, chaque adresse passe en Rust quand elle rend **exactement** les mêmes réponses (vérifié au banc de mesure), puis disparaît du code Node. À la fin, Node ne sert plus qu'à construire l'interface.
|
||||||
|
- **Ce que ça apporte.** Des types vérifiés de bout en bout, requêtes SQL comprises : les 88 erreurs TypeScript actuelles de `server/storage.ts` (comparaisons de texte et de date, champs possiblement vides) ne compileraient pas. Un serveur plus sobre en mémoire et plus rapide à démarrer, livré comme un seul binaire, sans `node_modules` en production. Et l'occasion de poser enfin des tests : le dépôt n'en a aucun.
|
||||||
|
- **Ce que ça coûte.** **4 à 6 mois pour un développeur à plein temps qui connaît Rust** (compter 1 à 2 mois de plus pour l'apprendre), pendant lesquels le serveur évolue moins vite ; deux serveurs à faire tourner pendant la transition ; une compétence Rust à garder dans l'équipe pour la maintenance.
|
||||||
|
- **Avant de commencer** (Lot 0, environ 3 semaines) : le banc de mesure versionné dans le dépôt, les sessions enregistrées dans PostgreSQL au lieu de la mémoire du serveur, le schéma réel de la base de production relevé, et les corrections de sécurité urgentes de la Phase 1a faites dans le serveur actuel, sans attendre Rust.
|
||||||
|
- Six décisions sont à prendre avant le Lot 0 ([Décisions à prendre](#décisions-à-prendre)) ; chacune a une proposition par défaut.
|
||||||
|
|
||||||
|
## Ce qui change, ce qui ne change pas
|
||||||
|
|
||||||
|
| | Change | Ne change pas |
|
||||||
|
|---|---|---|
|
||||||
|
| **Équipes en magasin** | Rien de visible, sauf les corrections décidées (fuseau horaire, protections). | Écrans, adresses, favoris, identifiants et mots de passe, session en cours au moment de la bascule. |
|
||||||
|
| **Interface** (`client/`) | En développement : `vite` lancé seul, qui renvoie `/api` vers le serveur Rust, au lieu de Vite intégré à Express (`server/vite.ts`). | React, Vite, Tailwind, React Query, le code des pages. |
|
||||||
|
| **Serveur** (`server/`) | Réécrit en Rust dans `server-rs/`, puis supprimé. | Les adresses, les réponses JSON, les codes d'erreur. |
|
||||||
|
| **Types partagés** (`shared/`) | Générés à partir du code Rust à la fin de la migration (Lot 5). | Le paquet `shared/` reste en TypeScript pour l'interface : 33 fichiers de `client/src` l'importent. |
|
||||||
|
| **Base de données** | Migrations suivies par une table (`_sqlx_migrations`) au lieu de scripts lancés à la main. | PostgreSQL, les tables, les données. Aucune migration de données. |
|
||||||
|
| **Déploiement** | Image Docker plus légère : un binaire, sans `node_modules`. | Docker Compose, le réseau nginx, le port 3000, `/api/health`, les sauvegardes `pg_dump`. |
|
||||||
|
|
||||||
|
**Pourquoi garder React.** Réécrire aussi l'interface en Rust (Leptos, Yew, Dioxus) reviendrait à refaire une trentaine de pages et leurs versions téléphone, au moment où le plan du webUI les refait déjà (Phases 2 et 3). Cela doublerait le coût sans rien apporter aux équipes : la lenteur venait du serveur et des volumes échangés (P4 du plan du webUI), pas de React. C'est la décision R1.
|
||||||
|
|
||||||
|
## Le serveur actuel
|
||||||
|
|
||||||
|
Inventaire au 15 octobre 2026, qui fixe le périmètre à reporter. Les numéros de ligne renvoient au code de cette date.
|
||||||
|
|
||||||
|
### Taille
|
||||||
|
|
||||||
|
| Fichier | Lignes | Contenu |
|
||||||
|
|---|---|---|
|
||||||
|
| `server/routes.ts` | 6 077 | 134 adresses, contrôles de droits écrits dans chaque gestionnaire |
|
||||||
|
| `server/storage.ts` | 5 914 | Accès aux données : environ 127 méthodes. Seule `DatabaseStorage` (lignes 387 à 3509) est à reporter ; `MemStorage` (stockage en mémoire de développement) disparaît. |
|
||||||
|
| `shared/schema.ts` | 1 047 | 26 tables Drizzle, schémas zod |
|
||||||
|
| `server/invoiceVerification.ts` | 922 | Vérification des factures dans NocoDB, avec cache |
|
||||||
|
| Les 27 autres fichiers | environ 4 900 | Authentification, API externe, sauvegardes, météo, courriels, chiffrement, migrations |
|
||||||
|
|
||||||
|
### Les 148 adresses, par module
|
||||||
|
|
||||||
|
| Module | Adresses | Lot |
|
||||||
|
|---|---|---|
|
||||||
|
| Santé (`/api/health`) | 1 | 1 |
|
||||||
|
| Contacts | 4 | 1 |
|
||||||
|
| Fiches fournisseurs | 4 | 1 |
|
||||||
|
| Informations (annonces) | 4 | 2 |
|
||||||
|
| Publicités (`/api/ad-campaigns`) | 7 | 2 |
|
||||||
|
| Météo | 6 | 2 |
|
||||||
|
| Statistiques (`/api/stats`) | 4 | 2 |
|
||||||
|
| Statistiques détaillées (`/api/analytics`, export CSV) | 5 | 2 |
|
||||||
|
| Tâches | 5 | 3 |
|
||||||
|
| DLC | 11 | 3 |
|
||||||
|
| Commandes clients | 6 | 3 |
|
||||||
|
| SAV | 7 | 3 |
|
||||||
|
| Commandes fournisseurs | 6 | 4 |
|
||||||
|
| Livraisons | 7 | 4 |
|
||||||
|
| Rapprochement BL / factures (commentaires, courriels fournisseurs, vérification de facture, cache) | 13 | 4 |
|
||||||
|
| Avoirs | 8 | 4 |
|
||||||
|
| Échéancier (dont export CSV) | 2 | 4 |
|
||||||
|
| Configuration NocoDB | 5 | 4 |
|
||||||
|
| Bon à payer (BAP) et webhook | 4 | 4 |
|
||||||
|
| API externe (`/api/ext/v1`) et gestion de ses clés | 8 | 4 |
|
||||||
|
| Relais ffnancy | 2 | 4 |
|
||||||
|
| Authentification | 6 | 5 |
|
||||||
|
| Utilisateurs | 8 | 5 |
|
||||||
|
| Magasins (`/api/groups`) | 5 | 5 |
|
||||||
|
| Sauvegardes | 4 | 5 |
|
||||||
|
| Paramètres (`/api/utilities`) | 2 | 5 |
|
||||||
|
| Administration et diagnostic (SQL, schéma, migration d'urgence) | 4 | 5 (décision R3) |
|
||||||
|
| **Total** | **148** | |
|
||||||
|
|
||||||
|
Le Calendrier n'a pas d'adresse à lui : il lit les commandes, les livraisons et les publicités. Il est entièrement servi par Rust à la fin du Lot 4.
|
||||||
|
|
||||||
|
### Ce que l'inventaire a révélé
|
||||||
|
|
||||||
|
Ces constats changent la façon de migrer ; certains sont déjà dans l'audit du webUI.
|
||||||
|
|
||||||
|
- **La production n'utilise pas `localAuth.production.ts`.** `server/routes.ts:4` importe `./localAuth` et c'est lui qui s'exécute : sessions dans la mémoire du processus (perdues à chaque redémarrage, AUTH-06), cookie `connect.sid` valable 24 heures, sans `sameSite`. `localAuth.production.ts`, `db.production.ts` et `monitoring.ts` ne sont jamais exécutés.
|
||||||
|
- **Aucune protection CSRF, aucune limite de débit, aucun en-tête de sécurité en production** : `server/security.ts` n'est branché que dans `server/index.ts`, qui n'est pas le point d'entrée de l'image Docker (`server/index.production.ts`). L'interface n'envoie d'ailleurs aucun jeton CSRF.
|
||||||
|
- **Mots de passe** : `scrypt` de Node (N = 16384, r = 8, p = 1, 64 octets), stockés en `empreinte_hex.sel_hex`, le sel hexadécimal étant utilisé tel quel comme texte (`server/localAuth.ts:20-57`). Le paquet `scrypt` de Rust les vérifie à l'identique : **aucun mot de passe à réinitialiser**.
|
||||||
|
- **Secrets chiffrés** (mot de passe SMTP des magasins, jeton NocoDB) : AES-256-GCM, clé = SHA-256 de `ENCRYPTION_KEY` (à défaut `SESSION_SECRET`), format `enc:v1:iv:tag:données` en base64 (`server/crypto.ts`). Reproductible à l'identique avec le paquet `aes-gcm`.
|
||||||
|
- **Droits** : une seule adresse utilise un intergiciel de droits (`requireAdmin`, `server/routes.ts:130`). Les autres contrôlent le rôle à la main (environ 131 comparaisons `user.role` et 14 appels `hasPermission`), et DLC, Tâches et Commandes clients ne contrôlent que le magasin.
|
||||||
|
- **Base** : aucune migration n'est suivie en production. `init.sql` ne crée que 25 tables et ignore des colonnes ajoutées depuis à la main (`contacts`, `suppliers.email`, `customer_orders.notified_at`…). Seul le schéma réel de la production fait foi.
|
||||||
|
- **Aucune transaction** dans tout le serveur, et un numéro de SAV calculé en « nombre de tickets + 1 » (`server/storage.ts:2824`), qui peut produire des doublons.
|
||||||
|
- **Dépendances inutilisées** : `xlsx`, `iconv-lite`, `multer`, `busboy`, `node-cron`, `csrf-csrf`, `openid-client`, `memoizee` ne sont importés nulle part. Pas de génération Excel ni PDF côté serveur ; les exports sont en CSV.
|
||||||
|
- **Aucun test** dans le dépôt. La seule intégration continue construit l'image Docker.
|
||||||
|
|
||||||
|
## Architecture cible
|
||||||
|
|
||||||
|
### Choix techniques
|
||||||
|
|
||||||
|
| Besoin | Aujourd'hui (Node) | En Rust |
|
||||||
|
|---|---|---|
|
||||||
|
| Serveur HTTP | Express | `axum` sur `tokio` |
|
||||||
|
| Compression, fichiers statiques, limite de taille, journal des requêtes | `compression`, `express.static`, `express.json({ limit: '10mb' })` | `tower-http` (`CompressionLayer`, `ServeDir`, `RequestBodyLimitLayer`, `TraceLayer`) |
|
||||||
|
| Base de données | Drizzle + `pg` | `sqlx` (PostgreSQL) : requêtes SQL écrites en clair, **vérifiées à la compilation** contre le schéma ; cache `.sqlx/` versionné pour compiler sans base |
|
||||||
|
| Migrations | scripts au démarrage + `psql` dans le `CMD` du Dockerfile | `sqlx::migrate!`, fichiers SQL numérotés, table de suivi |
|
||||||
|
| Validation des entrées | zod | `serde` (forme) + `validator` ou contrôles écrits à la main (règles) |
|
||||||
|
| Sessions, connexion | `express-session` + `passport-local` | intergiciel maison, d'environ 200 lignes : lecture du cookie signé, table `session` (voir [Sessions](#sessions-partagées-entre-node-et-rust)) |
|
||||||
|
| Mots de passe | `crypto.scrypt` | `scrypt` (mêmes paramètres) |
|
||||||
|
| Chiffrement des secrets | `crypto` AES-256-GCM | `aes-gcm`, `sha2`, `base64` |
|
||||||
|
| Clés de l'API externe | SHA-256 + `timingSafeEqual` | `sha2` + `subtle` |
|
||||||
|
| Appels sortants (NocoDB, Visual Crossing, Nominatim, ffnancy, webhooks n8n) | `node-fetch` | `reqwest` (TLS `rustls`), délai maximal sur chaque appel |
|
||||||
|
| Courriels fournisseurs | `nodemailer` | `lettre` (logo du magasin en pièce jointe intégrée `cid:logo`) |
|
||||||
|
| Sauvegardes | `exec('pg_dump … > fichier')` dans un shell | `tokio::process::Command` avec arguments séparés, sans shell |
|
||||||
|
| Tâches de fond | `setInterval` | tâches `tokio` (`tokio::time::interval`) |
|
||||||
|
| Dates et fuseau | `Date` de JavaScript | `chrono` + `chrono-tz` (`Europe/Paris`, décision R5) |
|
||||||
|
| Montants | texte renvoyé par `pg` | `rust_decimal`, sérialisé en texte (voir [Compatibilité](#compatibilité--les-pièges)) |
|
||||||
|
| Journaux | environ 150 `console.log` dans `routes.ts` | `tracing`, à niveaux, sans corps de requête ni données clients |
|
||||||
|
| Types pour l'interface | `shared/schema.ts` | `ts-rs` : les types TypeScript sont générés depuis les structures Rust (Lot 5) |
|
||||||
|
|
||||||
|
Les versions sont figées dans `Cargo.lock`, versionné. Une seule édition de Rust et une version minimale (`rust-version`) inscrites dans `Cargo.toml`.
|
||||||
|
|
||||||
|
### Organisation du code
|
||||||
|
|
||||||
|
Un seul paquet (`crate`) pour commencer, découpé en modules qui reprennent ceux de l'application. On ne le découpe en plusieurs paquets que si la compilation devient trop lente.
|
||||||
|
|
||||||
|
```
|
||||||
|
server-rs/
|
||||||
|
Cargo.toml
|
||||||
|
migrations/ fichiers SQL numérotés (sqlx), le premier = schéma de production relevé
|
||||||
|
.sqlx/ requêtes vérifiées, pour compiler sans base (SQLX_OFFLINE=true)
|
||||||
|
src/
|
||||||
|
main.rs configuration, démarrage, migrations, tâches de fond
|
||||||
|
router.rs table des adresses servies par Rust ; tout le reste part vers Node
|
||||||
|
proxy.rs relais vers le serveur Node (supprimé au Lot 5)
|
||||||
|
auth/ session, cookie signé, utilisateur courant, connexion
|
||||||
|
access.rs droits (rôle × module) et cloisonnement par magasin
|
||||||
|
json.rs formats partagés : dates, montants, erreurs { message }
|
||||||
|
crypto.rs secrets chiffrés (compatible enc:v1)
|
||||||
|
modules/ un fichier par module : contacts.rs, dlc.rs, deliveries.rs…
|
||||||
|
chacun avec ses gestionnaires HTTP et ses requêtes SQL
|
||||||
|
integrations/ nocodb.rs, mail.rs, weather.rs, webhooks.rs, ffnancy.rs, backup.rs
|
||||||
|
jobs.rs sauvegarde quotidienne, purge du cache des factures
|
||||||
|
tests/ tests d'intégration contre une base PostgreSQL jetable
|
||||||
|
```
|
||||||
|
|
||||||
|
**Une seule table de droits.** La matrice de `shared/permissions.ts` passe dans un fichier de données, `shared/permissions.json`, lu par le serveur Rust (`include_str!` + `serde`) et par l'interface (import JSON). C'est la « table de droits unique » de la Phase 2 du plan du webUI, sans recopie entre les deux langages. Chaque gestionnaire Rust déclare son module et son action ; le contrôle est fait par un extracteur `axum`, plus dans le corps de chaque fonction.
|
||||||
|
|
||||||
|
### Sessions partagées entre Node et Rust
|
||||||
|
|
||||||
|
Pendant la transition, un utilisateur connecté par Node doit être reconnu par Rust, et inversement. Les sessions en mémoire du processus Node ne le permettent pas : **il faut d'abord les enregistrer dans PostgreSQL** (Lot 0). C'est aussi la correction d'AUTH-06 (sessions perdues à chaque redéploiement), déjà prévue par le plan du webUI.
|
||||||
|
|
||||||
|
- **Côté Node (Lot 0)** : `connect-pg-simple` (déjà dans les dépendances), table `session (sid, sess, expire)`. Les utilisateurs se reconnectent une fois, à cette livraison-là.
|
||||||
|
- **Côté Rust (Lot 1)** : lire le cookie `connect.sid` (`s:` + identifiant + `.` + signature HMAC-SHA256 avec `SESSION_SECRET`, en base64 sans `=`), vérifier la signature en temps constant, lire `sess->'passport'->>'user'` dans la table `session` si `expire` n'est pas dépassé, puis charger l'utilisateur et ses magasins. Une seule lecture de l'utilisateur par requête (règle « utilisateur courant » de P4 § 5.2).
|
||||||
|
- **Connexion et déconnexion** restent dans Node jusqu'au Lot 5. Rust les reprend alors en écrivant des sessions **au même format** : aucune reconnexion à la bascule, et un retour à Node reste possible.
|
||||||
|
|
||||||
|
## Stratégie : une adresse à la fois
|
||||||
|
|
||||||
|
```
|
||||||
|
navigateur (PC, téléphone)
|
||||||
|
│
|
||||||
|
nginx (réseau nginx_default)
|
||||||
|
│
|
||||||
|
┌───────────────────▼────────────────────┐
|
||||||
|
│ logiflow : serveur Rust, port 3000 │
|
||||||
|
│ /assets, index.html, adresses portées │
|
||||||
|
└───────────────────┬────────────────────┘
|
||||||
|
│ toute autre adresse /api
|
||||||
|
┌───────────────────▼────────────────────┐
|
||||||
|
│ logiflow-node : serveur actuel │
|
||||||
|
│ port 3001, interne, non exposé │
|
||||||
|
└───────────────────┬────────────────────┘
|
||||||
|
│
|
||||||
|
PostgreSQL (logiflow-db), table session commune
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Le serveur Rust est devant dès le premier jour**, avec zéro adresse portée : il sert l'interface (`/assets` avec cache d'un an, `index.html` pour toutes les autres adresses de l'application) et relaie tout `/api` à Node. Cette première livraison ne change rien pour les équipes et valide la tuyauterie en production.
|
||||||
|
- **Le relais** transmet la méthode, l'adresse, les en-têtes (cookie, `X-API-Key`, `Authorization`, type de contenu) et le corps en flux, sans le lire (l'envoi de facture en `multipart` passe tel quel). Il rend la réponse telle quelle, `Set-Cookie` et redirections 302 comprises (la déconnexion redirige vers `/auth`), sans les suivre. Délai maximal de 120 secondes : certains appels attendent NocoDB ou un webhook jusqu'à 60 secondes.
|
||||||
|
- **La table des adresses est dans le code** (`router.rs`) : une adresse est servie par Rust si elle y figure, par Node sinon. Faire revenir une adresse à Node, c'est retirer sa ligne et redéployer.
|
||||||
|
- **Une adresse vit à un seul endroit.** Quand elle passe en Rust, son code Node n'est plus appelé ; on le supprime après deux semaines en production sans régression, dans une PR dédiée.
|
||||||
|
- **Les tâches de fond ne tournent jamais dans les deux serveurs** : sauvegarde quotidienne, purge du cache des factures, configuration météo et création des index restent dans Node jusqu'au Lot 5, puis passent toutes ensemble en Rust.
|
||||||
|
- **Le schéma ne bouge pas pendant qu'un module migre.** Un changement de schéma passe par un fichier de `server-rs/migrations/`, appliqué par Rust au démarrage. Les migrations de démarrage de Node (`server/migrations.production.ts`, toutes en `IF NOT EXISTS`) continuent jusqu'au Lot 5, puis deviennent des fichiers SQL.
|
||||||
|
|
||||||
|
## Feuille de route
|
||||||
|
|
||||||
|
Effort pour un développeur à plein temps qui connaît Rust ; échelle **S** / **M** / **L** du plan du webUI. Chaque lot se termine par une livraison en production et deux semaines de suivi, qui recouvrent le début du lot suivant.
|
||||||
|
|
||||||
|
| Lot | Contenu | Adresses | Effort | Prérequis |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| [0. Préparation](#lot-0--préparation) | Banc, sessions en base, schéma relevé, squelette Rust devant Node | 0 | 3 semaines | Décisions R1 à R6 ; Phase 1a du webUI |
|
||||||
|
| [1. Socle](#lot-1--socle) | Session, droits, magasin, formats JSON, premiers modules | 9 | 2 à 3 semaines | Lot 0 en production |
|
||||||
|
| [2. Informations et planning](#lot-2--informations-et-planning) | Annonces, Publicités, Météo, Statistiques | 26 | 3 semaines | Lot 1 |
|
||||||
|
| [3. Au quotidien](#lot-3--au-quotidien) | Tâches, DLC, Commandes clients, SAV | 29 | 3 à 4 semaines | Lot 1 |
|
||||||
|
| [4. Fournisseurs](#lot-4--fournisseurs) | Commandes, Livraisons, Rapprochement, Avoirs, Échéancier, NocoDB, BAP, API externe | 55 | 6 à 8 semaines | Lot 1 |
|
||||||
|
| [5. Administration et bascule](#lot-5--administration-et-bascule) | Connexion, Utilisateurs, Magasins, Sauvegardes, Paramètres, tâches de fond ; arrêt de Node | 29 | 3 à 4 semaines | Lots 2 à 4 |
|
||||||
|
| **Total** | | **148** | **20 à 25 semaines** | |
|
||||||
|
|
||||||
|
Les lots 2, 3 et 4 sont indépendants entre eux : un second développeur peut en prendre un en parallèle. L'ordre proposé va du plus simple au plus délicat : les premiers lots rodent les outils sur des modules à faible risque, et le Lot 4, qui porte l'essentiel de la logique métier (factures, NocoDB, rapprochement), arrive quand la méthode est éprouvée.
|
||||||
|
|
||||||
|
### Lot 0 — Préparation
|
||||||
|
|
||||||
|
**Objectif.** Pouvoir prouver, adresse par adresse, que Rust répond comme Node ; et poser le serveur Rust en production sans rien changer.
|
||||||
|
|
||||||
|
1. **Verser le banc de mesure dans le dépôt** (`scripts/bench/`, c'est le lot 2, n° 0 du plan du webUI), et l'étendre :
|
||||||
|
- les 148 adresses, pour 5 comptes : administrateur, directeur multi-magasins, manager, employé, compte sans magasin ;
|
||||||
|
- des scénarios d'écriture (créer, modifier, valider, supprimer) rejoués sur une copie neuve de la base, avec comparaison de l'état des tables touchées après coup ;
|
||||||
|
- la comparaison porte sur le code HTTP, le corps JSON (valeurs, ordre des listes, champs `null`), et les en-têtes qui comptent (`Content-Type`, `Cache-Control`, `Set-Cookie`, `Location`) ;
|
||||||
|
- une horloge figée pour les réponses qui dépendent de « aujourd'hui ».
|
||||||
|
2. **Sessions dans PostgreSQL** côté Node (`connect-pg-simple`, table `session`), cookie `sameSite: 'lax'`. Corrige AUTH-06.
|
||||||
|
3. **Relever le schéma réel de la production** (`pg_dump --schema-only` sur une copie) ; il devient `server-rs/migrations/0001_schema_initial.sql`, marqué comme déjà appliqué sur les bases existantes. Noter les écarts avec `init.sql`.
|
||||||
|
4. **Squelette Rust** : configuration (mêmes variables d'environnement : `DATABASE_URL`, `SESSION_SECRET`, `ENCRYPTION_KEY`, `EXTERNAL_API_KEYS`, `VISUAL_CROSSING_API_KEY`, `BACKUP_DIR`, `PORT`), `/api/health`, fichiers statiques, renvoi de `index.html`, relais vers Node, journaux `tracing`, arrêt propre.
|
||||||
|
5. **Docker** : un service `logiflow` (Rust, port 3000) et un service `logiflow-node` (port 3001, non exposé) dans `docker-compose.yml` ; Dockerfile en étapes : interface construite avec Node, binaire Rust compilé, image finale avec le binaire, `dist/public` et `postgresql-client` (pour `pg_dump`).
|
||||||
|
6. **Intégration continue** : `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test` avec un service PostgreSQL, compilation en `SQLX_OFFLINE=true`, en plus de la construction de l'image existante.
|
||||||
|
|
||||||
|
**Critères de réussite.**
|
||||||
|
|
||||||
|
- Le banc passe sur l'ensemble des 148 adresses, avec Rust devant Node : 0 différence de statut, de corps ou d'en-tête par rapport à Node seul.
|
||||||
|
- En production : connexion, déconnexion (302 vers `/auth`), envoi d'une facture en `multipart`, envoi d'un BAP (PDF de plusieurs Mo), appel de l'API externe avec sa clé, téléchargement d'une sauvegarde : tout fonctionne à travers le relais.
|
||||||
|
- Un redéploiement ne déconnecte plus personne.
|
||||||
|
|
||||||
|
### Lot 1 — Socle
|
||||||
|
|
||||||
|
**Objectif.** Tout ce dont chaque module aura besoin, éprouvé sur trois modules simples.
|
||||||
|
|
||||||
|
- **Utilisateur courant** : extracteur `axum` qui lit la session et charge l'utilisateur avec ses magasins, une fois par requête ; 401 `{ "message": … }` sinon.
|
||||||
|
- **Droits et magasins** : `shared/permissions.json` (voir [Organisation du code](#organisation-du-code)) ; extracteurs « module + action » et « magasin autorisé ». Les règles appliquées sont celles du serveur Node à ce moment-là, y compris les corrections de la Phase 1a.
|
||||||
|
- **Formats JSON** communs ([Compatibilité](#compatibilité--les-pièges)) : dates, horodatages, montants, noms de champs, `smtpPasswordSet` à la place du mot de passe SMTP, `Cache-Control: private, no-cache` sur `/api`.
|
||||||
|
- **Erreurs** : `{ "message": "…" }` en français, avec les mêmes codes HTTP que Node.
|
||||||
|
- **Chiffrement** compatible `enc:v1`, testé sur des valeurs chiffrées par le code Node.
|
||||||
|
- **Modules** : Santé (1), Contacts (4), Fiches fournisseurs (4).
|
||||||
|
|
||||||
|
**Critères de réussite.** 0 différence au banc sur les 9 adresses pour les 5 comptes ; un secret chiffré par Node est relu par Rust et inversement ; tests unitaires des droits pour chaque case du tableau rôle × module.
|
||||||
|
|
||||||
|
### Lot 2 — Informations et planning
|
||||||
|
|
||||||
|
Annonces (4), Publicités (7), Météo (6, avec Visual Crossing et Nominatim), Statistiques (4) et Statistiques détaillées (5, dont l'export CSV). Ce sont surtout des lectures et des agrégats : c'est le lot qui rode `sqlx`, les requêtes construites selon les filtres (aujourd'hui assemblées en texte dans `server/storage.ts:3337-3386` et `:3450-3487`, à remplacer par des requêtes paramétrées) et les exports CSV.
|
||||||
|
|
||||||
|
**Critères de réussite.** 0 différence au banc sur les 26 adresses ; mêmes octets pour les exports CSV ; aucun appel externe sans délai maximal.
|
||||||
|
|
||||||
|
### Lot 3 — Au quotidien
|
||||||
|
|
||||||
|
Tâches (5), DLC (11), Commandes clients (6), SAV (7). Les modules les plus utilisés en magasin, mais à la logique simple. Le numéro de SAV est attribué par la base (séquence ou `INSERT … RETURNING` dans une transaction) au lieu du comptage actuel, sans changer son format `SAV-AAAA-0001`.
|
||||||
|
|
||||||
|
**Critères de réussite.** 0 différence au banc sur les 29 adresses et les scénarios d'écriture (création, modification, validation, suppression, marquage d'une DLC traitée, client prévenu).
|
||||||
|
|
||||||
|
### Lot 4 — Fournisseurs
|
||||||
|
|
||||||
|
Commandes fournisseurs (6), Livraisons (7), Rapprochement BL / factures (13), Avoirs (8), Échéancier (2), Configuration NocoDB (5), BAP et webhook (4), API externe et ses clés (8), relais ffnancy (2). Le lot le plus lourd :
|
||||||
|
|
||||||
|
- `server/invoiceVerification.ts` (922 lignes) : recherche dans NocoDB par référence de facture puis par numéro de BL, rapprochement du nom de fournisseur, cache avec ses trois durées (6 heures, 12 heures, quasi permanent une fois rapproché). Reporter aussi le second client NocoDB (`server/nocodbService.ts`) ou, si `/api/verify-invoices` n'a pas d'appelant, le supprimer (décision R3) ;
|
||||||
|
- normalisation des échéances NocoDB (`server/dateUtils.ts`, jour avant mois par défaut) ;
|
||||||
|
- courriel de demande de documents au fournisseur (`shared/supplierMail.ts`, logo intégré), avec son journal ;
|
||||||
|
- API externe : clés `lf_…` (seules l'empreinte SHA-256 et un préfixe sont stockés), montants saisis à la française (« 1 234,56 »), règles de gel d'un rapprochement ; documentation `docs/API-RAPPROCHEMENT.md` inchangée ;
|
||||||
|
- les écritures qui touchent plusieurs tables (validation d'une livraison, rapprochement) passent dans une transaction.
|
||||||
|
|
||||||
|
**Critères de réussite.** 0 différence au banc sur les 55 adresses ; NocoDB et les webhooks remplacés au banc par un faux service qui enregistre les appels reçus, identiques entre Node et Rust ; l'outil externe qui appelle `/api/ext/v1` fonctionne sans changement de sa part.
|
||||||
|
|
||||||
|
### Lot 5 — Administration et bascule
|
||||||
|
|
||||||
|
- **Connexion et déconnexion** (6 adresses, dont la réinitialisation d'urgence : décision R3), **Utilisateurs** (8), **Magasins** (5), **Sauvegardes** (4), **Paramètres** (2), **Administration et diagnostic** (4, décision R3).
|
||||||
|
- **Tâches de fond** en Rust : sauvegarde quotidienne après 2 h (heure de Paris), purge du cache des factures toutes les 6 heures, configuration météo au démarrage ; création des index et migrations de démarrage converties en fichiers de `server-rs/migrations/`.
|
||||||
|
- **Types partagés** : `ts-rs` génère `shared/api/*.ts` depuis les structures Rust ; les imports de types de `@shared/schema` dans `client/src` sont remplacés ; les trois schémas zod de formulaire utilisés par l'interface (`insertTaskSchema`, `insertCustomerOrderFrontendSchema`, `insertAnnouncementSchema`) restent écrits à la main dans `shared/forms.ts`.
|
||||||
|
- **Arrêt de Node** : suppression de `server/`, du relais, du service `logiflow-node`, de Drizzle et des dépendances serveur de `package.json` ; l'image finale ne contient plus Node.
|
||||||
|
|
||||||
|
**Critères de réussite.** 0 différence au banc sur les 148 adresses, Rust seul ; un utilisateur connecté avant la bascule reste connecté après ; une sauvegarde automatique est créée la nuit suivante et téléchargeable ; `grep -r "server/" Dockerfile package.json` ne renvoie plus rien.
|
||||||
|
|
||||||
|
## Compatibilité : les pièges
|
||||||
|
|
||||||
|
L'interface lit les réponses JSON telles que Node les produit aujourd'hui. Chaque écart ci-dessous casserait un écran sans erreur visible ; le banc les détecte, mais autant les traiter dès le Lot 1.
|
||||||
|
|
||||||
|
| Sujet | Ce que Node renvoie | Ce qu'il faut en Rust |
|
||||||
|
|---|---|---|
|
||||||
|
| Montants (`decimal(10,2)`, 7 colonnes) | du texte : `"12.50"` | `rust_decimal` sérialisé en texte, échelle conservée ; jamais un nombre JSON |
|
||||||
|
| Dates (`date`, 10 colonnes) | `"2026-10-15"` | `chrono::NaiveDate` (format par défaut identique) |
|
||||||
|
| Horodatages (`timestamp` sans fuseau, 62 colonnes) | `"2026-10-15T08:30:00.000Z"` : lus comme UTC, millisecondes et `Z` | sérialiseur dédié `%Y-%m-%dT%H:%M:%S%.3fZ` ; le format par défaut de `chrono` n'a ni `Z` ni millisecondes fixes |
|
||||||
|
| Noms de champs | `camelCase` (Drizzle) ; certaines requêtes brutes rendent du `snake_case` | `#[serde(rename_all = "camelCase")]` par défaut, exceptions relevées au banc |
|
||||||
|
| Champs vides | présents avec `null` | ne jamais omettre un champ `None` |
|
||||||
|
| Objets imbriqués | `group`, `supplier`, `creator` dans la plupart des listes ; `dlcDate` en plus de `expiryDate` pour les DLC | mêmes structures, construites en une requête avec jointures |
|
||||||
|
| Identifiants | entiers (`serial`), sauf `users.id` (texte, ex. `admin_local`) et `database_backups.id` (nanoid) | mêmes types |
|
||||||
|
| Mot de passe SMTP | remplacé par `smtpPasswordSet: true/false` dans toute réponse | jamais sélectionné dans les requêtes ; `smtpPasswordSet` calculé en SQL |
|
||||||
|
| Erreurs | `{ "message": "…" }`, 401 si non connecté | identique |
|
||||||
|
| Déconnexion | redirection 302 vers `/auth` | identique |
|
||||||
|
| « Aujourd'hui » | `new Date().toISOString().split('T')[0]` : date UTC, fausse entre minuit et 2 h l'été | heure de Paris (décision R5), écart déclaré au banc |
|
||||||
|
| Corps des requêtes | jusqu'à 10 Mo (logos en data URI, PDF en base64) | même limite |
|
||||||
|
|
||||||
|
## Ce qu'on ne reporte pas
|
||||||
|
|
||||||
|
Une migration est l'occasion de ne pas recopier ce qui est dangereux ou mort. Chaque ligne est à confirmer (décision R3) ; les adresses sans appelant connu sont d'abord cherchées dans les journaux d'accès de production et les workflows n8n.
|
||||||
|
|
||||||
|
| Élément | Où | Proposition |
|
||||||
|
|---|---|---|
|
||||||
|
| Exécution de SQL arbitraire | `POST /api/admin/execute-sql` (`server/routes.ts:5797`), SQL-01 | Ne pas reporter : la base s'administre avec `psql`. |
|
||||||
|
| Réinitialisation d'urgence de l'administrateur | `POST /api/emergency-admin-reset` (`server/index.production.ts:87`) | Remplacer par une commande du binaire (`logiflow reset-admin`), lancée dans le conteneur. |
|
||||||
|
| Compte `admin` / `admin` créé au démarrage, mot de passe `admin.salt` accepté | `server/localAuth.ts:30`, `:59`, AUTH-04 | Ne pas reporter : premier administrateur créé par la commande ci-dessus. |
|
||||||
|
| Adresse de webhook fournie par le navigateur | `POST /api/reconciliation/send-invoice` (`server/routes.ts:823`), RAPPRO-21 | Adresse lue en base uniquement (configuration du magasin). |
|
||||||
|
| Nom de fichier de sauvegarde non contrôlé | `/api/backups/:filename` (`server/routes.ts:4916`, `:4938`), BACKUP-09 | Identifiant de sauvegarde lu en base, jamais un chemin. |
|
||||||
|
| Géolocalisation météo modifiable par tout utilisateur connecté | `POST /api/weather/geolocation` (`server/routes.ts:5740`) | Réservée à l'administrateur. |
|
||||||
|
| Routes masquées par une route identique déclarée avant | `POST /api/users/:id/groups`, `DELETE /api/users/:id/groups/:groupId` (`server/routes.ts:4179`, `:4199`) | Ne pas reporter (jamais atteintes). |
|
||||||
|
| Adresses sans appelant dans `client/src` | `bap/send-webhook`, `sync-order-delivery-status`, `cache/diagnosis`, `cache/update-reconciled`, `verify-invoice`, `verify-invoices`, `avoirs/:id/webhook-status`, `nocodb-config/active`, `ad-campaigns/debug`, `admin/emergency-migration`, diagnostic du schéma | Reporter seulement celles qui ont un appelant en production. |
|
||||||
|
| Stockage en mémoire de développement | `MemStorage`, `server/storage.ts:3510-5909` | Ne pas reporter : le développement utilise une base PostgreSQL locale (Docker). |
|
||||||
|
| Pilote Neon par WebSocket | `server/db.ts:55` | Ne pas reporter : PostgreSQL direct partout. |
|
||||||
|
|
||||||
|
**Protection des requêtes d'écriture** (décision R4). La production n'en a aucune aujourd'hui. Proposition : cookie `SameSite=Lax` et contrôle de l'en-tête `Origin` sur toute requête qui n'est pas une lecture, sauf `/api/ext/` (clé d'API). Les navigateurs envoient cet en-tête d'eux-mêmes : rien à changer dans l'interface.
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
|
||||||
|
- **Le banc fait foi.** Une adresse passe en Rust quand le banc ne montre aucune différence avec Node pour les 5 comptes, en lecture et en écriture. Une différence voulue (fuseau horaire, faille fermée) est inscrite dans la liste des écarts attendus du banc, avec sa raison.
|
||||||
|
- **Bug pour bug, sauf décision.** On reporte le comportement de Node tel qu'il est au moment du portage. Un bug découvert en route est corrigé d'abord dans Node (et vérifié au banc), ou inscrit comme écart attendu ; jamais corrigé en silence dans Rust.
|
||||||
|
- **Tests Rust** :
|
||||||
|
- unitaires sur les règles pures : droits (chaque case du tableau rôle × module), cloisonnement par magasin, recherche de facture, normalisation des dates, montants à la française, exports CSV ;
|
||||||
|
- de compatibilité, sur des valeurs produites par le code Node : empreintes de mots de passe, secrets `enc:v1`, cookies signés, clés d'API ;
|
||||||
|
- d'intégration, contre une base PostgreSQL jetable, par module.
|
||||||
|
- **Requêtes SQL vérifiées à la compilation** par `sqlx` : une colonne renommée ou un type faux ne compile plus.
|
||||||
|
- **Mesures de performance** au banc, Node puis Rust, à chaque lot : temps de réponse (médiane et 95ᵉ centile) par adresse, mémoire du serveur, temps de démarrage, taille de l'image. Objectif : aucune adresse plus lente qu'avec Node. Les gains sont à mesurer, pas à supposer.
|
||||||
|
- **Avec chaque rôle, sur PC et téléphone**, comme pour toute livraison (règles communes du plan du webUI).
|
||||||
|
|
||||||
|
## Articulation avec le plan du webUI
|
||||||
|
|
||||||
|
La migration ne remplace pas le plan d'optimisation du webUI (`docs/PLAN-OPTIMISATION-WEBUI.md`) ; elle change l'endroit où se fait sa partie serveur.
|
||||||
|
|
||||||
|
| Phase du plan du webUI | Avec la migration |
|
||||||
|
|---|---|
|
||||||
|
| **1a — Corrections critiques** | **À faire dans Node, avant le Lot 0**, sans attendre : ce sont des fuites de données et des failles en production aujourd'hui. Rust reprend ensuite le comportement corrigé. |
|
||||||
|
| **1b — Corrections rapides de l'interface** | Inchangée : elle ne touche que `client/`. |
|
||||||
|
| **2 — Socle** | La partie interface (menu, design system, composants) avance en parallèle. Les règles serveur T2 à T7 et la table de droits unique se font **dans Rust**, module par module, au lieu d'être écrites deux fois. |
|
||||||
|
| **3 — Refonte des pages** | En parallèle. Quand une page a besoin d'une nouvelle adresse (synthèse de l'accueil DASH-05, listes paginées), elle s'écrit en Rust si son module est déjà porté, et son module passe en tête de file sinon. |
|
||||||
|
| **4 — Performance, lots 2 et 3** | Le banc (lot 2, n° 0) devient le Lot 0 de la migration. Les chantiers côté serveur (requêtes ciblées, échéancier en SQL, journal à niveaux) se font en Rust, lors du portage du module concerné. |
|
||||||
|
| **5 — Dette** | La partie serveur (point d'entrée unique, suppression de `localAuth.production.ts`, `console.log`, SQL paramétré, identifiants générés par le serveur) est absorbée par la migration. Restent la fusion PC / téléphone et la dette de l'interface. |
|
||||||
|
|
||||||
|
## Risques
|
||||||
|
|
||||||
|
| Risque | Conséquence | Parade |
|
||||||
|
|---|---|---|
|
||||||
|
| Effort sous-estimé, surtout au Lot 4 | Deux serveurs plus longtemps que prévu | Lots indépendants, chacun livré seul : la migration peut s'arrêter après n'importe quel lot sans rien casser, Node servant le reste. |
|
||||||
|
| Écart de format JSON non vu | Un écran affiche une valeur fausse ou vide, sans erreur | Banc sur 5 comptes, en écriture comme en lecture ; types de l'interface générés depuis Rust au Lot 5. |
|
||||||
|
| Sessions incompatibles | Utilisateurs déconnectés, ou pire, connectés sous un autre compte | Lecture du cookie signé testée sur des cookies produits par Node ; une session illisible vaut « non connecté », jamais un autre utilisateur. |
|
||||||
|
| Tâche de fond lancée deux fois | Deux sauvegardes, purges concurrentes | Toutes les tâches restent dans Node jusqu'au Lot 5, puis passent ensemble. |
|
||||||
|
| Changement de schéma pendant la transition | Node et Rust en désaccord sur une table | Gel du schéma du module en cours ; tout changement passe par `server-rs/migrations/`. |
|
||||||
|
| Compétence Rust dans l'équipe | Personne pour maintenir le serveur | Décision R6 avant de commencer ; code simple, sans abstraction gratuite, commentaires en français comme le reste du dépôt. |
|
||||||
|
| Nouvelles demandes métier pendant la migration | Tentation d'écrire les fonctions deux fois | Gel des nouvelles fonctions serveur pour le module en cours de portage seulement (décision R2). |
|
||||||
|
| Mémoire : deux serveurs pendant la transition | Plus de mémoire consommée sur l'hôte | Le serveur Rust ajoute peu ; à mesurer au Lot 0. |
|
||||||
|
|
||||||
|
## Décisions à prendre
|
||||||
|
|
||||||
|
| N° | Question | Proposition par défaut |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | **Périmètre** : le serveur seul, ou aussi l'interface en Rust ? | **Le serveur seul.** L'interface React reste, et sa refonte suit le plan du webUI. |
|
||||||
|
| R2 | **Gel des fonctions** : peut-on suspendre les évolutions serveur d'un module pendant son portage (1 à 4 semaines) ? | Oui, module par module ; les urgences passent dans Node puis sont reportées au banc. |
|
||||||
|
| R3 | **Ce qu'on ne reporte pas** : la liste de [Ce qu'on ne reporte pas](#ce-quon-ne-reporte-pas). | Tout ce qui y est proposé, après lecture des journaux d'accès de production pour les adresses sans appelant connu. |
|
||||||
|
| R4 | **Protection des requêtes d'écriture** : l'ajouter à la migration ? | Oui : `SameSite=Lax` et contrôle de `Origin`, sans changement de l'interface. |
|
||||||
|
| R5 | **Fuseau horaire** : « aujourd'hui » et la sauvegarde de 2 h à l'heure de Paris ? | Oui (c'est T5 du plan du webUI) ; écart déclaré au banc. |
|
||||||
|
| R6 | **Qui écrit et maintient le code Rust**, et qui le relit ? | À désigner avant le Lot 0 ; si personne ne connaît Rust, prévoir 1 à 2 mois d'apprentissage, ou ne pas lancer la migration. |
|
||||||
@@ -380,17 +380,17 @@ Aujourd'hui : 19 entrées en 5 sections pour un admin, dont une section d'une se
|
|||||||
| Section | Ordre | Libellé exact | Icône (lucide) | Adresse | Libellé actuel PC / mobile |
|
| Section | Ordre | Libellé exact | Icône (lucide) | Adresse | Libellé actuel PC / mobile |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| *(en tête, sans titre)* | 1 | Accueil | `Home` | `/` | Tableau de bord / Accueil |
|
| *(en tête, sans titre)* | 1 | Accueil | `Home` | `/` | Tableau de bord / Accueil |
|
||||||
| **Au quotidien** | 2 | Tâches | `ListTodo` | `/tasks` | Tâches (icône `CheckSquare` sur mobile) |
|
| | 2 | Calendrier | `CalendarDays` | `/calendar` | Calendrier (« Agenda » sur l'accueil mobile, DASH-31) |
|
||||||
| | 3 | DLC | `CalendarClock` | `/dlc` | Gestion DLC / DLC |
|
| **Au quotidien** | 3 | Tâches | `ListTodo` | `/tasks` | Tâches (icône `CheckSquare` sur mobile) |
|
||||||
| | 4 | Commandes clients | `ShoppingCart` | `/customer-orders` | Commandes Client / Cmd Client |
|
| | 4 | DLC | `CalendarClock` | `/dlc` | Gestion DLC / DLC |
|
||||||
| | 5 | SAV | `Wrench` | `/sav` | SAV |
|
| | 5 | Commandes clients | `ShoppingCart` | `/customer-orders` | Commandes Client / Cmd Client |
|
||||||
| **Fournisseurs** | 6 | Commandes fournisseurs | `Package` | `/orders` | Commandes |
|
| | 6 | SAV | `Wrench` | `/sav` | SAV |
|
||||||
| | 7 | Livraisons | `Truck` | `/deliveries` | Livraisons |
|
| **Fournisseurs** | 7 | Commandes fournisseurs | `Package` | `/orders` | Commandes |
|
||||||
| | 8 | Rapprochement BL / factures | `FileCheck` | `/bl-reconciliation` | Rapprochement (absent du mobile) |
|
| | 8 | Livraisons | `Truck` | `/deliveries` | Livraisons |
|
||||||
| | 9 | Avoirs | `Receipt` | `/avoirs` | Avoirs |
|
| | 9 | Rapprochement BL / factures | `FileCheck` | `/bl-reconciliation` | Rapprochement (absent du mobile) |
|
||||||
| | 10 | Échéancier | `CreditCard` | `/payment-schedule` | Échéance (absent du mobile) |
|
| | 10 | Avoirs | `Receipt` | `/avoirs` | Avoirs |
|
||||||
| **Planning et infos** | 11 | Calendrier | `CalendarDays` | `/calendar` | Calendrier (« Agenda » sur l'accueil mobile, DASH-31) |
|
| | 11 | Échéancier | `CreditCard` | `/payment-schedule` | Échéance (absent du mobile) |
|
||||||
| | 12 | Publicités | `Megaphone` | `/publicities` | Publicités |
|
| **Planning et infos** | 12 | Publicités | `Megaphone` | `/publicities` | Publicités |
|
||||||
| | 13 | Contacts | `BookUser` | `/contacts` | Contacts (absent du mobile, CONT-09) |
|
| | 13 | Contacts | `BookUser` | `/contacts` | Contacts (absent du mobile, CONT-09) |
|
||||||
| | 14 | Statistiques | `LineChart` | `/analytics` | Statistiques (icône `BarChart3` en double ; titre de page « Tableau de bord Analytics », ANA-15) |
|
| | 14 | Statistiques | `LineChart` | `/analytics` | Statistiques (icône `BarChart3` en double ; titre de page « Tableau de bord Analytics », ANA-15) |
|
||||||
| **Administration** | 15 | Utilisateurs | `UserCog` | `/users` | Utilisateurs |
|
| **Administration** | 15 | Utilisateurs | `UserCog` | `/users` | Utilisateurs |
|
||||||
@@ -400,6 +400,7 @@ Aujourd'hui : 19 entrées en 5 sections pour un admin, dont une section d'une se
|
|||||||
|
|
||||||
Règles associées :
|
Règles associées :
|
||||||
|
|
||||||
|
- **Accueil et Calendrier en tête du menu**, les deux pages consultées le plus souvent, avant les sections (décision du 15 octobre 2026).
|
||||||
- **Chaque icône n'apparaît qu'une fois dans le menu.** `Store` sert aujourd'hui de logo dans l'en-tête du menu (Sidebar.tsx:400) : le logo passe à une autre icône (ex. `Boxes`) ou à une image, pour que `Store` désigne uniquement « Magasins ». (SHELL-12)
|
- **Chaque icône n'apparaît qu'une fois dans le menu.** `Store` sert aujourd'hui de logo dans l'en-tête du menu (Sidebar.tsx:400) : le logo passe à une autre icône (ex. `Boxes`) ou à une image, pour que `Store` désigne uniquement « Magasins ». (SHELL-12)
|
||||||
- **Titres de section** en casse normale, 12 px semi-gras, sans majuscules forcées (aujourd'hui `text-[11px] uppercase tracking-widest`, SHELL-22).
|
- **Titres de section** en casse normale, 12 px semi-gras, sans majuscules forcées (aujourd'hui `text-[11px] uppercase tracking-widest`, SHELL-22).
|
||||||
- **Les adresses ne changent pas** (liens existants, favoris). `/` et `/dashboard` activent tous deux « Accueil » (SHELL-13).
|
- **Les adresses ne changent pas** (liens existants, favoris). `/` et `/dashboard` activent tous deux « Accueil » (SHELL-13).
|
||||||
@@ -414,6 +415,7 @@ Légende : **Gérer** = voir, créer, modifier, supprimer · **Voir** = consulta
|
|||||||
| Module | Admin | Directeur | Manager | Employé | Incohérences actuelles corrigées |
|
| Module | Admin | Directeur | Manager | Employé | Incohérences actuelles corrigées |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| Accueil | ✓ | ✓ | ✓ | ✓ | — |
|
| Accueil | ✓ | ✓ | ✓ | ✓ | — |
|
||||||
|
| Calendrier | Gérer | Gérer | Créer, modifier | Voir | case cliquable même sans droit (CAL-08) |
|
||||||
| Tâches | Gérer | Gérer | Créer, modifier, terminer | Voir + terminer (D6) | « Supprimer » proposé aux managers sur PC, « Terminer » à tous sur mobile, serveur sans contrôle de rôle (TASKS-13, TASKS-26) |
|
| Tâches | Gérer | Gérer | Créer, modifier, terminer | Voir + terminer (D6) | « Supprimer » proposé aux managers sur PC, « Terminer » à tous sur mobile, serveur sans contrôle de rôle (TASKS-13, TASKS-26) |
|
||||||
| DLC | Gérer | Gérer | Créer, modifier, retirer | Voir, ajouter + retirer du rayon (D7) | « Valider » proposé puis refusé en silence sur mobile (DLC-13) |
|
| DLC | Gérer | Gérer | Créer, modifier, retirer | Voir, ajouter + retirer du rayon (D7) | « Valider » proposé puis refusé en silence sur mobile (DLC-13) |
|
||||||
| Commandes clients | Gérer | Gérer | Créer, modifier | Voir, créer + « client prévenu », « retirée » (D8) | serveur ne vérifie pas le rôle en modification (CMDCLI-07, CMDCLI-10) |
|
| Commandes clients | Gérer | Gérer | Créer, modifier | Voir, créer + « client prévenu », « retirée » (D8) | serveur ne vérifie pas le rôle en modification (CMDCLI-07, CMDCLI-10) |
|
||||||
@@ -423,7 +425,6 @@ Légende : **Gérer** = voir, créer, modifier, supprimer · **Voir** = consulta
|
|||||||
| Rapprochement BL / factures | Gérer | Gérer sauf supprimer | **D3** — proposé : — | — | message de la page « managers », menu sans manager, serveur sans manager (RAPPRO-17) |
|
| Rapprochement BL / factures | Gérer | Gérer sauf supprimer | **D3** — proposé : — | — | message de la page « managers », menu sans manager, serveur sans manager (RAPPRO-17) |
|
||||||
| Avoirs | Gérer | Gérer | Voir, créer | **D2** — proposé : — | visible pour tous sur mobile, masqué sur PC (SHELL-11, AVOIRS-04) |
|
| Avoirs | Gérer | Gérer | Voir, créer | **D2** — proposé : — | visible pour tous sur mobile, masqué sur PC (SHELL-11, AVOIRS-04) |
|
||||||
| Échéancier | Voir | Voir | — | — | inaccessible sur mobile (SHELL-10) |
|
| Échéancier | Voir | Voir | — | — | inaccessible sur mobile (SHELL-10) |
|
||||||
| Calendrier | Gérer | Gérer | Créer, modifier | Voir | case cliquable même sans droit (CAL-08) |
|
|
||||||
| Publicités | Gérer | Voir | Voir | Voir | « + » mobile sans action, visible pour tous (PUB-11) |
|
| Publicités | Gérer | Voir | Voir | Voir | « + » mobile sans action, visible pour tous (PUB-11) |
|
||||||
| Contacts | Gérer | Gérer | Gérer | Voir | employé bloqué sans magasin (CONT-04), absent du mobile (CONT-09) |
|
| Contacts | Gérer | Gérer | Gérer | Voir | employé bloqué sans magasin (CONT-04), absent du mobile (CONT-09) |
|
||||||
| Statistiques | Voir | Voir | Voir | **D4** — proposé : — | API ouverte à tous, sans filtre par magasin pour un bloc (ANA-05) |
|
| Statistiques | Voir | Voir | Voir | **D4** — proposé : — | API ouverte à tous, sans filtre par magasin pour un bloc (ANA-05) |
|
||||||
|
|||||||
@@ -438,6 +438,18 @@ CREATE TABLE IF NOT EXISTS "supplier_mail_logs" (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
||||||
|
|
||||||
|
-- Clés de l'API externe de rapprochement (empreinte SHA-256, jamais la clé en clair)
|
||||||
|
CREATE TABLE IF NOT EXISTS "external_api_keys" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(100) NOT NULL,
|
||||||
|
"key_prefix" varchar(16) NOT NULL,
|
||||||
|
"key_hash" varchar(64) NOT NULL UNIQUE,
|
||||||
|
"created_by" varchar,
|
||||||
|
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"last_used_at" timestamp,
|
||||||
|
"revoked_at" timestamp
|
||||||
|
);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- FOREIGN KEY CONSTRAINTS
|
-- FOREIGN KEY CONSTRAINTS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Backfill rapprochement automatique
|
||||||
|
--
|
||||||
|
-- Contexte : l'endpoint POST /api/deliveries/:id/validate ne posait pas
|
||||||
|
-- reconciled = true pour les fournisseurs en rapprochement automatique
|
||||||
|
-- (automatic_reconciliation = true). Ces livraisons n'apparaissaient ni dans
|
||||||
|
-- l'onglet "Manuels" (exclues car fournisseur auto) ni dans "Validées"
|
||||||
|
-- (reconciled = false). Même cas pour les livraisons antérieures au passage
|
||||||
|
-- d'un fournisseur en mode automatique.
|
||||||
|
--
|
||||||
|
-- Applique le même critère que l'auto-validation côté serveur :
|
||||||
|
-- livraison livrée + numéro de BL renseigné.
|
||||||
|
|
||||||
|
UPDATE deliveries d
|
||||||
|
SET reconciled = true,
|
||||||
|
validated_at = COALESCE(d.validated_at, NOW())
|
||||||
|
FROM suppliers s
|
||||||
|
WHERE d.supplier_id = s.id
|
||||||
|
AND s.automatic_reconciliation = true
|
||||||
|
AND d.status = 'delivered'
|
||||||
|
AND d.bl_number IS NOT NULL
|
||||||
|
AND TRIM(d.bl_number) <> ''
|
||||||
|
AND (d.reconciled IS NULL OR d.reconciled = false);
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Database Migration Script
|
|
||||||
* Automatically runs database migrations for production deployment
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { Pool } = require('pg');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Database connection
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL,
|
|
||||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
|
|
||||||
});
|
|
||||||
|
|
||||||
async function runMigrations() {
|
|
||||||
console.log('🔄 Starting database migrations...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create migrations table if it doesn't exist
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS migrations (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
filename VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
console.log('✅ Migrations table ready');
|
|
||||||
|
|
||||||
// Get list of executed migrations
|
|
||||||
const { rows: executedMigrations } = await pool.query(
|
|
||||||
'SELECT filename FROM migrations ORDER BY executed_at'
|
|
||||||
);
|
|
||||||
const executedSet = new Set(executedMigrations.map(row => row.filename));
|
|
||||||
|
|
||||||
// Read migration files
|
|
||||||
const migrationsDir = path.join(__dirname);
|
|
||||||
const files = fs.readdirSync(migrationsDir)
|
|
||||||
.filter(file => file.endsWith('.sql'))
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
console.log(`📁 Found ${files.length} migration files`);
|
|
||||||
|
|
||||||
let appliedCount = 0;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (executedSet.has(file)) {
|
|
||||||
console.log(`⏭️ Skipping ${file} (already executed)`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🔄 Executing migration: ${file}`);
|
|
||||||
|
|
||||||
const filePath = path.join(migrationsDir, file);
|
|
||||||
const sql = fs.readFileSync(filePath, 'utf8');
|
|
||||||
|
|
||||||
// Execute migration in a transaction
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN');
|
|
||||||
await client.query(sql);
|
|
||||||
await client.query(
|
|
||||||
'INSERT INTO migrations (filename) VALUES ($1)',
|
|
||||||
[file]
|
|
||||||
);
|
|
||||||
await client.query('COMMIT');
|
|
||||||
console.log(`✅ Successfully executed: ${file}`);
|
|
||||||
appliedCount++;
|
|
||||||
} catch (error) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
throw new Error(`Failed to execute ${file}: ${error.message}`);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appliedCount === 0) {
|
|
||||||
console.log('✅ All migrations are up to date');
|
|
||||||
} else {
|
|
||||||
console.log(`✅ Successfully applied ${appliedCount} migration(s)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Migration failed:', error.message);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run migrations if this file is executed directly
|
|
||||||
if (require.main === module) {
|
|
||||||
runMigrations();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { runMigrations };
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
// Script de migration production pour créer la table announcements
|
|
||||||
// Usage: node migrations/production_fix_announcements.js
|
|
||||||
|
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
||||||
import pg from 'pg';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
|
|
||||||
const { Pool } = pg;
|
|
||||||
|
|
||||||
async function createAnnouncementsTable() {
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
const db = drizzle(pool);
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('🔄 Création de la table announcements...');
|
|
||||||
|
|
||||||
// SQL pour créer la table announcements
|
|
||||||
const sql = `
|
|
||||||
-- Création de la table announcements
|
|
||||||
CREATE TABLE IF NOT EXISTS announcements (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
priority VARCHAR(20) NOT NULL DEFAULT 'normal' CHECK (priority IN ('normal', 'important', 'urgent')),
|
|
||||||
author_id VARCHAR(255) NOT NULL,
|
|
||||||
group_id INTEGER,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
CONSTRAINT announcements_author_id_fkey FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT announcements_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Création des index
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_announcements_priority ON announcements(priority);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_announcements_author_id ON announcements(author_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_announcements_group_id ON announcements(group_id);
|
|
||||||
|
|
||||||
-- Commentaires
|
|
||||||
COMMENT ON TABLE announcements IS 'Admin-managed announcements/information system';
|
|
||||||
COMMENT ON COLUMN announcements.priority IS 'Priority level: normal, important, urgent';
|
|
||||||
COMMENT ON COLUMN announcements.group_id IS 'NULL for global announcements, specific group_id for store-specific announcements';
|
|
||||||
`;
|
|
||||||
|
|
||||||
await pool.query(sql);
|
|
||||||
|
|
||||||
console.log('✅ Table announcements créée avec succès!');
|
|
||||||
|
|
||||||
// Vérification que la table existe
|
|
||||||
const checkResult = await pool.query(`
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'announcements'
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (checkResult.rows[0].exists) {
|
|
||||||
console.log('✅ Vérification: La table announcements existe bien');
|
|
||||||
|
|
||||||
// Afficher la structure de la table
|
|
||||||
const structure = await pool.query(`
|
|
||||||
SELECT column_name, data_type, is_nullable, column_default
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'announcements'
|
|
||||||
ORDER BY ordinal_position;
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log('📋 Structure de la table:');
|
|
||||||
structure.rows.forEach(row => {
|
|
||||||
console.log(` ${row.column_name}: ${row.data_type}${row.is_nullable === 'NO' ? ' NOT NULL' : ''}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('❌ Erreur: La table announcements n\'a pas été créée');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Erreur lors de la création de la table:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exécution
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
||||||
createAnnouncementsTable()
|
|
||||||
.then(() => {
|
|
||||||
console.log('🎉 Migration terminée avec succès');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('💥 Échec de la migration:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { createAnnouncementsTable };
|
|
||||||
Generated
+87
-2163
File diff suppressed because it is too large.
Load diff
+8
-53
@@ -1,19 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "rest-express",
|
"name": "logiflow",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development tsx server/index.ts",
|
"dev": "vite",
|
||||||
"build": "vite build && tsc --project tsconfig.server.json",
|
"dev:server": "cargo run --manifest-path server-rs/Cargo.toml",
|
||||||
"start": "NODE_ENV=production node dist/server/index.js",
|
"build": "vite build && cargo build --release --manifest-path server-rs/Cargo.toml",
|
||||||
|
"start": "./server-rs/target/release/logiflow",
|
||||||
"check": "tsc",
|
"check": "tsc",
|
||||||
"db:push": "drizzle-kit push"
|
"db:push": "drizzle-kit push",
|
||||||
|
"gen:rust-schema": "tsx scripts/gen-rust-schema.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
"@neondatabase/serverless": "^0.10.4",
|
|
||||||
"@radix-ui/react-accordion": "^1.2.4",
|
"@radix-ui/react-accordion": "^1.2.4",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
||||||
@@ -42,49 +43,20 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"@types/busboy": "^1.5.4",
|
|
||||||
"@types/cookie-parser": "^1.4.10",
|
|
||||||
"@types/form-data": "^2.2.1",
|
|
||||||
"@types/memoizee": "^0.4.12",
|
|
||||||
"@types/multer": "^2.0.0",
|
|
||||||
"@types/pg": "^8.15.4",
|
"@types/pg": "^8.15.4",
|
||||||
"@types/validator": "^13.15.10",
|
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"busboy": "^1.6.0",
|
|
||||||
"caniuse-lite": "^1.0.30001727",
|
"caniuse-lite": "^1.0.30001727",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"compression": "^1.8.2",
|
|
||||||
"connect-pg-simple": "^10.0.0",
|
|
||||||
"cookie-parser": "^1.4.7",
|
|
||||||
"csrf-csrf": "^4.0.3",
|
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"drizzle-orm": "^0.39.1",
|
"drizzle-orm": "^0.39.1",
|
||||||
"drizzle-zod": "^0.7.0",
|
"drizzle-zod": "^0.7.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"encoding": "^0.1.13",
|
|
||||||
"express": "^4.21.2",
|
|
||||||
"express-rate-limit": "^7.5.1",
|
|
||||||
"express-session": "^1.18.1",
|
|
||||||
"form-data": "^4.0.4",
|
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
"iconv-lite": "^0.6.3",
|
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"jsbarcode": "^3.12.1",
|
"jsbarcode": "^3.12.1",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memoizee": "^0.4.17",
|
|
||||||
"memorystore": "^1.6.7",
|
|
||||||
"multer": "^2.0.2",
|
|
||||||
"nanoid": "^5.1.5",
|
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"node-fetch": "^3.3.2",
|
|
||||||
"nodemailer": "^9.0.5",
|
|
||||||
"openid-client": "^6.7.1",
|
|
||||||
"passport": "^0.7.0",
|
|
||||||
"passport-local": "^1.0.0",
|
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
@@ -96,42 +68,25 @@
|
|||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
"validator": "^13.15.26",
|
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"ws": "^8.18.0",
|
"zod": "^3.24.2"
|
||||||
"xlsx": "^0.18.5",
|
|
||||||
"zod": "^3.24.2",
|
|
||||||
"zod-validation-error": "^3.4.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@replit/vite-plugin-cartographer": "^0.2.7",
|
"@replit/vite-plugin-cartographer": "^0.2.7",
|
||||||
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
|
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/vite": "^4.1.3",
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
"@types/compression": "^1.8.1",
|
|
||||||
"@types/connect-pg-simple": "^7.0.3",
|
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/express-session": "^1.18.0",
|
|
||||||
"@types/node": "^20.16.11",
|
"@types/node": "^20.16.11",
|
||||||
"@types/node-fetch": "^2.6.13",
|
|
||||||
"@types/nodemailer": "^8.0.1",
|
|
||||||
"@types/passport": "^1.0.16",
|
|
||||||
"@types/passport-local": "^1.0.38",
|
|
||||||
"@types/react": "^18.3.27",
|
"@types/react": "^18.3.27",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@types/ws": "^8.5.13",
|
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"drizzle-kit": "^0.30.4",
|
"drizzle-kit": "^0.30.4",
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tsx": "^4.19.1",
|
"tsx": "^4.19.1",
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
"vite": "^5.4.19"
|
"vite": "^5.4.19"
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"bufferutil": "^4.0.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ The platform is built with a clear separation of concerns, utilizing a modern fu
|
|||||||
|
|
||||||
**Technical Implementations & Design Patterns:**
|
**Technical Implementations & Design Patterns:**
|
||||||
- **Frontend:** React.js for interactive user interfaces, with React Query for data fetching and state management.
|
- **Frontend:** React.js for interactive user interfaces, with React Query for data fetching and state management.
|
||||||
- **Backend:** Node.js Express (ESM) provides a robust API layer. Data validation is performed using Zod schemas.
|
- **Backend:** Rust server (axum, tokio, sqlx) in `server-rs/` serves the API and the built UI, runs startup migrations and background jobs. Validation keeps the Zod rules and messages of `shared/schema.ts`, from which `server-rs/src/schema.rs` is generated.
|
||||||
- **Database:** PostgreSQL as the primary data store, with Drizzle ORM facilitating interaction and database migrations ensuring schema consistency.
|
- **Database:** PostgreSQL as the primary data store, with Drizzle ORM facilitating interaction and database migrations ensuring schema consistency.
|
||||||
- **Authentication:** Secure role-based access control (RBAC) for granular permissions.
|
- **Authentication:** Secure role-based access control (RBAC) for granular permissions.
|
||||||
- **Multi-tenancy:** Supports multi-tenant operations with group and permission management, ensuring data isolation between stores/groups.
|
- **Multi-tenancy:** Supports multi-tenant operations with group and permission management, ensuring data isolation between stores/groups.
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Banc de comparaison Node / Rust
|
||||||
|
|
||||||
|
Le serveur Node a quitté le dépôt ; il reste la référence du comportement attendu. Le banc rejoue les mêmes requêtes sur le serveur Node de référence (construit depuis le commit `REFERENCE_NODE_COMMIT`) et sur le serveur Rust et compare, requête par requête : code HTTP, corps (valeurs et ordre des listes), en-têtes utiles (`Content-Type`, `Cache-Control`, `Location`, `Content-Disposition`, attributs des cookies), état des tables après les écritures, et appels faits aux services externes. Tout changement du serveur Rust qui ne doit pas changer son comportement passe par lui ; un changement voulu y est déclaré comme écart (`deviation`).
|
||||||
|
|
||||||
|
## Préparer
|
||||||
|
|
||||||
|
1. PostgreSQL local, avec un rôle qui peut créer des bases :
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql -c "CREATE ROLE logiflow_admin LOGIN SUPERUSER PASSWORD 'bench'"
|
||||||
|
```
|
||||||
|
Autre adresse : variable `BENCH_PG_URL` (par défaut `postgres://logiflow_admin:bench@localhost:5432`).
|
||||||
|
2. Construire le serveur Node de référence : `scripts/bench/reference-node.sh`. Il en fait une copie de travail à part (`.bench-reference/`, ignorée par git) au commit `REFERENCE_NODE_COMMIT`, avec son interface ; à refaire seulement quand ce commit change. Autre dossier : variable `BENCH_NODE_DIR`.
|
||||||
|
3. Construire le serveur Rust : `cargo build --release --manifest-path server-rs/Cargo.toml` (autre binaire : `RUST_BIN`).
|
||||||
|
4. Créer la base modèle `bench_seed` : `scripts/bench/setup.sh`. Elle contient le schéma de production (`schema.sh`), les index créés au premier démarrage du Node de référence et les données de `seed.ts` : 3 magasins, 5 comptes, quelques lignes par table, datées par rapport au jour du passage.
|
||||||
|
|
||||||
|
## Lancer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsx scripts/bench/compare.ts # Node de référence contre Rust
|
||||||
|
npx tsx scripts/bench/compare.ts --only dlc,tasks # seulement ces modules
|
||||||
|
npx tsx scripts/bench/compare.ts --mode node # Node contre Node : le banc est-il stable ?
|
||||||
|
```
|
||||||
|
|
||||||
|
Les deux serveurs servent la même interface (celle de la référence). Plusieurs bancs en parallèle : `BENCH_ID` (bases distinctes) et `BENCH_PORT_BASE` (ports distincts).
|
||||||
|
|
||||||
|
Le résultat liste les écarts, puis un tableau par module. Les réponses complètes et les écarts sont écrits dans le dossier de journaux affiché au début (`gauche.json`, `droite.json`, `ecarts.json`), avec les journaux des serveurs.
|
||||||
|
|
||||||
|
## Ajouter des requêtes
|
||||||
|
|
||||||
|
Une liste par module dans `cases/` (lectures et scénarios d'écriture, voir `types.ts`), déclarée dans `cases/index.ts`.
|
||||||
|
|
||||||
|
- **Lecture** : rejouée pour les 5 comptes et sans session, sur la même base.
|
||||||
|
- **Écriture** : base remise à zéro avant chaque compte ; les tables listées sont comparées à la fin.
|
||||||
|
- **Gabarits** : `{{today}}`, `{{today+3}}`, `{{year-1}}`, `{{month}}` ; `{{0.id}}` reprend l'`id` de la réponse de l'étape 0.
|
||||||
|
- Les horodatages proches du passage sont remplacés par `<maintenant>` ; `ignore` masque d'autres champs propres à chaque passage.
|
||||||
|
- **Écart voulu** (faille fermée, décision du plan) : `deviation: { fields, reason }` retire ces champs des deux côtés avant la comparaison ; le banc rappelle la raison en fin de rapport.
|
||||||
|
|
||||||
|
## Comptes et services simulés
|
||||||
|
|
||||||
|
| Compte | Rôle | Magasins |
|
||||||
|
|---|---|---|
|
||||||
|
| `admin` | admin | aucun (voit tout) |
|
||||||
|
| `directeur` | directeur | Nancy, Metz |
|
||||||
|
| `manager` | manager | Nancy |
|
||||||
|
| `employe` | employee | Nancy |
|
||||||
|
| `sans-magasin` | employee | aucun |
|
||||||
|
|
||||||
|
Plus `anonyme` (sans session) et `api` (clé de l'API externe). Mots de passe et clé : `config.ts`.
|
||||||
|
|
||||||
|
`stub.ts` simule NocoDB (table de factures), les webhooks (avoirs, BAP) et un serveur SMTP ; les magasins du banc pointent vers lui. La météo (Visual Crossing) et ffnancy, dont les adresses sont écrites dans le code, ne sont pas rejouées.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
8f4d799016e6dcd75cbef17ad27193f47b79300a
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
// Lot 5 : connexion et session, utilisateurs, magasins, sauvegardes, paramètres
|
||||||
|
// généraux, diagnostic du schéma (docs/PLAN-MIGRATION-RUST.md). Les lectures
|
||||||
|
// de /api/user, /api/default-credentials-check (socle.ts, « auth »),
|
||||||
|
// /api/users et /api/groups (referentiel.ts) sont déjà au banc.
|
||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
import { LOGGED_ACCOUNTS } from "../types";
|
||||||
|
import { STUB_SMTP_PORT } from "../config";
|
||||||
|
|
||||||
|
// Le hash renvoyé par Node après POST / PUT /api/users (sel aléatoire) n'est pas
|
||||||
|
// renvoyé par Rust : écart voulu, retiré de la comparaison (réponses et table users)
|
||||||
|
const PASSWORD_HASH = {
|
||||||
|
fields: ["password"],
|
||||||
|
reason: "USERS-02 : l'empreinte du mot de passe n'est plus renvoyée après création ou modification d'un compte",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Session d'une connexion : identifiant et échéance propres à chaque passage
|
||||||
|
// originalMaxAge : express-session le recalcule à la milliseconde près quand il
|
||||||
|
// réenregistre une session (86399999 ou 86400000 selon l'instant)
|
||||||
|
const SESSION_IGNORE = ["sid", "expire", "expires", "originalMaxAge"];
|
||||||
|
|
||||||
|
const BROWSER_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
|
||||||
|
|
||||||
|
// Magasin dont le serveur SMTP est le service simulé du banc
|
||||||
|
const SMTP = {
|
||||||
|
smtpEnabled: true,
|
||||||
|
smtpHost: "127.0.0.1",
|
||||||
|
smtpPort: STUB_SMTP_PORT,
|
||||||
|
smtpSecure: false,
|
||||||
|
smtpUser: "toul@banc.test",
|
||||||
|
smtpSenderEmail: "toul@banc.test",
|
||||||
|
smtpSenderName: "Magasin Toul",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
{ module: "backups", path: "/api/backups" },
|
||||||
|
{ module: "backups", path: "/api/backups/backup_banc_1.sql/download" },
|
||||||
|
{ module: "backups", path: "/api/backups/backup_banc_500.sql/download", accounts: ["admin", "manager"] },
|
||||||
|
{ module: "backups", path: "/api/backups/absente.sql/download", accounts: ["admin", "manager"] },
|
||||||
|
{ module: "utilities", path: "/api/utilities" },
|
||||||
|
{ module: "admin", path: "/api/debug/log-schema" },
|
||||||
|
{ module: "admin", path: "/api/debug/download-schema" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
// ---------- Session ----------
|
||||||
|
{
|
||||||
|
module: "session",
|
||||||
|
name: "connexion réussie : réponse, cookie et session enregistrée",
|
||||||
|
accounts: ["anonyme"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "directeur", password: "banc-directeur" } },
|
||||||
|
{ method: "GET", path: "/api/user" },
|
||||||
|
],
|
||||||
|
tables: ["sessions"],
|
||||||
|
ignore: SESSION_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "session",
|
||||||
|
name: "connexion par formulaire ou par l'adresse",
|
||||||
|
accounts: ["anonyme"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "employe", password: "banc-employe" }, bodyType: "form" },
|
||||||
|
{ method: "POST", path: "/api/login?username=manager&password=banc-manager" },
|
||||||
|
{ method: "POST", path: "/api/login?username=sansmagasin", body: { password: "banc-sans-magasin" } },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "session",
|
||||||
|
name: "connexion refusée : identifiants mal formés",
|
||||||
|
accounts: ["anonyme"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "manager", password: 123 } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "manager", password: true } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "manager", password: ["banc-manager"] } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: ["manager"], password: "banc-manager" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "manager", password: "" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "MANAGER", password: "banc-manager" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: 0, password: "x" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: 12, password: "x" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: ["manager", "banc-manager"] },
|
||||||
|
{ method: "POST", path: "/api/login?username=manager&username=directeur&password=banc-manager" },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "admin", password: "admin" } },
|
||||||
|
],
|
||||||
|
tables: ["sessions"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "session",
|
||||||
|
name: "connexion par-dessus une session ouverte",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "sansmagasin", password: "banc-sans-magasin" } },
|
||||||
|
// L'ancienne session est détruite : le cookie du compte ne vaut plus rien
|
||||||
|
{ method: "GET", path: "/api/user" },
|
||||||
|
],
|
||||||
|
tables: ["sessions"],
|
||||||
|
ignore: SESSION_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "session",
|
||||||
|
name: "déconnexion (GET et POST, selon Accept) puis lecture de l'utilisateur",
|
||||||
|
accounts: [...LOGGED_ACCOUNTS, "anonyme"],
|
||||||
|
steps: [
|
||||||
|
{ method: "GET", path: "/api/logout", headers: { accept: BROWSER_ACCEPT } },
|
||||||
|
{ method: "GET", path: "/api/user" },
|
||||||
|
{ method: "POST", path: "/api/logout", headers: { accept: "application/json" } },
|
||||||
|
{ method: "POST", path: "/api/logout" },
|
||||||
|
],
|
||||||
|
tables: ["sessions"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Session ouverte par la connexion, lue par l'adresse de test SMTP
|
||||||
|
module: "session",
|
||||||
|
name: "session acceptée par le test SMTP d'un magasin",
|
||||||
|
steps: [{ method: "POST", path: "/api/groups/1/test-smtp" }],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Utilisateurs ----------
|
||||||
|
{
|
||||||
|
module: "users",
|
||||||
|
name: "créer, modifier, affecter, retirer et supprimer un utilisateur",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/users",
|
||||||
|
body: { id: "bench_nouveau", username: "nouveau", email: "nouveau@banc.test", firstName: "Noé", lastName: "Nouveau", password: "banc-nouveau", role: "employee" },
|
||||||
|
},
|
||||||
|
{ method: "PUT", path: "/api/users/bench_nouveau", body: { firstName: "Noémie", lastName: "", email: "", role: "manager", password: "banc-nouveau-2" } },
|
||||||
|
{ method: "POST", path: "/api/users/bench_nouveau/groups", body: { groupId: 2 } },
|
||||||
|
{ method: "POST", path: "/api/users/bench_nouveau/groups", body: { groupId: 3 } },
|
||||||
|
{ method: "DELETE", path: "/api/users/bench_nouveau/groups/2" },
|
||||||
|
{ method: "GET", path: "/api/users" },
|
||||||
|
{ method: "PUT", path: "/api/users/bench_employee", body: { password: "banc-employe-2" } },
|
||||||
|
{ method: "DELETE", path: "/api/users/bench_nouveau" },
|
||||||
|
{ method: "GET", path: "/api/users" },
|
||||||
|
// Le mot de passe modifié est vérifiable (en dernier : la connexion
|
||||||
|
// remplace la session du compte qui rejoue le scénario)
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "employe", password: "banc-employe-2" } },
|
||||||
|
],
|
||||||
|
tables: ["users", "user_groups"],
|
||||||
|
deviation: PASSWORD_HASH,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "users",
|
||||||
|
name: "créer un utilisateur sans identifiant ni adresse",
|
||||||
|
accounts: ["admin", "manager"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/users", body: { username: "sansid", password: "banc-sans-id", email: "" } },
|
||||||
|
{ method: "POST", path: "/api/users", body: { username: "blanc", password: "x", email: " " } },
|
||||||
|
{ method: "POST", path: "/api/users", body: { id: "", username: "sansid2", password: "x", email: null, firstName: "", role: "directeur" } },
|
||||||
|
// Le mot de passe du compte créé est vérifiable
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "sansid", password: "banc-sans-id" } },
|
||||||
|
],
|
||||||
|
tables: ["users"],
|
||||||
|
// Identifiant généré : manual_<millisecondes>_<aléatoire>
|
||||||
|
ignore: ["id"],
|
||||||
|
deviation: PASSWORD_HASH,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "users",
|
||||||
|
name: "utilisateurs invalides, en double ou introuvables",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/users", body: {} },
|
||||||
|
{ method: "POST", path: "/api/users", body: { username: "", password: "", email: 5, role: "chef" } },
|
||||||
|
{ method: "POST", path: "/api/users", body: { username: "a", password: "b", email: "pas-un-courriel", firstName: null } },
|
||||||
|
{ method: "POST", path: "/api/users", body: { username: "manager", password: "x" } },
|
||||||
|
{ method: "POST", path: "/api/users", body: { username: "autre", password: "x", email: "manager@banc.test" } },
|
||||||
|
{ method: "POST", path: "/api/users", body: { id: "bench_manager", username: "autre2", password: "x" } },
|
||||||
|
{ method: "POST", path: "/api/users", body: ["x"] },
|
||||||
|
{ method: "PUT", path: "/api/users/bench_employee", body: { role: "chef", firstName: 5 } },
|
||||||
|
{ method: "PUT", path: "/api/users/bench_employee", body: { username: "manager" } },
|
||||||
|
{ method: "PUT", path: "/api/users/bench_employee", body: { email: "directeur@banc.test" } },
|
||||||
|
{ method: "PUT", path: "/api/users/inconnu", body: { firstName: "X" } },
|
||||||
|
// Noms effacés ; adresse vide ignorée (non effacée, comme Node)
|
||||||
|
{ method: "PUT", path: "/api/users/bench_employee", body: { firstName: null, lastName: "", email: "" } },
|
||||||
|
{ method: "DELETE", path: "/api/users/admin_local" },
|
||||||
|
{ method: "DELETE", path: "/api/users/bench_employee" },
|
||||||
|
{ method: "POST", path: "/api/users/bench_manager/groups", body: { groupId: "2" } },
|
||||||
|
{ method: "POST", path: "/api/users/bench_manager/groups", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/users/bench_manager/groups/abc" },
|
||||||
|
{ method: "DELETE", path: "/api/users/bench_manager/groups/1" },
|
||||||
|
// Mot de passe vide enregistré tel quel (défaut de Node) : connexion
|
||||||
|
// impossible (en dernier : une connexion réussie remplacerait la session)
|
||||||
|
{ method: "PUT", path: "/api/users/bench_sans_magasin", body: { password: "" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "sansmagasin", password: "banc-sans-magasin" } },
|
||||||
|
],
|
||||||
|
tables: ["users", "user_groups"],
|
||||||
|
deviation: PASSWORD_HASH,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Magasins ----------
|
||||||
|
{
|
||||||
|
module: "groups",
|
||||||
|
name: "créer, modifier, tester et supprimer un magasin",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/groups",
|
||||||
|
body: { name: "Magasin Toul", color: "#7B1FA2", address: "", phone: "", logo: "", nocodbConfigId: null, ...SMTP, smtpPassword: "secret-toul" },
|
||||||
|
},
|
||||||
|
// Node relit le mot de passe chiffré par l'autre serveur
|
||||||
|
{ method: "POST", path: "/api/groups/{{0.id}}/test-smtp" },
|
||||||
|
{ method: "PUT", path: "/api/groups/{{0.id}}", body: { name: "Magasin de Toul", phone: "03 83 00 00 05", smtpPassword: "" } },
|
||||||
|
{ method: "PUT", path: "/api/groups/{{0.id}}", body: { smtpPassword: "nouveau-secret", smtpPort: String(STUB_SMTP_PORT) } },
|
||||||
|
{ method: "POST", path: "/api/groups/{{0.id}}/test-smtp" },
|
||||||
|
{ method: "PUT", path: "/api/groups/{{0.id}}", body: { smtpSenderEmail: "", smtpPort: null } },
|
||||||
|
{ method: "POST", path: "/api/groups/{{0.id}}/test-smtp" },
|
||||||
|
{ method: "GET", path: "/api/groups" },
|
||||||
|
{ method: "DELETE", path: "/api/groups/{{0.id}}" },
|
||||||
|
],
|
||||||
|
tables: ["groups"],
|
||||||
|
// Chiffré avec un vecteur aléatoire
|
||||||
|
ignore: ["smtp_password"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "groups",
|
||||||
|
name: "tester le SMTP : configuration incomplète, serveur injoignable, magasin introuvable",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/groups/2/test-smtp" },
|
||||||
|
{ method: "POST", path: "/api/groups/999/test-smtp" },
|
||||||
|
{ method: "POST", path: "/api/groups/abc/test-smtp" },
|
||||||
|
// Port fermé : connexion refusée (modification réservée à l'admin et au manager)
|
||||||
|
{ method: "PUT", path: "/api/groups/2", body: { smtpHost: "127.0.0.1", smtpPort: 1, smtpSenderEmail: "metz@banc.test" } },
|
||||||
|
{ method: "POST", path: "/api/groups/2/test-smtp" },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "groups",
|
||||||
|
name: "magasins invalides, introuvables ou encore utilisés",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/groups", body: {} },
|
||||||
|
{ method: "POST", path: "/api/groups", body: { name: "X", smtpSenderEmail: 5 } },
|
||||||
|
{ method: "POST", path: "/api/groups", body: { name: "X", smtpSenderEmail: "abc", smtpPort: "0" } },
|
||||||
|
{ method: "POST", path: "/api/groups", body: { name: "X", smtpPort: "abc", phone: 12 } },
|
||||||
|
{ method: "PUT", path: "/api/groups/1", body: { name: 5 } },
|
||||||
|
{ method: "PUT", path: "/api/groups/999", body: { name: "Fantôme" } },
|
||||||
|
{ method: "PUT", path: "/api/groups/abc", body: { name: "X" } },
|
||||||
|
// Un manager modifie un magasin qui n'est pas le sien (comme Node)
|
||||||
|
{ method: "PUT", path: "/api/groups/2", body: { color: "#000000", smtpPassword: null } },
|
||||||
|
{ method: "DELETE", path: "/api/groups/1" },
|
||||||
|
{ method: "DELETE", path: "/api/groups/999" },
|
||||||
|
{ method: "DELETE", path: "/api/groups/abc" },
|
||||||
|
],
|
||||||
|
tables: ["groups"],
|
||||||
|
ignore: ["smtp_password"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Sauvegardes ----------
|
||||||
|
{
|
||||||
|
module: "backups",
|
||||||
|
name: "créer une sauvegarde (pg_dump), ménage des anciennes",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/backups" },
|
||||||
|
{ method: "GET", path: "/api/backups" },
|
||||||
|
{ method: "DELETE", path: "/api/backups/{{0.filename}}" },
|
||||||
|
{ method: "GET", path: "/api/backups" },
|
||||||
|
],
|
||||||
|
// Les deux plus anciennes (sans fichier) ont été supprimées par le ménage
|
||||||
|
tables: ["database_backups"],
|
||||||
|
// Identifiant aléatoire, nom horodaté, taille du fichier pg_dump
|
||||||
|
ignore: ["id", "filename", "size"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "backups",
|
||||||
|
name: "supprimer une sauvegarde et son fichier",
|
||||||
|
steps: [
|
||||||
|
{ method: "DELETE", path: "/api/backups/backup_banc_500.sql" },
|
||||||
|
{ method: "GET", path: "/api/backups/backup_banc_500.sql/download" },
|
||||||
|
{ method: "DELETE", path: "/api/backups/backup_banc_500.sql" },
|
||||||
|
{ method: "DELETE", path: "/api/backups/absente.sql" },
|
||||||
|
{ method: "GET", path: "/api/backups" },
|
||||||
|
],
|
||||||
|
tables: ["database_backups"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Paramètres généraux ----------
|
||||||
|
{
|
||||||
|
module: "utilities",
|
||||||
|
name: "modifier les paramètres généraux",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/utilities", body: { salesAnalysisUrl: "https://analyse.banc.test/nouveau" } },
|
||||||
|
{ method: "POST", path: "/api/utilities", body: { automaticBackupsEnabled: true, autre: 1 } },
|
||||||
|
{ method: "POST", path: "/api/utilities", body: { salesAnalysisUrl: null } },
|
||||||
|
{ method: "GET", path: "/api/utilities" },
|
||||||
|
],
|
||||||
|
tables: ["utilities"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "utilities",
|
||||||
|
name: "paramètres généraux invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/utilities", body: { salesAnalysisUrl: 5 } },
|
||||||
|
{ method: "POST", path: "/api/utilities", body: { salesAnalysisUrl: "x".repeat(501), automaticBackupsEnabled: "oui" } },
|
||||||
|
{ method: "POST", path: "/api/utilities", body: [] },
|
||||||
|
],
|
||||||
|
tables: ["utilities"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
// Lot 4, commandes et livraisons : commandes fournisseurs, livraisons (validation,
|
||||||
|
// contrôle, rapprochement automatique, échéance relue dans NocoDB), API externe
|
||||||
|
// de rapprochement (/api/ext/v1, clé d'API) et relais ffnancy (sans réseau : seul
|
||||||
|
// le refus sans session est rejoué). NocoDB est simulé par stub.ts.
|
||||||
|
import { BENCH_API_KEY } from "../config";
|
||||||
|
import type { AccountKey, ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
// Clé d'API (en-tête X-API-Key), et deux sessions qui n'y donnent pas accès
|
||||||
|
const EXT_ACCOUNTS: AccountKey[] = ["api", "anonyme", "admin"];
|
||||||
|
|
||||||
|
// Durée de vie des entrées du cache des factures : relative à l'heure du passage
|
||||||
|
const CACHE_IGNORE = ["expires_at"];
|
||||||
|
|
||||||
|
const ext = (path: string): ReadCase => ({ module: "external-api-v1", path: `/api/ext/v1${path}`, accounts: EXT_ACCOUNTS });
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
// ---------- Commandes (Orders.tsx, Dashboard.tsx, Calendar.tsx, modales, mobile) ----------
|
||||||
|
{ module: "orders", path: "/api/orders" },
|
||||||
|
{ module: "orders", path: "/api/orders?storeId=1" },
|
||||||
|
{ module: "orders", path: "/api/orders?storeId=2" },
|
||||||
|
{ module: "orders", path: "/api/orders?storeId=3" },
|
||||||
|
{ module: "orders", path: "/api/orders?storeId=abc" },
|
||||||
|
{ module: "orders", path: "/api/orders?" },
|
||||||
|
{ module: "orders", path: "/api/orders?startDate={{today-30}}&endDate={{today+30}}" },
|
||||||
|
{ module: "orders", path: "/api/orders?startDate={{today-30}}&endDate={{today+30}}&storeId=1" },
|
||||||
|
{ module: "orders", path: "/api/orders?storeId=2&startDate={{today}}&endDate={{today}}" },
|
||||||
|
{ module: "orders", path: "/api/orders?startDate={{today-400}}&endDate={{today+400}}&storeId=2" },
|
||||||
|
{ module: "orders", path: "/api/orders?startDate={{today}}" },
|
||||||
|
{ module: "orders", path: "/api/orders?startDate=abc&endDate=xyz" },
|
||||||
|
{ module: "orders", path: "/api/orders/1" },
|
||||||
|
{ module: "orders", path: "/api/orders/2" },
|
||||||
|
{ module: "orders", path: "/api/orders/4" },
|
||||||
|
{ module: "orders", path: "/api/orders/6" },
|
||||||
|
{ module: "orders", path: "/api/orders/600" },
|
||||||
|
{ module: "orders", path: "/api/orders/999" },
|
||||||
|
{ module: "orders", path: "/api/orders/abc" },
|
||||||
|
|
||||||
|
// ---------- Livraisons (Deliveries.tsx, BLReconciliation.tsx, Calendar.tsx, mobile) ----------
|
||||||
|
{ module: "deliveries", path: "/api/deliveries" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?storeId=1" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?storeId=2" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?storeId=3" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?storeId=abc" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?status=delivered" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?status=delivered&storeId=2" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?status=inconnu" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?startDate={{today-30}}&endDate={{today+30}}" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?startDate={{today-30}}&endDate={{today+30}}&storeId=1" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?storeId=2&startDate={{today}}&endDate={{today}}" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?startDate={{today-30}}&endDate={{today+30}}&status=planned" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?startDate=abc&endDate=xyz" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?withBL=true" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries?withBL=true&storeId=1" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/1" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/3" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/5" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/6" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/7" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/600" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/601" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/999" },
|
||||||
|
{ module: "deliveries", path: "/api/deliveries/abc" },
|
||||||
|
|
||||||
|
// ---------- API externe (docs/API-RAPPROCHEMENT.md) ----------
|
||||||
|
ext("/stores"),
|
||||||
|
ext("/suppliers"),
|
||||||
|
ext("/deliveries"),
|
||||||
|
ext("/deliveries?storeId=1"),
|
||||||
|
ext("/deliveries?storeId=2&reconciled=true"),
|
||||||
|
ext("/deliveries?supplierId=2&hasInvoice=false"),
|
||||||
|
ext("/deliveries?blNumber=%20bl-1002%20"),
|
||||||
|
ext("/deliveries?blNumber=BL-1002&blNumber=BL-1004"),
|
||||||
|
ext("/deliveries?hasBl=true&from={{today-30}}&to={{today-2}}"),
|
||||||
|
ext("/deliveries?to={{today-6}}&reconciled=false"),
|
||||||
|
ext("/deliveries?limit=2&offset=1"),
|
||||||
|
ext("/deliveries?limit=1e3&offset=0x2"),
|
||||||
|
ext("/deliveries?offset=50&limit="),
|
||||||
|
ext("/deliveries?storeId=0&supplierId=1.5&reconciled=oui&hasBl=1&hasInvoice=&from=2026-1-1&to=x&limit=0&offset=-1"),
|
||||||
|
ext("/deliveries?storeId=1&storeId=2"),
|
||||||
|
ext("/deliveries?storeId=99999999999"),
|
||||||
|
ext("/deliveries/1"),
|
||||||
|
ext("/deliveries/2"),
|
||||||
|
ext("/deliveries/3"),
|
||||||
|
ext("/deliveries/602"),
|
||||||
|
ext("/deliveries/999"),
|
||||||
|
ext("/deliveries/abc"),
|
||||||
|
ext("/deliveries/0"),
|
||||||
|
ext("/deliveries/1.5"),
|
||||||
|
ext("/deliveries/99999999999"),
|
||||||
|
ext(""),
|
||||||
|
ext("/"),
|
||||||
|
ext("/inconnue"),
|
||||||
|
ext("/stores/extra"),
|
||||||
|
// Variantes qu'Express accepte (casse, barre finale) : relayées à Node
|
||||||
|
ext("/stores/"),
|
||||||
|
ext("/SUPPLIERS"),
|
||||||
|
|
||||||
|
// ---------- Relais ffnancy : sans session seulement (les autres appels partent sur Internet) ----------
|
||||||
|
{ module: "ffnancy", path: "/api/ffnancy/articles?ean=3245678901234&limit=1", accounts: ["anonyme"] },
|
||||||
|
{ module: "ffnancy", path: "/api/ffnancy/mouvements/entrees?artNoId=1&limit=1&dateDebut=2000-01-01", accounts: ["anonyme"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NEW_DELIVERY = { supplierId: 1, groupId: 1, scheduledDate: "{{today+4}}", quantity: 1, unit: "colis" };
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
// ---------- Commandes ----------
|
||||||
|
{
|
||||||
|
module: "orders",
|
||||||
|
name: "commandes : créer, modifier, supprimer",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/orders", body: { supplierId: 1, groupId: 1, plannedDate: "{{today+7}}", notes: "Commande du banc" } },
|
||||||
|
{ method: "PUT", path: "/api/orders/{{0.id}}", body: { plannedDate: "{{today+8}}", notes: "Modifiée", quantity: 3, unit: "palettes" } },
|
||||||
|
{ method: "GET", path: "/api/orders/{{0.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/orders/{{0.id}}" },
|
||||||
|
{ method: "POST", path: "/api/orders", body: { supplierId: 2, groupId: 2, plannedDate: "{{today+1}}" } },
|
||||||
|
{ method: "PUT", path: "/api/orders/4", body: { status: "planned" } },
|
||||||
|
{ method: "PUT", path: "/api/orders/6", body: { notes: "Épinal" } },
|
||||||
|
{ method: "DELETE", path: "/api/orders/6" },
|
||||||
|
{ method: "DELETE", path: "/api/orders/3" },
|
||||||
|
// Commande qui a des livraisons : clé étrangère
|
||||||
|
{ method: "DELETE", path: "/api/orders/2" },
|
||||||
|
{ method: "GET", path: "/api/orders?storeId=1" },
|
||||||
|
],
|
||||||
|
tables: ["orders"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "orders",
|
||||||
|
name: "commandes : corps invalides et identifiants inconnus",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/orders", body: {} },
|
||||||
|
{ method: "POST", path: "/api/orders", body: { supplierId: "1", groupId: 1.5, plannedDate: 5, quantity: "2" } },
|
||||||
|
{ method: "POST", path: "/api/orders", body: [] },
|
||||||
|
{ method: "POST", path: "/api/orders", body: { supplierId: 1, groupId: 1, plannedDate: "pas une date" } },
|
||||||
|
{ method: "POST", path: "/api/orders", body: { supplierId: 999, groupId: 1, plannedDate: "{{today}}" } },
|
||||||
|
{ method: "POST", path: "/api/orders", body: { supplierId: 1, groupId: 1, plannedDate: "{{today}}", createdBy: "admin_local", status: "delivered", id: 999, createdAt: "{{today}}" } },
|
||||||
|
{ method: "PUT", path: "/api/orders/1", body: { groupId: "x" } },
|
||||||
|
{ method: "PUT", path: "/api/orders/1", body: [] },
|
||||||
|
{ method: "PUT", path: "/api/orders/1", body: {} },
|
||||||
|
// Commande déplacée dans un autre magasin, créateur changé
|
||||||
|
{ method: "PUT", path: "/api/orders/3", body: { groupId: 3, createdBy: "bench_employee" } },
|
||||||
|
{ method: "PUT", path: "/api/orders/999", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/orders/abc", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/orders/999" },
|
||||||
|
{ method: "DELETE", path: "/api/orders/abc" },
|
||||||
|
{ method: "GET", path: "/api/orders/3" },
|
||||||
|
],
|
||||||
|
tables: ["orders"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "orders",
|
||||||
|
name: "synchroniser les statuts commandes / livraisons",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/sync-order-delivery-status" },
|
||||||
|
{ method: "POST", path: "/api/sync-order-delivery-status" },
|
||||||
|
{ method: "GET", path: "/api/orders/600" },
|
||||||
|
],
|
||||||
|
tables: ["orders"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Livraisons ----------
|
||||||
|
{
|
||||||
|
module: "deliveries",
|
||||||
|
name: "livraisons : créer, modifier, supprimer",
|
||||||
|
steps: [
|
||||||
|
// CreateDeliveryModal : commande 3 en attente, passée à « planned »
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { orderId: "3", supplierId: 3, groupId: 1, scheduledDate: "{{today+3}}", quantity: 2, unit: "palettes", status: "planned", notes: "Livraison du banc", createdBy: "admin_local" } },
|
||||||
|
// EditDeliveryModal
|
||||||
|
{ method: "PUT", path: "/api/deliveries/{{0.id}}", body: { supplierId: 3, groupId: 1, orderId: null, scheduledDate: "{{today+5}}", quantity: 4, unit: "palettes", notes: "Modifiée" } },
|
||||||
|
{ method: "GET", path: "/api/deliveries/{{0.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/deliveries/{{0.id}}" },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: "none", groupId: 2 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: 4 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: 999 } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/5", body: { orderId: 1 } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/5", body: { orderId: 999 } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/5", body: { notes: "Metz" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/601", body: { orderId: 3 } },
|
||||||
|
{ method: "DELETE", path: "/api/deliveries/7" },
|
||||||
|
// Commentaires supprimés avec la livraison (cascade)
|
||||||
|
{ method: "DELETE", path: "/api/deliveries/2" },
|
||||||
|
{ method: "GET", path: "/api/deliveries?storeId=1" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "orders", "reconciliation_comments"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "deliveries",
|
||||||
|
name: "livraisons : corps invalides et identifiants inconnus",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: {} },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: [] },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: "abc" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: "12abc" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: 0 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: "" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, deliveredDate: "{{today}}" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, status: "", blAmount: "12.5", blNumber: "BL-9000" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, groupId: 3, orderId: 6 } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/1", body: { invoiceAmountTTC: 5 } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/1", body: { dueDate: "pas une date" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4", body: { deliveredDate: "{{today}}" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4", body: { validatedAt: "", blAmount: 99.5, invoiceAmount: true, notes: null } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4", body: { blAmount: 99.5, notes: null, blNumber: "" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4", body: [] },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/999", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/abc", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/deliveries/999" },
|
||||||
|
{ method: "DELETE", path: "/api/deliveries/abc" },
|
||||||
|
{ method: "GET", path: "/api/deliveries/4" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "orders"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "deliveries",
|
||||||
|
name: "livraisons : référence de facture et échéance (NocoDB)",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/deliveries/2", body: { invoiceReference: "FAC-2002", invoiceAmount: "310.00" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/2", body: { invoiceReference: "FAC-2002" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/2", body: { invoiceReference: "FAC-9999" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/2", body: { invoiceReference: "", dueDate: "{{today+60}}" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4", body: { invoiceReference: "AV-3001" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/6", body: { invoiceReference: "FAC-2006" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/6", body: { invoiceReference: "FAC-2001", dueDate: null } },
|
||||||
|
// Validation rapide et dévalidation (BLReconciliation.tsx)
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4", body: { reconciled: true, validatedAt: "{{today}}T08:00:00.000Z" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/1", body: { reconciled: false, validatedAt: null } },
|
||||||
|
{ method: "GET", path: "/api/deliveries/2" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "deliveries",
|
||||||
|
name: "livraisons : rapprochement automatique à la modification",
|
||||||
|
steps: [
|
||||||
|
// Bonduelle : livrée avec un BL, rapprochée d'office (la réponse reste la première mise à jour)
|
||||||
|
{ method: "PUT", path: "/api/deliveries/601", body: { status: "delivered", blNumber: "BL-6010" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/3", body: { blNumber: "BL-3000" } },
|
||||||
|
// Fournisseur changé : relu dans la table
|
||||||
|
{ method: "PUT", path: "/api/deliveries/3", body: { supplierId: 1, status: "delivered" } },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/600", body: { supplierId: 2, blNumber: "BL-6000" } },
|
||||||
|
{ method: "GET", path: "/api/deliveries/601" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "deliveries",
|
||||||
|
name: "livraisons : valider",
|
||||||
|
steps: [
|
||||||
|
// Bonduelle, commande 2 : livraison rapprochée d'office, commande livrée
|
||||||
|
{ method: "POST", path: "/api/deliveries/3/validate", body: { blNumber: " BL-3001 ", blAmount: 150.5 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/5/validate", body: { blNumber: "BL-5001" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/7/validate", body: { blNumber: "BL-7001", blAmount: "" } },
|
||||||
|
// Cache : référence et BL de la livraison marqués « rapprochés »
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/validate", body: { blNumber: "BL-1004" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/8/validate", body: { blNumber: "BL-0900", blAmount: null } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/validate", body: { blNumber: " " } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/validate", body: {} },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/validate", body: [] },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/validate", body: { blNumber: 5 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/validate", body: { blNumber: "BL-1002", blAmount: "abc" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/999/validate", body: { blNumber: "x" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/abc/validate", body: { blNumber: "x" } },
|
||||||
|
{ method: "GET", path: "/api/deliveries/3" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "orders", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "deliveries",
|
||||||
|
name: "livraisons : contrôle effectué",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/deliveries/4/control" },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/6/control" },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/999/control" },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/abc/control" },
|
||||||
|
{ method: "PUT", path: "/api/deliveries/99999999999/control" },
|
||||||
|
{ method: "GET", path: "/api/deliveries?storeId=2" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- API externe ----------
|
||||||
|
{
|
||||||
|
module: "external-api-v1",
|
||||||
|
name: "API externe : écrire la facture",
|
||||||
|
accounts: ["api"],
|
||||||
|
steps: [
|
||||||
|
// Bonduelle (automatique), non rapprochée : échéance reprise de NocoDB
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceReference: " FAC-2002 ", invoiceAmount: "1 234,56", invoiceAmountTTC: 1481.47 } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceReference: "FAC-2002" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { dueDate: "{{today+45}}", reconciled: true } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceAmount: "10" } },
|
||||||
|
// Lactalis rapprochée : figée, sauf dévalidation dans la même requête
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/1", body: { invoiceAmount: 5 } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/1", body: { invoiceAmount: 5, reconciled: false } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/1", body: { invoiceReference: "" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/1", body: { reconciled: true } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/602", body: { invoiceReference: "FAC-2006", invoiceAmountTTC: null } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/3", body: { invoiceReference: "X" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/4", body: { invoiceReference: "FAC-9999" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/4", body: { dueDate: "2026-02-30" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/4", body: { dueDate: "2026-13-45" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/999", body: { reconciled: true } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/abc", body: { reconciled: true } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/99999999999", body: { reconciled: true } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/deliveries?storeId=1&hasInvoice=true" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "external-api-v1",
|
||||||
|
name: "API externe : corps invalides et routes inconnues",
|
||||||
|
accounts: ["api"],
|
||||||
|
steps: [
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: {} },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: [] },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { foo: 1, 2: 3 } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceAmount: "abc", dueDate: "15/10/2026", reconciled: "oui", extra: 1 } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceAmount: true, invoiceAmountTTC: "1e9" } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceReference: "x".repeat(101) } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceReference: 5, reconciled: null } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/abc", body: {} },
|
||||||
|
{ method: "POST", path: "/api/ext/v1/deliveries", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/ext/v1/deliveries/2", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/ext/v1/stores" },
|
||||||
|
{ method: "POST", path: "/api/ext/v1/inconnue", body: { a: 1 } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/deliveries/2" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "external-api-v1",
|
||||||
|
name: "API externe : clés refusées ou passées autrement",
|
||||||
|
accounts: ["api"],
|
||||||
|
steps: [
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": "lf_cle_revoquee" } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": "mauvaise" } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": " ", authorization: `Bearer ${BENCH_API_KEY}` } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/suppliers", headers: { "x-api-key": "", authorization: `bearer ${BENCH_API_KEY} ` } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": " ", authorization: "Bearer " } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": " ", authorization: `Basic ${BENCH_API_KEY}` } },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { reconciled: true }, headers: { "x-api-key": "mauvaise" } },
|
||||||
|
{ method: "POST", path: "/api/ext/v1/inconnue", headers: { "x-api-key": "mauvaise" } },
|
||||||
|
],
|
||||||
|
tables: ["deliveries"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "external-api-v1",
|
||||||
|
name: "API externe : désactivée sans clé active",
|
||||||
|
accounts: ["admin"],
|
||||||
|
steps: [
|
||||||
|
// La session ne donne pas accès à l'API externe
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores" },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": BENCH_API_KEY } },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/1" },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores" },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": BENCH_API_KEY } },
|
||||||
|
{ method: "GET", path: "/api/ext/v1/inconnue" },
|
||||||
|
{ method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { reconciled: true }, headers: { "x-api-key": BENCH_API_KEY } },
|
||||||
|
],
|
||||||
|
tables: ["external_api_keys"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
// Lot 4, rapprochement : avoirs (et leur webhook), échéancier des paiements (lecture
|
||||||
|
// complétée par NocoDB, export CSV), envoi d'une facture au webhook du magasin,
|
||||||
|
// SQL et migration d'urgence de l'administration. NocoDB et les webhooks sont
|
||||||
|
// simulés par stub.ts. Données complémentaires : seeds/lot4-rapprochement.ts.
|
||||||
|
import { STUB_URL } from "../config";
|
||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
// Mois « AAAA-MM » relatif au mois courant (sélecteur de l'échéancier)
|
||||||
|
function month(offset: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setUTCDate(1);
|
||||||
|
date.setUTCMonth(date.getUTCMonth() + offset);
|
||||||
|
return date.toISOString().slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Durée de vie des entrées du cache : relative à l'heure du passage, différente
|
||||||
|
// entre les deux serveurs
|
||||||
|
const CACHE_IGNORE = ["expires_at"];
|
||||||
|
|
||||||
|
const ALL_METHODS = ["virement", "prelevement", "cheque", "Non défini"];
|
||||||
|
|
||||||
|
// Fichier envoyé par BLReconciliation.tsx avec les champs du formulaire
|
||||||
|
const INVOICE_FILE = [{ field: "file", filename: "facture BL-1001.pdf", contentType: "application/pdf", size: 2048 }];
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
// ---------- Avoirs (Avoirs.tsx, mobile/AvoirsPage.tsx : storeId du magasin choisi) ----------
|
||||||
|
{ module: "avoirs", path: "/api/avoirs" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs?storeId=1" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs?storeId=2" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs?storeId=3" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs?storeId=" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs?storeId=abc" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs?storeId=99999999999" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs/1" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs/3" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs/701" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs/999" },
|
||||||
|
{ module: "avoirs", path: "/api/avoirs/abc" },
|
||||||
|
|
||||||
|
// ---------- Échéancier (PaymentSchedulePage.tsx : groupId du magasin choisi) ----------
|
||||||
|
// La lecture complète en base les échéances et montants manquants (NocoDB) :
|
||||||
|
// les comptes suivants voient les livraisons déjà complétées
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=1" },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=2" },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=3" },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=999" },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=abc", accounts: ["admin", "directeur", "manager"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule", accounts: ["admin", "directeur"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=", accounts: ["admin", "directeur"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=0", accounts: ["admin", "directeur"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=1.5", accounts: ["admin", "directeur"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=-1", accounts: ["admin", "directeur"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=%201%20", accounts: ["admin", "directeur"] },
|
||||||
|
{ module: "payment-schedule", path: "/api/payment-schedule?groupId=99999999999", accounts: ["admin", "directeur"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
// ---------- Avoirs ----------
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "créer un avoir (webhook du magasin à la création)",
|
||||||
|
steps: [
|
||||||
|
// Formulaire du bureau
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-4001", amount: 12.5, comment: "Casse", commercialProcessed: false, status: "En attente de demande" } },
|
||||||
|
{ method: "GET", path: "/api/avoirs/{{0.id}}" },
|
||||||
|
// Formulaire mobile : montant forcé à 0, sans référence ni commentaire
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 2, groupId: 1, amount: 0, status: "Demandé", createdBy: "pirate" } },
|
||||||
|
// Metz : sans webhook
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 3, groupId: 2, invoiceReference: "", amount: "-7.25", comment: "", commercialProcessed: true, status: "Reçu" } },
|
||||||
|
// Épinal : aucun compte du banc n'y est affecté (sauf l'administrateur)
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 4, groupId: 3, amount: null } },
|
||||||
|
{ method: "GET", path: "/api/avoirs" },
|
||||||
|
],
|
||||||
|
tables: ["avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Fiche du magasin modifiable par l'admin et le manager
|
||||||
|
module: "avoirs",
|
||||||
|
name: "webhook du magasin en erreur, injoignable ou invalide",
|
||||||
|
accounts: ["admin", "manager"],
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/groups/1", body: { webhookUrl: `${STUB_URL}/inconnu` } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-4101", amount: 1 } },
|
||||||
|
{ method: "PUT", path: "/api/groups/1", body: { webhookUrl: "http://127.0.0.1:1/webhook" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-4102", amount: 2 } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { status: "Reçu" } },
|
||||||
|
{ method: "PUT", path: "/api/groups/1", body: { webhookUrl: "pas une adresse" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-4103", amount: 3 } },
|
||||||
|
],
|
||||||
|
tables: ["avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "avoirs invalides à la création",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: {} },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: "1", groupId: 1.5 } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, amount: "abc", status: "Perdu", commercialProcessed: "oui" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: null, comment: null, commercialProcessed: null } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: ["x"] },
|
||||||
|
// Erreurs de la base : fournisseur ou magasin inconnu, référence trop longue, montant trop grand
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 999, groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 999 } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "R".repeat(300) } },
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, amount: 1e9 } },
|
||||||
|
// Montant vide : Number("") = 0
|
||||||
|
{ method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 2, amount: "" } },
|
||||||
|
],
|
||||||
|
tables: ["avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "modifier un avoir (passage à « Reçu » et webhook)",
|
||||||
|
steps: [
|
||||||
|
// Formulaire complet : passage à « Reçu », webhook du magasin
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-3001", amount: 50, comment: "Reçu par courrier", commercialProcessed: true, status: "Reçu" } },
|
||||||
|
// Déjà « Reçu » : pas de webhook ; montant, référence et commentaire effacés (AVOIRS-API-06)
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { status: "Reçu" } },
|
||||||
|
// Metz (sans webhook)
|
||||||
|
{ method: "PUT", path: "/api/avoirs/3", body: { status: "Demandé", amount: "15.5", invoiceReference: "AV-3003", comment: "" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/700", body: { status: "Reçu", invoiceReference: "FAC-2006", amount: 10.5 } },
|
||||||
|
// Changement de magasin et de créateur acceptés (AVOIRS-API-07)
|
||||||
|
{ method: "PUT", path: "/api/avoirs/2", body: { groupId: 2, createdBy: "pirate", invoiceReference: "AV-3002" } },
|
||||||
|
// Épinal
|
||||||
|
{ method: "PUT", path: "/api/avoirs/701", body: { status: "Reçu" } },
|
||||||
|
{ method: "GET", path: "/api/avoirs/1" },
|
||||||
|
],
|
||||||
|
tables: ["avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "avoirs invalides ou introuvables à la modification",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { status: "Perdu" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { amount: "abc", supplierId: "2" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { invoiceReference: null } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: { supplierId: 999 } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/999", body: { status: "Reçu" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/abc", body: { status: "Reçu" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1", body: [] },
|
||||||
|
],
|
||||||
|
tables: ["avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "supprimer des avoirs",
|
||||||
|
steps: [
|
||||||
|
{ method: "DELETE", path: "/api/avoirs/2" },
|
||||||
|
{ method: "DELETE", path: "/api/avoirs/700" },
|
||||||
|
{ method: "DELETE", path: "/api/avoirs/701" },
|
||||||
|
{ method: "DELETE", path: "/api/avoirs/999" },
|
||||||
|
{ method: "DELETE", path: "/api/avoirs/abc" },
|
||||||
|
{ method: "GET", path: "/api/avoirs" },
|
||||||
|
],
|
||||||
|
tables: ["avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "vérifier la facture d'un avoir dans NocoDB",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: "AV-3001", forceRefresh: false } },
|
||||||
|
// Depuis le cache, puis en forçant
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: "AV-3001" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: " av-3001 ", forceRefresh: "oui" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/2/verify-invoice", body: { invoiceReference: "AV-INCONNU" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/700/verify-invoice", body: { invoiceReference: "FAC-2006" } },
|
||||||
|
// Épinal : sans configuration NocoDB
|
||||||
|
{ method: "POST", path: "/api/avoirs/701/verify-invoice", body: { invoiceReference: "FAC-2004" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: {} },
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: " " } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: 2001 } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/999/verify-invoice", body: { invoiceReference: "AV-3001" } },
|
||||||
|
{ method: "POST", path: "/api/avoirs/abc/verify-invoice", body: { invoiceReference: "AV-3001" } },
|
||||||
|
],
|
||||||
|
tables: ["invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "avoirs",
|
||||||
|
name: "valider un avoir et l'état de son webhook",
|
||||||
|
steps: [
|
||||||
|
// Entrée du cache à marquer « rapprochée » à la validation
|
||||||
|
{ method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: "AV-3001" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1/nocodb-verification", body: { verified: true } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/2/nocodb-verification", body: { verified: false } },
|
||||||
|
// Référence absente ou blanche : rien à marquer ; autre magasin accepté (AVOIRS-API-08)
|
||||||
|
{ method: "PUT", path: "/api/avoirs/3/nocodb-verification", body: { verified: true } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/702/nocodb-verification", body: { verified: "yes" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/700/nocodb-verification", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/701/nocodb-verification", body: { verified: "oui" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/999/nocodb-verification", body: { verified: true } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/abc/nocodb-verification", body: { verified: true } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/1/webhook-status", body: { webhookSent: false } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/2/webhook-status", body: { webhookSent: 1 } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/700/webhook-status", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/701/webhook-status", body: { webhookSent: null } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/3/webhook-status", body: { webhookSent: "oui" } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/999/webhook-status", body: { webhookSent: true } },
|
||||||
|
{ method: "PUT", path: "/api/avoirs/abc/webhook-status", body: { webhookSent: true } },
|
||||||
|
],
|
||||||
|
tables: ["avoirs", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Échéancier ----------
|
||||||
|
{
|
||||||
|
module: "payment-schedule",
|
||||||
|
name: "échéancier : échéances et montants complétés par NocoDB",
|
||||||
|
steps: [
|
||||||
|
{ method: "GET", path: "/api/payment-schedule?groupId=1" },
|
||||||
|
// Seconde lecture : le repli « TTC » recommence (montant TTC jamais écrit)
|
||||||
|
{ method: "GET", path: "/api/payment-schedule?groupId=1" },
|
||||||
|
{ method: "GET", path: "/api/payment-schedule?groupId=2" },
|
||||||
|
{ method: "GET", path: "/api/payment-schedule?groupId=3" },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "payment-schedule",
|
||||||
|
name: "export CSV de l'échéancier",
|
||||||
|
steps: [
|
||||||
|
// Formulaire d'export : mois courant, tous les modes de paiement, HT et TTC
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: "1", month: month(0), paymentMethods: ALL_METHODS, includeHT: true, includeTTC: true } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0), paymentMethods: ["virement", "cheque"], includeHT: false, includeTTC: true } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(1), paymentMethods: ALL_METHODS, includeHT: "non", includeTTC: 0 } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 2, month: month(0), paymentMethods: ALL_METHODS, includeHT: true } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 3, month: month(0), paymentMethods: ALL_METHODS, includeTTC: true } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: "2020-13", paymentMethods: ALL_METHODS, includeHT: true } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 999, month: month(0), paymentMethods: ALL_METHODS } },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "payment-schedule",
|
||||||
|
name: "export CSV : paramètres invalides",
|
||||||
|
accounts: ["admin", "directeur", "manager"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: {} },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: "abc", month: month(0), paymentMethods: ALL_METHODS } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1.5, month: month(0), paymentMethods: ALL_METHODS } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: "2026-1", paymentMethods: ALL_METHODS } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: 202610, paymentMethods: ALL_METHODS } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0), paymentMethods: [] } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0), paymentMethods: "virement" } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0), paymentMethods: ["virement", 5, null] } },
|
||||||
|
{ method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0) } },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Envoi d'une facture au webhook (BLReconciliation.tsx) ----------
|
||||||
|
{
|
||||||
|
module: "send-invoice",
|
||||||
|
name: "envoyer une facture au webhook du magasin",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/reconciliation/send-invoice",
|
||||||
|
bodyType: "multipart",
|
||||||
|
body: { webhookUrl: `${STUB_URL}/webhook/avoirs`, supplier: "Lactalis", blNumber: "BL-1001", type: "Facture" },
|
||||||
|
files: INVOICE_FILE,
|
||||||
|
},
|
||||||
|
// Webhook BAP configuré ; champs facultatifs absents
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/reconciliation/send-invoice",
|
||||||
|
bodyType: "multipart",
|
||||||
|
body: { webhookUrl: `${STUB_URL}/webhook/bap` },
|
||||||
|
files: [{ field: "file", filename: "scan échéance.png", contentType: "image/PNG", size: 10 }],
|
||||||
|
},
|
||||||
|
// Fichier ou adresse manquants
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { webhookUrl: `${STUB_URL}/webhook/avoirs`, supplier: "Lactalis" } },
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { supplier: "Lactalis" }, files: INVOICE_FILE },
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { webhookUrl: "" }, files: INVOICE_FILE },
|
||||||
|
// Champ « file » en texte
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { file: "pas un fichier", webhookUrl: `${STUB_URL}/webhook/avoirs` } },
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { file: "", webhookUrl: `${STUB_URL}/webhook/avoirs` } },
|
||||||
|
// Pas de multipart
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice", body: { webhookUrl: `${STUB_URL}/webhook/avoirs` } },
|
||||||
|
{ method: "POST", path: "/api/reconciliation/send-invoice" },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "send-invoice",
|
||||||
|
name: "webhook de facture en erreur ou injoignable",
|
||||||
|
accounts: ["admin"],
|
||||||
|
steps: [
|
||||||
|
// Adresses enregistrées sur la fiche du magasin d'Épinal
|
||||||
|
{ method: "PUT", path: "/api/groups/3", body: { webhookUrl: `${STUB_URL}/inconnu` } },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/reconciliation/send-invoice",
|
||||||
|
bodyType: "multipart",
|
||||||
|
body: { webhookUrl: `${STUB_URL}/inconnu`, supplier: "Jardin & Co", blNumber: "", type: "Avoir" },
|
||||||
|
files: INVOICE_FILE,
|
||||||
|
},
|
||||||
|
{ method: "PUT", path: "/api/groups/3", body: { webhookUrl: "http://127.0.0.1:1/webhook" } },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/reconciliation/send-invoice",
|
||||||
|
bodyType: "multipart",
|
||||||
|
body: { webhookUrl: "http://127.0.0.1:1/webhook", supplier: "Jardin & Co" },
|
||||||
|
files: INVOICE_FILE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// RAPPRO-21 : Node envoie le fichier à toute adresse fournie par le navigateur
|
||||||
|
module: "send-invoice",
|
||||||
|
name: "envoyer une facture à une adresse non configurée",
|
||||||
|
// Les autres comptes sont refusés avant (403), comme dans le premier scénario
|
||||||
|
accounts: ["admin", "directeur"],
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/reconciliation/send-invoice",
|
||||||
|
bodyType: "multipart",
|
||||||
|
body: { webhookUrl: `${STUB_URL}/webhook/non-configure`, supplier: "Lactalis", blNumber: "BL-1001" },
|
||||||
|
files: INVOICE_FILE,
|
||||||
|
},
|
||||||
|
// Webhook BAP désactivé (par l'admin) : son adresse n'est plus acceptée
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: { webhookUrl: `${STUB_URL}/webhook/bap`, isActive: false } },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/reconciliation/send-invoice",
|
||||||
|
bodyType: "multipart",
|
||||||
|
body: { webhookUrl: `${STUB_URL}/webhook/bap`, supplier: "Lactalis" },
|
||||||
|
files: INVOICE_FILE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
deviation: {
|
||||||
|
fields: ["status", "body", "appels"],
|
||||||
|
reason:
|
||||||
|
"RAPPRO-21 : l'adresse du webhook doit être celle d'un magasin ou du webhook BAP actif ; toute autre adresse est refusée (400) au lieu d'être appelée par le serveur",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Administration : SQL et migration d'urgence (SQLExecutor.tsx) ----------
|
||||||
|
{
|
||||||
|
module: "admin-sql",
|
||||||
|
name: "exécuter du SQL : lectures, types de colonnes et erreurs",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT id, name, color, nocodb_config_id FROM groups ORDER BY id" } },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/admin/execute-sql",
|
||||||
|
body: {
|
||||||
|
sql: "SELECT count(*) AS total, sum(amount) AS somme, min(created_at) AS premier, min(created_at)::date AS jour, bool_and(true) AS vrai, " +
|
||||||
|
"1.5::float8 AS flottant, 2::float8 AS entier, 7::int2 AS petit, 12345678901::int8 AS grand, NULL AS rien, '{\"a\": [1, \"b\"]}'::jsonb AS objet, " +
|
||||||
|
"ARRAY[1, 2] AS tableau, ARRAY['a', NULL] AS textes, 'x'::bytea AS octets, '1 day 02:00'::interval AS duree, " +
|
||||||
|
"'2026-01-01 10:00+02'::timestamptz AS horodate, 'f'::bool AS faux, 'é\"\\\\'::text AS texte FROM avoirs",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 'a' AS x, 'b' AS x, 1" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT n, n * 1.5 AS demi FROM generate_series(1, 12) AS n" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 1 WHERE false" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 1 AS a; SELECT 2 AS b" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 1 AS a; SELEC 2" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "ANALYZE groups" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: " " } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELEC 1" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 1/0" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: 5 } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: {} },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "admin-sql",
|
||||||
|
name: "exécuter du SQL : modification d'une ligne",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "UPDATE groups SET color = '#123456' WHERE id = 3" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "UPDATE groups SET color = color WHERE id = 999" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "UPDATE avoirs SET comment = 'modifié' WHERE id = 1 RETURNING id, comment, amount" } },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT id, color FROM groups WHERE id = 3" } },
|
||||||
|
],
|
||||||
|
tables: ["groups", "avoirs"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "admin-sql",
|
||||||
|
name: "migration d'urgence : secrets en clair chiffrés",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "UPDATE groups SET smtp_password = 'secret-en-clair' WHERE id = 2" } },
|
||||||
|
{ method: "POST", path: "/api/admin/emergency-migration" },
|
||||||
|
{ method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT id, smtp_password LIKE 'enc:v1:%' AS chiffre FROM groups ORDER BY id" } },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// N'existe que si EMERGENCY_SECRET est défini (sinon 404) : le banc rejoue le
|
||||||
|
// secret de son propre environnement
|
||||||
|
module: "admin-sql",
|
||||||
|
name: "réinitialisation d'urgence du compte admin",
|
||||||
|
accounts: ["anonyme", "manager"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/emergency-admin-reset", body: { secret: "mauvais-secret" } },
|
||||||
|
{ method: "POST", path: "/api/emergency-admin-reset", body: {} },
|
||||||
|
{ method: "POST", path: "/api/emergency-admin-reset", body: { secret: process.env.EMERGENCY_SECRET || "secret-absent" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "admin", password: "admin" } },
|
||||||
|
],
|
||||||
|
tables: ["users"],
|
||||||
|
// Empreinte au sel aléatoire
|
||||||
|
ignore: ["password"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Lot 4, services partagés : rapprochement BL / factures (commentaires, courriels
|
||||||
|
// fournisseurs, vérification des factures, cache), configuration NocoDB, BAP et
|
||||||
|
// clés de l'API externe. NocoDB, les webhooks et le SMTP sont simulés par stub.ts.
|
||||||
|
import { STUB_SMTP_PORT, STUB_URL } from "../config";
|
||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
// Commentaires de rapprochement (ReconciliationComments.tsx)
|
||||||
|
{ module: "reconciliation", path: "/api/deliveries/2/reconciliation-comments" },
|
||||||
|
{ module: "reconciliation", path: "/api/deliveries/4/reconciliation-comments" },
|
||||||
|
{ module: "reconciliation", path: "/api/deliveries/6/reconciliation-comments" },
|
||||||
|
{ module: "reconciliation", path: "/api/deliveries/999/reconciliation-comments" },
|
||||||
|
{ module: "reconciliation", path: "/api/deliveries/abc/reconciliation-comments" },
|
||||||
|
// Historique des relances (BLReconciliation.tsx : « ? » seul, ou storeId pour admin et directeur)
|
||||||
|
{ module: "reconciliation", path: "/api/supplier-mail-logs?" },
|
||||||
|
{ module: "reconciliation", path: "/api/supplier-mail-logs?storeId=1" },
|
||||||
|
{ module: "reconciliation", path: "/api/supplier-mail-logs?storeId=2" },
|
||||||
|
{ module: "reconciliation", path: "/api/supplier-mail-logs?deliveryId=2" },
|
||||||
|
{ module: "reconciliation", path: "/api/supplier-mail-logs?deliveryId=abc" },
|
||||||
|
{ module: "reconciliation", path: "/api/supplier-mail-logs?storeId=abc" },
|
||||||
|
// Diagnostic du cache (sans appelant dans l'interface ; supprime au passage une entrée expirée)
|
||||||
|
{ module: "reconciliation", path: "/api/cache/diagnosis" },
|
||||||
|
// Configuration NocoDB (NocoDBConfig.tsx, Groups.tsx)
|
||||||
|
{ module: "nocodb-config", path: "/api/nocodb-config" },
|
||||||
|
{ module: "nocodb-config", path: "/api/nocodb-config/active" },
|
||||||
|
// Webhook BAP (WebhookBAPConfig.tsx, BapUploadModal.tsx)
|
||||||
|
{ module: "bap", path: "/api/webhook-bap-config" },
|
||||||
|
// Clés de l'API externe (ExternalApiSettings.tsx)
|
||||||
|
{ module: "external-api", path: "/api/external-api/keys" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Durée de vie des entrées du cache : relative à l'heure du passage, différente
|
||||||
|
// entre les deux serveurs (vérifiée par les tests unitaires de Rust)
|
||||||
|
const CACHE_IGNORE = ["expires_at"];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
// ---------- Commentaires ----------
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "commentaires : ajouter, modifier, supprimer",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: { content: "Relance faite", type: "warning" } },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/{{0.id}}", body: { content: "Relance faite (modifié)", type: "success" } },
|
||||||
|
{ method: "GET", path: "/api/deliveries/2/reconciliation-comments" },
|
||||||
|
{ method: "DELETE", path: "/api/reconciliation-comments/{{0.id}}" },
|
||||||
|
{ method: "POST", path: "/api/deliveries/6/reconciliation-comments", body: { content: "Commentaire à Metz" } },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/1", body: { content: "Modifié par un autre compte" } },
|
||||||
|
{ method: "DELETE", path: "/api/reconciliation-comments/2" },
|
||||||
|
{ method: "GET", path: "/api/deliveries/4/reconciliation-comments" },
|
||||||
|
],
|
||||||
|
tables: ["reconciliation_comments"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "commentaires : corps invalides et identifiants inconnus",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: {} },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: { content: "x", type: "bizarre" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: { content: 5 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: { content: "Forcé", deliveryId: 6, groupId: 2, authorId: "admin_local" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/999/reconciliation-comments", body: { content: "x" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/abc/reconciliation-comments", body: { content: "x" } },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/1", body: { type: "inconnu" } },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/1", body: { deliveryId: 4, authorId: "admin_local" } },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/2", body: { deliveryId: 99999 } },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/999", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/reconciliation-comments/abc", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/reconciliation-comments/999" },
|
||||||
|
{ method: "DELETE", path: "/api/reconciliation-comments/abc" },
|
||||||
|
],
|
||||||
|
tables: ["reconciliation_comments"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Courriel au fournisseur ----------
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "courriel au fournisseur : envoi et refus",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/send-supplier-mail" },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/send-supplier-mail" },
|
||||||
|
{ method: "POST", path: "/api/deliveries/6/send-supplier-mail" },
|
||||||
|
{ method: "POST", path: "/api/deliveries/7/send-supplier-mail" },
|
||||||
|
{ method: "POST", path: "/api/deliveries/999/send-supplier-mail" },
|
||||||
|
{ method: "POST", path: "/api/deliveries/abc/send-supplier-mail" },
|
||||||
|
{ method: "GET", path: "/api/supplier-mail-logs?deliveryId=2" },
|
||||||
|
],
|
||||||
|
tables: ["supplier_mail_logs"],
|
||||||
|
ignore: ["messageId", "message_id"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "courriel au fournisseur : SMTP incomplet ou injoignable",
|
||||||
|
steps: [
|
||||||
|
// PUT /api/groups/:id reste servi par Node (lot 5) : admin et manager
|
||||||
|
{ method: "PUT", path: "/api/groups/2", body: { smtpEnabled: true } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/6/send-supplier-mail" },
|
||||||
|
// Metz : sans logo, sans nom d'expéditeur ni authentification
|
||||||
|
{ method: "PUT", path: "/api/groups/2", body: { smtpHost: " 127.0.0.1 ", smtpPort: STUB_SMTP_PORT, smtpSenderEmail: "metz@banc.test", smtpUser: null } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/6/send-supplier-mail" },
|
||||||
|
// Nancy : serveur injoignable
|
||||||
|
{ method: "PUT", path: "/api/groups/1", body: { smtpPort: 1 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/send-supplier-mail" },
|
||||||
|
{ method: "GET", path: "/api/supplier-mail-logs?" },
|
||||||
|
{ method: "GET", path: "/api/supplier-mail-logs?deliveryId=6&storeId=2" },
|
||||||
|
],
|
||||||
|
tables: ["supplier_mail_logs"],
|
||||||
|
ignore: ["messageId", "message_id"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Vérification des factures ----------
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "vérifier la facture d'une livraison",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: false } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: true } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/verify-invoice", body: { blNumber: "BL-1002", forceRefresh: false } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/verify-invoice", body: { blNumber: "BL-1002" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/verify-invoice", body: { blNumber: "BL-1004" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/verify-invoice", body: { invoiceReference: "FAC-9999", blNumber: "BL-1002" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/3/verify-invoice", body: { invoiceReference: " BL-1001 " } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/1/verify-invoice", body: { invoiceReference: "FAC-2001" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/6/verify-invoice", body: { invoiceReference: "FAC-2006", forceRefresh: true } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/7/verify-invoice", body: { invoiceReference: "FAC-3000" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/8/verify-invoice", body: { invoiceReference: "AV-3001", forceRefresh: true } },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "vérifier la facture d'une livraison : corps invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: {} },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: " ", blNumber: "" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: 12 } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: " ", blNumber: true } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/999/verify-invoice", body: { invoiceReference: "FAC-2004" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/abc/verify-invoice", body: { invoiceReference: "FAC-2004" } },
|
||||||
|
],
|
||||||
|
tables: ["deliveries", "invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "vérifier une facture : NocoDB injoignable, en erreur ou désactivé",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/1", body: { baseUrl: "http://127.0.0.1:1" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: true } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/1", body: { baseUrl: `${STUB_URL}/autre` } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/verify-invoice", body: { invoiceReference: "FAC-2002", forceRefresh: true } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/2/verify-invoice", body: { blNumber: "BL-1002", forceRefresh: true } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/1", body: { baseUrl: "pas une adresse" } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: true } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/1", body: { isActive: false } },
|
||||||
|
{ method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: true } },
|
||||||
|
],
|
||||||
|
tables: ["invoice_verification_cache", "nocodb_config"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "vérifier des factures (adresses sans magasin contrôlé)",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 1, invoiceReference: "FAC-2002" } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: "1", invoiceReference: "fac-2002" } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 2, invoiceReference: "FAC-2001" } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 3, invoiceReference: "FAC-2001" } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 99, invoiceReference: "FAC-2001" } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: "abc", invoiceReference: "FAC-2001" } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 1, invoiceReference: 5 } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 1, invoiceReference: " " } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoice", body: { groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ groupId: 1, invoiceReference: "fac-2001", deliveryId: 10, supplierName: "lactalis" }, { groupId: 3, invoiceReference: "X", deliveryId: 2 }] } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ groupId: 1, invoiceReference: "FAC-2001", deliveryId: 1, supplierName: "Bonduelle" }] } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ groupId: 1, deliveryId: 3 }] } },
|
||||||
|
// Une seule clé non numérique par appel : Node les range dans l'ordre où ses
|
||||||
|
// vérifications parallèles se terminent
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ deliveryId: "a" }, { groupId: 99, deliveryId: 5 }] } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [7] } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ groupId: "abc", deliveryId: 4 }] } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [null] } },
|
||||||
|
{ method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: "FAC-2001" } },
|
||||||
|
],
|
||||||
|
tables: ["invoice_verification_cache"],
|
||||||
|
ignore: CACHE_IGNORE,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Cache des factures ----------
|
||||||
|
{
|
||||||
|
module: "reconciliation",
|
||||||
|
name: "cache : marquer rapproché, mise à jour en lot, diagnostic",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/cache/mark-reconciled", body: { invoiceReference: " FAC-2004 ", groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/cache/mark-reconciled", body: { invoiceReference: "FAC-1900", groupId: "1" } },
|
||||||
|
{ method: "POST", path: "/api/cache/mark-reconciled", body: { invoiceReference: 5, groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/cache/mark-reconciled", body: { invoiceReference: "FAC-2004" } },
|
||||||
|
{ method: "POST", path: "/api/cache/update-reconciled" },
|
||||||
|
{ method: "GET", path: "/api/cache/diagnosis" },
|
||||||
|
],
|
||||||
|
tables: ["invoice_verification_cache"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Configuration NocoDB ----------
|
||||||
|
{
|
||||||
|
module: "nocodb-config",
|
||||||
|
name: "configurations NocoDB : créer, modifier, supprimer",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/nocodb-config", body: { name: "NocoDB secours", baseUrl: `${STUB_URL}/nocodb`, projectId: "psecours", apiToken: "jeton-neuf", description: "Seconde base", isActive: false, createdBy: "pirate" } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/{{0.id}}", body: { name: "NocoDB renommée", apiToken: "jeton-modifié" } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/{{0.id}}", body: {} },
|
||||||
|
{ method: "GET", path: "/api/nocodb-config" },
|
||||||
|
{ method: "GET", path: "/api/nocodb-config/active" },
|
||||||
|
{ method: "DELETE", path: "/api/nocodb-config/{{0.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/nocodb-config/999" },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/999", body: { name: "Absente" } },
|
||||||
|
{ method: "POST", path: "/api/nocodb-config", body: { name: "Sans jeton", baseUrl: "http://x.test", projectId: "p", apiToken: "" } },
|
||||||
|
],
|
||||||
|
tables: ["nocodb_config"],
|
||||||
|
ignore: ["api_token"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "nocodb-config",
|
||||||
|
name: "configurations NocoDB : corps invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/nocodb-config", body: {} },
|
||||||
|
{ method: "POST", path: "/api/nocodb-config", body: { name: "x", baseUrl: 1, projectId: "p", apiToken: "t" } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/1", body: { isActive: "oui" } },
|
||||||
|
{ method: "PUT", path: "/api/nocodb-config/abc", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/nocodb-config/abc" },
|
||||||
|
],
|
||||||
|
tables: ["nocodb_config"],
|
||||||
|
ignore: ["api_token"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- BAP ----------
|
||||||
|
{
|
||||||
|
module: "bap",
|
||||||
|
name: "webhook BAP : configuration",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: { name: "BAP banc", webhookUrl: `${STUB_URL}/webhook/bap-nouveau`, description: "Modifiée", isActive: true, id: 7 } },
|
||||||
|
{ method: "GET", path: "/api/webhook-bap-config" },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: { webhookUrl: 12 } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: {} },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: { name: "x".repeat(101), webhookUrl: "u", isActive: "oui" } },
|
||||||
|
],
|
||||||
|
tables: ["webhook_bap_config"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "bap",
|
||||||
|
name: "webhook BAP : test de connexion",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: `${STUB_URL}/webhook/bap` } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: `${STUB_URL}/autre` } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config/test", body: {} },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: "pas-une-url" } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: "http://127.0.0.1:1" } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: "ftp://x.test/a" } },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "bap",
|
||||||
|
name: "envoi d'un BAP au webhook",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQKJcOkw7zDtsOf", fileName: "Facture-123.PDF", recipient: "Laurie" } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQK", fileName: "a.pdf", recipient: "Paul" } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQK", fileName: "a.txt", recipient: "Jeremy" } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: 5, fileName: "a.pdf", recipient: "Jeremy" } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: [1, 2], fileName: "a.pdf", recipient: "Jeremy" } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "x", fileName: 5, recipient: "Laurie" } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { fileName: "a.pdf" } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: { webhookUrl: `${STUB_URL}/autre` } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQK", fileName: "b.pdf", recipient: "Jeremy" } },
|
||||||
|
{ method: "POST", path: "/api/webhook-bap-config", body: { webhookUrl: `${STUB_URL}/webhook/bap`, isActive: false } },
|
||||||
|
{ method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQK", fileName: "c.pdf", recipient: "Laurie" } },
|
||||||
|
],
|
||||||
|
tables: ["webhook_bap_config"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Clés de l'API externe ----------
|
||||||
|
{
|
||||||
|
module: "external-api",
|
||||||
|
name: "clés d'API : créer, révoquer",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/external-api/keys", body: { name: " Nouvel outil " } },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/{{0.apiKey.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/{{0.apiKey.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/2" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/1" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/1e2" },
|
||||||
|
{ method: "GET", path: "/api/external-api/keys" },
|
||||||
|
],
|
||||||
|
tables: ["external_api_keys"],
|
||||||
|
ignore: ["key", "keyPrefix", "key_prefix", "key_hash"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "external-api",
|
||||||
|
name: "clés d'API : nom ou identifiant invalide",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/external-api/keys", body: {} },
|
||||||
|
{ method: "POST", path: "/api/external-api/keys", body: { name: " " } },
|
||||||
|
{ method: "POST", path: "/api/external-api/keys", body: { name: 5 } },
|
||||||
|
{ method: "POST", path: "/api/external-api/keys", body: { name: "x".repeat(101) } },
|
||||||
|
{ method: "POST", path: "/api/external-api/keys", body: [] },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/abc" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/0" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/1.5" },
|
||||||
|
{ method: "DELETE", path: "/api/external-api/keys/99999999999" },
|
||||||
|
],
|
||||||
|
tables: ["external_api_keys"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Toutes les requêtes du banc, une liste par lot (docs/PLAN-MIGRATION-RUST.md)
|
||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
import * as socle from "./socle";
|
||||||
|
import * as referentiel from "./referentiel";
|
||||||
|
import * as informations from "./informations";
|
||||||
|
import * as quotidien from "./quotidien";
|
||||||
|
import * as fournisseursServices from "./fournisseurs-services";
|
||||||
|
import * as fournisseursCommandes from "./fournisseurs-commandes";
|
||||||
|
import * as fournisseursRapprochement from "./fournisseurs-rapprochement";
|
||||||
|
import * as administration from "./administration";
|
||||||
|
|
||||||
|
const modules = [
|
||||||
|
socle,
|
||||||
|
referentiel,
|
||||||
|
informations,
|
||||||
|
quotidien,
|
||||||
|
fournisseursServices,
|
||||||
|
fournisseursCommandes,
|
||||||
|
fournisseursRapprochement,
|
||||||
|
administration,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const READS: ReadCase[] = modules.flatMap((m) => m.reads);
|
||||||
|
export const WRITES: WriteScenario[] = modules.flatMap((m) => m.writes);
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
// Lot 2 : Informations (annonces), Publicités, Météo, Statistiques et
|
||||||
|
// Statistiques détaillées. Paramètres repris des appels de client/src
|
||||||
|
// (AnnouncementCard, Calendar, Publicities, Dashboard, StatsPanel, Groups,
|
||||||
|
// Suppliers, Analytics, WeatherWidget, WeatherSettings).
|
||||||
|
// Les appels réels à Visual Crossing et Nominatim ne sont jamais déclenchés :
|
||||||
|
// la météo du jour vient du cache (seeds/lot2.ts), et les cas de test de
|
||||||
|
// connexion et de géolocalisation s'arrêtent avant l'appel externe.
|
||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
const YEAR = new Date().getUTCFullYear();
|
||||||
|
const PERIOD = "startDate={{today-30}}T00:00:00.000Z&endDate={{today}}T23:59:59.999Z";
|
||||||
|
const YEAR_PERIOD = "startDate={{year}}-01-01T00:00:00.000Z&endDate={{year}}-12-31T23:59:59.999Z";
|
||||||
|
|
||||||
|
const announcementReads: ReadCase[] = [
|
||||||
|
{ module: "announcements", path: "/api/announcements" },
|
||||||
|
{ module: "announcements", path: "/api/announcements?storeId=1" },
|
||||||
|
{ module: "announcements", path: "/api/announcements?storeId=3" },
|
||||||
|
// Magasin illisible : erreur SQL, repli de Node sur toutes les informations
|
||||||
|
{ module: "announcements", path: "/api/announcements?storeId=abc" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const publicityReads: ReadCase[] = [
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year={{year}}" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year={{year}}&storeId=1" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year={{year}}&storeId=2" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year={{year-1}}" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year={{year+1}}" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?storeId=3" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year=abc" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns?year=99999999999" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/1" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/3" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/200" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/202" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/999" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/abc" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/debug" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/debug?year={{year}}&storeId=1" },
|
||||||
|
{ module: "publicities", path: "/api/ad-campaigns/debug?year=99999999999" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const weatherReads: ReadCase[] = [
|
||||||
|
{ module: "weather", path: "/api/weather/settings" },
|
||||||
|
{ module: "weather", path: "/api/weather/current" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statsReads: ReadCase[] = [
|
||||||
|
{ module: "stats", path: "/api/stats/by-group" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-group?storeId=1" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-group?storeId=2" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-group?storeId=abc" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-supplier" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-supplier?storeId=1" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-supplier?storeId=3" },
|
||||||
|
{ module: "stats", path: "/api/stats/by-supplier?storeId=abc" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year}}&month={{month}}" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year}}&month={{month}}&storeId=1" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year}}&month={{month}}&storeId=2" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year}}&month={{month}}&storeId=abc" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year-1}}&month={{month}}" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year}}&month=12" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year={{year}}&month=13" },
|
||||||
|
{ module: "stats", path: "/api/stats/monthly?year=abc&month=3" },
|
||||||
|
{ module: "stats", path: "/api/stats/yearly" },
|
||||||
|
{ module: "stats", path: "/api/stats/yearly?year={{year}}" },
|
||||||
|
{ module: "stats", path: "/api/stats/yearly?year={{year}}&storeId=1" },
|
||||||
|
{ module: "stats", path: "/api/stats/yearly?year={{year}}&storeId=3" },
|
||||||
|
{ module: "stats", path: "/api/stats/yearly?year={{year-1}}" },
|
||||||
|
{ module: "stats", path: "/api/stats/yearly?year=abc" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const analyticsReads: ReadCase[] = [
|
||||||
|
{ module: "analytics", path: "/api/analytics/summary" },
|
||||||
|
{ module: "analytics", path: `/api/analytics/summary?${PERIOD}` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/summary?${YEAR_PERIOD}&supplierIds=1,2&groupIds=1` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/summary?${PERIOD}&groupIds=2` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/summary?${PERIOD}&status=delivered` },
|
||||||
|
{ module: "analytics", path: "/api/analytics/summary?startDate={{today+500}}T00:00:00.000Z" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/summary?supplierIds=abc" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/summary?groupIds=1.5" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/summary?startDate=pas-une-date" },
|
||||||
|
{ module: "analytics", path: `/api/analytics/timeseries?${PERIOD}&granularity=day` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/timeseries?${YEAR_PERIOD}&granularity=week` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/timeseries?${YEAR_PERIOD}&granularity=month&groupIds=1,2` },
|
||||||
|
{ module: "analytics", path: "/api/analytics/timeseries" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/timeseries?granularity=autre&supplierIds=1,4" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/timeseries?supplierIds=1.5,2" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/timeseries?supplierIds=abc" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/timeseries?endDate=pas-une-date" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/by-supplier" },
|
||||||
|
{ module: "analytics", path: `/api/analytics/by-supplier?${PERIOD}` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/by-supplier?${YEAR_PERIOD}&groupIds=1,3` },
|
||||||
|
{ module: "analytics", path: "/api/analytics/by-supplier?groupIds=abc" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/by-store" },
|
||||||
|
{ module: "analytics", path: `/api/analytics/by-store?${PERIOD}&supplierIds=1,2` },
|
||||||
|
{ module: "analytics", path: "/api/analytics/by-store?supplierIds=abc" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/by-store?startDate=pas-une-date" },
|
||||||
|
{ module: "analytics", path: `/api/analytics/export?${PERIOD}&type=summary` },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export" },
|
||||||
|
{ module: "analytics", path: `/api/analytics/export?${YEAR_PERIOD}&type=timeseries` },
|
||||||
|
{ module: "analytics", path: `/api/analytics/export?${PERIOD}&groupIds=1&type=suppliers` },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=stores&supplierIds=2" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=inconnu" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=timeseries&startDate={{today+500}}T00:00:00.000Z" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=stores&supplierIds=abc" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=suppliers&groupIds=abc" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=r%C3%A9sum%C3%A9" },
|
||||||
|
{ module: "analytics", path: "/api/analytics/export?type=%E2%82%AC" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
...announcementReads,
|
||||||
|
...publicityReads,
|
||||||
|
...weatherReads,
|
||||||
|
...statsReads,
|
||||||
|
...analyticsReads,
|
||||||
|
];
|
||||||
|
|
||||||
|
const announcementWrites: WriteScenario[] = [
|
||||||
|
{
|
||||||
|
module: "announcements",
|
||||||
|
name: "créer, modifier, supprimer une information",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "Inventaire tournant", content: "Rayon frais lundi", type: "warning" } },
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "Livraison décalée", content: "Camion attendu à 14 h", storeId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "Autre magasin", content: "Pour Metz", type: "", storeId: 2, createdBy: "pirate" } },
|
||||||
|
{ method: "PUT", path: "/api/announcements/{{0.id}}", body: { title: "Inventaire reporté", type: "error", storeId: null } },
|
||||||
|
{ method: "PUT", path: "/api/announcements/202", body: { content: "Réunion déplacée", storeId: 3 } },
|
||||||
|
{ method: "DELETE", path: "/api/announcements/{{1.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/announcements/3" },
|
||||||
|
{ method: "GET", path: "/api/announcements" },
|
||||||
|
],
|
||||||
|
tables: ["dashboard_messages"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "announcements",
|
||||||
|
name: "information invalide ou introuvable",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/announcements", body: {} },
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "x".repeat(300), content: 5 } },
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "Type inconnu", content: "Refusé par la base", type: "urgent" } },
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "Magasin inconnu", content: "Clé étrangère", storeId: 999 } },
|
||||||
|
{ method: "POST", path: "/api/announcements", body: { title: "Magasin en texte", content: "Refusé", storeId: "1" } },
|
||||||
|
{ method: "PUT", path: "/api/announcements/999", body: { title: "Introuvable" } },
|
||||||
|
{ method: "PUT", path: "/api/announcements/abc", body: { title: "Illisible" } },
|
||||||
|
{ method: "PUT", path: "/api/announcements/1", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/announcements/1", body: { type: 5, storeId: "2" } },
|
||||||
|
{ method: "PUT", path: "/api/announcements/1", body: { type: "urgent" } },
|
||||||
|
{ method: "DELETE", path: "/api/announcements/999" },
|
||||||
|
{ method: "DELETE", path: "/api/announcements/abc" },
|
||||||
|
],
|
||||||
|
tables: ["dashboard_messages"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const publicityWrites: WriteScenario[] = [
|
||||||
|
{
|
||||||
|
module: "publicities",
|
||||||
|
name: "créer, modifier, supprimer une publicité",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST", path: "/api/ad-campaigns",
|
||||||
|
body: { pubNumber: "PUB-50", designation: "Fête des jardins", startDate: "{{today+40}}", endDate: "{{today+50}}", year: YEAR, participatingGroups: [1, 2] },
|
||||||
|
},
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/{{0.id}}", body: { designation: "Fête des jardins (prolongée)", endDate: "{{today+55}}", participatingGroups: [3] } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/1", body: { pubNumber: "PUB-01", year: String(YEAR), participatingGroups: [] } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/2", body: { designation: ["Spécial", "jardin"], createdBy: "bench_manager", inconnu: 1 } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/202", body: { participatingGroups: 5 } },
|
||||||
|
{ method: "GET", path: "/api/ad-campaigns/{{0.id}}" },
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns/{{0.id}}/delete", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/ad-campaigns/3" },
|
||||||
|
{ method: "GET", path: "/api/ad-campaigns?year={{year}}" },
|
||||||
|
],
|
||||||
|
tables: ["publicities", "publicity_participations"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "publicities",
|
||||||
|
name: "publicité invalide, en double ou introuvable",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns", body: {} },
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns", body: { pubNumber: "PUB-01", designation: "Doublon", startDate: "{{today}}", endDate: "{{today+7}}", year: YEAR } },
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns", body: { pubNumber: "PUB-60", designation: "Magasin inconnu", startDate: "{{today}}", endDate: "{{today+7}}", year: YEAR, participatingGroups: [1, 999] } },
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns", body: { pubNumber: "PUB-61", designation: "Groupes en texte", startDate: "{{today}}", endDate: "{{today+7}}", year: YEAR, participatingGroups: "12" } },
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns", body: { pubNumber: "PUB-62", designation: "Date illisible", startDate: "bientôt", endDate: "{{today+7}}", year: YEAR } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/1", body: { startDate: "pas une date" } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/1", body: { createdAt: "2020-01-01" } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/1", body: { pubNumber: "PUB-02" } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/1", body: { year: 2030.5 } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/1", body: { designation: null } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/2", body: { participatingGroups: null } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/200", body: { participatingGroups: [2, 2] } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/999", body: { designation: "Introuvable" } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/998", body: { participatingGroups: [1] } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/abc", body: { designation: "Illisible" } },
|
||||||
|
{ method: "PUT", path: "/api/ad-campaigns/201", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/ad-campaigns/999" },
|
||||||
|
{ method: "DELETE", path: "/api/ad-campaigns/abc" },
|
||||||
|
{ method: "POST", path: "/api/ad-campaigns/99999999999/delete", body: {} },
|
||||||
|
],
|
||||||
|
tables: ["publicities", "publicity_participations"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const weatherWrites: WriteScenario[] = [
|
||||||
|
{
|
||||||
|
module: "weather",
|
||||||
|
name: "configurer la météo",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/1", body: { location: "Metz, France", isActive: true, apiKey: "cle-meteo-modifiee" } },
|
||||||
|
{ method: "GET", path: "/api/weather/current" },
|
||||||
|
{ method: "POST", path: "/api/weather/settings", body: { apiKey: "seconde-cle-meteo", location: "Épinal, France" } },
|
||||||
|
{ method: "GET", path: "/api/weather/settings" },
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/999", body: { location: "Introuvable" } },
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/1", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/1", body: { isActive: false } },
|
||||||
|
{ method: "GET", path: "/api/weather/current" },
|
||||||
|
],
|
||||||
|
tables: ["weather_settings", "weather_data"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "weather",
|
||||||
|
name: "configuration météo invalide",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/weather/settings", body: {} },
|
||||||
|
{ method: "POST", path: "/api/weather/settings", body: { apiKey: 5, isActive: "oui" } },
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/1", body: { isActive: "oui" } },
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/abc", body: { location: "Illisible" } },
|
||||||
|
{ method: "PUT", path: "/api/weather/settings/abc", body: { location: 12 } },
|
||||||
|
],
|
||||||
|
tables: ["weather_settings"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Corps incomplets : refusés avant tout appel à Visual Crossing ou Nominatim
|
||||||
|
module: "weather",
|
||||||
|
name: "test de connexion et géolocalisation incomplets",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/weather/test-connection", body: {} },
|
||||||
|
{ method: "POST", path: "/api/weather/test-connection", body: { apiKey: "cle-de-test-meteo" } },
|
||||||
|
{ method: "POST", path: "/api/weather/test-connection", body: { apiKey: "", location: "Nancy, France" } },
|
||||||
|
{ method: "POST", path: "/api/weather/geolocation", body: {} },
|
||||||
|
{ method: "POST", path: "/api/weather/geolocation", body: { latitude: 0, longitude: 6.18 } },
|
||||||
|
{ method: "POST", path: "/api/weather/geolocation", body: { latitude: 48.69 } },
|
||||||
|
{ method: "POST", path: "/api/weather/geolocation", body: { latitude: "", longitude: "6.18" } },
|
||||||
|
],
|
||||||
|
tables: ["weather_settings", "weather_data"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [...announcementWrites, ...publicityWrites, ...weatherWrites];
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
// Lot 3 « Au quotidien » : tâches, DLC, commandes clients et SAV.
|
||||||
|
// Lignes du banc par magasin (seed.ts et seeds/lot3.ts) :
|
||||||
|
// Nancy (1) : tâches 1, 2, 3, 301, 302 ; DLC 1 à 5, 301, 302, 303, 305 ;
|
||||||
|
// commandes 1, 2, 3, 301 ; SAV 1, 2, 301
|
||||||
|
// Metz (2) : tâches 4, 5, 303 ; DLC 6, 304 ; commandes 4, 5, 302, 303 ; SAV 3, 302
|
||||||
|
// Épinal (3) : tâche 300 ; DLC 300 ; commande 300 ; SAV 300 (aucun compte du banc sauf l'admin)
|
||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
// Tâches : sans magasin choisi, un non-admin reçoit une liste vide
|
||||||
|
{ module: "tasks", path: "/api/tasks" },
|
||||||
|
{ module: "tasks", path: "/api/tasks?storeId=1" },
|
||||||
|
{ module: "tasks", path: "/api/tasks?storeId=2" },
|
||||||
|
{ module: "tasks", path: "/api/tasks?storeId=3" },
|
||||||
|
{ module: "tasks", path: "/api/tasks?storeId=abc" },
|
||||||
|
{ module: "tasks", path: "/api/tasks?storeId=99999999999" },
|
||||||
|
{ module: "tasks", path: "/api/tasks?storeId=1.9" },
|
||||||
|
|
||||||
|
// DLC : filtres de DlcPage, de la fenêtre d'alerte et du tableau de bord
|
||||||
|
{ module: "dlc", path: "/api/dlc-products" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?storeId=1" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?storeId=2" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?storeId=3" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?storeId=abc" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=expires" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=expires&storeId=1" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=expires_soon" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=expires_soon&storeId=2" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=en_cours" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=valides" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=all" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?status=inconnu" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?supplierId=2" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?supplierId=all" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?supplierId=abc" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?supplierId=0" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?supplierId=99999999999" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?storeId=99999999999" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?search=camem" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?search=BONDU" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?search=%25" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products?storeId=1&status=expires_soon&supplierId=1&search=e" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/stats" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/stats?storeId=1" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/stats?storeId=2" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/stats?storeId=3" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/stats?storeId=abc" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/1" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/6" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/300" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/303" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/9999" },
|
||||||
|
{ module: "dlc", path: "/api/dlc-products/abc" },
|
||||||
|
|
||||||
|
// Commandes clients : la page n'envoie storeId que pour l'admin, les appels à passer pour tous
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders?storeId=1" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders?storeId=2" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders?storeId=3" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders?storeId=abc" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders?storeId=2.5" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders/pending-calls" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders/pending-calls?storeId=1" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders/pending-calls?storeId=3" },
|
||||||
|
{ module: "customer-orders", path: "/api/customer-orders/pending-calls?storeId=abc" },
|
||||||
|
|
||||||
|
// SAV : filtres de SavTickets (groupId pour l'admin seulement côté interface)
|
||||||
|
{ module: "sav", path: "/api/sav/tickets" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?groupId=1" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?groupId=3" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?groupId=abc" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?groupId=0" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?groupId=99999999999" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?supplierId=99999999999" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?endDate={{today}}T23:59:59%2B02:00" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?status=nouveau" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?priority=critique&groupId=3" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?supplierId=4" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?supplierId=abc" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?status=attente_pieces&priority=normale&supplierId=4&groupId=1" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?startDate={{today-10}}&endDate={{today}}" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets?startDate=pas-une-date" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets/1" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets/3" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets/300" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets/301" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets/9999" },
|
||||||
|
{ module: "sav", path: "/api/sav/tickets/abc" },
|
||||||
|
{ module: "sav", path: "/api/sav/stats" },
|
||||||
|
{ module: "sav", path: "/api/sav/stats?groupId=1" },
|
||||||
|
{ module: "sav", path: "/api/sav/stats?groupId=3" },
|
||||||
|
{ module: "sav", path: "/api/sav/stats?groupId=abc" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
// ---------- Tâches ----------
|
||||||
|
{
|
||||||
|
module: "tasks",
|
||||||
|
name: "créer une tâche, la modifier, la terminer, la supprimer",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Nouvelle tâche", description: "", priority: "high", status: "pending", assignedTo: "Sophie", startDate: "{{today+1}}", dueDate: "{{today+3}}", groupId: 1, createdBy: "manager" } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/{{0.id}}", body: { title: "Tâche modifiée", priority: "low", assignedTo: "Marc", startDate: "", dueDate: "{{today+5}}" } },
|
||||||
|
{ method: "POST", path: "/api/tasks/{{0.id}}/complete" },
|
||||||
|
{ method: "GET", path: "/api/tasks?storeId=1" },
|
||||||
|
{ method: "DELETE", path: "/api/tasks/{{0.id}}" },
|
||||||
|
],
|
||||||
|
tables: ["tasks"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "tasks",
|
||||||
|
name: "tâches de son magasin et d'un autre",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/tasks/2", body: { status: "completed", description: null, startDate: "{{today}}T14:30", dueDate: null } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/4", body: { title: "Autre magasin" } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/300", body: { title: "Épinal" } },
|
||||||
|
{ method: "POST", path: "/api/tasks/1/complete" },
|
||||||
|
{ method: "POST", path: "/api/tasks/5/complete" },
|
||||||
|
{ method: "POST", path: "/api/tasks/300/complete" },
|
||||||
|
{ method: "DELETE", path: "/api/tasks/3" },
|
||||||
|
{ method: "DELETE", path: "/api/tasks/303" },
|
||||||
|
{ method: "DELETE", path: "/api/tasks/300" },
|
||||||
|
{ method: "PUT", path: "/api/tasks/9999", body: { title: "Absente" } },
|
||||||
|
{ method: "POST", path: "/api/tasks/9999/complete" },
|
||||||
|
{ method: "DELETE", path: "/api/tasks/9999" },
|
||||||
|
{ method: "DELETE", path: "/api/tasks/abc" },
|
||||||
|
{ method: "POST", path: "/api/tasks/abc/complete" },
|
||||||
|
],
|
||||||
|
tables: ["tasks"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "tasks",
|
||||||
|
name: "créer des tâches : magasin par défaut, autre magasin, corps invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Sans magasin", assignedTo: "Léa" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Magasin d'Épinal", assignedTo: "Léa", groupId: 3, dueDate: "{{today+2}}T18:00:00.000Z" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Magasin en texte", assignedTo: "Léa", groupId: "1", startDate: 0 } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Metz", assignedTo: "Léa", groupId: 2, status: "completed", completedBy: "x" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: {} },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Priorité inconnue", assignedTo: "Léa", groupId: 1, priority: "urgent" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Échéance invalide", assignedTo: "Léa", groupId: 1, dueDate: "pas une date" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Départ invalide", assignedTo: "Léa", groupId: 1, startDate: "2026-13-45" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { title: "Date de création", assignedTo: "Léa", groupId: 1, createdAt: "{{today}}" } },
|
||||||
|
{ method: "POST", path: "/api/tasks", body: { id: 900, title: "Identifiant imposé", assignedTo: "Léa", groupId: 1, inconnu: true } },
|
||||||
|
],
|
||||||
|
tables: ["tasks"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "tasks",
|
||||||
|
name: "modifier une tâche avec des valeurs invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/tasks/1", body: { priority: "urgent" } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/1", body: { startDate: "n'importe quoi" } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/1", body: { dueDate: "n'importe quoi" } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/1", body: { title: null } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/1", body: { assignedTo: 42, groupId: 2, createdBy: "pirate" } },
|
||||||
|
{ method: "PUT", path: "/api/tasks/1", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/tasks/abc", body: {} },
|
||||||
|
{ method: "PUT", path: "/api/tasks/99999999999", body: {} },
|
||||||
|
],
|
||||||
|
tables: ["tasks"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- DLC ----------
|
||||||
|
{
|
||||||
|
module: "dlc",
|
||||||
|
name: "créer un produit DLC, le modifier, le valider, le supprimer",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST", path: "/api/dlc-products", body: {
|
||||||
|
productName: "Jambon blanc", name: "Jambon blanc", gencode: "3000000000999", supplierId: 1, groupId: 1,
|
||||||
|
dlcDate: "{{today+4}}", expiryDate: "{{today+4}}T00:00:00.000Z", dateType: "dlc", quantity: 1, unit: "unité",
|
||||||
|
location: "Magasin", alertThreshold: 15, status: "en_cours", notes: "", createdBy: "pirate",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/{{0.id}}", body: { productName: "Jambon supérieur", name: "Jambon supérieur", expiryDate: "{{today+6}}", dlcDate: "{{today+6}}", notes: "Modifié", groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/dlc-products/{{0.id}}/validate" },
|
||||||
|
{ method: "GET", path: "/api/dlc-products/{{0.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/dlc-products/{{0.id}}" },
|
||||||
|
{ method: "GET", path: "/api/dlc-products/{{0.id}}" },
|
||||||
|
],
|
||||||
|
tables: ["dlc_products"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "dlc",
|
||||||
|
name: "actions sur les produits de son magasin",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/2/stock-epuise" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/4/restore-stock" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/2/mark-processed" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/5/unmark-processed" },
|
||||||
|
{ method: "POST", path: "/api/dlc-products/1/validate" },
|
||||||
|
{ method: "GET", path: "/api/dlc-products/stats?storeId=1" },
|
||||||
|
{ method: "DELETE", path: "/api/dlc-products/302" },
|
||||||
|
],
|
||||||
|
tables: ["dlc_products"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "dlc",
|
||||||
|
name: "actions sur les produits d'un autre magasin",
|
||||||
|
steps: [
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/6/stock-epuise" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/6/mark-processed" },
|
||||||
|
{ method: "POST", path: "/api/dlc-products/304/validate" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/300/stock-epuise" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/300/restore-stock" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/300/mark-processed" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/300/unmark-processed" },
|
||||||
|
{ method: "POST", path: "/api/dlc-products/300/validate" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/300", body: { notes: "Épinal" } },
|
||||||
|
{ method: "DELETE", path: "/api/dlc-products/300" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/6", body: { quantity: 2 } },
|
||||||
|
{ method: "DELETE", path: "/api/dlc-products/6" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/9999/stock-epuise" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/9999/restore-stock" },
|
||||||
|
{ method: "POST", path: "/api/dlc-products/9999/validate" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/9999", body: { notes: "x" } },
|
||||||
|
{ method: "DELETE", path: "/api/dlc-products/abc" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/abc/mark-processed" },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/abc/unmark-processed" },
|
||||||
|
],
|
||||||
|
tables: ["dlc_products"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "dlc",
|
||||||
|
name: "créer et modifier des produits DLC avec des corps invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/dlc-products", body: { groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/dlc-products", body: { name: "Texte", supplierId: 1, groupId: "1", expiryDate: "{{today+2}}" } },
|
||||||
|
{ method: "POST", path: "/api/dlc-products", body: { name: "Épinal", supplierId: 1, groupId: 3, expiryDate: "{{today+2}}" } },
|
||||||
|
{ method: "POST", path: "/api/dlc-products", body: { name: "Type inconnu", supplierId: 1, groupId: 1, expiryDate: "{{today+2}}", dateType: "xyz" } },
|
||||||
|
{ method: "POST", path: "/api/dlc-products", body: { name: "Horodatage", supplierId: 1, groupId: 1, expiryDate: "{{today+2}}", validatedAt: "{{today}}" } },
|
||||||
|
{ method: "POST", path: "/api/dlc-products", body: { name: "Minimal", supplierId: 2, groupId: 1, expiryDate: "{{today+9}}", stockEpuise: null, quantity: "7" } },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/1", body: { status: "inconnu" } },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/1", body: { expiryDate: null } },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/1", body: { processedAt: "{{today}}" } },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/1", body: { updatedAt: "{{today}}", inconnu: 1 } },
|
||||||
|
{ method: "PUT", path: "/api/dlc-products/2", body: { groupId: 3, createdBy: "pirate" } },
|
||||||
|
],
|
||||||
|
tables: ["dlc_products"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Commandes clients ----------
|
||||||
|
{
|
||||||
|
module: "customer-orders",
|
||||||
|
name: "créer une commande client, la modifier, prévenir le client, la supprimer",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST", path: "/api/customer-orders", body: {
|
||||||
|
orderTaker: "Sophie", customerName: "M. Nouveau", customerPhone: "06 12 34 56 78", customerEmail: "nouveau@client.test",
|
||||||
|
productDesignation: "Barbecue", productReference: "BBQ-1", gencode: "3245678909999", quantity: 1, supplierId: 4,
|
||||||
|
status: "En attente de Commande", deposit: 30, isPromotionalPrice: false, groupId: 1, notes: "Livraison",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/{{0.id}}", body: { status: "Disponible", deposit: "45.5", quantity: 2 } },
|
||||||
|
{ method: "GET", path: "/api/customer-orders/pending-calls" },
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/{{0.id}}/mark-called", body: { comment: "Message laissé" } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/{{0.id}}", body: { customerNotified: false, notifiedAt: null, notifiedComment: null } },
|
||||||
|
{ method: "DELETE", path: "/api/customer-orders/{{0.id}}" },
|
||||||
|
],
|
||||||
|
tables: ["customer_orders"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "customer-orders",
|
||||||
|
name: "commandes clients de son magasin et d'un autre",
|
||||||
|
steps: [
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/3/mark-called", body: {} },
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/301/mark-called", body: { comment: "" } },
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/303/mark-called", body: { comment: "Metz" } },
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/300/mark-called", body: { comment: "Épinal" } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/4", body: { status: "Retiré" } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/300", body: { status: "Retiré" } },
|
||||||
|
{ method: "DELETE", path: "/api/customer-orders/5" },
|
||||||
|
{ method: "DELETE", path: "/api/customer-orders/300" },
|
||||||
|
{ method: "DELETE", path: "/api/customer-orders/2" },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/9999", body: { status: "x" } },
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/9999/mark-called", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/customer-orders/9999" },
|
||||||
|
{ method: "PATCH", path: "/api/customer-orders/abc/mark-called", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/customer-orders/abc" },
|
||||||
|
],
|
||||||
|
tables: ["customer_orders"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "customer-orders",
|
||||||
|
name: "créer et modifier des commandes clients avec des corps invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/customer-orders", body: {} },
|
||||||
|
{ method: "POST", path: "/api/customer-orders", body: { orderTaker: "Léa", customerName: "Texte", customerPhone: "06", productDesignation: "Pelle", gencode: "1", supplierId: 4, groupId: "1" } },
|
||||||
|
{ method: "POST", path: "/api/customer-orders", body: { orderTaker: "Léa", customerName: "Épinal", customerPhone: "06", productDesignation: "Pelle", gencode: "1", supplierId: 4, groupId: 3 } },
|
||||||
|
{ method: "POST", path: "/api/customer-orders", body: { orderTaker: "Léa", customerName: "Horodatage", customerPhone: "06", productDesignation: "Pelle", gencode: "1", supplierId: 4, groupId: 1, notifiedAt: "{{today}}" } },
|
||||||
|
{ method: "POST", path: "/api/customer-orders", body: { orderTaker: "Léa", customerName: "Fournisseur absent", customerPhone: "06", productDesignation: "Pelle", gencode: "1", supplierId: 999, groupId: 1 } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/1", body: { quantity: "abc" } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/1", body: { createdAt: "{{today}}" } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/1", body: { deposit: "12,5" } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/1", body: { updatedAt: "2020-01-01", inconnu: 1, notes: ["a", "b"] } },
|
||||||
|
{ method: "PUT", path: "/api/customer-orders/2", body: {} },
|
||||||
|
],
|
||||||
|
tables: ["customer_orders"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- SAV ----------
|
||||||
|
{
|
||||||
|
module: "sav",
|
||||||
|
name: "créer un ticket SAV, le modifier, le commenter, le supprimer",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST", path: "/api/sav/tickets", body: {
|
||||||
|
supplierId: 3, groupId: 1, productGencode: "3245678901241", productReference: "PS-18", productDesignation: "Perceuse sans fil",
|
||||||
|
problemType: "defectueux", problemDescription: "Batterie HS", priority: "haute", status: "nouveau", createdBy: "1",
|
||||||
|
clientName: "M. Laurent", clientPhone: "06 22 22 22 22",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/{{0.id}}", body: { status: "en_cours", priority: "critique", resolutionDescription: "Pièce commandée" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets/{{0.id}}/history", body: { description: "Appel au fournisseur" } },
|
||||||
|
{ method: "GET", path: "/api/sav/tickets/{{0.id}}" },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets", body: { supplierId: 4, productGencode: "1", productDesignation: "Second ticket", problemDescription: "Sans magasin choisi" } },
|
||||||
|
{ method: "GET", path: "/api/sav/stats" },
|
||||||
|
{ method: "DELETE", path: "/api/sav/tickets/{{0.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/sav/tickets/{{4.id}}" },
|
||||||
|
],
|
||||||
|
tables: ["sav_tickets"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "sav",
|
||||||
|
name: "tickets SAV de son magasin et d'un autre",
|
||||||
|
steps: [
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/1", body: { status: "resolu" } },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/3", body: { priority: "haute" } },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/300", body: { status: "ferme" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets/2/history", body: { description: "Relance" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets/3/history", body: { description: "Metz" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets/300/history", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/sav/tickets/302" },
|
||||||
|
{ method: "DELETE", path: "/api/sav/tickets/300" },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/9999", body: {} },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets/9999/history", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/sav/tickets/9999" },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/abc", body: {} },
|
||||||
|
{ method: "DELETE", path: "/api/sav/tickets/abc" },
|
||||||
|
],
|
||||||
|
tables: ["sav_tickets"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "sav",
|
||||||
|
name: "créer et modifier des tickets SAV avec des corps invalides",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/sav/tickets", body: {} },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets", body: { supplierId: null, groupId: "2", productGencode: 12, productDesignation: "x".repeat(501), problemType: "casse", problemDescription: "x", priority: "urgente", status: "ouvert" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets", body: { supplierId: 1, groupId: 3, productGencode: "1", productDesignation: "Épinal", problemDescription: "Autre magasin" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets", body: { supplierId: 1, groupId: 99, productGencode: "1", productDesignation: "Magasin inconnu", problemDescription: "x" } },
|
||||||
|
{ method: "POST", path: "/api/sav/tickets", body: { supplierId: 999, groupId: 1, productGencode: "1", productDesignation: "Fournisseur inconnu", problemDescription: "x", ticketNumber: "SAV-PIRATE", createdAt: "2020-01-01" } },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/1", body: { status: "inconnu", priority: 3 } },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/1", body: { supplierId: 999 } },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/2", body: { groupId: 3, createdBy: "pirate", clientName: null, ticketNumber: "SAV-PIRATE" } },
|
||||||
|
{ method: "PATCH", path: "/api/sav/tickets/2", body: {} },
|
||||||
|
],
|
||||||
|
tables: ["sav_tickets"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
{ module: "contacts", path: "/api/contacts" },
|
||||||
|
{ module: "contacts", path: "/api/contacts?groupId=1" },
|
||||||
|
{ module: "contacts", path: "/api/contacts?groupId=2" },
|
||||||
|
{ module: "suppliers", path: "/api/suppliers" },
|
||||||
|
{ module: "suppliers", path: "/api/suppliers?dlc=true" },
|
||||||
|
{ module: "groups", path: "/api/groups" },
|
||||||
|
{ module: "users", path: "/api/users" },
|
||||||
|
// Express ignore la casse et une « / » finale
|
||||||
|
{ module: "contacts", path: "/api/Contacts?groupId=1" },
|
||||||
|
{ module: "suppliers", path: "/api/suppliers/" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
{
|
||||||
|
module: "contacts",
|
||||||
|
name: "créer, modifier, supprimer un contact",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/contacts", body: { groupId: 1, name: "Nouveau contact", company: "Société", phone: "06 99 99 99 99", email: "nouveau@banc.test", notes: "Note" } },
|
||||||
|
{ method: "PUT", path: "/api/contacts/{{0.id}}", body: { name: "Contact modifié", phone: "06 88 88 88 88" } },
|
||||||
|
{ method: "PUT", path: "/api/contacts/3", body: { name: "Autre magasin" } },
|
||||||
|
{ method: "DELETE", path: "/api/contacts/{{0.id}}" },
|
||||||
|
{ method: "DELETE", path: "/api/contacts/3" },
|
||||||
|
],
|
||||||
|
tables: ["contacts"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "contacts",
|
||||||
|
name: "créer un contact invalide",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/contacts", body: { groupId: 1 } },
|
||||||
|
{ method: "POST", path: "/api/contacts", body: { groupId: 2, name: "Metz" } },
|
||||||
|
],
|
||||||
|
tables: ["contacts"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "suppliers",
|
||||||
|
name: "créer, modifier, supprimer un fournisseur",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/suppliers", body: { name: "Nouveau fournisseur", contact: "M. Neuf", phone: "01 23 45 67 89", hasDlc: true, email: "neuf@banc.test", codefou: "F099" } },
|
||||||
|
{ method: "PUT", path: "/api/suppliers/{{0.id}}", body: { name: "Fournisseur renommé", automaticReconciliation: true } },
|
||||||
|
{ method: "DELETE", path: "/api/suppliers/{{0.id}}" },
|
||||||
|
{ method: "POST", path: "/api/suppliers", body: { contact: "sans nom" } },
|
||||||
|
],
|
||||||
|
tables: ["suppliers"],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ReadCase, WriteScenario } from "../types";
|
||||||
|
|
||||||
|
export const reads: ReadCase[] = [
|
||||||
|
{ module: "socle", path: "/", accounts: ["anonyme"] },
|
||||||
|
{ module: "socle", path: "/deliveries", accounts: ["anonyme", "manager"] },
|
||||||
|
{ module: "socle", path: "/favicon.ico", accounts: ["anonyme"] },
|
||||||
|
{ module: "socle", path: "/api/health", accounts: ["anonyme"] },
|
||||||
|
{ module: "socle", path: "/api/adresse-inconnue", accounts: ["anonyme", "admin"] },
|
||||||
|
{ module: "auth", path: "/api/user" },
|
||||||
|
{ module: "auth", path: "/api/default-credentials-check" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const writes: WriteScenario[] = [
|
||||||
|
{
|
||||||
|
module: "auth",
|
||||||
|
name: "déconnexion puis lecture de l'utilisateur",
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/logout" },
|
||||||
|
{ method: "GET", path: "/api/user" },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "auth",
|
||||||
|
name: "connexion refusée",
|
||||||
|
accounts: ["anonyme"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "manager", password: "mauvais" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: { username: "inconnu", password: "x" } },
|
||||||
|
{ method: "POST", path: "/api/login", body: {} },
|
||||||
|
],
|
||||||
|
tables: ["users"],
|
||||||
|
},
|
||||||
|
// ---------- Requêtes d'écriture d'un autre site (décision R4) ----------
|
||||||
|
{
|
||||||
|
module: "socle",
|
||||||
|
name: "écriture d'un autre site refusée",
|
||||||
|
accounts: ["manager"],
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/logout",
|
||||||
|
headers: { origin: "https://site-tiers.example", "sec-fetch-site": "cross-site" },
|
||||||
|
},
|
||||||
|
{ method: "POST", path: "/api/logout", headers: { origin: "null" } },
|
||||||
|
// La session est toujours ouverte côté Rust
|
||||||
|
{ method: "GET", path: "/api/user" },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
deviation: {
|
||||||
|
fields: ["status", "headers", "body"],
|
||||||
|
reason:
|
||||||
|
"R4 : une requête /api qui n'est pas une lecture est refusée (403) quand le navigateur indique qu'elle vient d'un autre site (Sec-Fetch-Site, sinon Origin)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "socle",
|
||||||
|
name: "écriture du même site acceptée",
|
||||||
|
accounts: ["manager"],
|
||||||
|
steps: [
|
||||||
|
{ method: "POST", path: "/api/logout", headers: { "sec-fetch-site": "same-origin" } },
|
||||||
|
{ method: "GET", path: "/api/user" },
|
||||||
|
],
|
||||||
|
tables: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
// Banc de comparaison : rejoue les mêmes requêtes sur deux serveurs et compare
|
||||||
|
// statuts, corps JSON, en-têtes utiles, état de la base et appels aux services
|
||||||
|
// externes (docs/PLAN-MIGRATION-RUST.md, « Vérification »).
|
||||||
|
//
|
||||||
|
// npx tsx scripts/bench/compare.ts Node de référence contre Rust
|
||||||
|
// npx tsx scripts/bench/compare.ts --mode node Node contre Node (le banc est-il stable ?)
|
||||||
|
// npx tsx scripts/bench/compare.ts --only dlc,tasks seulement ces modules
|
||||||
|
//
|
||||||
|
// Prérequis : PostgreSQL local, base modèle bench_seed (voir README.md), serveur Node
|
||||||
|
// de référence construit (reference-node.sh), binaire Rust construit (server-rs/target/release).
|
||||||
|
import { spawn, execFileSync, type ChildProcess } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import pg from "pg";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { BENCH_ACCOUNTS, BENCH_API_KEY, BENCH_ENV, DB_PREFIX, LEFT_PORT, RIGHT_PORT, STUB_URL, TEMPLATE_DB } from "./config";
|
||||||
|
import { startStub } from "./stub";
|
||||||
|
import { READS, WRITES } from "./cases";
|
||||||
|
import { ALL_ACCOUNTS, LOGGED_ACCOUNTS, type AccountKey, type Deviation, type ReadCase, type Step, type WriteScenario } from "./types";
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
// Fichiers des sauvegardes déclarées par les seeds (database_backups), copiés
|
||||||
|
// dans le dossier de sauvegardes de chaque serveur
|
||||||
|
const SEED_BACKUPS_DIR = path.join(ROOT, "scripts/bench/seeds/sauvegardes");
|
||||||
|
const PG_ADMIN_URL = process.env.BENCH_PG_URL || "postgres://logiflow_admin:bench@localhost:5432";
|
||||||
|
// Chemin relatif : depuis cette copie de travail (les serveurs sont lancés depuis BENCH_NODE_DIR)
|
||||||
|
const RUST_BIN = path.resolve(ROOT, process.env.RUST_BIN || "server-rs/target/release/logiflow");
|
||||||
|
// Serveur Node de référence construit par reference-node.sh (dist/ et node_modules/)
|
||||||
|
const NODE_DIR = process.env.BENCH_NODE_DIR || path.join(ROOT, ".bench-reference");
|
||||||
|
const WORK_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "logiflow-banc-"));
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const mode = args.includes("--mode") ? args[args.indexOf("--mode") + 1] : "rust";
|
||||||
|
const only = args.includes("--only") ? args[args.indexOf("--only") + 1].split(",") : null;
|
||||||
|
const keep = args.includes("--keep");
|
||||||
|
|
||||||
|
// ---------- Gabarits ----------
|
||||||
|
|
||||||
|
function isoDay(offset: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setUTCDate(date.getUTCDate() + offset);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fill(template: string, results: unknown[]): string {
|
||||||
|
return template.replace(/\{\{([^}]+)\}\}/g, (_, expr: string) => {
|
||||||
|
const day = expr.match(/^today([+-]\d+)?$/);
|
||||||
|
if (day) return isoDay(Number(day[1] || 0));
|
||||||
|
const year = expr.match(/^year([+-]\d+)?$/);
|
||||||
|
if (year) return String(new Date().getUTCFullYear() + Number(year[1] || 0));
|
||||||
|
if (expr === "month") return String(new Date().getUTCMonth() + 1);
|
||||||
|
const ref = expr.match(/^(\d+)\.(.+)$/);
|
||||||
|
if (ref) {
|
||||||
|
const value = ref[2].split(".").reduce<any>((acc, key) => acc?.[key], results[Number(ref[1])]);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
throw new Error(`Gabarit inconnu : {{${expr}}}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillDeep(value: unknown, results: unknown[]): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const whole = value.match(/^\{\{(\d+\.[^}]+)\}\}$/);
|
||||||
|
if (whole) {
|
||||||
|
// Valeur entière ({{0.id}}) : garder son type (nombre, booléen…)
|
||||||
|
const [index, ...keys] = whole[1].split(".");
|
||||||
|
return keys.reduce<any>((acc, key) => acc?.[key], results[Number(index)]);
|
||||||
|
}
|
||||||
|
return fill(value, results);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.map((item) => fillDeep(item, results));
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, fillDeep(item, results)]));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Serveurs ----------
|
||||||
|
|
||||||
|
interface Target {
|
||||||
|
name: string;
|
||||||
|
baseUrl: string;
|
||||||
|
dbName: string;
|
||||||
|
processes: ChildProcess[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbUrl(name: string) {
|
||||||
|
return `${PG_ADMIN_URL}/${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function psql(database: string, sql: string) {
|
||||||
|
execFileSync("psql", ["-q", "-v", "ON_ERROR_STOP=1", dbUrl(database), "-c", sql], { stdio: ["ignore", "ignore", "inherit"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProcess(label: string, command: string, commandArgs: string[], env: Record<string, string>): ChildProcess {
|
||||||
|
const log = fs.openSync(path.join(WORK_DIR, `${label}.log`), "a");
|
||||||
|
const child = spawn(command, commandArgs, { cwd: NODE_DIR, env: { ...process.env, ...env }, stdio: ["ignore", log, log] });
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code !== null && code !== 0) console.error(` ${label} s'est arrêté (code ${code}), voir ${WORK_DIR}/${label}.log`);
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitHealthy(url: string, label: string) {
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/api/health`);
|
||||||
|
if (res.ok) return;
|
||||||
|
} catch { /* pas encore prêt */ }
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
throw new Error(`${label} ne répond pas, voir ${WORK_DIR}/${label}.log`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dossier de sauvegardes propre à chaque serveur, avec les fichiers des données du banc
|
||||||
|
function backupDir(port: number): string {
|
||||||
|
const dir = path.join(WORK_DIR, `sauvegardes-${port}`);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
if (fs.existsSync(SEED_BACKUPS_DIR)) fs.cpSync(SEED_BACKUPS_DIR, dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeEnv(port: number, database: string): Record<string, string> {
|
||||||
|
return { ...BENCH_ENV, NODE_ENV: "production", PORT: String(port), DATABASE_URL: dbUrl(database), BACKUP_DIR: backupDir(port) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTarget(name: string, kind: "node" | "rust", port: number): Promise<Target> {
|
||||||
|
const dbName = `${DB_PREFIX}_${name}`;
|
||||||
|
psql("postgres", `DROP DATABASE IF EXISTS ${dbName} WITH (FORCE)`);
|
||||||
|
psql("postgres", `CREATE DATABASE ${dbName} TEMPLATE ${TEMPLATE_DB}`);
|
||||||
|
const processes: ChildProcess[] = [];
|
||||||
|
if (kind === "node") {
|
||||||
|
processes.push(startProcess(`${name}-node`, "node", ["dist/index.js"], nodeEnv(port, dbName)));
|
||||||
|
} else {
|
||||||
|
processes.push(startProcess(`${name}-rust`, RUST_BIN, [], {
|
||||||
|
...BENCH_ENV,
|
||||||
|
PORT: String(port),
|
||||||
|
DATABASE_URL: dbUrl(dbName),
|
||||||
|
STATIC_DIR: path.join(NODE_DIR, "dist/public"),
|
||||||
|
BACKUP_DIR: backupDir(port),
|
||||||
|
RUST_LOG: "warn",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const target = { name, baseUrl: `http://127.0.0.1:${port}`, dbName, processes };
|
||||||
|
await waitHealthy(target.baseUrl, name);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTarget(target: Target) {
|
||||||
|
for (const child of target.processes) child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remise à zéro de la base d'une cible : vider toutes les tables, recharger les données du modèle
|
||||||
|
let seedDump: string | null = null;
|
||||||
|
function resetDatabase(database: string) {
|
||||||
|
if (!seedDump) {
|
||||||
|
seedDump = path.join(WORK_DIR, "donnees.sql");
|
||||||
|
execFileSync("pg_dump", ["--data-only", "--disable-triggers", "-f", seedDump, dbUrl(TEMPLATE_DB)]);
|
||||||
|
}
|
||||||
|
const tables = execFileSync("psql", ["-At", dbUrl(database), "-c",
|
||||||
|
"SELECT string_agg(format('%I', tablename), ', ') FROM pg_tables WHERE schemaname = 'public'"]).toString().trim();
|
||||||
|
psql(database, `TRUNCATE ${tables} RESTART IDENTITY CASCADE`);
|
||||||
|
execFileSync("psql", ["-q", "-v", "ON_ERROR_STOP=1", "-f", seedDump, dbUrl(database)], { stdio: ["ignore", "ignore", "inherit"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Requêtes ----------
|
||||||
|
|
||||||
|
interface Recorded {
|
||||||
|
status: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEPT_HEADERS = ["content-type", "cache-control", "location", "content-disposition"];
|
||||||
|
|
||||||
|
function describeCookie(header: string): string {
|
||||||
|
// Nom et attributs du cookie, sans sa valeur (propre à chaque session)
|
||||||
|
const [pair, ...attributes] = header.split(";").map((part) => part.trim());
|
||||||
|
const name = pair.split("=")[0];
|
||||||
|
const kept = attributes.filter((a) => !/^expires=/i.test(a)).map((a) => a.toLowerCase()).sort();
|
||||||
|
return [name, ...kept].join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(baseUrl: string, account: AccountKey): Promise<Record<string, string>> {
|
||||||
|
if (account === "anonyme") return {};
|
||||||
|
if (account === "api") return { "x-api-key": BENCH_API_KEY };
|
||||||
|
const known = BENCH_ACCOUNTS.find((a) => a.key === account)!;
|
||||||
|
const res = await fetch(`${baseUrl}/api/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: known.username, password: known.password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Connexion impossible pour ${account} sur ${baseUrl} : ${res.status}`);
|
||||||
|
const cookie = res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
|
||||||
|
return { cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBody(step: Step, results: unknown[]): { body?: BodyInit; contentType?: string } {
|
||||||
|
if (step.body === undefined && !step.files) return {};
|
||||||
|
const data = fillDeep(step.body ?? {}, results) as Record<string, unknown>;
|
||||||
|
if (step.bodyType === "form") {
|
||||||
|
return { body: new URLSearchParams(data as Record<string, string>).toString(), contentType: "application/x-www-form-urlencoded" };
|
||||||
|
}
|
||||||
|
if (step.bodyType === "multipart") {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(data)) form.append(key, String(value));
|
||||||
|
for (const file of step.files ?? []) {
|
||||||
|
const content = Buffer.alloc(file.size, 0x25); // contenu fixe : « %%%% »
|
||||||
|
form.append(file.field, new Blob([content], { type: file.contentType }), file.filename);
|
||||||
|
}
|
||||||
|
return { body: form };
|
||||||
|
}
|
||||||
|
return { body: JSON.stringify(data), contentType: "application/json" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(baseUrl: string, step: Step, auth: Record<string, string>, results: unknown[]): Promise<Recorded> {
|
||||||
|
const { body, contentType } = buildBody(step, results);
|
||||||
|
const headers: Record<string, string> = { ...auth, ...(step.headers ?? {}) };
|
||||||
|
if (contentType) headers["content-type"] = contentType;
|
||||||
|
const res = await fetch(`${baseUrl}${fill(step.path, results)}`, { method: step.method, headers, body, redirect: "manual" });
|
||||||
|
const kept: Record<string, string> = {};
|
||||||
|
for (const name of KEPT_HEADERS) {
|
||||||
|
const value = res.headers.get(name);
|
||||||
|
if (value !== null) kept[name] = value.replace(/;\s*/g, "; ").toLowerCase();
|
||||||
|
}
|
||||||
|
const cookies = res.headers.getSetCookie();
|
||||||
|
if (cookies.length) kept["set-cookie"] = cookies.map(describeCookie).join(" | ");
|
||||||
|
const text = await res.text();
|
||||||
|
let parsed: unknown = text;
|
||||||
|
if ((res.headers.get("content-type") || "").includes("json")) {
|
||||||
|
try { parsed = JSON.parse(text); } catch { /* texte brut */ }
|
||||||
|
} else if (text.length > 2000) {
|
||||||
|
// Gros contenu non JSON (HTML, fichiers) : taille et début suffisent
|
||||||
|
parsed = { taille: Buffer.byteLength(text), debut: text.slice(0, 200) };
|
||||||
|
}
|
||||||
|
return { status: res.status, headers: kept, body: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stubCalls(reset = false): Promise<unknown[]> {
|
||||||
|
const res = await fetch(`${STUB_URL}/__calls`, { method: reset ? "DELETE" : "GET" });
|
||||||
|
return res.json() as Promise<unknown[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshotTables(database: string, tables: string[]) {
|
||||||
|
const client = new pg.Client({ connectionString: dbUrl(database) });
|
||||||
|
await client.connect();
|
||||||
|
const out: Record<string, unknown[]> = {};
|
||||||
|
for (const table of tables) {
|
||||||
|
const { rows } = await client.query(`SELECT * FROM ${table} ORDER BY 1, 2`);
|
||||||
|
out[table] = rows;
|
||||||
|
}
|
||||||
|
await client.end();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Normalisation ----------
|
||||||
|
|
||||||
|
const ISO_DATETIME = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g;
|
||||||
|
|
||||||
|
// drop : champs d'un écart voulu, retirés des deux côtés
|
||||||
|
function normalize(value: unknown, window: [number, number], ignore: string[] = [], drop: string[] = []): unknown {
|
||||||
|
if (value instanceof Date) return normalize(value.toISOString(), window, ignore, drop);
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.replace(ISO_DATETIME, (match) => {
|
||||||
|
const time = Date.parse(match);
|
||||||
|
return time >= window[0] && time <= window[1] ? "<maintenant>" : match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.map((item) => normalize(item, window, ignore, drop));
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([key]) => !drop.includes(key)).map(([key, item]) =>
|
||||||
|
[key, ignore.includes(key) ? "<ignoré>" : normalize(item, window, ignore, drop)]));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Passage ----------
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
key: string; // module | cas | compte
|
||||||
|
module: string;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTarget(target: Target): Promise<Entry[]> {
|
||||||
|
const entries: Entry[] = [];
|
||||||
|
const startedAt = Date.now() - 60_000;
|
||||||
|
const reads = READS.filter((c) => !only || only.includes(c.module));
|
||||||
|
const writes = WRITES.filter((c) => !only || only.includes(c.module));
|
||||||
|
|
||||||
|
// Lectures : une base fraîche, une connexion par compte
|
||||||
|
resetDatabase(target.dbName);
|
||||||
|
const sessions = new Map<AccountKey, Record<string, string>>();
|
||||||
|
for (const account of [...LOGGED_ACCOUNTS, "anonyme", "api"] as AccountKey[]) {
|
||||||
|
sessions.set(account, await login(target.baseUrl, account));
|
||||||
|
}
|
||||||
|
for (const readCase of reads) {
|
||||||
|
for (const account of readCase.accounts ?? ALL_ACCOUNTS) {
|
||||||
|
await stubCalls(true);
|
||||||
|
const recorded = await send(target.baseUrl, { method: "GET", path: readCase.path }, sessions.get(account)!, []);
|
||||||
|
const calls = await stubCalls();
|
||||||
|
entries.push({
|
||||||
|
key: `${readCase.module} | GET ${readCase.path} | ${account}`,
|
||||||
|
module: readCase.module,
|
||||||
|
data: { reponse: recorded, appels: calls, ignore: readCase.ignore, deviation: readCase.deviation },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Écritures : base remise à zéro pour chaque compte
|
||||||
|
for (const scenario of writes) {
|
||||||
|
for (const account of scenario.accounts ?? LOGGED_ACCOUNTS) {
|
||||||
|
resetDatabase(target.dbName);
|
||||||
|
await stubCalls(true);
|
||||||
|
const auth = await login(target.baseUrl, account);
|
||||||
|
const results: unknown[] = [];
|
||||||
|
const responses: Recorded[] = [];
|
||||||
|
for (const step of scenario.steps) {
|
||||||
|
const recorded = await send(target.baseUrl, step, auth, results);
|
||||||
|
responses.push(recorded);
|
||||||
|
results.push(recorded.body);
|
||||||
|
}
|
||||||
|
entries.push({
|
||||||
|
key: `${scenario.module} | ${scenario.name} | ${account}`,
|
||||||
|
module: scenario.module,
|
||||||
|
data: {
|
||||||
|
reponses: responses,
|
||||||
|
tables: await snapshotTables(target.dbName, scenario.tables),
|
||||||
|
appels: await stubCalls(),
|
||||||
|
ignore: scenario.ignore,
|
||||||
|
deviation: scenario.deviation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endedAt = Date.now() + 60_000;
|
||||||
|
return entries.map((entry) => {
|
||||||
|
const { ignore, deviation, ...data } = entry.data as { ignore?: string[]; deviation?: Deviation };
|
||||||
|
return { ...entry, data: normalize(data, [startedAt, endedAt], ignore, deviation?.fields) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Comparaison ----------
|
||||||
|
|
||||||
|
function differences(a: unknown, b: unknown, where = ""): string[] {
|
||||||
|
if (JSON.stringify(a) === JSON.stringify(b)) return [];
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
const out: string[] = [];
|
||||||
|
if (a.length !== b.length) out.push(`${where} : ${a.length} éléments contre ${b.length}`);
|
||||||
|
for (let i = 0; i < Math.min(a.length, b.length); i++) out.push(...differences(a[i], b[i], `${where}[${i}]`));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
|
||||||
|
const out: string[] = [];
|
||||||
|
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!(key in (a as object))) out.push(`${where}.${key} : absent à gauche, ${JSON.stringify((b as any)[key])?.slice(0, 120)} à droite`);
|
||||||
|
else if (!(key in (b as object))) out.push(`${where}.${key} : ${JSON.stringify((a as any)[key])?.slice(0, 120)} à gauche, absent à droite`);
|
||||||
|
else out.push(...differences((a as any)[key], (b as any)[key], `${where}.${key}`));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return [`${where} : ${JSON.stringify(a)?.slice(0, 160)} contre ${JSON.stringify(b)?.slice(0, 160)}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Décision R5 : Rust compte « aujourd'hui » à Paris, Node en UTC. Les deux
|
||||||
|
// dates diffèrent en fin de soirée (après 22 h ou 23 h UTC) : les écarts de
|
||||||
|
// date relevés alors sont attendus.
|
||||||
|
const parisDay = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Paris" }).format(new Date());
|
||||||
|
if (mode !== "node" && parisDay !== isoDay(0)) {
|
||||||
|
console.log(`Attention : il est déjà le ${parisDay} à Paris (${isoDay(0)} en UTC) ; écarts de date attendus (décision R5). Relancer après minuit UTC.`);
|
||||||
|
}
|
||||||
|
console.log(`Banc : ${mode === "node" ? "Node contre Node" : "Node de référence contre Rust"}${only ? ` (modules : ${only.join(", ")})` : ""}`);
|
||||||
|
console.log(`Journaux : ${WORK_DIR}`);
|
||||||
|
const stopStub = await startStub();
|
||||||
|
const targets: Target[] = [];
|
||||||
|
try {
|
||||||
|
const left = await startTarget("gauche", "node", LEFT_PORT);
|
||||||
|
targets.push(left);
|
||||||
|
const right = await startTarget("droite", mode === "node" ? "node" : "rust", RIGHT_PORT);
|
||||||
|
targets.push(right);
|
||||||
|
|
||||||
|
const leftEntries = await runTarget(left);
|
||||||
|
const rightEntries = await runTarget(right);
|
||||||
|
|
||||||
|
const report: { key: string; differences: string[] }[] = [];
|
||||||
|
const byModule = new Map<string, { total: number; ko: number }>();
|
||||||
|
for (const entry of leftEntries) {
|
||||||
|
const other = rightEntries.find((e) => e.key === entry.key);
|
||||||
|
const diffs = differences(entry.data, other?.data);
|
||||||
|
const stats = byModule.get(entry.module) ?? { total: 0, ko: 0 };
|
||||||
|
stats.total++;
|
||||||
|
if (diffs.length) { stats.ko++; report.push({ key: entry.key, differences: diffs }); }
|
||||||
|
byModule.set(entry.module, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(WORK_DIR, "gauche.json"), JSON.stringify(leftEntries, null, 2));
|
||||||
|
fs.writeFileSync(path.join(WORK_DIR, "droite.json"), JSON.stringify(rightEntries, null, 2));
|
||||||
|
fs.writeFileSync(path.join(WORK_DIR, "ecarts.json"), JSON.stringify(report, null, 2));
|
||||||
|
|
||||||
|
for (const item of report.slice(0, 40)) {
|
||||||
|
console.log(`\n✗ ${item.key}`);
|
||||||
|
for (const line of item.differences.slice(0, 8)) console.log(` ${line}`);
|
||||||
|
if (item.differences.length > 8) console.log(` … ${item.differences.length - 8} autre(s)`);
|
||||||
|
}
|
||||||
|
if (report.length > 40) console.log(`\n… ${report.length - 40} autre(s) cas en écart, voir ${WORK_DIR}/ecarts.json`);
|
||||||
|
|
||||||
|
console.log("\nModule cas écarts");
|
||||||
|
for (const [module, stats] of [...byModule].sort()) {
|
||||||
|
console.log(`${module.padEnd(22)} ${String(stats.total).padStart(4)} ${stats.ko}`);
|
||||||
|
}
|
||||||
|
const deviations = new Set(
|
||||||
|
[...READS, ...WRITES]
|
||||||
|
.filter((c) => c.deviation && (!only || only.includes(c.module)))
|
||||||
|
.map((c) => `${c.module} : ${c.deviation!.fields.join(", ")} — ${c.deviation!.reason}`),
|
||||||
|
);
|
||||||
|
if (deviations.size) {
|
||||||
|
console.log("\nÉcarts voulus (champs retirés de la comparaison) :");
|
||||||
|
for (const line of deviations) console.log(` ${line}`);
|
||||||
|
}
|
||||||
|
const total = leftEntries.length;
|
||||||
|
console.log(`\n${total} cas comparés, ${report.length} en écart.`);
|
||||||
|
process.exitCode = report.length ? 1 : 0;
|
||||||
|
} finally {
|
||||||
|
targets.forEach(stopTarget);
|
||||||
|
await stopStub();
|
||||||
|
if (!keep) {
|
||||||
|
for (const target of targets) {
|
||||||
|
try { psql("postgres", `DROP DATABASE IF EXISTS ${target.dbName} WITH (FORCE)`); } catch { /* déjà supprimée */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => { console.error(error); process.exit(1); });
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Réglages communs du banc de comparaison Node / Rust.
|
||||||
|
|
||||||
|
export interface BenchAccount {
|
||||||
|
key: string;
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role: "admin" | "directeur" | "manager" | "employee";
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
groupIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un compte par profil à tester (règles communes du plan du webUI)
|
||||||
|
export const BENCH_ACCOUNTS: BenchAccount[] = [
|
||||||
|
{ key: "admin", id: "admin_local", username: "admin", password: "banc-admin", role: "admin", firstName: "Alice", lastName: "Admin", groupIds: [] },
|
||||||
|
{ key: "directeur", id: "bench_directeur", username: "directeur", password: "banc-directeur", role: "directeur", firstName: "Denis", lastName: "Directeur", groupIds: [1, 2] },
|
||||||
|
{ key: "manager", id: "bench_manager", username: "manager", password: "banc-manager", role: "manager", firstName: "Manon", lastName: "Manager", groupIds: [1] },
|
||||||
|
{ key: "employe", id: "bench_employee", username: "employe", password: "banc-employe", role: "employee", firstName: "Émile", lastName: "Employé", groupIds: [1] },
|
||||||
|
{ key: "sans-magasin", id: "bench_sans_magasin", username: "sansmagasin", password: "banc-sans-magasin", role: "employee", firstName: "Sacha", lastName: "Sans", groupIds: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Clé de l'API externe enregistrée par seed.ts (empreinte seulement)
|
||||||
|
export const BENCH_API_KEY = "lf_banc_cle_de_test_0123456789";
|
||||||
|
|
||||||
|
// Plusieurs bancs peuvent tourner en parallèle (un par copie de travail) :
|
||||||
|
// BENCH_ID sépare les bases, BENCH_PORT_BASE les ports.
|
||||||
|
export const BENCH_ID = process.env.BENCH_ID || "";
|
||||||
|
const SUFFIX = BENCH_ID ? `_${BENCH_ID}` : "";
|
||||||
|
const PORT_BASE = Number(process.env.BENCH_PORT_BASE || 3200);
|
||||||
|
|
||||||
|
export const TEMPLATE_DB = process.env.BENCH_TEMPLATE_DB || `bench_seed${SUFFIX}`;
|
||||||
|
export const DB_PREFIX = `bench${SUFFIX}`;
|
||||||
|
export const LEFT_PORT = PORT_BASE + 1;
|
||||||
|
export const RIGHT_PORT = PORT_BASE + 3;
|
||||||
|
|
||||||
|
// Services externes simulés (NocoDB, webhooks, SMTP) : voir stub.ts.
|
||||||
|
// Les magasins de seed.ts pointent vers ces ports.
|
||||||
|
export const STUB_PORT = PORT_BASE + 90;
|
||||||
|
export const STUB_URL = `http://127.0.0.1:${STUB_PORT}`;
|
||||||
|
export const STUB_SMTP_PORT = PORT_BASE + 91;
|
||||||
|
|
||||||
|
// Environnement commun aux deux serveurs
|
||||||
|
export const BENCH_ENV = {
|
||||||
|
SESSION_SECRET: "banc-secret-de-session",
|
||||||
|
ENCRYPTION_KEY: "banc-cle-de-chiffrement",
|
||||||
|
EXTERNAL_API_KEYS: "",
|
||||||
|
TZ: "UTC",
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Fonctions reprises de l'ancien serveur Node, pour écrire les données du banc
|
||||||
|
// comme il les aurait écrites (le serveur Node a été retiré du dépôt).
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
const PREFIX = "enc:v1:";
|
||||||
|
|
||||||
|
/** Ancien server/crypto.ts : AES-256-GCM, clé = SHA-256(ENCRYPTION_KEY ou SESSION_SECRET) */
|
||||||
|
export function encryptSecret(value: string): string {
|
||||||
|
const secret = process.env.ENCRYPTION_KEY || process.env.SESSION_SECRET || "logiflow-fallback-secret-key";
|
||||||
|
const key = crypto.createHash("sha256").update(secret).digest();
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ancien weatherService.getPreviousYearDate, serveur en UTC : même jour de la
|
||||||
|
* semaine, même rang dans le mois, l'année précédente
|
||||||
|
*/
|
||||||
|
export function previousYearDate(date: Date): string {
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const month = date.getUTCMonth();
|
||||||
|
const dayOfWeek = date.getUTCDay();
|
||||||
|
const firstDayOfMonth = new Date(Date.UTC(year - 1, month, 1)).getUTCDay();
|
||||||
|
const daysToAdd = (dayOfWeek - firstDayOfMonth + 7) % 7;
|
||||||
|
const weekOfMonth = Math.ceil(date.getUTCDate() / 7);
|
||||||
|
const target = new Date(Date.UTC(year - 1, month, 1 + daysToAdd + (weekOfMonth - 1) * 7));
|
||||||
|
if (target.getUTCMonth() !== month) {
|
||||||
|
target.setUTCDate(target.getUTCDate() - 7);
|
||||||
|
}
|
||||||
|
return target.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Construit le serveur Node de référence du banc, depuis le dernier commit qui le
|
||||||
|
# contient (REFERENCE_NODE_COMMIT), dans une copie de travail à part. Le serveur
|
||||||
|
# Node ayant quitté le dépôt, c'est contre lui que Rust est comparé.
|
||||||
|
# Usage : scripts/bench/reference-node.sh [dossier] (défaut : .bench-reference)
|
||||||
|
# Puis : export BENCH_NODE_DIR=<dossier>
|
||||||
|
set -euo pipefail
|
||||||
|
root="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
commit="$(tr -d '[:space:]' < "$root/scripts/bench/REFERENCE_NODE_COMMIT")"
|
||||||
|
dir="${1:-$root/.bench-reference}"
|
||||||
|
|
||||||
|
if [ ! -d "$dir/.git" ] && [ ! -f "$dir/.git" ]; then
|
||||||
|
git -C "$root" worktree add --detach "$dir" "$commit" > /dev/null
|
||||||
|
else
|
||||||
|
git -C "$dir" checkout --detach -q "$commit"
|
||||||
|
fi
|
||||||
|
cd "$dir"
|
||||||
|
if [ ! -f dist/index.js ] || [ "$(cat dist/.commit 2>/dev/null)" != "$commit" ]; then
|
||||||
|
npm ci --no-audit --no-fund > /dev/null
|
||||||
|
scripts/bench/build-node.sh > /dev/null
|
||||||
|
echo "$commit" > dist/.commit
|
||||||
|
fi
|
||||||
|
echo "$dir"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Compare les colonnes déclarées dans shared/schema.ts à celles d'une base réelle.
|
||||||
|
// Usage : DATABASE_URL=postgres://… npx tsx scripts/bench/schema-check.ts
|
||||||
|
import pg from "pg";
|
||||||
|
import * as schema from "../../shared/schema";
|
||||||
|
import { getTableConfig, PgTable } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const declared = new Map<string, Map<string, string>>();
|
||||||
|
for (const value of Object.values(schema)) {
|
||||||
|
if (!(value instanceof PgTable)) continue;
|
||||||
|
const config = getTableConfig(value);
|
||||||
|
declared.set(config.name, new Map(config.columns.map((c) => [c.name, `${c.getSQLType()}${c.notNull ? " not null" : ""}`])));
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
await client.connect();
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`SELECT table_name, column_name, data_type, is_nullable
|
||||||
|
FROM information_schema.columns WHERE table_schema = 'public'`,
|
||||||
|
);
|
||||||
|
await client.end();
|
||||||
|
|
||||||
|
const actual = new Map<string, Map<string, string>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!actual.has(row.table_name)) actual.set(row.table_name, new Map());
|
||||||
|
actual.get(row.table_name)!.set(row.column_name, `${row.data_type}${row.is_nullable === "NO" ? " not null" : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let problems = 0;
|
||||||
|
for (const [table, columns] of declared) {
|
||||||
|
const real = actual.get(table);
|
||||||
|
if (!real) { console.log(`table absente : ${table}`); problems++; continue; }
|
||||||
|
for (const [column, type] of columns) {
|
||||||
|
if (!real.has(column)) { console.log(`colonne absente : ${table}.${column} (${type})`); problems++; }
|
||||||
|
}
|
||||||
|
for (const [column, type] of real) {
|
||||||
|
if (!columns.has(column)) console.log(`colonne en plus dans la base : ${table}.${column} (${type})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const table of actual.keys()) {
|
||||||
|
if (!declared.has(table)) console.log(`table en plus dans la base : ${table}`);
|
||||||
|
}
|
||||||
|
console.log(problems === 0 ? "Schéma conforme à shared/schema.ts" : `${problems} écart(s) bloquant(s)`);
|
||||||
|
process.exit(problems === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => { console.error(error); process.exit(1); });
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Construit, dans une base vide, le schéma que le serveur Node attend :
|
||||||
|
# init.sql, puis les migrations appliquées à la main en production, puis celles
|
||||||
|
# de server/migrations.ts (jamais appelé au démarrage). Le reste (index, colonnes
|
||||||
|
# des magasins, tables récentes) est ajouté par server/migrations.production.ts
|
||||||
|
# au premier démarrage de Node.
|
||||||
|
# Usage : scripts/bench/schema.sh postgres://utilisateur:motdepasse@hôte/base
|
||||||
|
set -euo pipefail
|
||||||
|
url="$1"
|
||||||
|
root="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
run() { psql -q -v ON_ERROR_STOP=1 "$url" "$@"; }
|
||||||
|
|
||||||
|
run -f "$root/init.sql"
|
||||||
|
run -f "$root/migrations/20260515_add_contacts_and_supplier_email.sql"
|
||||||
|
run -f "$root/migrations/002_add_dlc_stock_epuise.sql"
|
||||||
|
run <<'SQL'
|
||||||
|
ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_at TIMESTAMP;
|
||||||
|
ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_comment TEXT;
|
||||||
|
ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS codefou VARCHAR;
|
||||||
|
SQL
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Données du banc : 3 magasins, 5 comptes (un par profil à tester) et quelques
|
||||||
|
// lignes dans chaque table, datées par rapport au jour du passage pour que les
|
||||||
|
// alertes (DLC, retards, échéances) aient toujours quelque chose à montrer.
|
||||||
|
// Usage : DATABASE_URL=… SESSION_SECRET=… npx tsx scripts/bench/seed.ts
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import pg from "pg";
|
||||||
|
import { scryptSync, createHash } from "crypto";
|
||||||
|
import { encryptSecret } from "./legacy";
|
||||||
|
import { BENCH_ACCOUNTS, BENCH_API_KEY, STUB_URL, STUB_SMTP_PORT } from "./config";
|
||||||
|
|
||||||
|
// Même format que l'ancien server/localAuth.ts : empreinte_hex.sel_hex, le sel hexadécimal
|
||||||
|
// servant tel quel de texte. Sel fixe pour un banc reproductible.
|
||||||
|
function hashPassword(password: string, salt: string): string {
|
||||||
|
return `${scryptSync(password, salt, 64).toString("hex")}.${salt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Petit PNG 1×1 : un magasin a un logo, comme en production
|
||||||
|
const LOGO = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
await client.connect();
|
||||||
|
const q = (text: string, values: unknown[] = []) => client.query(text, values);
|
||||||
|
|
||||||
|
await q("BEGIN");
|
||||||
|
|
||||||
|
await q(`INSERT INTO nocodb_config (id, name, base_url, project_id, api_token, description, is_active, created_by, created_at, updated_at)
|
||||||
|
VALUES (1, 'NocoDB banc', $1, 'pbench', $2, 'Service simulé du banc', true, 'admin_local', CURRENT_DATE - 30 + time '08:00', CURRENT_DATE - 30 + time '08:00')`,
|
||||||
|
[`${STUB_URL}/nocodb`, encryptSecret("nocodb-token-banc")]);
|
||||||
|
|
||||||
|
await q(`INSERT INTO groups (id, name, color, nocodb_config_id, nocodb_table_name, nocodb_table_id, invoice_column_name, nocodb_bl_column_name,
|
||||||
|
nocodb_amount_column_name, nocodb_invoice_amount_ttc_column_name, nocodb_supplier_column_name, nocodb_due_date_column_name,
|
||||||
|
webhook_url, address, phone, logo, smtp_enabled, smtp_host, smtp_port, smtp_secure, smtp_user, smtp_password,
|
||||||
|
smtp_sender_email, smtp_sender_name, created_at, updated_at) VALUES
|
||||||
|
(1, 'Magasin Nancy', '#1976D2', 1, 'factures', 'tnancy', 'RefFacture', 'NumBL', 'MontantHT', 'MontantTTC', 'Fournisseur', 'Echeance',
|
||||||
|
$1, '12 rue des Dominicains, 54000 Nancy', '03 83 00 00 01', $2, true, '127.0.0.1', $3, false, 'nancy@banc.test', $4,
|
||||||
|
'nancy@banc.test', 'Magasin Nancy', CURRENT_DATE - 400 + time '08:00', CURRENT_DATE - 10 + time '08:00'),
|
||||||
|
(2, 'Magasin Metz', '#388E3C', 1, 'factures', 'tmetz', 'RefFacture', 'NumBL', 'MontantHT', 'MontantTTC', 'Fournisseur', 'Echeance',
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, NULL, false, NULL, NULL, NULL, NULL, CURRENT_DATE - 300 + time '08:00', CURRENT_DATE - 300 + time '08:00'),
|
||||||
|
(3, 'Magasin Épinal', '#F57C00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, NULL, false, NULL, NULL, NULL, NULL, CURRENT_DATE - 200 + time '08:00', CURRENT_DATE - 200 + time '08:00')`,
|
||||||
|
[`${STUB_URL}/webhook/avoirs`, LOGO, STUB_SMTP_PORT, encryptSecret("smtp-secret-banc")]);
|
||||||
|
|
||||||
|
for (const account of BENCH_ACCOUNTS) {
|
||||||
|
await q(`INSERT INTO users (id, username, email, first_name, last_name, password, role, password_changed, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, true, CURRENT_DATE - 100 + time '08:00', CURRENT_DATE - 100 + time '08:00')`,
|
||||||
|
[account.id, account.username, `${account.username}@banc.test`, account.firstName, account.lastName,
|
||||||
|
hashPassword(account.password, createHash("md5").update(account.username).digest("hex")), account.role]);
|
||||||
|
for (const groupId of account.groupIds) {
|
||||||
|
await q(`INSERT INTO user_groups (user_id, group_id, created_at) VALUES ($1, $2, CURRENT_DATE - 100 + time '08:00')`, [account.id, groupId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await q(`INSERT INTO suppliers (id, name, contact, phone, has_dlc, payment_method, automatic_reconciliation, requires_control, email, codefou, created_at, updated_at) VALUES
|
||||||
|
(1, 'Lactalis', 'M. Durand', '01 00 00 00 01', true, 'virement', false, false, 'commandes@lactalis.test', 'F001', CURRENT_DATE - 500 + time '08:00', CURRENT_DATE - 500 + time '08:00'),
|
||||||
|
(2, 'Bonduelle', 'Mme Petit', '01 00 00 00 02', true, 'prelevement', true, false, 'factures@bonduelle.test', 'F002', CURRENT_DATE - 490 + time '08:00', CURRENT_DATE - 490 + time '08:00'),
|
||||||
|
(3, 'Brico Pro', NULL, NULL, false, 'cheque', false, true, NULL, NULL, CURRENT_DATE - 480 + time '08:00', CURRENT_DATE - 480 + time '08:00'),
|
||||||
|
(4, 'Jardin & Co', 'M. Martin', '01 00 00 00 04', false, NULL, false, false, 'contact@jardin.test', 'F004', CURRENT_DATE - 470 + time '08:00', CURRENT_DATE - 470 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO contacts (id, group_id, name, company, role, phone, email, notes, created_at, updated_at) VALUES
|
||||||
|
(1, 1, 'Paul Lefort', 'Lactalis', 'Commercial', '06 00 00 00 01', 'paul@lactalis.test', 'Passe le mardi', CURRENT_DATE - 50 + time '09:00', CURRENT_DATE - 50 + time '09:00'),
|
||||||
|
(2, 1, 'Agence intérim', NULL, NULL, '03 83 00 00 99', NULL, NULL, CURRENT_DATE - 40 + time '09:00', CURRENT_DATE - 40 + time '09:00'),
|
||||||
|
(3, 2, 'Julie Roux', 'Bonduelle', 'Livraisons', '06 00 00 00 03', NULL, NULL, CURRENT_DATE - 30 + time '09:00', CURRENT_DATE - 30 + time '09:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO orders (id, supplier_id, group_id, planned_date, quantity, unit, status, notes, created_by, created_at, updated_at) VALUES
|
||||||
|
(1, 1, 1, CURRENT_DATE - 20, 10, 'palettes', 'delivered', 'Commande mensuelle', 'admin_local', CURRENT_DATE - 25 + time '10:00', CURRENT_DATE - 20 + time '10:00'),
|
||||||
|
(2, 2, 1, CURRENT_DATE - 2, 5, 'colis', 'planned', NULL, 'bench_manager', CURRENT_DATE - 6 + time '10:00', CURRENT_DATE - 5 + time '10:00'),
|
||||||
|
(3, 3, 1, CURRENT_DATE + 3, 2, 'palettes', 'pending', 'Urgent', 'bench_manager', CURRENT_DATE - 1 + time '10:00', CURRENT_DATE - 1 + time '10:00'),
|
||||||
|
(4, 1, 2, CURRENT_DATE, 8, 'colis', 'pending', NULL, 'bench_directeur', CURRENT_DATE - 3 + time '10:00', CURRENT_DATE - 3 + time '10:00'),
|
||||||
|
(5, 4, 2, CURRENT_DATE - 40, 3, 'palettes', 'delivered', NULL, 'bench_directeur', CURRENT_DATE - 45 + time '10:00', CURRENT_DATE - 40 + time '10:00'),
|
||||||
|
(6, 2, 3, CURRENT_DATE + 10, 1, 'palettes', 'pending', NULL, 'admin_local', CURRENT_DATE + time '07:30', CURRENT_DATE + time '07:30'),
|
||||||
|
(7, 1, 1, CURRENT_DATE - 380, 4, 'palettes', 'delivered', 'Année précédente', 'admin_local', CURRENT_DATE - 385 + time '10:00', CURRENT_DATE - 380 + time '10:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO deliveries (id, order_id, supplier_id, group_id, scheduled_date, delivered_date, quantity, unit, status, notes, bl_number, bl_amount,
|
||||||
|
invoice_reference, invoice_amount, invoice_amount_ttc, due_date, reconciled, validated_at, control_validated, control_validated_by,
|
||||||
|
control_validated_at, created_by, created_at, updated_at) VALUES
|
||||||
|
(1, 1, 1, 1, CURRENT_DATE - 20, CURRENT_DATE - 20 + time '11:00', 10, 'palettes', 'delivered', NULL, 'BL-1001', 1250.50,
|
||||||
|
'FAC-2001', 1250.50, 1500.60, CURRENT_DATE + 10, true, CURRENT_DATE - 20 + time '11:05', false, NULL, NULL, 'admin_local', CURRENT_DATE - 21 + time '10:00', CURRENT_DATE - 19 + time '10:00'),
|
||||||
|
(2, NULL, 2, 1, CURRENT_DATE - 5, CURRENT_DATE - 5 + time '14:00', 6, 'colis', 'delivered', 'Carton abîmé', 'BL-1002', 310.00,
|
||||||
|
NULL, NULL, NULL, NULL, false, CURRENT_DATE - 5 + time '14:10', false, NULL, NULL, 'bench_manager', CURRENT_DATE - 6 + time '10:00', CURRENT_DATE - 5 + time '14:10'),
|
||||||
|
(3, 2, 2, 1, CURRENT_DATE - 2, NULL, 5, 'colis', 'planned', NULL, NULL, NULL,
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'bench_manager', CURRENT_DATE - 5 + time '10:00', CURRENT_DATE - 5 + time '10:00'),
|
||||||
|
(4, NULL, 3, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '09:30', 2, 'palettes', 'delivered', NULL, 'BL-1004', 980.00,
|
||||||
|
'FAC-2004', 975.00, 1170.00, CURRENT_DATE + 25, false, CURRENT_DATE - 1 + time '09:40', true, 'bench_manager', CURRENT_DATE - 1 + time '10:00', 'bench_manager', CURRENT_DATE - 2 + time '10:00', CURRENT_DATE - 1 + time '10:00'),
|
||||||
|
(5, 4, 1, 2, CURRENT_DATE, NULL, 8, 'colis', 'planned', NULL, NULL, NULL,
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'bench_directeur', CURRENT_DATE - 3 + time '10:00', CURRENT_DATE - 3 + time '10:00'),
|
||||||
|
(6, 5, 4, 2, CURRENT_DATE - 40, CURRENT_DATE - 40 + time '16:00', 3, 'palettes', 'delivered', NULL, 'BL-1006', 450.25,
|
||||||
|
'FAC-2006', 450.25, 540.30, CURRENT_DATE - 5, true, CURRENT_DATE - 40 + time '16:10', false, NULL, NULL, 'bench_directeur', CURRENT_DATE - 41 + time '10:00', CURRENT_DATE - 39 + time '10:00'),
|
||||||
|
(7, NULL, 1, 3, CURRENT_DATE + 2, NULL, 1, 'palettes', 'planned', NULL, NULL, NULL,
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'admin_local', CURRENT_DATE + time '07:00', CURRENT_DATE + time '07:00'),
|
||||||
|
(8, 7, 1, 1, CURRENT_DATE - 380, CURRENT_DATE - 380 + time '11:00', 4, 'palettes', 'delivered', NULL, 'BL-0900', 800.00,
|
||||||
|
'FAC-1900', 800.00, 960.00, CURRENT_DATE - 350, true, CURRENT_DATE - 380 + time '11:05', false, NULL, NULL, 'admin_local', CURRENT_DATE - 381 + time '10:00', CURRENT_DATE - 379 + time '10:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO reconciliation_comments (id, delivery_id, group_id, content, type, author_id, created_at, updated_at) VALUES
|
||||||
|
(1, 2, 1, 'Facture non reçue, relancer le fournisseur', 'warning', 'bench_manager', CURRENT_DATE - 4 + time '09:00', CURRENT_DATE - 4 + time '09:00'),
|
||||||
|
(2, 4, 1, 'Écart de 5 € sur le BL', 'info', 'admin_local', CURRENT_DATE - 1 + time '15:00', CURRENT_DATE - 1 + time '15:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO invoice_verification_cache (id, cache_key, group_id, invoice_reference, supplier_name, invoice_amount, invoice_amount_ttc, due_date,
|
||||||
|
exists, match_type, error_message, cache_hit, api_call_time, is_reconciled, expires_at, created_at, updated_at) VALUES
|
||||||
|
(1, '1_fac-2001', 1, 'FAC-2001', 'Lactalis', 1250.50, 1500.60, CURRENT_DATE + 10, true, 'invoice_reference', NULL, false, 120, true,
|
||||||
|
CURRENT_DATE + 18000, CURRENT_DATE - 20 + time '11:10', CURRENT_DATE - 20 + time '11:10'),
|
||||||
|
(2, '1_fac-2004', 1, 'FAC-2004', 'Brico Pro', 975.00, 1170.00, CURRENT_DATE + 25, true, 'invoice_reference', NULL, false, 95, false,
|
||||||
|
CURRENT_DATE + 1, CURRENT_DATE - 1 + time '10:30', CURRENT_DATE - 1 + time '10:30')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO publicities (id, pub_number, designation, start_date, end_date, year, created_by, created_at, updated_at) VALUES
|
||||||
|
(1, 'PUB-01', 'Foire aux vins', CURRENT_DATE - 10, CURRENT_DATE + 5, EXTRACT(YEAR FROM CURRENT_DATE)::int, 'admin_local', CURRENT_DATE - 60 + time '09:00', CURRENT_DATE - 60 + time '09:00'),
|
||||||
|
(2, 'PUB-02', 'Spécial jardin', CURRENT_DATE + 20, CURRENT_DATE + 34, EXTRACT(YEAR FROM CURRENT_DATE)::int, 'admin_local', CURRENT_DATE - 50 + time '09:00', CURRENT_DATE - 50 + time '09:00'),
|
||||||
|
(3, 'PUB-99', 'Rentrée', CURRENT_DATE - 400, CURRENT_DATE - 386, EXTRACT(YEAR FROM CURRENT_DATE)::int - 1, 'admin_local', CURRENT_DATE - 420 + time '09:00', CURRENT_DATE - 420 + time '09:00')`);
|
||||||
|
await q(`INSERT INTO publicity_participations (publicity_id, group_id, created_at) VALUES
|
||||||
|
(1, 1, CURRENT_DATE - 60 + time '09:00'), (1, 2, CURRENT_DATE - 60 + time '09:00'), (2, 1, CURRENT_DATE - 50 + time '09:00'), (3, 2, CURRENT_DATE - 420 + time '09:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO customer_orders (id, order_taker, customer_name, customer_phone, customer_email, product_designation, product_reference, gencode,
|
||||||
|
quantity, supplier_id, status, deposit, is_promotional_price, customer_notified, notes, group_id, created_by, created_at, updated_at,
|
||||||
|
notified_at, notified_comment) VALUES
|
||||||
|
(1, 'Sophie', 'Mme Bernard', '06 11 11 11 11', 'bernard@client.test', 'Tondeuse électrique', 'TD-450', '3245678901234', 1, 4,
|
||||||
|
'En attente de Commande', 50.00, false, false, NULL, 1, 'bench_employee', CURRENT_DATE - 3 + time '15:00', CURRENT_DATE - 3 + time '15:00', NULL, NULL),
|
||||||
|
(2, 'Marc', 'M. Laurent', '06 22 22 22 22', NULL, 'Perceuse sans fil', 'PS-18', '3245678901241', 2, 3,
|
||||||
|
'Commande passée', 0.00, true, false, 'Prix promo', 1, 'bench_manager', CURRENT_DATE - 8 + time '11:00', CURRENT_DATE - 6 + time '11:00', NULL, NULL),
|
||||||
|
(3, 'Sophie', 'Mme Garcia', '06 33 33 33 33', NULL, 'Fromage affiné', NULL, '3245678901258', 1, 1,
|
||||||
|
'Disponible', 0.00, false, false, NULL, 1, 'bench_employee', CURRENT_DATE - 10 + time '10:00', CURRENT_DATE - 1 + time '10:00', NULL, NULL),
|
||||||
|
(4, 'Léa', 'M. Thomas', '06 44 44 44 44', NULL, 'Graines de gazon 5 kg', 'GZ-5', '3245678901265', 3, 4,
|
||||||
|
'Disponible', 10.00, false, true, NULL, 2, 'bench_directeur', CURRENT_DATE - 12 + time '10:00', CURRENT_DATE - 2 + time '10:00', CURRENT_DATE - 2 + time '11:00', 'Message laissé'),
|
||||||
|
(5, 'Léa', 'Mme Petit', '06 55 55 55 55', NULL, 'Arrosoir', NULL, '3245678901272', 1, 4,
|
||||||
|
'Retiré', 0.00, false, true, NULL, 2, 'bench_directeur', CURRENT_DATE - 30 + time '10:00', CURRENT_DATE - 20 + time '10:00', CURRENT_DATE - 25 + time '10:00', NULL)`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO dlc_products (id, name, product_name, gencode, supplier_id, group_id, expiry_date, date_type, quantity, unit, location, alert_threshold,
|
||||||
|
status, stock_epuise, stock_epuise_by, stock_epuise_at, processed_at, processed_by, processed_until_expiry, notes, created_by,
|
||||||
|
validated_by, validated_at, created_at, updated_at) VALUES
|
||||||
|
(1, 'Yaourt nature', 'Yaourt nature x4', '3000000000011', 1, 1, CURRENT_DATE - 2, 'dlc', 12, 'unité', 'Rayon frais', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_employee', NULL, NULL, CURRENT_DATE - 9 + time '08:30', CURRENT_DATE - 9 + time '08:30'),
|
||||||
|
(2, 'Camembert', 'Camembert 250 g', '3000000000028', 1, 1, CURRENT_DATE + 3, 'dlc', 6, 'unité', 'Rayon frais', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, 'Promo possible', 'bench_manager', NULL, NULL, CURRENT_DATE - 7 + time '08:30', CURRENT_DATE - 7 + time '08:30'),
|
||||||
|
(3, 'Haricots verts', 'Haricots verts 400 g', '3000000000035', 2, 1, CURRENT_DATE + 60, 'ddm', 24, 'unité', 'Réserve', 15,
|
||||||
|
'valides', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_manager', 'bench_manager', CURRENT_DATE - 6 + time '09:00', CURRENT_DATE - 6 + time '08:30', CURRENT_DATE - 6 + time '09:00'),
|
||||||
|
(4, 'Crème fraîche', 'Crème fraîche 20 cl', '3000000000042', 1, 1, CURRENT_DATE + 1, 'dlc', 4, 'unité', 'Rayon frais', 15,
|
||||||
|
'en_cours', true, 'bench_employee', CURRENT_DATE + time '08:00', NULL, NULL, false, NULL, 'bench_employee', NULL, NULL, CURRENT_DATE - 5 + time '08:30', CURRENT_DATE + time '08:00'),
|
||||||
|
(5, 'Beurre doux', 'Beurre doux 250 g', '3000000000059', 1, 1, CURRENT_DATE + 2, 'dlc', 10, 'unité', 'Rayon frais', 15,
|
||||||
|
'en_cours', false, NULL, NULL, CURRENT_DATE - 1 + time '17:00', 'bench_manager', true, NULL, 'bench_manager', NULL, NULL, CURRENT_DATE - 4 + time '08:30', CURRENT_DATE - 1 + time '17:00'),
|
||||||
|
(6, 'Petits pois', 'Petits pois 800 g', '3000000000066', 2, 2, CURRENT_DATE + 10, 'dlc', 30, 'unité', 'Magasin', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_directeur', NULL, NULL, CURRENT_DATE - 3 + time '08:30', CURRENT_DATE - 3 + time '08:30')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO tasks (id, title, description, start_date, due_date, priority, status, assigned_to, created_by, group_id, completed_at, completed_by, created_at, updated_at) VALUES
|
||||||
|
(1, 'Inventaire rayon frais', 'Compter les yaourts', CURRENT_DATE - 2 + time '08:00', CURRENT_DATE - 1, 'high', 'pending', 'Équipe frais', 'bench_manager', 1, NULL, NULL, CURRENT_DATE - 3 + time '08:00', CURRENT_DATE - 3 + time '08:00'),
|
||||||
|
(2, 'Mise en rayon promo', NULL, NULL, CURRENT_DATE + 2, 'medium', 'pending', 'Sophie', 'bench_manager', 1, NULL, NULL, CURRENT_DATE - 1 + time '08:00', CURRENT_DATE - 1 + time '08:00'),
|
||||||
|
(3, 'Nettoyage réserve', NULL, CURRENT_DATE + 5 + time '08:00', CURRENT_DATE + 7, 'low', 'pending', 'Marc', 'bench_directeur', 1, NULL, NULL, CURRENT_DATE - 1 + time '09:00', CURRENT_DATE - 1 + time '09:00'),
|
||||||
|
(4, 'Relevé températures', NULL, NULL, CURRENT_DATE - 3, 'medium', 'completed', 'Léa', 'bench_directeur', 2, CURRENT_DATE - 3 + time '18:00', 'bench_directeur', CURRENT_DATE - 5 + time '08:00', CURRENT_DATE - 3 + time '18:00'),
|
||||||
|
(5, 'Affichage prix', NULL, NULL, CURRENT_DATE, 'high', 'pending', 'Léa', 'bench_directeur', 2, NULL, NULL, CURRENT_DATE - 2 + time '08:00', CURRENT_DATE - 2 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO dashboard_messages (id, title, content, type, store_id, created_by, created_at) VALUES
|
||||||
|
(1, 'Inventaire annuel', 'Inventaire le 30 du mois', 'info', NULL, 'admin_local', CURRENT_DATE - 1 + time '08:00'),
|
||||||
|
(2, 'Panne caisse 3', 'Caisse 3 hors service', 'warning', 1, 'bench_manager', CURRENT_DATE + time '07:45'),
|
||||||
|
(3, 'Ancienne information', 'Texte ancien', 'info', 2, 'admin_local', CURRENT_DATE - 20 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO avoirs (id, supplier_id, group_id, invoice_reference, amount, comment, commercial_processed, status, webhook_sent, nocodb_verified,
|
||||||
|
nocodb_verified_at, processed_at, created_by, created_at, updated_at) VALUES
|
||||||
|
(1, 1, 1, 'AV-3001', 45.90, 'Produits périmés à la livraison', false, 'En attente de demande', false, false, NULL, NULL, 'bench_manager', CURRENT_DATE - 4 + time '10:00', CURRENT_DATE - 4 + time '10:00'),
|
||||||
|
(2, 3, 1, 'AV-3002', 120.00, NULL, true, 'Demandé', true, false, NULL, CURRENT_DATE - 2 + time '10:00', 'admin_local', CURRENT_DATE - 6 + time '10:00', CURRENT_DATE - 2 + time '10:00'),
|
||||||
|
(3, 4, 2, NULL, 15.00, 'Casse', true, 'Reçu', true, true, CURRENT_DATE - 1 + time '12:00', CURRENT_DATE - 1 + time '12:00', 'bench_directeur', CURRENT_DATE - 9 + time '10:00', CURRENT_DATE - 1 + time '12:00')`);
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
await q(`INSERT INTO sav_tickets (id, ticket_number, supplier_id, group_id, product_gencode, product_reference, product_designation, problem_type,
|
||||||
|
problem_description, resolution_description, status, priority, client_name, client_phone, created_by, created_at, updated_at, resolved_at, closed_at) VALUES
|
||||||
|
(1, $1, 3, 1, '3245678901241', 'PS-18', 'Perceuse sans fil', 'defectueux', 'Ne démarre pas', NULL, 'nouveau', 'haute', 'M. Laurent', '06 22 22 22 22', 'bench_employee', CURRENT_DATE - 2 + time '16:00', CURRENT_DATE - 2 + time '16:00', NULL, NULL),
|
||||||
|
(2, $2, 4, 1, '3245678901234', 'TD-450', 'Tondeuse électrique', 'pieces_manquantes', 'Manque le bac', NULL, 'attente_pieces', 'normale', NULL, NULL, 'bench_manager', CURRENT_DATE - 15 + time '10:00', CURRENT_DATE - 5 + time '10:00', NULL, NULL),
|
||||||
|
(3, $3, 4, 2, '3245678901265', 'GZ-5', 'Graines de gazon 5 kg', 'non_conforme', 'Sac déchiré', 'Échangé', 'resolu', 'faible', 'M. Thomas', NULL, 'bench_directeur', CURRENT_DATE - 25 + time '10:00', CURRENT_DATE - 20 + time '10:00', CURRENT_DATE - 20 + time '10:00', NULL)`,
|
||||||
|
[`SAV-${year}-0001`, `SAV-${year}-0002`, `SAV-${year}-0003`]);
|
||||||
|
|
||||||
|
await q(`INSERT INTO supplier_mail_logs (id, delivery_id, group_id, supplier_id, supplier_name, sent_to, subject, status, error_message, message_id, sent_by, sent_by_name, created_at) VALUES
|
||||||
|
(1, 2, 1, 2, 'Bonduelle', 'factures@bonduelle.test', 'Demande de facture BL-1002', 'sent', NULL, '<banc-1@banc.test>', 'bench_manager', 'Manon Manager', CURRENT_DATE - 3 + time '09:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO weather_settings (id, api_key, location, is_active, created_at, updated_at) VALUES
|
||||||
|
(1, 'cle-meteo-banc', 'Nancy, France', true, CURRENT_DATE - 100 + time '08:00', CURRENT_DATE - 100 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO utilities (id, sales_analysis_url, automatic_backups_enabled, created_at, updated_at) VALUES
|
||||||
|
(1, 'https://analyse.banc.test/rapport', false, CURRENT_DATE - 100 + time '08:00', CURRENT_DATE - 100 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO webhook_bap_config (id, name, webhook_url, description, is_active, created_at, updated_at) VALUES
|
||||||
|
(1, 'Configuration BAP', $1, 'Webhook simulé du banc', true, CURRENT_DATE - 100 + time '08:00', CURRENT_DATE - 100 + time '08:00')`,
|
||||||
|
[`${STUB_URL}/webhook/bap`]);
|
||||||
|
|
||||||
|
await q(`INSERT INTO database_backups (id, filename, description, size, created_at, created_by, tables_count, status, backup_type) VALUES
|
||||||
|
('banc-sauvegarde-1', 'backup_banc_1.sql', 'Sauvegarde du banc', 2048, CURRENT_DATE - 1 + time '02:00', 'system', 26, 'completed', 'automatic')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO external_api_keys (id, name, key_prefix, key_hash, created_by, created_at, last_used_at, revoked_at) VALUES
|
||||||
|
(1, 'Outil de rapprochement', $1, $2, 'admin_local', CURRENT_DATE - 30 + time '08:00', NULL, NULL),
|
||||||
|
(2, 'Ancienne clé', 'lf_oldkey', $3, 'admin_local', CURRENT_DATE - 90 + time '08:00', NULL, CURRENT_DATE - 60 + time '08:00')`,
|
||||||
|
[BENCH_API_KEY.slice(0, 10), createHash("sha256").update(BENCH_API_KEY).digest("hex"), createHash("sha256").update("lf_cle_revoquee").digest("hex")]);
|
||||||
|
|
||||||
|
// Données complémentaires de chaque lot (seeds/*.ts, voir seeds/README.md)
|
||||||
|
const seedsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "seeds");
|
||||||
|
for (const file of fs.readdirSync(seedsDir).filter((f) => f.endsWith(".ts")).sort()) {
|
||||||
|
const extra = await import(path.join(seedsDir, file));
|
||||||
|
await extra.seed(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les séquences reprennent après les identifiants fixés ci-dessus
|
||||||
|
const { rows } = await q(`SELECT table_name, column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND column_default LIKE 'nextval%'`);
|
||||||
|
for (const row of rows) {
|
||||||
|
await q(`SELECT setval(pg_get_serial_sequence($1, $2), COALESCE((SELECT MAX(${row.column_name}) FROM ${row.table_name}), 0) + 1, false)`,
|
||||||
|
[row.table_name, row.column_name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await q("COMMIT");
|
||||||
|
await client.end();
|
||||||
|
console.log("Données du banc insérées");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => { console.error(error); process.exit(1); });
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Données complémentaires du banc, une par lot : chaque fichier `*.ts` exporte
|
||||||
|
`seed(q)` (voir `seed.ts`), appelée après les données de base, par ordre
|
||||||
|
alphabétique de nom de fichier. Identifiants à partir de 100 pour ne pas
|
||||||
|
heurter ceux de `seed.ts` ni ceux d'un autre fichier : choisir une centaine
|
||||||
|
libre (lot 2 : 200…, lot 3 : 300…, lot 4 : 400…, lot 5 : 500…).
|
||||||
|
|
||||||
|
Les fichiers de `sauvegardes/` (sauvegardes déclarées dans `database_backups`)
|
||||||
|
sont copiés par `compare.ts` dans le dossier de sauvegardes de chaque serveur.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Données du lot 2 (Informations et planning), identifiants à partir de 200 :
|
||||||
|
// - informations au-delà de la limite de 5, par magasin et globales, avec des
|
||||||
|
// auteurs de chaque forme (prénom et nom, nom d'affichage seul, prénom seul,
|
||||||
|
// compte disparu) ;
|
||||||
|
// - publicités dont les N° testent le tri numérique (« 12 », « 3 », « 3-bis »)
|
||||||
|
// et des participations du magasin d'Épinal ;
|
||||||
|
// - une commande livrée en retard par magasin hors Nancy, pour des délais moyens
|
||||||
|
// non nuls dans les statistiques ;
|
||||||
|
// - la météo en cache pour aujourd'hui et pour la date de l'an dernier calculée
|
||||||
|
// par le serveur Node : /api/weather/current ne sollicite jamais Visual
|
||||||
|
// Crossing pendant le banc (fenêtre de 9 jours, au cas où le banc tourne
|
||||||
|
// quelques jours après setup.sh).
|
||||||
|
import { previousYearDate } from "../legacy";
|
||||||
|
|
||||||
|
type Query = (text: string, values?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
function isoDay(offset: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setUTCDate(date.getUTCDate() + offset);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seed(q: Query) {
|
||||||
|
// Auteurs d'informations sans magasin (connexion impossible : empreinte factice)
|
||||||
|
await q(`INSERT INTO users (id, username, email, name, first_name, last_name, password, role, password_changed, created_at, updated_at) VALUES
|
||||||
|
('bench_lot2_redaction', 'redaction', 'redaction@banc.test', 'Équipe Rédaction Nancy', NULL, NULL, 'x', 'employee', true, CURRENT_DATE - 90 + time '08:00', CURRENT_DATE - 90 + time '08:00'),
|
||||||
|
('bench_lot2_prenom', 'prenom-seul', NULL, NULL, 'Paulette', NULL, 'x', 'employee', true, CURRENT_DATE - 90 + time '08:00', CURRENT_DATE - 90 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO dashboard_messages (id, title, content, type, store_id, created_by, created_at) VALUES
|
||||||
|
(200, 'Fermeture exceptionnelle', 'Fermeture à 18 h samedi', 'error', 1, 'manager', CURRENT_DATE - 2 + time '09:00'),
|
||||||
|
(201, 'Nouvelle procédure', 'Procédure de réception mise à jour', 'success', NULL, 'admin', CURRENT_DATE - 3 + time '10:00'),
|
||||||
|
(202, 'Réunion Metz', 'Réunion d''équipe jeudi', 'info', 2, 'directeur', CURRENT_DATE - 1 + time '14:00'),
|
||||||
|
(203, 'Message de la rédaction', 'Nouvelle charte affichage', 'warning', 3, 'redaction', CURRENT_DATE - 5 + time '11:00'),
|
||||||
|
(204, 'Prénom seul', 'Auteur sans nom de famille', 'info', NULL, 'prenom-seul', CURRENT_DATE - 4 + time '16:30'),
|
||||||
|
(205, 'Auteur supprimé', 'Compte disparu depuis', 'info', 1, 'ancien-compte', CURRENT_DATE - 6 + time '08:15')`);
|
||||||
|
|
||||||
|
const year = new Date().getUTCFullYear();
|
||||||
|
await q(`INSERT INTO publicities (id, pub_number, designation, start_date, end_date, year, created_by, created_at, updated_at) VALUES
|
||||||
|
(200, '12', 'Promo hiver', CURRENT_DATE + 40, CURRENT_DATE + 50, $1, 'admin_local', CURRENT_DATE - 30 + time '09:00', CURRENT_DATE - 30 + time '09:00'),
|
||||||
|
(201, '3', 'Semaine du blanc', CURRENT_DATE - 3, CURRENT_DATE + 4, $1, 'admin_local', CURRENT_DATE - 40 + time '09:00', CURRENT_DATE - 35 + time '09:00'),
|
||||||
|
(202, '3-bis', 'Semaine du blanc (suite)', CURRENT_DATE + 5, CURRENT_DATE + 9, $1, 'admin_local', CURRENT_DATE - 40 + time '09:30', CURRENT_DATE - 40 + time '09:30'),
|
||||||
|
(203, 'PUB-03', 'Catalogue de l''an prochain', CURRENT_DATE + 200, CURRENT_DATE + 214, $2, 'admin_local', CURRENT_DATE - 10 + time '09:00', CURRENT_DATE - 10 + time '09:00')`,
|
||||||
|
[year, year + 1]);
|
||||||
|
await q(`INSERT INTO publicity_participations (publicity_id, group_id, created_at) VALUES
|
||||||
|
(200, 3, CURRENT_DATE - 30 + time '09:00'), (201, 1, CURRENT_DATE - 40 + time '09:00'), (201, 3, CURRENT_DATE - 40 + time '09:00'),
|
||||||
|
(203, 2, CURRENT_DATE - 10 + time '09:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO orders (id, supplier_id, group_id, planned_date, quantity, unit, status, notes, created_by, created_at, updated_at) VALUES
|
||||||
|
(200, 2, 2, CURRENT_DATE - 12, 7, 'palettes', 'delivered', NULL, 'bench_directeur', CURRENT_DATE - 14 + time '10:00', CURRENT_DATE - 9 + time '15:10'),
|
||||||
|
(201, 4, 3, CURRENT_DATE - 15, 2, 'colis', 'delivered', NULL, 'admin_local', CURRENT_DATE - 16 + time '10:00', CURRENT_DATE - 13 + time '10:10')`);
|
||||||
|
await q(`INSERT INTO deliveries (id, order_id, supplier_id, group_id, scheduled_date, delivered_date, quantity, unit, status, notes, bl_number, bl_amount,
|
||||||
|
invoice_reference, invoice_amount, invoice_amount_ttc, due_date, reconciled, validated_at, control_validated, created_by, created_at, updated_at) VALUES
|
||||||
|
(200, 200, 2, 2, CURRENT_DATE - 12, CURRENT_DATE - 9 + time '15:00', 7, 'palettes', 'delivered', NULL, 'BL-2200', 640.40,
|
||||||
|
'FAC-2200', 640.40, 768.48, CURRENT_DATE + 20, false, CURRENT_DATE - 9 + time '15:10', false, 'bench_directeur', CURRENT_DATE - 14 + time '10:00', CURRENT_DATE - 9 + time '15:10'),
|
||||||
|
(201, 201, 4, 3, CURRENT_DATE - 15, CURRENT_DATE - 13 + time '10:00', 2, 'colis', 'delivered', NULL, 'BL-2201', NULL,
|
||||||
|
NULL, NULL, NULL, NULL, false, CURRENT_DATE - 13 + time '10:10', false, 'admin_local', CURRENT_DATE - 16 + time '10:00', CURRENT_DATE - 13 + time '10:10')`);
|
||||||
|
|
||||||
|
// Météo en cache : même date de l'an dernier que GET /api/weather/current
|
||||||
|
const conditions = [
|
||||||
|
["partly-cloudy-day", "Partiellement nuageux"],
|
||||||
|
["rain", "Pluie légère"],
|
||||||
|
["clear-day", "Dégagé"],
|
||||||
|
["fog", "Brouillard"],
|
||||||
|
["icone-inconnue", "Variable"],
|
||||||
|
];
|
||||||
|
const rows: unknown[][] = [];
|
||||||
|
const previousDates = new Set<string>();
|
||||||
|
for (let offset = -1; offset <= 7; offset++) {
|
||||||
|
const day = isoDay(offset);
|
||||||
|
const [icon, label] = conditions[(offset + 1) % conditions.length];
|
||||||
|
rows.push([day, "Nancy, France", (14.3 + offset).toFixed(1), (6 + offset / 2).toString(), icon, label, true]);
|
||||||
|
const previous = previousYearDate(new Date(`${day}T12:00:00Z`));
|
||||||
|
if (!previousDates.has(previous)) {
|
||||||
|
previousDates.add(previous);
|
||||||
|
const [oldIcon, oldLabel] = conditions[(offset + 3) % conditions.length];
|
||||||
|
rows.push([previous, "Nancy, France", (12 + offset).toString(), (3.5 - offset).toFixed(2), oldIcon, oldLabel, false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let id = 200;
|
||||||
|
for (const row of rows) {
|
||||||
|
await q(`INSERT INTO weather_data (id, date, location, temp_max, temp_min, icon, conditions, is_current_year, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_DATE - 1 + time '06:00', CURRENT_DATE - 1 + time '06:00')`, [id++, ...row]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Lot 3 « Au quotidien » : tâches, DLC, commandes clients et SAV (identifiants 300…).
|
||||||
|
// Complète seed.ts pour exercer ce que ses lignes ne couvrent pas : une ligne du
|
||||||
|
// magasin d'Épinal (aucun compte du banc n'y est affecté, sauf l'administrateur),
|
||||||
|
// les bornes de date des filtres (aujourd'hui, J+15, J+16) et des tâches
|
||||||
|
// programmées plus tard dans la journée.
|
||||||
|
type Query = (text: string, values?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
export async function seed(q: Query) {
|
||||||
|
await q(`INSERT INTO tasks (id, title, description, start_date, due_date, priority, status, assigned_to, created_by, group_id, completed_at, completed_by, created_at, updated_at) VALUES
|
||||||
|
(300, 'Inventaire Épinal', 'Magasin sans compte du banc', NULL, CURRENT_DATE + 4, 'medium', 'pending', 'Équipe Épinal', 'admin_local', 3, NULL, NULL, CURRENT_DATE - 2 + time '09:00', CURRENT_DATE - 2 + time '09:00'),
|
||||||
|
(301, 'Réception livraison', NULL, CURRENT_DATE + time '00:00', CURRENT_DATE, 'high', 'pending', 'Marc', 'bench_manager', 1, NULL, NULL, CURRENT_DATE - 1 + time '07:00', CURRENT_DATE - 1 + time '07:00'),
|
||||||
|
(302, 'Préparer la vitrine', NULL, CURRENT_DATE + time '10:00', CURRENT_DATE + 1, 'low', 'pending', 'Sophie', 'bench_directeur', 1, NULL, NULL, CURRENT_DATE - 1 + time '06:00', CURRENT_DATE - 1 + time '06:00'),
|
||||||
|
(303, 'Commande ancienne', NULL, CURRENT_DATE - 10 + time '08:00', CURRENT_DATE - 9, 'medium', 'completed', 'Léa', 'bench_directeur', 2, CURRENT_DATE - 9 + time '17:00', 'bench_directeur', CURRENT_DATE - 12 + time '08:00', CURRENT_DATE - 9 + time '17:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO dlc_products (id, name, product_name, gencode, supplier_id, group_id, expiry_date, date_type, quantity, unit, location, alert_threshold,
|
||||||
|
status, stock_epuise, stock_epuise_by, stock_epuise_at, processed_at, processed_by, processed_until_expiry, notes, created_by,
|
||||||
|
validated_by, validated_at, created_at, updated_at) VALUES
|
||||||
|
(300, 'Saucisson', 'Saucisson sec', '3000000000301', 1, 3, CURRENT_DATE + 5, 'dlc', 8, 'unité', 'Magasin', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'admin_local', NULL, NULL, CURRENT_DATE - 2 + time '08:30', CURRENT_DATE - 2 + time '08:30'),
|
||||||
|
(301, 'Lait demi-écrémé', 'Lait demi-écrémé 1 L', '3000000000318', 1, 1, CURRENT_DATE + 30, 'dlc', 20, 'unité', 'Réserve', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_employee', NULL, NULL, CURRENT_DATE - 1 + time '08:30', CURRENT_DATE - 1 + time '08:30'),
|
||||||
|
(302, 'Compote', 'Compote pomme x4', '3000000000325', 2, 1, CURRENT_DATE, 'ddm', 5, 'unité', 'Magasin', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_manager', NULL, NULL, CURRENT_DATE - 8 + time '08:30', CURRENT_DATE - 8 + time '08:30'),
|
||||||
|
(303, 'Jus d''orange', NULL, NULL, 2, 1, CURRENT_DATE + 15, 'dluo', 3, 'unité', 'Magasin', 15,
|
||||||
|
'en_cours', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'bench_manager', NULL, NULL, CURRENT_DATE - 6 + time '09:30', CURRENT_DATE - 6 + time '09:30'),
|
||||||
|
(304, 'Fromage blanc', 'Fromage blanc 500 g', '3000000000349', 1, 2, CURRENT_DATE + 16, 'dlc', 7, 'unité', 'Rayon frais', 15,
|
||||||
|
'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_directeur', NULL, NULL, CURRENT_DATE - 4 + time '10:30', CURRENT_DATE - 4 + time '10:30'),
|
||||||
|
(305, 'Pâté de campagne', 'Pâté de campagne 180 g', '3000000000356', 1, 1, CURRENT_DATE - 5, 'dlc', 2, 'unité', 'Rayon frais', 15,
|
||||||
|
'valides', false, NULL, NULL, NULL, NULL, false, 'Retiré du rayon', 'bench_employee', 'bench_manager', CURRENT_DATE - 5 + time '08:00', CURRENT_DATE - 12 + time '08:30', CURRENT_DATE - 5 + time '08:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO customer_orders (id, order_taker, customer_name, customer_phone, customer_email, product_designation, product_reference, gencode,
|
||||||
|
quantity, supplier_id, status, deposit, is_promotional_price, customer_notified, notes, group_id, created_by, created_at, updated_at,
|
||||||
|
notified_at, notified_comment) VALUES
|
||||||
|
(300, 'Hugo', 'M. Colin', '06 66 66 66 66', NULL, 'Tronçonneuse', 'TR-35', '3245678901300', 1, 3,
|
||||||
|
'Arrivé', 20.00, false, false, NULL, 3, 'admin_local', CURRENT_DATE - 4 + time '12:00', CURRENT_DATE - 1 + time '12:00', NULL, NULL),
|
||||||
|
(301, 'Sophie', 'Mme Robert', '06 77 77 77 77', 'robert@client.test', 'Plateau de fromages', NULL, '3245678901317', 1, 1,
|
||||||
|
'prêt', 0.00, false, false, 'Pour samedi', 1, 'bench_employee', CURRENT_DATE - 2 + time '09:00', CURRENT_DATE + time '06:00', NULL, NULL),
|
||||||
|
(302, 'Léa', 'M. Simon', '06 88 88 88 88', NULL, 'Sécateur', 'SC-20', '3245678901324', 2, 4,
|
||||||
|
'Disponible', 0.00, false, NULL, NULL, 2, 'bench_directeur', CURRENT_DATE - 7 + time '09:00', CURRENT_DATE - 3 + time '09:00', NULL, NULL),
|
||||||
|
(303, 'Léa', 'Mme Michel', '06 99 99 99 99', NULL, 'Terreau 40 L', NULL, '3245678901331', 4, 4,
|
||||||
|
'Prêt', 5.50, true, false, NULL, 2, 'bench_directeur', CURRENT_DATE - 9 + time '09:00', CURRENT_DATE - 2 + time '09:00', NULL, NULL)`);
|
||||||
|
|
||||||
|
// 301 : créé l'an dernier (hors du compte qui numérote les nouveaux tickets),
|
||||||
|
// par un compte supprimé depuis (créateur absent de la jointure)
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
await q(`INSERT INTO sav_tickets (id, ticket_number, supplier_id, group_id, product_gencode, product_reference, product_designation, problem_type,
|
||||||
|
problem_description, resolution_description, status, priority, client_name, client_phone, created_by, created_at, updated_at, resolved_at, closed_at) VALUES
|
||||||
|
(300, $1, 2, 3, '3245678901300', 'TR-35', 'Tronçonneuse', 'autre', 'Chaîne bloquée', NULL, 'en_cours', 'critique', 'M. Colin', '06 66 66 66 66', 'admin_local', CURRENT_DATE - 3 + time '11:00', CURRENT_DATE - 1 + time '11:00', NULL, NULL),
|
||||||
|
(301, $2, 1, 1, '3000000000011', NULL, 'Yaourt nature', 'non_conforme', 'Emballage gonflé', 'Remboursé', 'ferme', 'normale', NULL, NULL, 'utilisateur_supprime', CURRENT_DATE - 400 + time '10:00', CURRENT_DATE - 390 + time '10:00', CURRENT_DATE - 395 + time '10:00', CURRENT_DATE - 390 + time '10:00'),
|
||||||
|
(302, $3, 4, 2, '3245678901324', 'SC-20', 'Sécateur', 'defectueux', 'Ressort cassé', NULL, 'attente_echange', 'faible', 'M. Simon', NULL, 'bench_directeur', CURRENT_DATE - 6 + time '14:00', CURRENT_DATE - 6 + time '14:00', NULL, NULL)`,
|
||||||
|
[`SAV-${year}-0004`, `SAV-${year - 1}-0042`, `SAV-${year}-0005`]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Lot 4, commandes et livraisons (cases/fournisseurs-commandes.ts) : cas que les
|
||||||
|
// données de base n'exercent pas.
|
||||||
|
type Query = (text: string, values?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
export async function seed(q: Query) {
|
||||||
|
// Commande de Metz dont le créateur n'existe plus (creator: null), en attente
|
||||||
|
// alors que sa livraison est livrée (synchronisation des statuts)
|
||||||
|
await q(`INSERT INTO orders (id, supplier_id, group_id, planned_date, quantity, unit, status, notes, created_by, created_at, updated_at) VALUES
|
||||||
|
(600, 2, 2, CURRENT_DATE + 5, 6, 'colis', 'pending', 'Créateur supprimé', 'utilisateur_supprime',
|
||||||
|
CURRENT_DATE - 2 + time '11:00', CURRENT_DATE - 2 + time '11:00')`);
|
||||||
|
|
||||||
|
await q(`INSERT INTO deliveries (id, order_id, supplier_id, group_id, scheduled_date, delivered_date, quantity, unit, status, notes, bl_number, bl_amount,
|
||||||
|
invoice_reference, invoice_amount, invoice_amount_ttc, due_date, reconciled, validated_at, control_validated, control_validated_by,
|
||||||
|
control_validated_at, created_by, created_at, updated_at) VALUES
|
||||||
|
-- Livraison de Nancy liée à la commande 600 de Metz : la commande n'est jamais jointe
|
||||||
|
(600, 600, 2, 1, CURRENT_DATE - 3, CURRENT_DATE - 3 + time '15:00', 6, 'colis', 'delivered', 'Commande d''un autre magasin', 'BL-6000', 120.00,
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'bench_manager', CURRENT_DATE - 4 + time '10:00', CURRENT_DATE - 3 + time '15:00'),
|
||||||
|
-- Bonduelle (rapprochement automatique), prévue, créateur supprimé ; même date
|
||||||
|
-- prévue que la livraison 7 (ordre à égalité de tri)
|
||||||
|
(601, NULL, 2, 1, CURRENT_DATE + 2, NULL, 3, 'colis', 'planned', NULL, NULL, NULL,
|
||||||
|
NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'utilisateur_supprime', CURRENT_DATE - 1 + time '12:00', CURRENT_DATE - 1 + time '12:00'),
|
||||||
|
-- Bonduelle à Metz, rapprochée d'office, sans facture (API externe)
|
||||||
|
(602, NULL, 2, 2, CURRENT_DATE - 7, CURRENT_DATE - 7 + time '08:15', 4, 'colis', 'delivered', NULL, 'BL-6002', 88.40,
|
||||||
|
NULL, NULL, NULL, NULL, true, CURRENT_DATE - 7 + time '08:20', false, NULL, NULL, 'bench_directeur', CURRENT_DATE - 8 + time '10:00', CURRENT_DATE - 7 + time '08:20')`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Lot 4, rapprochement (cases/fournisseurs-rapprochement.ts) : avoirs et échéancier
|
||||||
|
// (identifiants 700…).
|
||||||
|
// - avoirs hors de Nancy : un de Metz à vérifier dans NocoDB, un d'Épinal (aucun
|
||||||
|
// compte du banc n'y est affecté, sauf l'administrateur) créé par un compte
|
||||||
|
// supprimé depuis, et un avoir validé à la référence vide ;
|
||||||
|
// - livraisons que GET /api/payment-schedule complète avec NocoDB : échéance
|
||||||
|
// absente (trouvée, introuvable, ou sans échéance dans NocoDB), montant TTC nul
|
||||||
|
// (repli « TTC »), magasin d'Épinal sans configuration NocoDB ;
|
||||||
|
// - échéances aux bornes du mois courant (premier jour à minuit, dernière
|
||||||
|
// microseconde, premier jour du mois suivant) pour l'export CSV.
|
||||||
|
type Query = (text: string, values?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
export async function seed(q: Query) {
|
||||||
|
await q(`INSERT INTO avoirs (id, supplier_id, group_id, invoice_reference, amount, comment, commercial_processed, status, webhook_sent, nocodb_verified,
|
||||||
|
nocodb_verified_at, processed_at, created_by, created_at, updated_at) VALUES
|
||||||
|
(700, 2, 2, 'FAC-2006', 10.50, 'Remise oubliée', false, 'Demandé', false, false, NULL, NULL, 'bench_directeur',
|
||||||
|
CURRENT_DATE - 3 + time '11:00', CURRENT_DATE - 3 + time '11:00'),
|
||||||
|
(701, 1, 3, NULL, NULL, NULL, NULL, 'En attente de demande', NULL, NULL, NULL, NULL, 'compte_disparu',
|
||||||
|
CURRENT_DATE - 2 + time '11:00', CURRENT_DATE - 2 + time '11:00'),
|
||||||
|
(702, 4, 1, ' ', 0.00, '', true, 'Reçu', true, true, CURRENT_DATE - 1 + time '09:00', CURRENT_DATE - 1 + time '09:00', 'bench_employee',
|
||||||
|
CURRENT_DATE - 1 + time '08:00', CURRENT_DATE - 1 + time '09:00')`);
|
||||||
|
|
||||||
|
const month = `date_trunc('month', CURRENT_DATE::timestamp)`;
|
||||||
|
await q(`INSERT INTO deliveries (id, order_id, supplier_id, group_id, scheduled_date, delivered_date, quantity, unit, status, notes, bl_number, bl_amount,
|
||||||
|
invoice_reference, invoice_amount, invoice_amount_ttc, due_date, reconciled, validated_at, control_validated, created_by, created_at, updated_at) VALUES
|
||||||
|
-- Échéance et montant absents : retrouvés dans NocoDB (FAC-2002)
|
||||||
|
(700, NULL, 2, 1, CURRENT_DATE - 8, CURRENT_DATE - 8 + time '10:00', 3, 'colis', 'delivered', NULL, 'BL-7000', 300.00,
|
||||||
|
'FAC-2002', NULL, NULL, NULL, false, CURRENT_DATE - 8 + time '10:10', false, 'bench_manager', CURRENT_DATE - 9 + time '10:00', CURRENT_DATE - 8 + time '10:10'),
|
||||||
|
-- Référence introuvable dans NocoDB
|
||||||
|
(701, NULL, 1, 1, CURRENT_DATE - 7, CURRENT_DATE - 7 + time '10:00', 1, 'colis', 'delivered', NULL, 'BL-7001', 50.00,
|
||||||
|
'FAC-INCONNUE', NULL, NULL, NULL, false, CURRENT_DATE - 7 + time '10:10', false, 'bench_manager', CURRENT_DATE - 8 + time '09:00', CURRENT_DATE - 7 + time '10:10'),
|
||||||
|
-- Trouvée sans échéance (avoir AV-3001) : seul le montant est repris
|
||||||
|
(702, NULL, 3, 1, CURRENT_DATE - 6, CURRENT_DATE - 6 + time '10:00', 1, 'colis', 'delivered', NULL, NULL, NULL,
|
||||||
|
'AV-3001', NULL, NULL, NULL, false, CURRENT_DATE - 6 + time '10:10', false, 'bench_manager', CURRENT_DATE - 7 + time '09:00', CURRENT_DATE - 6 + time '10:10'),
|
||||||
|
-- Montant TTC nul : repli « TTC », qui réécrit le HT et l'échéance de NocoDB (FAC-2006)
|
||||||
|
(703, NULL, 4, 1, CURRENT_DATE - 5, CURRENT_DATE - 5 + time '10:00', 2, 'palettes', 'delivered', NULL, 'BL-7003', 450.25,
|
||||||
|
'FAC-2006', 450.25, 0.00, CURRENT_DATE + 3 + time '00:00', false, CURRENT_DATE - 5 + time '10:10', false, 'bench_manager', CURRENT_DATE - 6 + time '09:00', CURRENT_DATE - 5 + time '10:10'),
|
||||||
|
-- Référence vide : hors échéancier
|
||||||
|
(704, NULL, 2, 1, CURRENT_DATE - 4, CURRENT_DATE - 4 + time '10:00', 1, 'colis', 'delivered', NULL, NULL, NULL,
|
||||||
|
'', 12.00, 14.40, CURRENT_DATE + 4, false, CURRENT_DATE - 4 + time '10:10', false, 'bench_manager', CURRENT_DATE - 5 + time '09:00', CURRENT_DATE - 4 + time '10:10'),
|
||||||
|
-- Metz : échéance absente, retrouvée dans NocoDB (FAC-2001)
|
||||||
|
(705, NULL, 1, 2, CURRENT_DATE - 4, CURRENT_DATE - 4 + time '11:00', 4, 'colis', 'delivered', NULL, 'BL-7005', 1200.00,
|
||||||
|
'FAC-2001', NULL, NULL, NULL, false, CURRENT_DATE - 4 + time '11:10', false, 'bench_directeur', CURRENT_DATE - 5 + time '11:00', CURRENT_DATE - 4 + time '11:10'),
|
||||||
|
-- Épinal : magasin sans configuration NocoDB
|
||||||
|
(706, NULL, 3, 3, CURRENT_DATE - 3, CURRENT_DATE - 3 + time '11:00', 1, 'palettes', 'delivered', NULL, 'BL-7006', 99.00,
|
||||||
|
'FAC-2004', NULL, NULL, NULL, false, CURRENT_DATE - 3 + time '11:10', false, 'admin_local', CURRENT_DATE - 4 + time '11:00', CURRENT_DATE - 3 + time '11:10'),
|
||||||
|
(707, NULL, 4, 3, CURRENT_DATE - 3, CURRENT_DATE - 3 + time '12:00', 1, 'palettes', 'delivered', NULL, 'BL-7007', 80.00,
|
||||||
|
'FAC-7007', 80.00, 96.00, ${month} + interval '12 days', false, CURRENT_DATE - 3 + time '12:10', false, 'admin_local', CURRENT_DATE - 4 + time '12:00', CURRENT_DATE - 3 + time '12:10'),
|
||||||
|
-- Bornes du mois courant (export CSV)
|
||||||
|
(708, NULL, 1, 1, CURRENT_DATE - 2, CURRENT_DATE - 2 + time '09:00', 1, 'colis', 'delivered', NULL, 'BL-7008', 100.00,
|
||||||
|
'FAC-7008', 100.00, 120.00, ${month}, false, CURRENT_DATE - 2 + time '09:10', false, 'bench_manager', CURRENT_DATE - 3 + time '08:00', CURRENT_DATE - 2 + time '09:10'),
|
||||||
|
(709, NULL, 2, 1, CURRENT_DATE - 2, CURRENT_DATE - 2 + time '10:00', 1, 'colis', 'delivered', NULL, 'BL-7009', 20.10,
|
||||||
|
'FAC-7009', 20.10, 24.12, ${month} + interval '1 month' - interval '1 microsecond', false, CURRENT_DATE - 2 + time '10:10', false, 'bench_manager', CURRENT_DATE - 3 + time '08:30', CURRENT_DATE - 2 + time '10:10'),
|
||||||
|
(710, NULL, 4, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '09:00', 1, 'colis', 'delivered', NULL, 'BL-7010', 55.55,
|
||||||
|
'FAC-7010', 55.55, 66.66, ${month} + interval '1 month', false, CURRENT_DATE - 1 + time '09:10', false, 'bench_manager', CURRENT_DATE - 2 + time '08:00', CURRENT_DATE - 1 + time '09:10'),
|
||||||
|
(711, NULL, 3, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '10:00', 1, 'colis', 'delivered', NULL, 'BL-7011', 0.10,
|
||||||
|
'FAC-7011', 0.10, 0.20, ${month} + interval '14 days 10:30', false, CURRENT_DATE - 1 + time '10:10', false, 'bench_manager', CURRENT_DATE - 2 + time '08:30', CURRENT_DATE - 1 + time '10:10'),
|
||||||
|
-- Échéances de NocoDB au format JJ/MM/AAAA (illisible par new Date) et MM/JJ/AAAA
|
||||||
|
(712, NULL, 2, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '11:00', 1, 'colis', 'delivered', NULL, 'BL-1007', 99.90,
|
||||||
|
'FAC-2007', NULL, NULL, NULL, false, CURRENT_DATE - 1 + time '11:10', false, 'bench_manager', CURRENT_DATE - 2 + time '09:00', CURRENT_DATE - 1 + time '11:10'),
|
||||||
|
(713, NULL, 3, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '12:00', 1, 'colis', 'delivered', NULL, 'BL-1008', 12.34,
|
||||||
|
'FAC-2008', NULL, NULL, NULL, true, CURRENT_DATE - 1 + time '12:10', false, 'bench_manager', CURRENT_DATE - 2 + time '09:30', CURRENT_DATE - 1 + time '12:10')`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Lot 4, services partagés (cases/fournisseurs-services.ts) : entrées du cache des
|
||||||
|
// factures pour exercer le marquage « rapproché » et le diagnostic du cache.
|
||||||
|
type Query = (text: string, values?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
export async function seed(q: Query) {
|
||||||
|
await q(`INSERT INTO invoice_verification_cache (id, cache_key, group_id, invoice_reference, supplier_name, invoice_amount, invoice_amount_ttc,
|
||||||
|
due_date, exists, match_type, error_message, cache_hit, api_call_time, is_reconciled, expires_at, created_at, updated_at) VALUES
|
||||||
|
-- Livraison 6 validée (Metz), entrée encore valable : marquée par la mise à jour en lot
|
||||||
|
(400, '2_fac-2006', 2, 'FAC-2006', 'Jardin & Co', 450.25, 540.30, CURRENT_DATE - 5, true, 'invoice_reference', NULL, false, 80, false,
|
||||||
|
CURRENT_DATE + 2, CURRENT_DATE - 3 + time '10:00', CURRENT_DATE - 3 + time '10:00'),
|
||||||
|
-- Numéro de BL de la livraison 8, validée : recherche infructueuse en cache
|
||||||
|
(401, '1_bl-0900', 1, 'BL-0900', NULL, NULL, NULL, NULL, false, 'none', 'Aucune correspondance trouvée dans NocoDB', false, NULL, false,
|
||||||
|
CURRENT_DATE + 1, CURRENT_DATE - 1 + time '09:00', CURRENT_DATE - 1 + time '09:00'),
|
||||||
|
-- Facture de la livraison 8, expirée : ni marquée, et supprimée à sa lecture
|
||||||
|
(402, '1_fac-1900', 1, 'FAC-1900', 'Lactalis', 800.00, 960.00, CURRENT_DATE - 350, true, 'invoice_reference', NULL, false, 70, false,
|
||||||
|
CURRENT_DATE - 1, CURRENT_DATE - 2 + time '09:00', CURRENT_DATE - 2 + time '09:00'),
|
||||||
|
-- Clé au format du diagnostic (référence_magasin), expirée : supprimée par GET /api/cache/diagnosis
|
||||||
|
(403, 'fac-2001_1', 1, 'FAC-2001', 'Lactalis', 1250.50, 1500.60, NULL, true, 'invoice_reference', NULL, false, NULL, false,
|
||||||
|
CURRENT_DATE - 2, CURRENT_DATE - 30 + time '09:00', CURRENT_DATE - 30 + time '09:00'),
|
||||||
|
-- Même format, valable : trouvée par le diagnostic
|
||||||
|
(404, 'fac-2006_2', 2, 'FAC-2006', 'Jardin & Co', 450.25, 540.30, NULL, true, 'invoice_reference', NULL, false, NULL, true,
|
||||||
|
CURRENT_DATE + 3, CURRENT_DATE - 30 + time '09:00', CURRENT_DATE - 30 + time '09:00')`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Données du lot 5 (administration) : des sauvegardes en plus de celle de
|
||||||
|
// seed.ts, pour que la liste soit tronquée à dix et que la création d'une
|
||||||
|
// sauvegarde fasse le ménage des plus anciennes (backupService.cleanOldBackups).
|
||||||
|
// banc-sauvegarde-500 a un fichier (sauvegardes/backup_banc_500.sql), copié
|
||||||
|
// par compare.ts dans le dossier de sauvegardes de chaque serveur, comme celui
|
||||||
|
// de banc-sauvegarde-1.
|
||||||
|
type Query = (text: string, values?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
export async function seed(q: Query) {
|
||||||
|
await q(`INSERT INTO database_backups (id, filename, description, size, created_at, created_by, tables_count, status, backup_type) VALUES
|
||||||
|
('banc-sauvegarde-500', 'backup_banc_500.sql', 'Manuel backup du banc', 64, CURRENT_DATE - 2 + time '14:00', 'admin_local', 26, 'completed', 'manual')`);
|
||||||
|
// Neuf anciennes sauvegardes automatiques, sans fichier, un jour d'écart chacune
|
||||||
|
for (let n = 1; n <= 9; n++) {
|
||||||
|
await q(`INSERT INTO database_backups (id, filename, description, size, created_at, created_by, tables_count, status, backup_type)
|
||||||
|
VALUES ($1, $2, 'Automatique backup du banc', $3, CURRENT_DATE - $4::int + time '02:00', 'system', 26, 'completed', 'automatic')`,
|
||||||
|
[`banc-sauvegarde-${500 + n}`, `backup_banc_${500 + n}.sql`, 1024 * n, 2 + n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Sauvegarde du banc (banc-sauvegarde-1)
|
||||||
|
SELECT 1;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Sauvegarde manuelle du banc (banc-sauvegarde-500)
|
||||||
|
SELECT 500;
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Prépare la base modèle du banc (bench_seed) : schéma de production, index créés
|
||||||
|
# par le premier démarrage du Node de référence, puis données du banc (seed.ts).
|
||||||
|
# Prérequis : PostgreSQL local, serveur Node de référence construit (reference-node.sh).
|
||||||
|
# Usage : scripts/bench/setup.sh [postgres://utilisateur:motdepasse@hôte:port]
|
||||||
|
set -euo pipefail
|
||||||
|
root="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
pg="${1:-${BENCH_PG_URL:-postgres://logiflow_admin:bench@localhost:5432}}"
|
||||||
|
# Mêmes noms que config.ts : BENCH_ID sépare les bases, BENCH_PORT_BASE les ports
|
||||||
|
suffix="${BENCH_ID:+_$BENCH_ID}"
|
||||||
|
template="${BENCH_TEMPLATE_DB:-bench_seed$suffix}"
|
||||||
|
schema_db="logiflow_schema$suffix"
|
||||||
|
port=$(( ${BENCH_PORT_BASE:-3200} + 92 ))
|
||||||
|
node_dir="${BENCH_NODE_DIR:-$root/.bench-reference}"
|
||||||
|
|
||||||
|
psql -q "$pg/postgres" -c "DROP DATABASE IF EXISTS $schema_db WITH (FORCE)" -c "CREATE DATABASE $schema_db"
|
||||||
|
"$root/scripts/bench/schema.sh" "$pg/$schema_db" 2>&1 | grep -v NOTICE || true
|
||||||
|
|
||||||
|
# Premier démarrage du Node de référence : migrations de son server/migrations.production.ts
|
||||||
|
# exec : $! est bien le processus Node, pas un sous-shell
|
||||||
|
(cd "$node_dir" && exec env NODE_ENV=production PORT=$port DATABASE_URL="$pg/$schema_db" SESSION_SECRET=setup \
|
||||||
|
node dist/index.js > /dev/null 2>&1) &
|
||||||
|
node_pid=$!
|
||||||
|
trap 'kill $node_pid 2>/dev/null || true' EXIT
|
||||||
|
for _ in $(seq 1 120); do
|
||||||
|
curl -sf "http://127.0.0.1:$port/api/health" > /dev/null && break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
# Les index sont créés en arrière-plan : attendre la fin des CREATE INDEX
|
||||||
|
sleep 2
|
||||||
|
while [ "$(psql -At "$pg/$schema_db" -c "SELECT count(*) FROM pg_stat_activity WHERE query ILIKE 'CREATE INDEX%' OR query ILIKE 'ANALYZE%'")" != "0" ]; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
kill $node_pid 2>/dev/null || true; wait $node_pid 2>/dev/null || true
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
# Le modèle ne garde que le schéma : Node a pu créer l'administrateur par défaut
|
||||||
|
tables=$(psql -At "$pg/$schema_db" -c "SELECT string_agg(format('%I', tablename), ', ') FROM pg_tables WHERE schemaname = 'public'")
|
||||||
|
psql -q "$pg/$schema_db" -c "TRUNCATE $tables RESTART IDENTITY CASCADE"
|
||||||
|
|
||||||
|
psql -q "$pg/postgres" -c "DROP DATABASE IF EXISTS $template WITH (FORCE)" -c "CREATE DATABASE $template TEMPLATE $schema_db"
|
||||||
|
(cd "$root" && DATABASE_URL="$pg/$template" SESSION_SECRET=banc-secret-de-session ENCRYPTION_KEY=banc-cle-de-chiffrement \
|
||||||
|
npx tsx scripts/bench/seed.ts)
|
||||||
|
echo "Base modèle $template prête ($(psql -At "$pg/$template" -c "SELECT count(*) FROM pg_indexes WHERE schemaname = 'public'") index)."
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// Services externes simulés pour le banc : NocoDB, webhooks (n8n) et SMTP.
|
||||||
|
// Chaque appel reçu est enregistré ; le banc le lit (GET /__calls) pour vérifier
|
||||||
|
// que Node et Rust appellent les mêmes services avec les mêmes données.
|
||||||
|
import http from "http";
|
||||||
|
import net from "net";
|
||||||
|
import { STUB_PORT, STUB_SMTP_PORT } from "./config";
|
||||||
|
|
||||||
|
export interface StubCall {
|
||||||
|
service: "nocodb" | "webhook" | "smtp" | "autre";
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
contentType?: string;
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calls: StubCall[] = [];
|
||||||
|
|
||||||
|
function isoDate(offsetDays: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setUTCDate(date.getUTCDate() + offsetDays);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lignes de la table de factures NocoDB simulée (colonnes des magasins du banc)
|
||||||
|
function invoiceRows() {
|
||||||
|
return [
|
||||||
|
{ Id: 1, RefFacture: "FAC-2001", NumBL: "BL-1001", MontantHT: 1250.5, MontantTTC: 1500.6, Fournisseur: "Lactalis", Echeance: isoDate(10) },
|
||||||
|
{ Id: 2, RefFacture: "FAC-2004", NumBL: "BL-1004", MontantHT: 975, MontantTTC: 1170, Fournisseur: "Brico Pro", Echeance: isoDate(25) },
|
||||||
|
{ Id: 3, RefFacture: "FAC-2002", NumBL: "BL-1002", MontantHT: 310, MontantTTC: 372, Fournisseur: "Bonduelle", Echeance: isoDate(30) },
|
||||||
|
{ Id: 4, RefFacture: "FAC-2006", NumBL: "BL-1006", MontantHT: 450.25, MontantTTC: 540.3, Fournisseur: "Jardin & Co", Echeance: isoDate(-5) },
|
||||||
|
{ Id: 5, RefFacture: "AV-3001", NumBL: null, MontantHT: -45.9, MontantTTC: -55.08, Fournisseur: "Lactalis", Echeance: null },
|
||||||
|
// Échéances saisies à la française (JJ/MM/AAAA) ou à l'américaine (MM/JJ/AAAA)
|
||||||
|
{ Id: 6, RefFacture: "FAC-2007", NumBL: "BL-1007", MontantHT: 99.9, MontantTTC: 119.88, Fournisseur: "Bonduelle", Echeance: "25/12/2030" },
|
||||||
|
{ Id: 7, RefFacture: "FAC-2008", NumBL: "BL-1008", MontantHT: 12.34, MontantTTC: 14.81, Fournisseur: "Brico Pro", Echeance: "12/24/2030" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// where=(colonne,eq,valeur) : seul opérateur utilisé par LogiFlow
|
||||||
|
function filterRows(where: string | null) {
|
||||||
|
const rows = invoiceRows();
|
||||||
|
if (!where) return rows;
|
||||||
|
const match = where.match(/^\(([^,]+),eq,(.*)\)$/);
|
||||||
|
if (!match) return rows;
|
||||||
|
const [, column, value] = match;
|
||||||
|
return rows.filter((row) => String((row as Record<string, unknown>)[column] ?? "") === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req: http.IncomingMessage): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on("data", (chunk) => chunks.push(chunk));
|
||||||
|
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corps enregistré sans ce qui change d'un passage à l'autre (séparateur multipart)
|
||||||
|
function describeBody(contentType: string | undefined, raw: Buffer): unknown {
|
||||||
|
if (!raw.length) return null;
|
||||||
|
if (contentType?.includes("application/json")) {
|
||||||
|
try { return JSON.parse(raw.toString("utf8")); } catch { return raw.toString("utf8"); }
|
||||||
|
}
|
||||||
|
if (contentType?.includes("multipart/form-data")) {
|
||||||
|
const boundary = contentType.split("boundary=")[1]?.replace(/"/g, "");
|
||||||
|
if (!boundary) return { taille: raw.length };
|
||||||
|
const parts = raw.toString("latin1").split(`--${boundary}`).slice(1, -1);
|
||||||
|
return parts.map((part) => {
|
||||||
|
const [head, ...rest] = part.split("\r\n\r\n");
|
||||||
|
const name = head.match(/name="([^"]*)"/)?.[1];
|
||||||
|
const filename = head.match(/filename="([^"]*)"/)?.[1];
|
||||||
|
const content = rest.join("\r\n\r\n").replace(/\r\n$/, "");
|
||||||
|
return filename !== undefined ? { name, filename, taille: Buffer.from(content, "latin1").length } : { name, valeur: content };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return raw.toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Courriels reçus : décodés pour comparer le contenu, pas l'encodage ----------
|
||||||
|
// nodemailer et lettre n'encodent pas de la même façon (quoted-printable ou base64,
|
||||||
|
// mots encodés RFC 2047, guillemets des noms) : on compare le texte décodé.
|
||||||
|
|
||||||
|
// Octets d'un texte où =XX désigne un octet (quoted-printable, mots encodés « Q »)
|
||||||
|
function decodeEscapes(text: string): Buffer {
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === "=" && /^[0-9A-Fa-f]{2}$/.test(text.slice(i + 1, i + 3))) {
|
||||||
|
bytes.push(parseInt(text.slice(i + 1, i + 3), 16));
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
bytes.push(...Buffer.from(text[i], "utf8"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Buffer.from(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =?utf-8?Q?…?= et =?utf-8?B?…?= ; blancs entre deux mots encodés ignorés
|
||||||
|
function decodeWords(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/(=\?[^?]+\?[bBqQ]\?[^?]*\?=)\s+(?==\?[^?]+\?[bBqQ]\?[^?]*\?=)/g, "$1")
|
||||||
|
.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (_, _charset: string, encoding: string, text: string) =>
|
||||||
|
encoding.toUpperCase() === "B"
|
||||||
|
? Buffer.from(text, "base64").toString("utf8")
|
||||||
|
: decodeEscapes(text.replace(/_/g, " ")).toString("utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// En-têtes dépliés, noms en minuscules (premier exemplaire de chaque)
|
||||||
|
function parseHeaders(head: string): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const lines = head.split("\n");
|
||||||
|
const unfolded: string[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
if (/^[ \t]/.test(line) && unfolded.length) unfolded[unfolded.length - 1] += line;
|
||||||
|
else unfolded.push(line);
|
||||||
|
}
|
||||||
|
for (const line of unfolded) {
|
||||||
|
const index = line.indexOf(":");
|
||||||
|
if (index <= 0) continue;
|
||||||
|
const name = line.slice(0, index).trim().toLowerCase();
|
||||||
|
if (!(name in headers)) headers[name] = line.slice(index + 1).trim();
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// « Nom <adresse> » sans guillemets ni encodage, séparés par « , »
|
||||||
|
function normalizeAddresses(value: string | undefined): string | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
return decodeWords(value)
|
||||||
|
.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
|
||||||
|
.map((part) => {
|
||||||
|
const match = part.trim().match(/^(.*)<([^>]*)>$/);
|
||||||
|
if (!match) return part.trim();
|
||||||
|
const name = match[1].trim().replace(/^"(.*)"$/, "$1");
|
||||||
|
return name ? `${name} <${match[2].trim()}>` : match[2].trim();
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type et paramètres d'un en-tête Content-Type ou Content-Disposition
|
||||||
|
function parseParams(value: string | undefined): { value: string; params: Record<string, string> } {
|
||||||
|
const [main, ...rest] = (value ?? "").split(";");
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (const part of rest) {
|
||||||
|
const index = part.indexOf("=");
|
||||||
|
if (index < 0) continue;
|
||||||
|
params[part.slice(0, index).trim().toLowerCase()] = part.slice(index + 1).trim().replace(/^"(.*)"$/, "$1");
|
||||||
|
}
|
||||||
|
return { value: main.trim().toLowerCase(), params };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeTransfer(body: string, encoding: string | undefined): Buffer {
|
||||||
|
switch ((encoding ?? "7bit").toLowerCase()) {
|
||||||
|
case "base64":
|
||||||
|
return Buffer.from(body.replace(/\s/g, ""), "base64");
|
||||||
|
case "quoted-printable":
|
||||||
|
return decodeEscapes(body.replace(/=\n/g, ""));
|
||||||
|
default:
|
||||||
|
return Buffer.from(body, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parties terminales du message, dans l'ordre, avec le chemin de leurs conteneurs
|
||||||
|
function describeParts(head: Record<string, string>, body: string, path: string[]): unknown[] {
|
||||||
|
const type = parseParams(head["content-type"] ?? "text/plain");
|
||||||
|
if (type.value.startsWith("multipart/")) {
|
||||||
|
const boundary = type.params.boundary;
|
||||||
|
const sections = body.split(`--${boundary}`);
|
||||||
|
const parts: unknown[] = [];
|
||||||
|
// sections[0] : préambule ; la dernière commence par « -- » (fin)
|
||||||
|
for (const section of sections.slice(1)) {
|
||||||
|
if (section.startsWith("--")) break;
|
||||||
|
const content = section.replace(/^[^\n]*\n/, "").replace(/\n$/, "");
|
||||||
|
const index = content.indexOf("\n\n");
|
||||||
|
const partHead = parseHeaders(index < 0 ? content : content.slice(0, index));
|
||||||
|
const partBody = index < 0 ? "" : content.slice(index + 2);
|
||||||
|
parts.push(...describeParts(partHead, partBody, [...path, type.value]));
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
const decoded = decodeTransfer(body, head["content-transfer-encoding"]);
|
||||||
|
const disposition = parseParams(head["content-disposition"]);
|
||||||
|
const part: Record<string, unknown> = { chemin: path.join(" > "), type: type.value };
|
||||||
|
if (type.params.charset) part.charset = type.params.charset.toLowerCase();
|
||||||
|
if (head["content-id"]) part.cid = head["content-id"];
|
||||||
|
if (disposition.value) part.disposition = disposition.value;
|
||||||
|
const filename = disposition.params.filename ?? type.params.name;
|
||||||
|
if (filename) part.fichier = decodeWords(filename);
|
||||||
|
if (type.value.startsWith("text/")) part.contenu = decoded.toString("utf8").replace(/\r\n/g, "\n");
|
||||||
|
else part.taille = decoded.length;
|
||||||
|
return [part];
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeMail(message: string): Record<string, unknown> {
|
||||||
|
const index = message.indexOf("\n\n");
|
||||||
|
const headers = parseHeaders(index < 0 ? message : message.slice(0, index));
|
||||||
|
const body = index < 0 ? "" : message.slice(index + 2).replace(/\n$/, "");
|
||||||
|
return {
|
||||||
|
sujet: headers.subject === undefined ? undefined : decodeWords(headers.subject),
|
||||||
|
de: normalizeAddresses(headers.from),
|
||||||
|
a: normalizeAddresses(headers.to),
|
||||||
|
repondreA: normalizeAddresses(headers["reply-to"]),
|
||||||
|
parties: describeParts(headers, body, []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startStub(): Promise<() => Promise<void>> {
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url || "/", `http://127.0.0.1:${STUB_PORT}`);
|
||||||
|
const raw = await readBody(req);
|
||||||
|
|
||||||
|
if (url.pathname === "/__calls") {
|
||||||
|
if (req.method === "DELETE") calls.length = 0;
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
res.end(JSON.stringify(calls));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = req.headers["content-type"];
|
||||||
|
const body = describeBody(contentType, raw);
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/nocodb/")) {
|
||||||
|
calls.push({ service: "nocodb", method: req.method, path: `${url.pathname}${url.search}` });
|
||||||
|
const list = filterRows(url.searchParams.get("where"));
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ list, pageInfo: { totalRows: list.length, page: 1, pageSize: 25, isFirstPage: true, isLastPage: true } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/webhook/")) {
|
||||||
|
calls.push({ service: "webhook", method: req.method, path: url.pathname, contentType: contentType?.split(";")[0], body });
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true, message: "Reçu par le service simulé" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
calls.push({ service: "autre", method: req.method, path: url.pathname });
|
||||||
|
res.writeHead(404, { "content-type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ message: "inconnu du service simulé" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// SMTP minimal : accepte tout, sans STARTTLS ni authentification obligatoire
|
||||||
|
const smtp = net.createServer((socket) => {
|
||||||
|
let inData = false;
|
||||||
|
let message = "";
|
||||||
|
const recipients: string[] = [];
|
||||||
|
socket.write("220 banc.test ESMTP simulé\r\n");
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString("utf8");
|
||||||
|
let index: number;
|
||||||
|
while ((index = buffer.indexOf("\r\n")) >= 0) {
|
||||||
|
const line = buffer.slice(0, index);
|
||||||
|
buffer = buffer.slice(index + 2);
|
||||||
|
if (inData) {
|
||||||
|
if (line === ".") {
|
||||||
|
inData = false;
|
||||||
|
// Sujet, adresses et parties décodés (voir describeMail)
|
||||||
|
calls.push({ service: "smtp", body: { destinataires: recipients.slice(), ...describeMail(message) } });
|
||||||
|
socket.write("250 2.0.0 message accepté\r\n");
|
||||||
|
} else {
|
||||||
|
// Point doublé en début de ligne (RFC 5321, 4.5.2)
|
||||||
|
message += `${line.startsWith("..") ? line.slice(1) : line}\n`;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const command = line.slice(0, 4).toUpperCase();
|
||||||
|
if (command === "EHLO") socket.write("250-banc.test\r\n250-AUTH PLAIN LOGIN\r\n250 8BITMIME\r\n");
|
||||||
|
else if (command === "HELO") socket.write("250 banc.test\r\n");
|
||||||
|
else if (command === "AUTH") socket.write("235 2.7.0 authentifié\r\n");
|
||||||
|
else if (command === "MAIL") socket.write("250 2.1.0 ok\r\n");
|
||||||
|
else if (command === "RCPT") { recipients.push(line.replace(/^RCPT TO:\s*/i, "")); socket.write("250 2.1.5 ok\r\n"); }
|
||||||
|
else if (command === "DATA") { inData = true; message = ""; socket.write("354 fin par .\r\n"); }
|
||||||
|
else if (command === "QUIT") { socket.write("221 2.0.0 au revoir\r\n"); socket.end(); }
|
||||||
|
else if (command === "RSET" || command === "NOOP") socket.write("250 2.0.0 ok\r\n");
|
||||||
|
else socket.write("502 5.5.2 commande inconnue\r\n");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", () => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.listen(STUB_PORT, "127.0.0.1", () => {
|
||||||
|
smtp.listen(STUB_SMTP_PORT, "127.0.0.1", () => {
|
||||||
|
resolve(() => new Promise<void>((done) => server.close(() => smtp.close(() => done()))));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Description des requêtes rejouées par le banc.
|
||||||
|
|
||||||
|
// "anonyme" : sans session ; "api" : clé de l'API externe (en-tête X-API-Key)
|
||||||
|
export type AccountKey = "admin" | "directeur" | "manager" | "employe" | "sans-magasin" | "anonyme" | "api";
|
||||||
|
|
||||||
|
export const ALL_ACCOUNTS: AccountKey[] = ["admin", "directeur", "manager", "employe", "sans-magasin", "anonyme"];
|
||||||
|
export const LOGGED_ACCOUNTS: AccountKey[] = ["admin", "directeur", "manager", "employe", "sans-magasin"];
|
||||||
|
|
||||||
|
// Écart voulu entre Node et Rust (faille fermée…), décidé et inscrit au banc :
|
||||||
|
// ces champs sont retirés des deux côtés avant la comparaison, et le banc
|
||||||
|
// rappelle la raison à la fin de son rapport
|
||||||
|
export interface Deviation {
|
||||||
|
fields: string[];
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lecture : rejouée sur la même base pour chaque compte
|
||||||
|
export interface ReadCase {
|
||||||
|
module: string;
|
||||||
|
path: string;
|
||||||
|
accounts?: AccountKey[]; // par défaut ALL_ACCOUNTS
|
||||||
|
// Champs ignorés dans la comparaison (valeurs propres à chaque passage)
|
||||||
|
ignore?: string[];
|
||||||
|
deviation?: Deviation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les chemins et corps acceptent des gabarits :
|
||||||
|
// {{today}} {{today+3}} {{today-30}} → AAAA-MM-JJ ; {{year}} {{year-1}} {{month}}
|
||||||
|
// {{0.id}} → champ id de la réponse JSON de l'étape 0 du même scénario
|
||||||
|
export interface Step {
|
||||||
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
path: string;
|
||||||
|
body?: unknown;
|
||||||
|
// json (par défaut), form (urlencoded) ou multipart (champs + fichiers)
|
||||||
|
bodyType?: "json" | "form" | "multipart";
|
||||||
|
files?: { field: string; filename: string; contentType: string; size: number }[];
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Écriture : base remise à zéro avant chaque compte, puis état des tables comparé
|
||||||
|
export interface WriteScenario {
|
||||||
|
module: string;
|
||||||
|
name: string;
|
||||||
|
accounts?: AccountKey[]; // par défaut LOGGED_ACCOUNTS
|
||||||
|
steps: Step[];
|
||||||
|
tables: string[];
|
||||||
|
ignore?: string[];
|
||||||
|
deviation?: Deviation;
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
// Script utilitaire pour créer une nouvelle migration
|
|
||||||
// Usage: node scripts/create-migration.js "nom de la migration"
|
|
||||||
|
|
||||||
import { createMigration } from '../server/migrations.js';
|
|
||||||
|
|
||||||
const migrationName = process.argv[2];
|
|
||||||
|
|
||||||
if (!migrationName) {
|
|
||||||
console.error('❌ Usage: node scripts/create-migration.js "nom de la migration"');
|
|
||||||
console.error(' Exemple: node scripts/create-migration.js "add user roles table"');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filePath = createMigration(migrationName);
|
|
||||||
console.log(`✅ Migration créée avec succès: ${filePath}`);
|
|
||||||
console.log(' Éditez ce fichier pour ajouter votre SQL');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Erreur lors de la création de la migration:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Script d'entrée Docker - Applique automatiquement les migrations au démarrage
|
|
||||||
# Auteur: Système de gestion automatisé
|
|
||||||
# Date: 2025-08-18
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🚀 [ENTRYPOINT] Démarrage du conteneur de production"
|
|
||||||
echo "=================================================="
|
|
||||||
|
|
||||||
# Fonction de logging avec timestamp
|
|
||||||
log() {
|
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
log "🔧 Initialisation des variables d'environnement..."
|
|
||||||
|
|
||||||
# Construire DATABASE_URL si nécessaire
|
|
||||||
if [ -z "$DATABASE_URL" ]; then
|
|
||||||
if [ -n "$POSTGRES_USER" ] && [ -n "$POSTGRES_PASSWORD" ] && [ -n "$POSTGRES_DB" ]; then
|
|
||||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@logiflow-db:5432/${POSTGRES_DB}"
|
|
||||||
log "✅ URL de base de données construite automatiquement"
|
|
||||||
else
|
|
||||||
log "⚠️ Variables PostgreSQL manquantes, utilisation de DATABASE_URL existant"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Attendre que PostgreSQL soit prêt
|
|
||||||
log "⏳ Attente de PostgreSQL..."
|
|
||||||
max_attempts=30
|
|
||||||
attempt=1
|
|
||||||
|
|
||||||
while [ $attempt -le $max_attempts ]; do
|
|
||||||
if psql "$DATABASE_URL" -c "SELECT 1;" > /dev/null 2>&1; then
|
|
||||||
log "✅ PostgreSQL est prêt"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ $attempt -eq $max_attempts ]; then
|
|
||||||
log "❌ PostgreSQL non accessible après $max_attempts tentatives"
|
|
||||||
log "🔍 Variables DB: USER=${POSTGRES_USER:-'non défini'} DB=${POSTGRES_DB:-'non défini'}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "⏳ Tentative $attempt/$max_attempts - attente 2 secondes..."
|
|
||||||
sleep 2
|
|
||||||
((attempt++))
|
|
||||||
done
|
|
||||||
|
|
||||||
# Exécuter les migrations automatiques
|
|
||||||
log "🔄 Exécution des migrations automatiques..."
|
|
||||||
if [ -f "/app/scripts/auto-migrate-production.sh" ]; then
|
|
||||||
log "📂 Script de migration trouvé, exécution..."
|
|
||||||
chmod +x /app/scripts/auto-migrate-production.sh
|
|
||||||
|
|
||||||
# Forcer l'affichage des logs du script de migration
|
|
||||||
log "🔄 Démarrage des migrations avec logs détaillés..."
|
|
||||||
if /app/scripts/auto-migrate-production.sh 2>&1; then
|
|
||||||
log "✅ Migrations exécutées avec succès"
|
|
||||||
else
|
|
||||||
log "❌ Erreur lors des migrations, mais continuation..."
|
|
||||||
# Ne pas exit pour éviter de bloquer le démarrage
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "⚠️ Script de migration non trouvé à /app/scripts/auto-migrate-production.sh"
|
|
||||||
log "🔍 Contenu du répertoire scripts:"
|
|
||||||
ls -la /app/scripts/ || echo "Répertoire scripts non trouvé"
|
|
||||||
|
|
||||||
# Créer la table webhook_bap_config directement si le script manque
|
|
||||||
log "🛠️ Tentative de création directe de la table webhook_bap_config..."
|
|
||||||
if [ -n "$DATABASE_URL" ]; then
|
|
||||||
psql "$DATABASE_URL" << 'EOF'
|
|
||||||
CREATE TABLE IF NOT EXISTS webhook_bap_config (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP',
|
|
||||||
webhook_url TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO webhook_bap_config (name, webhook_url, description, is_active)
|
|
||||||
SELECT
|
|
||||||
'Configuration BAP',
|
|
||||||
'https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d',
|
|
||||||
'Configuration par défaut pour envoi des fichiers BAP vers n8n',
|
|
||||||
true
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM webhook_bap_config);
|
|
||||||
|
|
||||||
COMMENT ON TABLE webhook_bap_config IS 'Configuration pour webhook BAP n8n';
|
|
||||||
EOF
|
|
||||||
log "✅ Table webhook_bap_config créée directement"
|
|
||||||
else
|
|
||||||
log "❌ DATABASE_URL manquante pour création directe"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Vérification post-migration
|
|
||||||
log "🔍 Vérification des colonnes DLC..."
|
|
||||||
if psql "$DATABASE_URL" -tAc "SELECT EXISTS (SELECT FROM information_schema.columns WHERE table_name='dlc_products' AND column_name='stock_epuise');" | grep -q "t"; then
|
|
||||||
log "✅ Colonnes DLC stock épuisé présentes"
|
|
||||||
else
|
|
||||||
log "❌ Colonnes DLC stock épuisé manquantes"
|
|
||||||
log "🛠️ Tentative de migration directe..."
|
|
||||||
|
|
||||||
psql "$DATABASE_URL" << 'EOF'
|
|
||||||
ALTER TABLE dlc_products
|
|
||||||
ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL,
|
|
||||||
ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255),
|
|
||||||
ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise);
|
|
||||||
EOF
|
|
||||||
|
|
||||||
log "✅ Migration directe DLC terminée"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Vérification de la table webhook_bap_config
|
|
||||||
log "🔍 Vérification de la table webhook_bap_config..."
|
|
||||||
if psql "$DATABASE_URL" -tAc "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema='public' AND table_name='webhook_bap_config');" | grep -q "t"; then
|
|
||||||
log "✅ Table webhook_bap_config présente"
|
|
||||||
else
|
|
||||||
log "❌ Table webhook_bap_config manquante"
|
|
||||||
log "🛠️ Création directe de la table webhook_bap_config..."
|
|
||||||
|
|
||||||
psql "$DATABASE_URL" << 'EOF'
|
|
||||||
CREATE TABLE webhook_bap_config (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP',
|
|
||||||
webhook_url TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO webhook_bap_config (name, webhook_url, description, is_active)
|
|
||||||
VALUES (
|
|
||||||
'Configuration BAP',
|
|
||||||
'https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d',
|
|
||||||
'Configuration par défaut pour envoi des fichiers BAP vers n8n',
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE webhook_bap_config IS 'Configuration pour webhook BAP n8n';
|
|
||||||
EOF
|
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
log "✅ Migration directe réussie"
|
|
||||||
else
|
|
||||||
log "❌ Migration directe échouée"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Démarrage de l'application
|
|
||||||
log "🚀 Démarrage de l'application..."
|
|
||||||
log "=================================================="
|
|
||||||
|
|
||||||
# Exécuter la commande passée en paramètre (normalement "node dist/index.js")
|
|
||||||
exec "$@"
|
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// Génère server-rs/src/schema.rs à partir de shared/schema.ts :
|
||||||
|
// - une structure par table, avec les mêmes clés JSON, dans le même ordre et
|
||||||
|
// aux mêmes formats que ce que Drizzle renvoie à l'interface ;
|
||||||
|
// - le schéma de validation que drizzle-zod (createInsertSchema) en tire.
|
||||||
|
// Les colonnes nulles dans la base réelle le sont aussi en Rust (DATABASE_URL).
|
||||||
|
//
|
||||||
|
// Usage : DATABASE_URL=postgres://… npx tsx scripts/gen-rust-schema.ts
|
||||||
|
import { execFileSync } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import pg from "pg";
|
||||||
|
import { getTableColumns } from "drizzle-orm";
|
||||||
|
import { getTableConfig, PgTable } from "drizzle-orm/pg-core";
|
||||||
|
import * as schema from "../shared/schema";
|
||||||
|
|
||||||
|
const OUTPUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../server-rs/src/schema.rs");
|
||||||
|
|
||||||
|
const RUST_KEYWORDS = new Set(["type", "match", "ref", "move", "use", "loop", "where", "self", "struct", "enum"]);
|
||||||
|
|
||||||
|
function pascal(name: string): string {
|
||||||
|
return name.split("_").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
jsKey: string;
|
||||||
|
name: string;
|
||||||
|
columnType: string;
|
||||||
|
notNull: boolean;
|
||||||
|
hasDefault: boolean;
|
||||||
|
length?: number;
|
||||||
|
scale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type SQL vers lequel le texte d'un paramètre est converti (comme node-pg, qui
|
||||||
|
// envoie tous les paramètres en texte)
|
||||||
|
function sqlType(column: ColumnInfo): string {
|
||||||
|
switch (column.columnType) {
|
||||||
|
case "PgSerial":
|
||||||
|
case "PgInteger":
|
||||||
|
return "int4";
|
||||||
|
case "PgBigInt53":
|
||||||
|
return "int8";
|
||||||
|
case "PgVarchar":
|
||||||
|
case "PgText":
|
||||||
|
return "text";
|
||||||
|
case "PgBoolean":
|
||||||
|
return "bool";
|
||||||
|
case "PgTimestamp":
|
||||||
|
return "timestamp";
|
||||||
|
case "PgDateString":
|
||||||
|
return "date";
|
||||||
|
case "PgNumeric":
|
||||||
|
return "numeric";
|
||||||
|
case "PgJsonb":
|
||||||
|
return "jsonb";
|
||||||
|
default:
|
||||||
|
throw new Error(`Type de colonne non géré : ${column.columnType} (${column.name})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rustType(column: ColumnInfo): string {
|
||||||
|
switch (column.columnType) {
|
||||||
|
case "PgSerial":
|
||||||
|
case "PgInteger":
|
||||||
|
return "i32";
|
||||||
|
case "PgBigInt53":
|
||||||
|
return "i64";
|
||||||
|
case "PgVarchar":
|
||||||
|
case "PgText":
|
||||||
|
return "String";
|
||||||
|
case "PgBoolean":
|
||||||
|
return "bool";
|
||||||
|
case "PgTimestamp":
|
||||||
|
return "NaiveDateTime";
|
||||||
|
case "PgDateString":
|
||||||
|
return "NaiveDate";
|
||||||
|
case "PgNumeric":
|
||||||
|
return "Decimal";
|
||||||
|
case "PgJsonb":
|
||||||
|
return "serde_json::Value";
|
||||||
|
default:
|
||||||
|
throw new Error(`Type de colonne non géré : ${column.columnType} (${column.name})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Même correspondance que drizzle-zod 0.7 (columnToSchema)
|
||||||
|
function zodField(column: ColumnInfo): string {
|
||||||
|
let field: string;
|
||||||
|
switch (column.columnType) {
|
||||||
|
case "PgSerial":
|
||||||
|
case "PgInteger":
|
||||||
|
field = `Field::int32("${column.jsKey}")`;
|
||||||
|
break;
|
||||||
|
case "PgBigInt53":
|
||||||
|
field = `Field::safe_integer("${column.jsKey}")`;
|
||||||
|
break;
|
||||||
|
case "PgVarchar":
|
||||||
|
field = column.length ? `Field::string("${column.jsKey}").max_len(${column.length})` : `Field::string("${column.jsKey}")`;
|
||||||
|
break;
|
||||||
|
case "PgText":
|
||||||
|
case "PgDateString":
|
||||||
|
case "PgNumeric":
|
||||||
|
field = `Field::string("${column.jsKey}")`;
|
||||||
|
break;
|
||||||
|
case "PgBoolean":
|
||||||
|
field = `Field::boolean("${column.jsKey}")`;
|
||||||
|
break;
|
||||||
|
case "PgTimestamp":
|
||||||
|
field = `Field::date("${column.jsKey}")`;
|
||||||
|
break;
|
||||||
|
case "PgJsonb":
|
||||||
|
field = `Field::json("${column.jsKey}")`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Type de colonne non géré : ${column.columnType}`);
|
||||||
|
}
|
||||||
|
if (!column.notNull) field += ".nullable()";
|
||||||
|
if (!column.notNull || column.hasDefault) field += ".optional()";
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const nullableInDb = new Set<string>();
|
||||||
|
if (process.env.DATABASE_URL) {
|
||||||
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
await client.connect();
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' AND is_nullable = 'YES'`,
|
||||||
|
);
|
||||||
|
for (const row of rows) nullableInDb.add(`${row.table_name}.${row.column_name}`);
|
||||||
|
await client.end();
|
||||||
|
} else {
|
||||||
|
console.warn("DATABASE_URL absent : nullabilité tirée de shared/schema.ts seulement");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tables = new Map<string, ColumnInfo[]>();
|
||||||
|
for (const value of Object.values(schema)) {
|
||||||
|
if (!(value instanceof PgTable)) continue;
|
||||||
|
const config = getTableConfig(value);
|
||||||
|
if (tables.has(config.name)) continue;
|
||||||
|
const columns = Object.entries(getTableColumns(value)).map(([jsKey, column]: [string, any]) => ({
|
||||||
|
jsKey,
|
||||||
|
name: column.name,
|
||||||
|
columnType: column.columnType,
|
||||||
|
notNull: column.notNull,
|
||||||
|
hasDefault: column.hasDefault,
|
||||||
|
length: column.length,
|
||||||
|
scale: column.scale,
|
||||||
|
}));
|
||||||
|
tables.set(config.name, columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
out.push("// Généré par scripts/gen-rust-schema.ts à partir de shared/schema.ts : ne pas modifier à la main.");
|
||||||
|
out.push("// Une structure par table (clés, ordre et formats JSON de Drizzle) et son schéma");
|
||||||
|
out.push("// d'insertion drizzle-zod (createInsertSchema).");
|
||||||
|
out.push("#![allow(dead_code)]");
|
||||||
|
out.push("");
|
||||||
|
out.push("use chrono::{NaiveDate, NaiveDateTime};");
|
||||||
|
out.push("use rust_decimal::Decimal;");
|
||||||
|
out.push("use serde::Serialize;");
|
||||||
|
out.push("use sqlx::FromRow;");
|
||||||
|
out.push("");
|
||||||
|
out.push("use crate::db::{Col, Table};");
|
||||||
|
out.push("use crate::zod::{Field, Object};");
|
||||||
|
out.push("");
|
||||||
|
|
||||||
|
for (const [table, columns] of [...tables].sort(([a], [b]) => a.localeCompare(b))) {
|
||||||
|
const name = `${pascal(table)}Row`;
|
||||||
|
out.push(`/// Table ${table}`);
|
||||||
|
out.push("#[derive(Debug, Clone, Serialize, FromRow)]");
|
||||||
|
out.push(`pub struct ${name} {`);
|
||||||
|
for (const column of columns) {
|
||||||
|
const nullable = !column.notNull || nullableInDb.has(`${table}.${column.name}`);
|
||||||
|
const base = rustType(column);
|
||||||
|
const type = nullable ? `Option<${base}>` : base;
|
||||||
|
const attributes: string[] = [];
|
||||||
|
const field = RUST_KEYWORDS.has(column.name) ? `${column.name}_` : column.name;
|
||||||
|
if (field !== column.name) attributes.push(`#[sqlx(rename = "${column.name}")]`);
|
||||||
|
// timestamp dans shared/schema.ts, mais parfois timestamptz en production
|
||||||
|
// (tables créées hors des migrations du dépôt) : lu dans les deux cas
|
||||||
|
if (base === "NaiveDateTime") {
|
||||||
|
attributes.push(`#[sqlx(try_from = "${nullable ? "crate::db::AnyTimestampOpt" : "crate::db::AnyTimestamp"}")]`);
|
||||||
|
}
|
||||||
|
const serde = [`rename = "${column.jsKey}"`];
|
||||||
|
if (base === "NaiveDateTime") serde.push(`serialize_with = "${nullable ? "crate::json::ts_opt" : "crate::json::ts"}"`);
|
||||||
|
if (base === "Decimal") {
|
||||||
|
// Échelle de la colonne : PostgreSQL rend "0.00", sqlx lit 0
|
||||||
|
if (typeof column.scale !== "number") throw new Error(`Échelle inconnue : ${table}.${column.name}`);
|
||||||
|
serde.push(`serialize_with = "${nullable ? "crate::json::numeric_opt" : "crate::json::numeric"}::<${column.scale}, _>"`);
|
||||||
|
}
|
||||||
|
attributes.push(`#[serde(${serde.join(", ")})]`);
|
||||||
|
for (const attribute of attributes) out.push(` ${attribute}`);
|
||||||
|
out.push(` pub ${field}: ${type},`);
|
||||||
|
}
|
||||||
|
out.push("}");
|
||||||
|
out.push("");
|
||||||
|
out.push(`impl Table for ${name} {`);
|
||||||
|
out.push(` const TABLE: &'static str = "${table}";`);
|
||||||
|
out.push(` const COLUMNS: &'static str = "${columns.map((c) => `\\"${c.name}\\"`).join(", ")}";`);
|
||||||
|
out.push(" const FIELDS: &'static [Col] = &[");
|
||||||
|
for (const column of columns) {
|
||||||
|
out.push(` Col { key: "${column.jsKey}", column: "${column.name}", sql_type: "${sqlType(column)}" },`);
|
||||||
|
}
|
||||||
|
out.push(" ];");
|
||||||
|
out.push("}");
|
||||||
|
out.push("");
|
||||||
|
out.push(`impl ${name} {`);
|
||||||
|
out.push(` /// createInsertSchema(${table})`);
|
||||||
|
out.push(" pub fn insert_schema() -> Object {");
|
||||||
|
out.push(" Object::new(vec![");
|
||||||
|
for (const column of columns) out.push(` ${zodField(column)},`);
|
||||||
|
out.push(" ])");
|
||||||
|
out.push(" }");
|
||||||
|
out.push("}");
|
||||||
|
out.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(OUTPUT, out.join("\n"));
|
||||||
|
// Même mise en forme que cargo fmt (server-rs/rustfmt.toml)
|
||||||
|
execFileSync("rustfmt", ["--edition", "2024", "--config-path", path.dirname(path.dirname(OUTPUT)), OUTPUT]);
|
||||||
|
console.log(`${tables.size} tables écrites dans ${path.relative(process.cwd(), OUTPUT)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+3188
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,57 @@
|
|||||||
|
[package]
|
||||||
|
name = "logiflow"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.85"
|
||||||
|
description = "Serveur LogiFlow en Rust, placé devant le serveur Node (docs/PLAN-MIGRATION-RUST.md)"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
anyhow = "1"
|
||||||
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
bytes = "1"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock", "std", "serde"] }
|
||||||
|
form_urlencoded = "1"
|
||||||
|
futures-util = "0.3"
|
||||||
|
hmac = "0.12"
|
||||||
|
matchit = "0.8"
|
||||||
|
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "hostname"] }
|
||||||
|
rand = "0.8"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
|
# Raison HTTP d'origine (« Not Found »…) lue sur les réponses reqwest (météo)
|
||||||
|
hyper = "1"
|
||||||
|
rust_decimal = { version = "1", features = ["serde"] }
|
||||||
|
scrypt = { version = "0.11", default-features = false, features = ["std"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = { version = "1", features = ["preserve_order"] }
|
||||||
|
sha1 = "0.10"
|
||||||
|
sha2 = "0.10"
|
||||||
|
subtle = "2"
|
||||||
|
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "postgres", "macros", "chrono", "rust_decimal", "json"] }
|
||||||
|
thiserror = "2"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
tower-http = { version = "0.6", features = ["compression-gzip", "trace"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
chrono-tz = { version = "0.10", default-features = false, features = ["std"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
codegen-units = 4
|
||||||
|
|
||||||
|
# Connexion (scrypt, N = 16384) : sans optimisation, une vérification de mot de
|
||||||
|
# passe prend plusieurs secondes en mode debug (banc, développement)
|
||||||
|
[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,71 @@
|
|||||||
|
# Modifier le serveur Rust sans changer son comportement
|
||||||
|
|
||||||
|
Guide écrit pour le portage des adresses de l'ancien serveur Node (`server/routes.ts`) vers `server-rs/` (`docs/PLAN-MIGRATION-RUST.md`). Le portage est terminé et `server/` a quitté le dépôt ; son code reste lisible dans la copie de référence du banc (`.bench-reference/server/`, construite par `scripts/bench/reference-node.sh`). Les règles valent toujours : une adresse est fidèle quand le banc (`scripts/bench/`) ne montre **aucun écart** avec le Node de référence, pour les 5 comptes, en lecture comme en écriture ; tout changement voulu y est déclaré comme écart.
|
||||||
|
|
||||||
|
## La règle : le même comportement que Node, bug compris
|
||||||
|
|
||||||
|
- Même code HTTP, même corps JSON (valeurs, champs `null`, ordre des listes), mêmes messages d'erreur, même ordre des contrôles (droits, puis validation, puis base).
|
||||||
|
- Les défauts connus de Node (cloisonnement par magasin incomplet, contrôles de rôle manquants…) ont été **reproduits** et signalés par un commentaire qui cite l'identifiant de l'audit s'il existe (ex. `CONT-02`). Les corriger, c'est déclarer l'écart au banc (`deviation`) dans le même commit.
|
||||||
|
- Ne rien « améliorer » en route : une différence voulue doit être décidée et inscrite au banc.
|
||||||
|
|
||||||
|
## Lire le code Node de référence
|
||||||
|
|
||||||
|
Dans `.bench-reference/`, pour chaque adresse :
|
||||||
|
|
||||||
|
1. le gestionnaire dans `server/routes.ts` (ou `server/externalApi.ts`, `server/localAuth.ts`) : contrôles, paramètres lus (`req.query`, `req.params`, `req.body`), schéma zod, messages, codes ;
|
||||||
|
2. la méthode de `server/storage.ts` (`DatabaseStorage`, lignes 387 à 3509 ; ignorer `MemStorage`) : requête, jointures, tri, projections (`groupSummaryColumns`…) ;
|
||||||
|
3. les schémas de `shared/schema.ts` (`insertXSchema`, `.omit`, `.extend`, `.partial`) ;
|
||||||
|
4. les services appelés (`server/invoiceVerification.ts`, `server/emailService.ts`…).
|
||||||
|
|
||||||
|
## Outils du socle
|
||||||
|
|
||||||
|
| Besoin | Rust | Équivalent Node |
|
||||||
|
|---|---|---|
|
||||||
|
| Utilisateur connecté (401 « Authentification requise » sinon) | extracteur `auth::CurrentUser` : `user.user` (ligne `UsersRow`), `role()`, `is_admin()`, `group_ids()` | `isAuthenticated`, `req.user`, `getCurrentUser(req)`, `storage.getUser(req.user.id)` |
|
||||||
|
| Utilisateur facultatif | `auth::MaybeUser` | `req.isAuthenticated()` |
|
||||||
|
| Corps de la requête | `request::JsonBody(body)` : `serde_json::Value`, `{}` si le type n'est pas JSON | `req.body` (`express.json()`) |
|
||||||
|
| Paramètres d'adresse | `request::JsQuery` : `get("x")`, `truthy("x")` (présent et non vide) | `req.query.x`, `if (req.query.x)` |
|
||||||
|
| Identifiant d'adresse | `Path<String>` puis `request::int_id(&id, "message 500")` | `parseInt(req.params.id)` (NaN → erreur SQL → 500) |
|
||||||
|
| Lignes d'une table | `schema::XRow` (généré : clés, ordre et formats JSON de Drizzle), `XRow::select("WHERE … ORDER BY …")` | `db.select().from(x)` |
|
||||||
|
| Insertion, mise à jour, suppression | `db::insert::<XRow>`, `db::update_by_id::<XRow>(…, true)` (ajoute `updatedAt`), `db::delete_by_id` | `insert().values().returning()`, `update().set({ ...data, updatedAt: new Date() })` |
|
||||||
|
| Validation zod | `XRow::insert_schema()` (createInsertSchema), puis `.omit`, `.pick`, `.extend(vec![Field::…])`, `.partial()`, `.parse(&body)` | `insertXSchema.parse(req.body)` |
|
||||||
|
| Erreurs | `error::AppError::{forbidden, not_found, bad_request, internal, validation}`, `.or_500("message")` sur un `Result`, `error::json_or_empty` (`res.json(undefined)`) | `res.status(…).json({ message })` |
|
||||||
|
| Projections jointes | `projections::{GroupSummary, GroupReconciliation, UserSummary, PublicUser}` | `groupSummaryColumns`, `userSummaryColumns`… |
|
||||||
|
| Droits par module | `access::has_permission(role, "deliveries", "edit")` | `hasPermission(...)` (`shared/permissions.json`) |
|
||||||
|
| Secrets chiffrés | `crypto::decrypt(value, &state.config.encryption_key)` / `encrypt` | `decryptSecret` / `encryptSecret` |
|
||||||
|
| Sémantique JavaScript | `js::to_number`, `js::parse_int`, `js::new_date`, `js::iso`, `js::length` | `Number()`, `parseInt()`, `new Date()`, `toISOString()`, `.length` |
|
||||||
|
| Formats de date | `json::ts` (`2026-10-15T08:30:00.000Z`), `json::pg_text` (valeur brute du pilote : `2026-10-15 08:30:00`) ; `modules::now_iso()` | Date JSON ; colonnes lues par `sql\`…\`` |
|
||||||
|
|
||||||
|
Points d'attention :
|
||||||
|
|
||||||
|
- **Tri** : reprendre l'`ORDER BY` de Drizzle. Un tri fait en JavaScript (`.sort`) se refait en Rust à l'identique (tri stable, mêmes clés). Sans `ORDER BY` côté Node, garder la même requête (même jointure) pour obtenir le même ordre.
|
||||||
|
- **Montants** (`decimal`) : `rust_decimal::Decimal`, sérialisé en texte (`"12.50"`), comme Node.
|
||||||
|
- **Dates** (`date`) : `NaiveDate`, `"2026-10-15"`. **Horodatages** : `NaiveDateTime` sérialisé par `json::ts`.
|
||||||
|
- **Requêtes SQL** : `sqlx::query_as` à l'exécution, avec des paramètres liés (`$1`), jamais de valeur collée dans le texte SQL. Pour une jointure, une structure locale `#[derive(FromRow)]` ou des tuples, puis la forme JSON de Node.
|
||||||
|
- **Paramètres envoyés en texte** : `db::insert` / `update_by_id` envoient chaque valeur en texte converti par PostgreSQL (`$1::date`), comme node-pg. Pour une requête écrite à la main, lier des types Rust (`i32`, `String`, `NaiveDate`…).
|
||||||
|
- **Socle** (`db.rs`, `zod.rs`, `js.rs`…) : un ajout doit être purement additif ; changer un outil existant touche tous les modules, donc tout le banc.
|
||||||
|
- **Nouvelle adresse** : la déclarer dans le module (`.route(…)`) ; `build.rs` l'ajoute aux motifs d'Express (casse, « / » final) et elle passe par `router.rs` sans autre changement.
|
||||||
|
|
||||||
|
## Le banc
|
||||||
|
|
||||||
|
Une base et des ports par copie de travail (`BENCH_ID`, `BENCH_PORT_BASE`), le Node de référence construit une fois (`BENCH_NODE_DIR`, par défaut `.bench-reference`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/bench/reference-node.sh # une fois, puis à chaque changement de REFERENCE_NODE_COMMIT
|
||||||
|
export BENCH_ID=essai BENCH_PORT_BASE=3600
|
||||||
|
scripts/bench/setup.sh # base modèle (après tout changement de seeds/)
|
||||||
|
cargo build --manifest-path server-rs/Cargo.toml
|
||||||
|
RUST_BIN=server-rs/target/debug/logiflow npx tsx scripts/bench/compare.ts --only dlc,tasks
|
||||||
|
npx tsx scripts/bench/compare.ts --mode node --only dlc,tasks # les cas sont-ils stables ?
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Les cas** : dans le fichier de cas du module (`scripts/bench/cases/`), des lectures avec les paramètres que l'interface envoie (chercher les appels dans `client/src`), et un scénario d'écriture par adresse qui modifie quelque chose, avec des corps valides et invalides, sur des lignes du magasin de l'utilisateur et d'un autre magasin. `--mode node` doit donner 0 écart.
|
||||||
|
2. **Données** : si la base du banc ne permet pas d'exercer un cas, ajouter des lignes dans `scripts/bench/seeds/<lot>.ts` (voir `seeds/README.md`), puis relancer `setup.sh`.
|
||||||
|
3. **Rust** : 0 écart, hors écarts déclarés.
|
||||||
|
4. Les services externes (NocoDB, webhooks, SMTP) sont simulés par `stub.ts` ; les appels reçus font partie de la comparaison.
|
||||||
|
|
||||||
|
## Avant de rendre la main
|
||||||
|
|
||||||
|
- `cargo fmt`, `cargo clippy --all-targets -- -D warnings`, `cargo test` ;
|
||||||
|
- banc complet : 0 écart, hors écarts déclarés ;
|
||||||
|
- un commit par changement, message en français, qui donne le résultat du banc.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Serveur LogiFlow (Rust)
|
||||||
|
|
||||||
|
Serveur unique de l'application : il sert l'API (`src/router.rs`) et l'interface construite par Vite (`dist/public`), applique les migrations au démarrage (`src/startup.rs`) et lance les tâches de fond (`src/jobs.rs` : sauvegarde automatique quotidienne, purge des caches, compte `admin` par défaut sur une base vide). Il a remplacé le serveur Node/Express, dont il reprend le comportement à l'identique (`docs/PLAN-MIGRATION-RUST.md`) : mêmes réponses, même cookie de session (`connect.sid`, table `sessions`), mêmes secrets chiffrés.
|
||||||
|
|
||||||
|
## Lancer en local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vite build # interface dans dist/public
|
||||||
|
DATABASE_URL=postgres://… SESSION_SECRET=… cargo run --manifest-path server-rs/Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis ouvrir http://localhost:3000. Pour travailler sur l'interface : `npm run dev:server` et, à côté, `npm run dev` (Vite sur http://localhost:5173, qui relaie `/api` vers le port 3000).
|
||||||
|
|
||||||
|
| Variable | Rôle | Défaut |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | port d'écoute | `3000` |
|
||||||
|
| `DATABASE_URL` | base PostgreSQL | obligatoire |
|
||||||
|
| `STATIC_DIR` | interface construite par Vite | `dist/public` |
|
||||||
|
| `SESSION_SECRET` | signature du cookie de session | `fallback-secret-key` |
|
||||||
|
| `ENCRYPTION_KEY` | clé des secrets chiffrés en base (à défaut `SESSION_SECRET`) | — |
|
||||||
|
| `BACKUP_DIR` | dossier des sauvegardes (`pg_dump` doit être installé) | `backups` |
|
||||||
|
| `NODE_ENV` | renvoyé par `/api/health` | `production` |
|
||||||
|
| `ALLOWED_ORIGINS` | origines acceptées en plus de l'hôte du serveur pour les requêtes d'écriture (`https://a.fr,https://b.fr`), voir `src/origin.rs` | — |
|
||||||
|
| `RUST_LOG` | niveau des journaux (`info`, `debug`…) | `info` |
|
||||||
|
|
||||||
|
Ne pas changer `SESSION_SECRET` ni `ENCRYPTION_KEY` d'une installation existante : les sessions ouvertes et les secrets déjà chiffrés (mot de passe SMTP, jeton NocoDB) deviendraient illisibles.
|
||||||
|
|
||||||
|
## Vérifier
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis le banc de comparaison avec le serveur Node de référence (`scripts/bench/README.md`) : aucun écart attendu, hors écarts déclarés.
|
||||||
|
|
||||||
|
## Organisation
|
||||||
|
|
||||||
|
- `src/router.rs` : table des adresses de l'API. `src/express_routes.rs` retrouve l'adresse qu'Express aurait servie (casse, « / » final) ; `src/static_files.rs` sert l'interface et la page 404 d'Express.
|
||||||
|
- `src/modules/` : un fichier par module de l'application ; `src/services/` : services partagés (NocoDB, vérification des factures, courriels, dates).
|
||||||
|
- Socle : session et utilisateur (`auth.rs`), droits (`access.rs`, matrice de `shared/permissions.json`), validation au format zod (`zod.rs`), accès aux tables (`db.rs`), formats JSON de Node (`json.rs`, `js.rs`), secrets chiffrés (`crypto.rs`), erreurs (`error.rs`).
|
||||||
|
- `src/schema.rs` est **généré** depuis `shared/schema.ts` : après un changement de schéma, `DATABASE_URL=… npm run gen:rust-schema`, et la migration correspondante dans `src/startup.rs`.
|
||||||
|
- Ajouter ou modifier une adresse : `PORTAGE.md` (règles et outils du socle).
|
||||||
|
|
||||||
|
## Maintenance (décision R6)
|
||||||
|
|
||||||
|
- Mainteneur et relecteur : @R0m1k3 (`.github/CODEOWNERS`). Toute modification passe par une PR dont la CI est verte : `cargo fmt`, `clippy -D warnings`, tests, contrôle de type de l'interface et banc.
|
||||||
|
- Dans les réglages GitHub du dépôt, protéger `main` : relecture des CODEOWNERS et vérifications « Serveur Rust » obligatoires.
|
||||||
|
- Dépendances : `cargo update` puis banc complet, une fois par mois ; Rust stable, image `rust:1.97-alpine` montée en même temps.
|
||||||
|
- Heure : « aujourd'hui » se calcule à Paris (`src/clock.rs`, décision R5) ; ne jamais utiliser `Utc::now().date_naive()` pour une date du jour.
|
||||||
|
- Écritures d'un autre site refusées (`src/origin.rs`, décision R4) : un nouveau service qui appelle l'API depuis un navigateur sur une autre adresse se déclare dans `ALLOWED_ORIGINS`.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Liste des motifs d'adresse déclarés dans src/modules/*.rs et src/router.rs
|
||||||
|
// (`.route("…")`), pour retrouver la route qu'Express aurait servie
|
||||||
|
// (src/express_routes.rs).
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=src/modules");
|
||||||
|
println!("cargo:rerun-if-changed=src/router.rs");
|
||||||
|
let mut patterns = BTreeSet::new();
|
||||||
|
collect(
|
||||||
|
&fs::read_to_string("src/router.rs").expect("src/router.rs"),
|
||||||
|
&mut patterns,
|
||||||
|
);
|
||||||
|
for entry in fs::read_dir("src/modules").expect("src/modules") {
|
||||||
|
let path = entry.expect("fichier de module").path();
|
||||||
|
if path.extension().is_some_and(|ext| ext == "rs") {
|
||||||
|
println!("cargo:rerun-if-changed={}", path.display());
|
||||||
|
collect(&fs::read_to_string(&path).expect("module lisible"), &mut patterns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let list: Vec<String> = patterns.iter().map(|p| format!(" {p:?},")).collect();
|
||||||
|
let out = Path::new(&std::env::var("OUT_DIR").unwrap()).join("route_patterns.rs");
|
||||||
|
fs::write(
|
||||||
|
out,
|
||||||
|
format!(
|
||||||
|
"pub const ROUTE_PATTERNS: &[&str] = &[\n{}\n];\n",
|
||||||
|
list.join("\n")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Motifs littéraux passés à `.route(` ; les jokers et les motifs finissant par « / »
|
||||||
|
/// sont laissés de côté
|
||||||
|
fn collect(source: &str, patterns: &mut BTreeSet<String>) {
|
||||||
|
let mut rest = source;
|
||||||
|
while let Some(index) = rest.find(".route(") {
|
||||||
|
rest = &rest[index + ".route(".len()..];
|
||||||
|
let trimmed = rest.trim_start();
|
||||||
|
let Some(literal) = trimmed.strip_prefix('"') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(end) = literal.find('"') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let pattern = &literal[..end];
|
||||||
|
if pattern.starts_with("/api") && !pattern.contains("{*") && !pattern.ends_with('/') {
|
||||||
|
patterns.insert(pattern.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
max_width = 110
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Droits par rôle et par module : même matrice que l'interface et Node
|
||||||
|
// (shared/permissions.json, lue par shared/permissions.ts).
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
type Matrix = HashMap<String, HashMap<String, Vec<String>>>;
|
||||||
|
|
||||||
|
static PERMISSIONS: LazyLock<Matrix> = LazyLock::new(|| {
|
||||||
|
serde_json::from_str(include_str!("../../shared/permissions.json"))
|
||||||
|
.expect("shared/permissions.json valide")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// hasPermission(role, module, action)
|
||||||
|
pub fn has_permission(role: &str, module: &str, action: &str) -> bool {
|
||||||
|
PERMISSIONS
|
||||||
|
.get(module)
|
||||||
|
.and_then(|roles| roles.get(&role.to_lowercase()))
|
||||||
|
.is_some_and(|actions| actions.iter().any(|a| a == action))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matrice_partagee() {
|
||||||
|
assert!(has_permission("manager", "deliveries", "validate"));
|
||||||
|
assert!(!has_permission("manager", "deliveries", "delete"));
|
||||||
|
assert!(has_permission("Admin", "avoir", "delete"));
|
||||||
|
assert!(!has_permission("employee", "orders", "view"));
|
||||||
|
assert!(!has_permission("employee", "avoir", "view"));
|
||||||
|
assert!(!has_permission("admin", "inconnu", "view"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Session partagée avec Node : cookie connect.sid signé par express-session,
|
||||||
|
// session stockée par connect-pg-simple dans la table sessions
|
||||||
|
// (docs/PLAN-MIGRATION-RUST.md, « Sessions partagées entre Node et Rust »).
|
||||||
|
// L'utilisateur est relu à chaque requête, avec ses magasins, comme
|
||||||
|
// deserializeUser → storage.getUserWithGroups (server/storage.ts).
|
||||||
|
|
||||||
|
use axum::extract::FromRequestParts;
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::http::request::Parts;
|
||||||
|
use base64::Engine;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::db::Table;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::schema::UsersRow;
|
||||||
|
|
||||||
|
pub const SESSION_COOKIE: &str = "connect.sid";
|
||||||
|
|
||||||
|
/// Magasin joint aux affectations (userGroupColumns : dates en valeur brute du pilote)
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct GroupBrief {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub color: Option<String>,
|
||||||
|
#[serde(rename = "createdAt", serialize_with = "crate::json::pg_text_opt")]
|
||||||
|
pub created_at: Option<NaiveDateTime>,
|
||||||
|
#[serde(rename = "updatedAt", serialize_with = "crate::json::pg_text_opt")]
|
||||||
|
pub updated_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct UserGroup {
|
||||||
|
#[serde(rename = "userId")]
|
||||||
|
pub user_id: String,
|
||||||
|
#[serde(rename = "groupId")]
|
||||||
|
pub group_id: i32,
|
||||||
|
pub group: GroupBrief,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Utilisateur connecté (UserWithGroups)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CurrentUser {
|
||||||
|
pub user: UsersRow,
|
||||||
|
pub user_groups: Vec<UserGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CurrentUser {
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.user.id
|
||||||
|
}
|
||||||
|
pub fn role(&self) -> &str {
|
||||||
|
&self.user.role
|
||||||
|
}
|
||||||
|
pub fn is_admin(&self) -> bool {
|
||||||
|
self.user.role == "admin"
|
||||||
|
}
|
||||||
|
/// (user.userGroups || []).map(ug => ug.groupId)
|
||||||
|
pub fn group_ids(&self) -> Vec<i32> {
|
||||||
|
self.user_groups
|
||||||
|
.iter()
|
||||||
|
.map(|membership| membership.group_id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Utilisateur connecté obligatoire : sinon 401 « Authentification requise »
|
||||||
|
impl FromRequestParts<AppState> for CurrentUser {
|
||||||
|
type Rejection = AppError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||||
|
load_current_user(&parts.headers, state)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(AppError::unauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Utilisateur connecté s'il y en a un (routes publiques qui changent selon la session)
|
||||||
|
pub struct MaybeUser(pub Option<CurrentUser>);
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for MaybeUser {
|
||||||
|
type Rejection = AppError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||||
|
Ok(Self(load_current_user(&parts.headers, state).await?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_current_user(headers: &HeaderMap, state: &AppState) -> Result<Option<CurrentUser>, AppError> {
|
||||||
|
let Some(sid) = session_id(headers, &state.config.session_secret) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let session: Option<(serde_json::Value,)> =
|
||||||
|
sqlx::query_as("SELECT sess FROM sessions WHERE sid = $1 AND expire >= now()")
|
||||||
|
.bind(&sid)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(server_error)?;
|
||||||
|
let Some(user_id) = session
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|(sess,)| sess.pointer("/passport/user"))
|
||||||
|
.and_then(|user| user.as_str())
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
user_with_groups(&state.db, user_id).await.map_err(server_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_error(error: sqlx::Error) -> AppError {
|
||||||
|
tracing::error!(%error, "lecture de la session");
|
||||||
|
AppError::internal("Internal Server Error")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// storage.getUserWithGroups : l'utilisateur et ses magasins, en une requête
|
||||||
|
pub async fn user_with_groups(db: &sqlx::PgPool, user_id: &str) -> Result<Option<CurrentUser>, sqlx::Error> {
|
||||||
|
let Some(user) = sqlx::query_as::<_, UsersRow>(&UsersRow::select("WHERE id = $1"))
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
// Même jointure que Node (sans tri) : les affectations vers un magasin
|
||||||
|
// inexistant sont ignorées
|
||||||
|
let rows: Vec<(i32, GroupBrief)> = sqlx::query_as::<_, (i32, i32, String, Option<String>, Option<NaiveDateTime>, Option<NaiveDateTime>)>(
|
||||||
|
"SELECT user_groups.group_id, groups.id, groups.name, groups.color, groups.created_at, groups.updated_at
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN user_groups ON user_groups.user_id = users.id
|
||||||
|
LEFT JOIN groups ON user_groups.group_id = groups.id
|
||||||
|
WHERE users.id = $1 AND groups.id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(group_id, id, name, color, created_at, updated_at)| {
|
||||||
|
(group_id, GroupBrief { id, name, color, created_at, updated_at })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let user_groups = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(group_id, group)| UserGroup {
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
group_id,
|
||||||
|
group,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Some(CurrentUser { user, user_groups }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identifiant de session du cookie signé, si la signature est bonne
|
||||||
|
pub fn session_id(headers: &HeaderMap, secret: &str) -> Option<String> {
|
||||||
|
let raw = cookie_value(headers, SESSION_COOKIE)?;
|
||||||
|
unsign(&percent_decode(&raw)?, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Premier cookie de ce nom, comme le paquet cookie utilisé par express-session
|
||||||
|
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get_all(axum::http::header::COOKIE)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|value| value.to_str().ok())
|
||||||
|
.flat_map(|value| value.split(';'))
|
||||||
|
.filter_map(|pair| pair.split_once('='))
|
||||||
|
.find(|(key, _)| key.trim() == name)
|
||||||
|
.map(|(_, value)| value.trim().trim_matches('"').to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// decodeURIComponent ; valeur brute si l'encodage est invalide (paquet cookie)
|
||||||
|
fn percent_decode(input: &str) -> Option<String> {
|
||||||
|
let bytes = input.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%' {
|
||||||
|
let decoded = bytes
|
||||||
|
.get(i + 1..i + 3)
|
||||||
|
.and_then(|hex| std::str::from_utf8(hex).ok())
|
||||||
|
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
|
||||||
|
match decoded {
|
||||||
|
Some(byte) => {
|
||||||
|
out.push(byte);
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
None => return Some(input.to_string()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(String::from_utf8(out).unwrap_or_else(|_| input.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// « s:<sid>.<signature> », signature = HMAC-SHA256(secret, sid) en base64 sans « = »
|
||||||
|
/// (paquet cookie-signature)
|
||||||
|
fn unsign(value: &str, secret: &str) -> Option<String> {
|
||||||
|
let signed = value.strip_prefix("s:")?;
|
||||||
|
let (sid, signature) = signed.rsplit_once('.')?;
|
||||||
|
let expected = sign(sid, secret);
|
||||||
|
if expected.as_bytes().ct_eq(signature.as_bytes()).into() {
|
||||||
|
Some(sid.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign(sid: &str, secret: &str) -> String {
|
||||||
|
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepte toute clé");
|
||||||
|
mac.update(sid.as_bytes());
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.encode(mac.finalize().into_bytes())
|
||||||
|
.trim_end_matches('=')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
|
||||||
|
// Valeur produite par Node : encodeURIComponent('s:' + cookieSignature.sign(sid, secret))
|
||||||
|
const SID: &str = "wWepyClqhhFUoW9EuP9PHnH9zV0N0SHv";
|
||||||
|
const SECRET: &str = "banc-secret-de-session";
|
||||||
|
const NODE_COOKIE: &str =
|
||||||
|
"s%3AwWepyClqhhFUoW9EuP9PHnH9zV0N0SHv.9iMe%2BgwWTIz9RKnc97t6VsEcCzLrdYtWzVPRpG3dTT0";
|
||||||
|
|
||||||
|
fn headers(cookie: &str) -> HeaderMap {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(axum::http::header::COOKIE, HeaderValue::from_str(cookie).unwrap());
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lit_le_cookie_signe_par_node() {
|
||||||
|
let cookies = headers(&format!("autre=1; connect.sid={NODE_COOKIE}"));
|
||||||
|
assert_eq!(session_id(&cookies, SECRET).as_deref(), Some(SID));
|
||||||
|
assert_eq!(
|
||||||
|
format!("{SID}.{}", sign(SID, SECRET)),
|
||||||
|
"wWepyClqhhFUoW9EuP9PHnH9zV0N0SHv.9iMe+gwWTIz9RKnc97t6VsEcCzLrdYtWzVPRpG3dTT0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refuse_un_autre_secret_ou_une_signature_falsifiee() {
|
||||||
|
assert_eq!(
|
||||||
|
session_id(&headers(&format!("connect.sid={NODE_COOKIE}")), "autre-secret"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(session_id(&headers("connect.sid=s%3Aabc.ZmF1c3Nl"), SECRET), None);
|
||||||
|
assert_eq!(session_id(&headers("connect.sid=abc"), SECRET), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Date et heure du jour à Paris (décision R5) : les magasins sont en France, et
|
||||||
|
// l'ancien serveur Node, qui comptait en UTC, rendait la date de la veille entre
|
||||||
|
// minuit et 1 h (2 h l'été). Les instants enregistrés en base restent en UTC ;
|
||||||
|
// seul « aujourd'hui » (et ce qui s'en déduit : année, mois, heure de la
|
||||||
|
// sauvegarde automatique) se calcule ici.
|
||||||
|
|
||||||
|
use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};
|
||||||
|
use chrono_tz::{Europe::Paris, Tz};
|
||||||
|
|
||||||
|
/// Instant présent, à l'heure de Paris
|
||||||
|
pub fn now() -> DateTime<Tz> {
|
||||||
|
Utc::now().with_timezone(&Paris)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Date du jour à Paris
|
||||||
|
pub fn today() -> NaiveDate {
|
||||||
|
now().date_naive()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Date à Paris d'un instant enregistré en UTC (colonne timestamp sans fuseau)
|
||||||
|
pub fn paris_date(utc: NaiveDateTime) -> NaiveDate {
|
||||||
|
Utc.from_utc_datetime(&utc).with_timezone(&Paris).date_naive()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instant UTC (sans fuseau, comme les colonnes) du début d'un jour de Paris
|
||||||
|
pub fn start_of_day_utc(day: NaiveDate) -> NaiveDateTime {
|
||||||
|
let midnight = day.and_hms_opt(0, 0, 0).expect("minuit existe");
|
||||||
|
// Minuit existe toujours à Paris (les changements d'heure ont lieu à 2 h et 3 h)
|
||||||
|
Paris
|
||||||
|
.from_local_datetime(&midnight)
|
||||||
|
.earliest()
|
||||||
|
.map(|local| local.naive_utc())
|
||||||
|
.unwrap_or(midnight)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn utc(text: &str) -> NaiveDateTime {
|
||||||
|
NaiveDateTime::parse_from_str(text, "%Y-%m-%d %H:%M:%S").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn date_de_paris() {
|
||||||
|
// Été (UTC+2) : 23 h 30 UTC, c'est déjà le lendemain à Paris
|
||||||
|
assert_eq!(
|
||||||
|
paris_date(utc("2026-07-14 23:30:00")),
|
||||||
|
NaiveDate::from_ymd_opt(2026, 7, 15).unwrap()
|
||||||
|
);
|
||||||
|
// Hiver (UTC+1)
|
||||||
|
assert_eq!(
|
||||||
|
paris_date(utc("2026-12-31 22:59:59")),
|
||||||
|
NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
paris_date(utc("2026-12-31 23:00:00")),
|
||||||
|
NaiveDate::from_ymd_opt(2027, 1, 1).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debut_du_jour_en_utc() {
|
||||||
|
let day = NaiveDate::from_ymd_opt(2027, 1, 1).unwrap();
|
||||||
|
assert_eq!(start_of_day_utc(day), utc("2026-12-31 23:00:00"));
|
||||||
|
let day = NaiveDate::from_ymd_opt(2026, 7, 15).unwrap();
|
||||||
|
assert_eq!(start_of_day_utc(day), utc("2026-07-14 22:00:00"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Configuration lue dans l'environnement, mêmes variables que le serveur Node.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub port: u16,
|
||||||
|
pub database_url: String,
|
||||||
|
/// Interface construite par Vite (dist/public)
|
||||||
|
pub static_dir: PathBuf,
|
||||||
|
pub session_secret: String,
|
||||||
|
/// Clé de chiffrement des secrets (à défaut SESSION_SECRET), comme server/crypto.ts
|
||||||
|
pub encryption_key: String,
|
||||||
|
pub environment: String,
|
||||||
|
/// Dossier des sauvegardes pg_dump, comme server/backupService.ts : BACKUP_DIR
|
||||||
|
/// (à défaut /app/backups) en production, ./backups sinon
|
||||||
|
pub backup_dir: PathBuf,
|
||||||
|
/// Origines autorisées en plus de l'hôte du serveur pour les requêtes
|
||||||
|
/// d'écriture (ALLOWED_ORIGINS, séparées par des virgules), voir origin.rs
|
||||||
|
pub allowed_origins: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> anyhow::Result<Self> {
|
||||||
|
let session_secret = env::var("SESSION_SECRET").unwrap_or_else(|_| "fallback-secret-key".into());
|
||||||
|
let encryption_key = env::var("ENCRYPTION_KEY")
|
||||||
|
.ok()
|
||||||
|
.filter(|key| !key.is_empty())
|
||||||
|
.or_else(|| env::var("SESSION_SECRET").ok().filter(|key| !key.is_empty()))
|
||||||
|
.unwrap_or_else(|| "logiflow-fallback-secret-key".into());
|
||||||
|
let environment = env::var("NODE_ENV").unwrap_or_else(|_| "production".into());
|
||||||
|
let backup_dir = if environment == "production" {
|
||||||
|
env::var("BACKUP_DIR")
|
||||||
|
.ok()
|
||||||
|
.filter(|dir| !dir.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/app/backups"))
|
||||||
|
} else {
|
||||||
|
env::current_dir().unwrap_or_default().join("backups")
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
port: env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(3000),
|
||||||
|
database_url: env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL manquant"))?,
|
||||||
|
static_dir: env::var("STATIC_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("dist/public")),
|
||||||
|
session_secret,
|
||||||
|
encryption_key,
|
||||||
|
environment,
|
||||||
|
backup_dir,
|
||||||
|
allowed_origins: env::var("ALLOWED_ORIGINS")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split(',')
|
||||||
|
.map(|origin| origin.trim().trim_end_matches('/').to_ascii_lowercase())
|
||||||
|
.filter(|origin| !origin.is_empty())
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Secrets chiffrés au repos (mot de passe SMTP des magasins, jeton NocoDB),
|
||||||
|
// compatibles avec server/crypto.ts : AES-256-GCM, clé = SHA-256 de
|
||||||
|
// ENCRYPTION_KEY (à défaut SESSION_SECRET), format enc:v1:<iv>:<tag>:<données>
|
||||||
|
// en base64. Une valeur en clair (héritée) est rendue telle quelle.
|
||||||
|
|
||||||
|
use aes_gcm::aead::{Aead, KeyInit};
|
||||||
|
use aes_gcm::{Aes256Gcm, Nonce};
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
const PREFIX: &str = "enc:v1:";
|
||||||
|
|
||||||
|
fn cipher(secret: &str) -> Aes256Gcm {
|
||||||
|
let key = Sha256::digest(secret.as_bytes());
|
||||||
|
Aes256Gcm::new_from_slice(&key).expect("clé de 32 octets")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_encrypted(value: &str) -> bool {
|
||||||
|
value.starts_with(PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// encryptSecret : valeur vide ou déjà chiffrée rendue telle quelle
|
||||||
|
pub fn encrypt(value: &str, secret: &str) -> String {
|
||||||
|
if value.is_empty() || is_encrypted(value) {
|
||||||
|
return value.to_string();
|
||||||
|
}
|
||||||
|
let mut iv = [0u8; 12];
|
||||||
|
rand::thread_rng().fill_bytes(&mut iv);
|
||||||
|
let sealed = cipher(secret)
|
||||||
|
.encrypt(Nonce::from_slice(&iv), value.as_bytes())
|
||||||
|
.expect("chiffrement AES-GCM");
|
||||||
|
// aes-gcm place l'étiquette (16 octets) à la fin ; Node la stocke à part
|
||||||
|
let (data, tag) = sealed.split_at(sealed.len() - 16);
|
||||||
|
format!(
|
||||||
|
"{PREFIX}{}:{}:{}",
|
||||||
|
STANDARD.encode(iv),
|
||||||
|
STANDARD.encode(tag),
|
||||||
|
STANDARD.encode(data)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// decryptSecret : Err avec le message de Node si la valeur est illisible
|
||||||
|
pub fn decrypt(value: &str, secret: &str) -> Result<String, &'static str> {
|
||||||
|
if !is_encrypted(value) {
|
||||||
|
return Ok(value.to_string());
|
||||||
|
}
|
||||||
|
let parts: Vec<&str> = value[PREFIX.len()..].split(':').collect();
|
||||||
|
let [iv, tag, data] = parts.as_slice() else {
|
||||||
|
return Err("Secret chiffré illisible : format inattendu");
|
||||||
|
};
|
||||||
|
const UNREADABLE: &str =
|
||||||
|
"Secret chiffré illisible : clé de chiffrement changée ou donnée altérée. Ressaisissez la valeur.";
|
||||||
|
let decode = |part: &str| STANDARD.decode(part).map_err(|_| UNREADABLE);
|
||||||
|
let (iv, tag, mut data) = (decode(iv)?, decode(tag)?, decode(data)?);
|
||||||
|
if iv.len() != 12 {
|
||||||
|
return Err(UNREADABLE);
|
||||||
|
}
|
||||||
|
data.extend_from_slice(&tag);
|
||||||
|
let plain = cipher(secret)
|
||||||
|
.decrypt(Nonce::from_slice(&iv), data.as_ref())
|
||||||
|
.map_err(|_| UNREADABLE)?;
|
||||||
|
String::from_utf8(plain).map_err(|_| UNREADABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// Chiffré par server/crypto.ts avec ENCRYPTION_KEY=banc-cle-de-chiffrement
|
||||||
|
const FROM_NODE: &str = "enc:v1:n8TrNmt+VyaEal3x:gYKLZrnmBiGakpe8TI0Klg==:mnA5kYLSoJAdXxU3j1lb3g==";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dechiffre_une_valeur_chiffree_par_node() {
|
||||||
|
assert_eq!(
|
||||||
|
decrypt(FROM_NODE, "banc-cle-de-chiffrement").unwrap(),
|
||||||
|
"smtp-secret-banc"
|
||||||
|
);
|
||||||
|
assert!(decrypt(FROM_NODE, "autre-cle").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aller_retour_et_valeurs_en_clair() {
|
||||||
|
let sealed = encrypt("jeton", "cle");
|
||||||
|
assert!(is_encrypted(&sealed));
|
||||||
|
assert_eq!(decrypt(&sealed, "cle").unwrap(), "jeton");
|
||||||
|
assert_eq!(encrypt(&sealed, "cle"), sealed);
|
||||||
|
assert_eq!(decrypt("en clair", "cle").unwrap(), "en clair");
|
||||||
|
assert_eq!(encrypt("", "cle"), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
// Accès générique aux tables décrites dans schema.rs (généré), au comportement de
|
||||||
|
// Drizzle : insert().values(), update().set().returning(), select().from().
|
||||||
|
//
|
||||||
|
// Comme node-pg, chaque valeur part en texte et PostgreSQL la convertit vers le
|
||||||
|
// type de la colonne ($1::date, $2::numeric…) : mêmes conversions, mêmes erreurs.
|
||||||
|
|
||||||
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::error::BoxDynError;
|
||||||
|
use sqlx::postgres::{PgArguments, PgRow, PgTypeInfo, PgValueRef};
|
||||||
|
use sqlx::{Arguments, Decode, FromRow, PgPool, Postgres, Type, ValueRef};
|
||||||
|
|
||||||
|
use crate::zod::Parsed;
|
||||||
|
|
||||||
|
/// Colonne : clé JSON (Drizzle), nom SQL, type SQL
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Col {
|
||||||
|
pub key: &'static str,
|
||||||
|
pub column: &'static str,
|
||||||
|
pub sql_type: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Table: for<'r> FromRow<'r, PgRow> + Send + Unpin {
|
||||||
|
const TABLE: &'static str;
|
||||||
|
/// Colonnes dans l'ordre de shared/schema.ts, entre guillemets
|
||||||
|
const COLUMNS: &'static str;
|
||||||
|
const FIELDS: &'static [Col];
|
||||||
|
|
||||||
|
fn field(key: &str) -> Option<&'static Col> {
|
||||||
|
Self::FIELDS.iter().find(|col| col.key == key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SELECT des colonnes de la table, suivi de `rest` (WHERE, ORDER BY…)
|
||||||
|
fn select(rest: &str) -> String {
|
||||||
|
format!("SELECT {} FROM {} {rest}", Self::COLUMNS, Self::TABLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colonne `timestamp` de shared/schema.ts, lue que la base réelle la déclare
|
||||||
|
/// `timestamp` ou `timestamptz`. Certaines tables de production ont été créées en
|
||||||
|
/// dehors des migrations du dépôt avec `timestamptz` (weather_settings.created_at,
|
||||||
|
/// avoirs…), et sqlx refuse de lire un `timestamptz` en NaiveDateTime.
|
||||||
|
///
|
||||||
|
/// Un `timestamptz` est rendu en heure UTC : la même valeur que l'API sérialise
|
||||||
|
/// ensuite avec crate::json::ts (« …Z »). schema.rs l'utilise par
|
||||||
|
/// `#[sqlx(try_from = "crate::db::AnyTimestamp")]` : le champ reste NaiveDateTime.
|
||||||
|
pub struct AnyTimestamp(pub NaiveDateTime);
|
||||||
|
|
||||||
|
/// AnyTimestamp d'une colonne qui peut être NULL
|
||||||
|
pub struct AnyTimestampOpt(pub Option<NaiveDateTime>);
|
||||||
|
|
||||||
|
fn is_timestamptz(ty: &PgTypeInfo) -> bool {
|
||||||
|
<DateTime<Utc> as Type<Postgres>>::compatible(ty)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn any_timestamp_compatible(ty: &PgTypeInfo) -> bool {
|
||||||
|
<NaiveDateTime as Type<Postgres>>::compatible(ty) || is_timestamptz(ty)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_any_timestamp(value: PgValueRef<'_>) -> Result<NaiveDateTime, BoxDynError> {
|
||||||
|
let timestamptz = is_timestamptz(&value.type_info());
|
||||||
|
if timestamptz {
|
||||||
|
Ok(<DateTime<Utc> as Decode<'_, Postgres>>::decode(value)?.naive_utc())
|
||||||
|
} else {
|
||||||
|
<NaiveDateTime as Decode<'_, Postgres>>::decode(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Type<Postgres> for AnyTimestamp {
|
||||||
|
fn type_info() -> PgTypeInfo {
|
||||||
|
<NaiveDateTime as Type<Postgres>>::type_info()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compatible(ty: &PgTypeInfo) -> bool {
|
||||||
|
any_timestamp_compatible(ty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'r> Decode<'r, Postgres> for AnyTimestamp {
|
||||||
|
fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
|
||||||
|
decode_any_timestamp(value).map(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AnyTimestamp> for NaiveDateTime {
|
||||||
|
fn from(value: AnyTimestamp) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Type<Postgres> for AnyTimestampOpt {
|
||||||
|
fn type_info() -> PgTypeInfo {
|
||||||
|
<NaiveDateTime as Type<Postgres>>::type_info()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compatible(ty: &PgTypeInfo) -> bool {
|
||||||
|
any_timestamp_compatible(ty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'r> Decode<'r, Postgres> for AnyTimestampOpt {
|
||||||
|
fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
|
||||||
|
// Row::try_get ne vérifie pas le type d'une valeur NULL et la passe telle
|
||||||
|
// quelle à decode (comme pour Option<T>)
|
||||||
|
if value.is_null() {
|
||||||
|
return Ok(Self(None));
|
||||||
|
}
|
||||||
|
decode_any_timestamp(value).map(|timestamp| Self(Some(timestamp)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AnyTimestampOpt> for Option<NaiveDateTime> {
|
||||||
|
fn from(value: AnyTimestampOpt) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valeur JavaScript telle que node-pg l'envoie (prepareValue) : texte, ou NULL
|
||||||
|
pub fn pg_text(value: &Value) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
Value::Null => None,
|
||||||
|
Value::Bool(flag) => Some(flag.to_string()),
|
||||||
|
Value::Number(number) => Some(match number.as_f64() {
|
||||||
|
Some(float) => crate::js::number_to_string(float),
|
||||||
|
None => number.to_string(),
|
||||||
|
}),
|
||||||
|
Value::String(text) => Some(text.clone()),
|
||||||
|
other => Some(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// prepareValue de node-pg pour une valeur du corps passée telle quelle à une
|
||||||
|
/// colonne qui n'est pas jsonb (sans schéma zod ni conversion par Drizzle) :
|
||||||
|
/// tableau → littéral de tableau PostgreSQL, objet → JSON, autre → String(valeur)
|
||||||
|
pub fn node_pg_text(value: &Value) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
Value::Null => None,
|
||||||
|
Value::Array(items) => Some(pg_array_literal(items)),
|
||||||
|
Value::Object(_) => Some(value.to_string()),
|
||||||
|
other => Some(crate::js::string_of(other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nombre JavaScript envoyé par node-pg à une colonne integer (String(nombre)) :
|
||||||
|
/// la valeur si PostgreSQL l'accepte, None s'il refuse (NaN, décimal, hors limites)
|
||||||
|
pub fn js_int4(number: f64) -> Option<i32> {
|
||||||
|
let integral = number.is_finite() && number.fract() == 0.0;
|
||||||
|
(integral && number >= f64::from(i32::MIN) && number <= f64::from(i32::MAX)).then_some(number as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// parseInt(texte) envoyé à une colonne integer (identifiants, magasins)
|
||||||
|
pub fn parse_int4(text: &str) -> Option<i32> {
|
||||||
|
crate::js::parse_int(text).and_then(js_int4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// arrayString de node-pg : {"a","b",NULL,{"c"}}
|
||||||
|
fn pg_array_literal(items: &[Value]) -> String {
|
||||||
|
let elements: Vec<String> = items
|
||||||
|
.iter()
|
||||||
|
.map(|item| match item {
|
||||||
|
Value::Null => "NULL".to_string(),
|
||||||
|
Value::Array(nested) => pg_array_literal(nested),
|
||||||
|
other => {
|
||||||
|
let text = node_pg_text(other).unwrap_or_default();
|
||||||
|
format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!("{{{}}}", elements.join(","))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Une ligne par son identifiant (colonne id)
|
||||||
|
pub async fn find_by_id<T: Table>(db: &PgPool, id: i32) -> Result<Option<T>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, T>(&T::select("WHERE id = $1"))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lignes dont l'identifiant figure dans `ids` : seconde moitié d'une jointure
|
||||||
|
/// (leftJoin de Drizzle), relue par identifiant pour garder les structures de schema.rs
|
||||||
|
pub async fn find_by_ids<T: Table>(db: &PgPool, ids: &[i32]) -> Result<Vec<T>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, T>(&T::select("WHERE id = ANY($1)"))
|
||||||
|
.bind(ids)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colonnes de la table préfixées par `alias` (requête avec jointures, où « id »
|
||||||
|
/// serait ambigu)
|
||||||
|
pub fn columns_of<T: Table>(alias: &str) -> String {
|
||||||
|
T::COLUMNS
|
||||||
|
.split(", ")
|
||||||
|
.map(|column| format!("{alias}.{column}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Objet de la requête passé tel quel à Drizzle, sans schéma zod
|
||||||
|
/// (db.insert(t).values({ ...req.body }), db.update(t).set({ ...req.body })) :
|
||||||
|
/// - les clés qui ne sont pas des colonnes sont ignorées ;
|
||||||
|
/// - une colonne timestamp attend un Date (mapToDriverValue appelle toISOString) :
|
||||||
|
/// toute valeur JSON autre que null lève une TypeError avant la requête, rendue ici
|
||||||
|
/// en Err avec le message de JavaScript ;
|
||||||
|
/// - jsonb : JSON.stringify ; autres colonnes : valeur préparée par node-pg.
|
||||||
|
pub fn drizzle_values<T: Table>(data: &serde_json::Map<String, Value>) -> Result<Parsed, String> {
|
||||||
|
let mut parsed = Parsed::new();
|
||||||
|
for col in T::FIELDS {
|
||||||
|
let Some(value) = data.get(col.key) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let prepared = match (col.sql_type, value) {
|
||||||
|
(_, Value::Null) => Value::Null,
|
||||||
|
("timestamp", _) => return Err("value.toISOString is not a function".into()),
|
||||||
|
("jsonb", other) => Value::String(other.to_string()),
|
||||||
|
(_, other) => node_pg_value(other),
|
||||||
|
};
|
||||||
|
parsed.insert(col.key.to_string(), prepared);
|
||||||
|
}
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// prepareValue de node-pg pour une valeur JSON : un tableau devient un littéral de
|
||||||
|
/// tableau PostgreSQL ({"a","b"}), le reste part tel quel (pg_text)
|
||||||
|
pub fn node_pg_value(value: &Value) -> Value {
|
||||||
|
match value {
|
||||||
|
Value::Array(items) => Value::String(pg_array_literal(items)),
|
||||||
|
other => other.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// db.insert(table).values(data).returning() : les champs absents prennent la
|
||||||
|
/// valeur par défaut de la colonne
|
||||||
|
pub async fn insert<T: Table>(db: &PgPool, data: &Parsed) -> Result<T, sqlx::Error> {
|
||||||
|
insert_with::<T>(db, data, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comme `insert`, mais les valeurs sont converties avant le calcul des valeurs
|
||||||
|
/// par défaut, comme node-pg dont PostgreSQL lit les paramètres dès leur
|
||||||
|
/// réception : une valeur refusée (date, nombre illisible) ne consomme pas de
|
||||||
|
/// numéro de la séquence de l'identifiant, et la ligne créée ensuite a le même
|
||||||
|
/// identifiant que sous Node. Une erreur d'exécution (clé étrangère) en consomme
|
||||||
|
/// un, comme sous Node.
|
||||||
|
pub async fn insert_values_first<T: Table>(db: &PgPool, data: &Parsed) -> Result<T, sqlx::Error> {
|
||||||
|
insert_with::<T>(db, data, true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_with<T: Table>(db: &PgPool, data: &Parsed, values_first: bool) -> Result<T, sqlx::Error> {
|
||||||
|
let mut columns = Vec::new();
|
||||||
|
let mut placeholders = Vec::new();
|
||||||
|
let mut arguments = PgArguments::default();
|
||||||
|
for col in T::FIELDS {
|
||||||
|
if let Some(value) = data.get(col.key) {
|
||||||
|
columns.push(format!("\"{}\"", col.column));
|
||||||
|
arguments.add(pg_text(value)).map_err(sqlx::Error::Encode)?;
|
||||||
|
placeholders.push(format!("${}::{}", columns.len(), col.sql_type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sql = if columns.is_empty() {
|
||||||
|
format!("INSERT INTO {} DEFAULT VALUES RETURNING {}", T::TABLE, T::COLUMNS)
|
||||||
|
} else if values_first {
|
||||||
|
// CTE matérialisée : ses conversions s'exécutent avant la projection de
|
||||||
|
// l'insertion, où nextval() est appelé
|
||||||
|
let converted: Vec<String> = placeholders
|
||||||
|
.iter()
|
||||||
|
.zip(&columns)
|
||||||
|
.map(|(placeholder, column)| format!("{placeholder} AS {column}"))
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"WITH converted AS MATERIALIZED (SELECT {}) INSERT INTO {} ({}) SELECT {} FROM converted RETURNING {}",
|
||||||
|
converted.join(", "),
|
||||||
|
T::TABLE,
|
||||||
|
columns.join(", "),
|
||||||
|
columns.join(", "),
|
||||||
|
T::COLUMNS
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"INSERT INTO {} ({}) VALUES ({}) RETURNING {}",
|
||||||
|
T::TABLE,
|
||||||
|
columns.join(", "),
|
||||||
|
placeholders.join(", "),
|
||||||
|
T::COLUMNS
|
||||||
|
)
|
||||||
|
};
|
||||||
|
sqlx::query_as_with::<_, T, _>(&sql, arguments)
|
||||||
|
.fetch_one(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// db.update(table).set({ ...data, updatedAt: new Date() }).where(id).returning()
|
||||||
|
/// None si aucune ligne n'a cet identifiant
|
||||||
|
pub async fn update_by_id<T: Table>(
|
||||||
|
db: &PgPool,
|
||||||
|
id: i32,
|
||||||
|
data: &Parsed,
|
||||||
|
touch_updated_at: bool,
|
||||||
|
) -> Result<Option<T>, sqlx::Error> {
|
||||||
|
let mut sets = Vec::new();
|
||||||
|
let mut arguments = PgArguments::default();
|
||||||
|
let mut count = 0;
|
||||||
|
for col in T::FIELDS {
|
||||||
|
let value = if touch_updated_at && col.key == "updatedAt" {
|
||||||
|
Some(Value::String(crate::modules::now_iso()))
|
||||||
|
} else {
|
||||||
|
data.get(col.key).cloned()
|
||||||
|
};
|
||||||
|
if let Some(value) = value {
|
||||||
|
count += 1;
|
||||||
|
arguments.add(pg_text(&value)).map_err(sqlx::Error::Encode)?;
|
||||||
|
sets.push(format!("\"{}\" = ${count}::{}", col.column, col.sql_type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sets.is_empty() {
|
||||||
|
// Drizzle refuse un set() vide (« No values to set »)
|
||||||
|
return Err(sqlx::Error::Protocol("No values to set".into()));
|
||||||
|
}
|
||||||
|
arguments.add(id).map_err(sqlx::Error::Encode)?;
|
||||||
|
let sql = format!(
|
||||||
|
"UPDATE {} SET {} WHERE id = ${} RETURNING {}",
|
||||||
|
T::TABLE,
|
||||||
|
sets.join(", "),
|
||||||
|
count + 1,
|
||||||
|
T::COLUMNS
|
||||||
|
);
|
||||||
|
sqlx::query_as_with::<_, T, _>(&sql, arguments)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// db.delete(table).where(eq(table.id, id))
|
||||||
|
pub async fn delete_by_id<T: Table>(db: &PgPool, id: i32) -> Result<u64, sqlx::Error> {
|
||||||
|
let sql = format!("DELETE FROM {} WHERE id = $1", T::TABLE);
|
||||||
|
Ok(sqlx::query(&sql).bind(id).execute(db).await?.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::schema::CustomerOrdersRow;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn object(value: Value) -> serde_json::Map<String, Value> {
|
||||||
|
value.as_object().cloned().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn horodatage_lu_en_timestamp_comme_en_timestamptz() {
|
||||||
|
let timestamp = <NaiveDateTime as Type<Postgres>>::type_info();
|
||||||
|
let timestamptz = <DateTime<Utc> as Type<Postgres>>::type_info();
|
||||||
|
let date = <chrono::NaiveDate as Type<Postgres>>::type_info();
|
||||||
|
for ty in [×tamp, ×tamptz] {
|
||||||
|
assert!(<AnyTimestamp as Type<Postgres>>::compatible(ty));
|
||||||
|
assert!(<AnyTimestampOpt as Type<Postgres>>::compatible(ty));
|
||||||
|
}
|
||||||
|
assert!(!<AnyTimestamp as Type<Postgres>>::compatible(&date));
|
||||||
|
assert!(!<AnyTimestampOpt as Type<Postgres>>::compatible(&date));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valeurs_comme_node_pg() {
|
||||||
|
// Valeurs de require('pg/lib/utils').prepareValue dans Node
|
||||||
|
assert_eq!(node_pg_text(&json!(null)), None);
|
||||||
|
assert_eq!(node_pg_text(&json!(2030.5)).as_deref(), Some("2030.5"));
|
||||||
|
assert_eq!(node_pg_text(&json!(true)).as_deref(), Some("true"));
|
||||||
|
assert_eq!(node_pg_text(&json!({ "a": 1 })).as_deref(), Some("{\"a\":1}"));
|
||||||
|
assert_eq!(
|
||||||
|
node_pg_text(&json!(["Spécial", "jar\"din", null, [1, 2], { "b": "c\\d" }])).as_deref(),
|
||||||
|
Some(r#"{"Spécial","jar\"din",NULL,{"1","2"},"{\"b\":\"c\\\\d\"}"}"#)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entiers_acceptes_par_postgresql() {
|
||||||
|
assert_eq!(parse_int4("12abc"), Some(12));
|
||||||
|
assert_eq!(parse_int4("-0"), Some(0));
|
||||||
|
assert_eq!(parse_int4("abc"), None);
|
||||||
|
assert_eq!(parse_int4("99999999999"), None);
|
||||||
|
assert_eq!(js_int4(1.5), None);
|
||||||
|
assert_eq!(js_int4(f64::NAN), None);
|
||||||
|
assert_eq!(js_int4(-2147483648.0), Some(i32::MIN));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corps_brut_comme_drizzle() {
|
||||||
|
let parsed = drizzle_values::<CustomerOrdersRow>(&object(json!({
|
||||||
|
"inconnu": 1, "notes": ["a", "b\"c", null], "quantity": "2", "notifiedAt": null, "groupId": 1
|
||||||
|
})))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
Value::Object(parsed),
|
||||||
|
json!({ "quantity": "2", "notifiedAt": null, "notes": r#"{"a","b\"c",NULL}"#, "groupId": 1 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn horodatage_en_texte_refuse() {
|
||||||
|
let error =
|
||||||
|
drizzle_values::<CustomerOrdersRow>(&object(json!({ "createdAt": "2026-10-15" }))).unwrap_err();
|
||||||
|
assert_eq!(error, "value.toISOString is not a function");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colonnes_prefixees() {
|
||||||
|
assert!(columns_of::<CustomerOrdersRow>("co").starts_with("co.\"id\", co.\"order_taker\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Erreurs de l'API : { "message": … } avec le code HTTP, comme les gestionnaires Node.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::http::{HeaderValue, StatusCode, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AppError {
|
||||||
|
pub status: StatusCode,
|
||||||
|
pub body: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ApiResult<T = Response> = Result<T, AppError>;
|
||||||
|
|
||||||
|
impl AppError {
|
||||||
|
pub fn new(status: StatusCode, message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status,
|
||||||
|
body: json!({ "message": message }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn with_body(status: StatusCode, body: Value) -> Self {
|
||||||
|
Self { status, body }
|
||||||
|
}
|
||||||
|
/// requireAuth (server/localAuth.ts)
|
||||||
|
pub fn unauthorized() -> Self {
|
||||||
|
Self::new(StatusCode::UNAUTHORIZED, "Authentification requise")
|
||||||
|
}
|
||||||
|
pub fn bad_request(message: &str) -> Self {
|
||||||
|
Self::new(StatusCode::BAD_REQUEST, message)
|
||||||
|
}
|
||||||
|
pub fn forbidden(message: &str) -> Self {
|
||||||
|
Self::new(StatusCode::FORBIDDEN, message)
|
||||||
|
}
|
||||||
|
pub fn not_found(message: &str) -> Self {
|
||||||
|
Self::new(StatusCode::NOT_FOUND, message)
|
||||||
|
}
|
||||||
|
pub fn internal(message: &str) -> Self {
|
||||||
|
Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
|
||||||
|
}
|
||||||
|
/// Erreur zod renvoyée telle quelle : { message, errors }
|
||||||
|
pub fn validation(message: &str, issues: Vec<Value>) -> Self {
|
||||||
|
Self::with_body(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
json!({ "message": message, "errors": issues }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
(self.status, Json(self.body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erreur imprévue (base, réseau) : journalisée, puis le message 500 du gestionnaire Node
|
||||||
|
pub trait OrInternal<T> {
|
||||||
|
fn or_500(self, message: &str) -> Result<T, AppError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T, E: std::fmt::Display> OrInternal<T> for Result<T, E> {
|
||||||
|
fn or_500(self, message: &str) -> Result<T, AppError> {
|
||||||
|
self.map_err(|error| {
|
||||||
|
tracing::error!(%error, "{message}");
|
||||||
|
AppError::internal(message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// res.json(undefined) : 200, type JSON, corps vide (ligne absente après une mise à jour)
|
||||||
|
pub fn empty_json() -> Response {
|
||||||
|
let mut response = StatusCode::OK.into_response();
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("application/json; charset=utf-8"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse JSON d'une valeur sérialisable, ou corps vide si elle est absente
|
||||||
|
pub fn json_or_empty<T: serde::Serialize>(value: Option<T>) -> Response {
|
||||||
|
match value {
|
||||||
|
Some(value) => Json(value).into_response(),
|
||||||
|
None => empty_json(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// Correspondance des adresses à la manière d'Express : insensible à la casse, et une
|
||||||
|
// barre oblique finale tolérée (« /api/Orders/ » sert « /api/orders »), alors
|
||||||
|
// qu'axum compare exactement. Quand aucune route ne correspond, on cherche celle
|
||||||
|
// qu'Express aurait choisie et on rejoue la requête sur l'adresse canonique ; les
|
||||||
|
// paramètres gardent leur casse d'origine, comme req.params.
|
||||||
|
|
||||||
|
use std::sync::{LazyLock, OnceLock};
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use axum::extract::Request;
|
||||||
|
use axum::http::Uri;
|
||||||
|
use axum::response::Response;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/route_patterns.rs"));
|
||||||
|
|
||||||
|
/// Motif en minuscules → motif déclaré
|
||||||
|
static MATCHER: LazyLock<matchit::Router<&'static str>> = LazyLock::new(|| {
|
||||||
|
let mut matcher = matchit::Router::new();
|
||||||
|
for pattern in ROUTE_PATTERNS {
|
||||||
|
if let Err(error) = matcher.insert(lowercase_static(pattern), *pattern) {
|
||||||
|
tracing::warn!(%error, pattern, "motif d'adresse ignoré");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matcher
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Adresse demandée, avant sa réécriture en adresse canonique
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct OriginalPath(pub String);
|
||||||
|
|
||||||
|
/// Routeur de l'API, pour rejouer une requête sur l'adresse canonique
|
||||||
|
static API: OnceLock<Router> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn install(api: Router) {
|
||||||
|
let _ = API.set(api);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Segments fixes en minuscules, paramètres ({id}) inchangés
|
||||||
|
fn lowercase_static(pattern: &str) -> String {
|
||||||
|
pattern
|
||||||
|
.split('/')
|
||||||
|
.map(|segment| {
|
||||||
|
if segment.starts_with('{') {
|
||||||
|
segment.to_string()
|
||||||
|
} else {
|
||||||
|
segment.to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adresse qu'Express servirait pour `path`, si elle diffère de `path`
|
||||||
|
pub fn canonical_path(path: &str) -> Option<String> {
|
||||||
|
let trimmed = match path.strip_suffix('/') {
|
||||||
|
Some(stripped) if !stripped.is_empty() && !stripped.ends_with('/') => stripped,
|
||||||
|
_ => path,
|
||||||
|
};
|
||||||
|
let lowered = trimmed.to_ascii_lowercase();
|
||||||
|
let matched = MATCHER.at(&lowered).ok()?;
|
||||||
|
let canonical = matched
|
||||||
|
.value
|
||||||
|
.split('/')
|
||||||
|
.zip(trimmed.split('/'))
|
||||||
|
.map(|(pattern, original)| {
|
||||||
|
if pattern.starts_with('{') {
|
||||||
|
original
|
||||||
|
} else {
|
||||||
|
pattern
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
(canonical != path).then_some(canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rejoue la requête sur l'adresse canonique ; rend la requête intacte s'il n'y en a pas
|
||||||
|
pub async fn redispatch(mut request: Request) -> Result<Response, Request> {
|
||||||
|
let Some(api) = API.get() else {
|
||||||
|
return Err(request);
|
||||||
|
};
|
||||||
|
let Some(path) = canonical_path(request.uri().path()) else {
|
||||||
|
return Err(request);
|
||||||
|
};
|
||||||
|
let path_and_query = match request.uri().query() {
|
||||||
|
Some(query) => format!("{path}?{query}"),
|
||||||
|
None => path,
|
||||||
|
};
|
||||||
|
let Ok(uri) = path_and_query.parse::<Uri>() else {
|
||||||
|
return Err(request);
|
||||||
|
};
|
||||||
|
let original = request.uri().path().to_string();
|
||||||
|
request.extensions_mut().insert(OriginalPath(original));
|
||||||
|
*request.uri_mut() = uri;
|
||||||
|
Ok(api
|
||||||
|
.clone()
|
||||||
|
.oneshot(request)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|never| match never {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adresses_comme_express() {
|
||||||
|
assert_eq!(
|
||||||
|
canonical_path("/api/ext/v1/stores/").as_deref(),
|
||||||
|
Some("/api/ext/v1/stores")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
canonical_path("/api/ext/v1/SUPPLIERS").as_deref(),
|
||||||
|
Some("/api/ext/v1/suppliers")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
canonical_path("/api/ext/v1/Deliveries/5/").as_deref(),
|
||||||
|
Some("/api/ext/v1/deliveries/5")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
canonical_path("/API/Backups/Backup_A.SQL/download").as_deref(),
|
||||||
|
Some("/api/backups/Backup_A.SQL/download")
|
||||||
|
);
|
||||||
|
// Déjà canonique, ou deux barres finales (refusées par Express) : rien à faire
|
||||||
|
assert_eq!(canonical_path("/api/orders"), None);
|
||||||
|
assert_eq!(canonical_path("/api/orders//"), None);
|
||||||
|
assert_eq!(canonical_path("/api/inconnue/"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn motifs_extraits_des_modules() {
|
||||||
|
assert!(ROUTE_PATTERNS.contains(&"/api/orders"));
|
||||||
|
assert!(ROUTE_PATTERNS.contains(&"/api/dlc-products/{id}/stock-epuise"));
|
||||||
|
assert!(ROUTE_PATTERNS.contains(&"/api/health"));
|
||||||
|
// Une entrée par adresse, toutes méthodes confondues
|
||||||
|
assert!(ROUTE_PATTERNS.len() > 80, "{} motifs", ROUTE_PATTERNS.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// En-têtes des réponses de l'API servies par Rust, identiques à celles d'Express :
|
||||||
|
// - Cache-Control: private, no-cache (server/routes.ts, middleware /api), sauf
|
||||||
|
// sous /api/ext/v1 (API externe, enregistrée avant ce middleware) ;
|
||||||
|
// - JSON en « application/json; charset=utf-8 » ;
|
||||||
|
// - ETag faible calculé sur le corps (paquet etag d'Express) et 304 si le
|
||||||
|
// navigateur a déjà la même réponse.
|
||||||
|
|
||||||
|
use axum::body::{Body, to_bytes};
|
||||||
|
use axum::extract::Request;
|
||||||
|
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use base64::Engine;
|
||||||
|
use sha1::{Digest, Sha1};
|
||||||
|
|
||||||
|
// Au-delà, le corps n'est pas mis en mémoire pour l'ETag (exports volumineux)
|
||||||
|
const ETAG_MAX_BODY: usize = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub async fn api_response_headers(request: Request, next: Next) -> Response {
|
||||||
|
let method = request.method().clone();
|
||||||
|
let request_headers = request.headers().clone();
|
||||||
|
let external_api = is_external_api(request.uri().path());
|
||||||
|
let response = next.run(request).await;
|
||||||
|
let (mut parts, body) = response.into_parts();
|
||||||
|
|
||||||
|
if !external_api {
|
||||||
|
parts
|
||||||
|
.headers
|
||||||
|
.entry(header::CACHE_CONTROL)
|
||||||
|
.or_insert(HeaderValue::from_static("private, no-cache"));
|
||||||
|
}
|
||||||
|
if parts
|
||||||
|
.headers
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.is_some_and(|v| v == "application/json")
|
||||||
|
{
|
||||||
|
parts.headers.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("application/json; charset=utf-8"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if parts.headers.contains_key(header::ETAG) || parts.headers.contains_key(header::CONTENT_DISPOSITION) {
|
||||||
|
return Response::from_parts(parts, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = match to_bytes(body, ETAG_MAX_BODY).await {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Réponse trop volumineuse").into_response(),
|
||||||
|
};
|
||||||
|
let etag = express_etag(&bytes);
|
||||||
|
if let Ok(value) = HeaderValue::from_str(&etag) {
|
||||||
|
parts.headers.insert(header::ETAG, value);
|
||||||
|
}
|
||||||
|
if matches!(method, Method::GET | Method::HEAD)
|
||||||
|
&& (parts.status.is_success() || parts.status == StatusCode::NOT_MODIFIED)
|
||||||
|
&& is_fresh(&request_headers, &etag)
|
||||||
|
{
|
||||||
|
parts.status = StatusCode::NOT_MODIFIED;
|
||||||
|
for name in [
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
header::CONTENT_LENGTH,
|
||||||
|
header::TRANSFER_ENCODING,
|
||||||
|
] {
|
||||||
|
parts.headers.remove(name);
|
||||||
|
}
|
||||||
|
return Response::from_parts(parts, Body::empty());
|
||||||
|
}
|
||||||
|
Response::from_parts(parts, Body::from(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adresse de l'API externe (/api/ext/v1…) : Node l'enregistre avant son
|
||||||
|
/// middleware /api, ses réponses n'ont donc pas de Cache-Control
|
||||||
|
fn is_external_api(path: &str) -> bool {
|
||||||
|
let prefix = crate::modules::external_api::EXTERNAL_API_PREFIX;
|
||||||
|
path.strip_prefix(prefix)
|
||||||
|
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// W/"<taille en hexadécimal>-<SHA-1 base64, 27 caractères>", comme etag(body, { weak: true })
|
||||||
|
pub fn express_etag(body: &[u8]) -> String {
|
||||||
|
let hash = base64::engine::general_purpose::STANDARD.encode(Sha1::digest(body));
|
||||||
|
format!("W/\"{:x}-{}\"", body.len(), &hash[..27])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_fresh(request: &HeaderMap, etag: &str) -> bool {
|
||||||
|
let cache_control = request
|
||||||
|
.get(header::CACHE_CONTROL)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
if cache_control.contains("no-cache") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(none_match) = request.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let bare = etag.trim_start_matches("W/");
|
||||||
|
none_match.trim() == "*"
|
||||||
|
|| none_match
|
||||||
|
.split(',')
|
||||||
|
.any(|candidate| candidate.trim().trim_start_matches("W/") == bare)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn etag_identique_a_express() {
|
||||||
|
// Valeurs produites par require('etag')(…, { weak: true }) dans Node
|
||||||
|
assert_eq!(express_etag(b""), "W/\"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk\"");
|
||||||
|
assert_eq!(
|
||||||
|
express_etag(b"{\"showDefault\":false}"),
|
||||||
|
express_etag(b"{\"showDefault\":false}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// Tâches de démarrage et de fond, reprises du serveur Node :
|
||||||
|
// - compte administrateur par défaut (createDefaultAdminUser, server/localAuth.ts) ;
|
||||||
|
// - configuration météo depuis VISUAL_CROSSING_API_KEY (server/weatherAutoConfig.ts) ;
|
||||||
|
// - sauvegarde automatique quotidienne, vérifiée toutes les heures (server/backupService.ts) ;
|
||||||
|
// - purge du cache de vérification des factures toutes les 6 h (server/maintenance.ts).
|
||||||
|
// Comme dans Node, une tâche en échec est journalisée sans arrêter le serveur. Le
|
||||||
|
// serveur tourne en UTC (image Docker sans TZ) : « 2 h » et « aujourd'hui » sont en UTC.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::{NaiveDateTime, Timelike, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::modules::{backups, session_routes, weather};
|
||||||
|
|
||||||
|
const BACKUP_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60);
|
||||||
|
const CACHE_PURGE_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||||
|
const CACHE_PURGE_FIRST_DELAY: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// Lance les tâches de fond (sauvegarde, purge, configuration météo)
|
||||||
|
pub fn spawn(state: AppState) {
|
||||||
|
tokio::spawn(initialize_weather_config(state.db.clone()));
|
||||||
|
tokio::spawn(backup_scheduler(state.clone()));
|
||||||
|
tokio::spawn(cache_purge(state.db.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compte « admin » créé s'il n'existe pas (mot de passe « admin », à changer à la
|
||||||
|
/// première connexion), ou mot de passe remis au bon format s'il est illisible.
|
||||||
|
/// La valeur « admin.salt », acceptée par l'accès de développement de Node pour le
|
||||||
|
/// mot de passe « admin », est remplacée par une vraie empreinte du même mot de passe.
|
||||||
|
pub async fn ensure_default_admin(db: &PgPool) {
|
||||||
|
let existing: Result<Option<(String, Option<String>)>, sqlx::Error> =
|
||||||
|
sqlx::query_as("SELECT id, password FROM users WHERE username = 'admin'")
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await;
|
||||||
|
let result = match existing {
|
||||||
|
Ok(None) => {
|
||||||
|
let password = session_routes::hash_password("admin".into()).await;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id, username, email, first_name, last_name, password, role, password_changed)
|
||||||
|
VALUES ('admin_local', 'admin', 'admin@logiflow.com', 'Administrateur', 'Système', $1, 'admin', false)",
|
||||||
|
)
|
||||||
|
.bind(password)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.map(|_| tracing::info!("compte admin créé (mot de passe par défaut : admin)"))
|
||||||
|
}
|
||||||
|
Ok(Some((id, password))) => {
|
||||||
|
let unreadable = password
|
||||||
|
.as_deref()
|
||||||
|
.is_none_or(|p| !p.contains('.') || p == "admin.salt");
|
||||||
|
if unreadable {
|
||||||
|
let password = session_routes::hash_password("admin".into()).await;
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE users SET password = $1, password_changed = false, updated_at = $2 WHERE id = $3",
|
||||||
|
)
|
||||||
|
.bind(password)
|
||||||
|
.bind(now_ms())
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.map(|_| tracing::info!("mot de passe du compte admin remis au bon format"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
};
|
||||||
|
if let Err(error) = result {
|
||||||
|
tracing::error!(%error, "vérification du compte admin en échec");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instant présent à la milliseconde, comme new Date() écrit par Drizzle
|
||||||
|
fn now_ms() -> NaiveDateTime {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
now.with_nanosecond(now.nanosecond() / 1_000_000 * 1_000_000)
|
||||||
|
.unwrap_or(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// initializeWeatherConfig : configuration active déjà présente, ou clé d'API de
|
||||||
|
/// l'environnement testée puis enregistrée pour « Nancy, France »
|
||||||
|
async fn initialize_weather_config(db: PgPool) {
|
||||||
|
let settings = match weather::weather_settings(&db).await {
|
||||||
|
Ok(settings) => settings,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(%error, "configuration météo illisible");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if settings.as_ref().is_some_and(|s| s.is_active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(api_key) = std::env::var("VISUAL_CROSSING_API_KEY")
|
||||||
|
.ok()
|
||||||
|
.filter(|key| !key.is_empty())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
const LOCATION: &str = "Nancy, France";
|
||||||
|
let (success, message) = weather::test_api_connection(&api_key, LOCATION).await;
|
||||||
|
if !success {
|
||||||
|
tracing::error!(%message, "clé météo de l'environnement refusée");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let result = match settings {
|
||||||
|
Some(existing) => sqlx::query(
|
||||||
|
"UPDATE weather_settings SET api_key = $1, location = $2, is_active = true, updated_at = $3 WHERE id = $4",
|
||||||
|
)
|
||||||
|
.bind(&api_key)
|
||||||
|
.bind(LOCATION)
|
||||||
|
.bind(now_ms())
|
||||||
|
.bind(existing.id)
|
||||||
|
.execute(&db)
|
||||||
|
.await,
|
||||||
|
None => sqlx::query("INSERT INTO weather_settings (api_key, location, is_active) VALUES ($1, $2, true)")
|
||||||
|
.bind(&api_key)
|
||||||
|
.bind(LOCATION)
|
||||||
|
.execute(&db)
|
||||||
|
.await,
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(_) => tracing::info!("météo configurée depuis VISUAL_CROSSING_API_KEY"),
|
||||||
|
Err(error) => tracing::error!(%error, "enregistrement de la configuration météo en échec"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toutes les heures (et au démarrage) : après 2 h, une sauvegarde automatique par
|
||||||
|
/// jour, sauf si Paramètres les a désactivées
|
||||||
|
async fn backup_scheduler(state: AppState) {
|
||||||
|
let mut last_automatic_backup: Option<String> = None;
|
||||||
|
let mut interval = tokio::time::interval(BACKUP_CHECK_INTERVAL);
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if let Err(error) = check_automatic_backup(&state, &mut last_automatic_backup).await {
|
||||||
|
tracing::error!(%error, "vérification de la sauvegarde automatique en échec");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_automatic_backup(state: &AppState, last_done: &mut Option<String>) -> anyhow::Result<()> {
|
||||||
|
let enabled: Option<(Option<bool>,)> =
|
||||||
|
sqlx::query_as("SELECT automatic_backups_enabled FROM utilities LIMIT 1")
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
if matches!(enabled, Some((Some(false),))) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Une fois par jour, à partir de 2 h à Paris (décision R5)
|
||||||
|
let now = crate::clock::now();
|
||||||
|
let today = now.format("%Y-%m-%d").to_string();
|
||||||
|
if now.hour() < 2 || last_done.as_deref() == Some(today.as_str()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let last_backup: Option<(Option<NaiveDateTime>,)> = sqlx::query_as(
|
||||||
|
"SELECT created_at FROM database_backups WHERE backup_type = 'automatic' ORDER BY created_at DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
let last_backup_day = last_backup
|
||||||
|
.and_then(|(created,)| created)
|
||||||
|
.map(|created| crate::clock::paris_date(created).format("%Y-%m-%d").to_string());
|
||||||
|
if last_backup_day.as_deref() == Some(today.as_str()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Un administrateur, à défaut n'importe quel compte (clé étrangère created_by)
|
||||||
|
let user: Option<(String,)> =
|
||||||
|
sqlx::query_as("SELECT id FROM users ORDER BY (role = 'admin') DESC LIMIT 1")
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
let Some((user_id,)) = user else {
|
||||||
|
tracing::warn!("aucun compte : sauvegarde automatique ignorée");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
tracing::info!("sauvegarde automatique en cours");
|
||||||
|
backups::create_backup(state, "automatic", &user_id).await?;
|
||||||
|
*last_done = Some(today);
|
||||||
|
tracing::info!("sauvegarde automatique terminée");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Purge du cache de vérification des factures : 60 s après le démarrage, puis toutes les 6 h
|
||||||
|
async fn cache_purge(db: PgPool) {
|
||||||
|
tokio::time::sleep(CACHE_PURGE_FIRST_DELAY).await;
|
||||||
|
let mut interval = tokio::time::interval(CACHE_PURGE_INTERVAL);
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
match sqlx::query("DELETE FROM invoice_verification_cache WHERE expires_at < NOW()")
|
||||||
|
.execute(&db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => tracing::info!(rows = result.rows_affected(), "cache des factures purgé"),
|
||||||
|
Err(error) => tracing::error!(%error, "purge du cache des factures en échec"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
// Conversions au comportement de JavaScript, sur lesquelles le code Node s'appuie
|
||||||
|
// (Number(), parseInt(), new Date(), longueur d'une chaîne, toISOString()).
|
||||||
|
// Le serveur Node tourne en UTC (image Docker sans TZ) : l'heure locale est l'UTC.
|
||||||
|
|
||||||
|
use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};
|
||||||
|
use serde_json::{Number, Value};
|
||||||
|
|
||||||
|
const MAX_DATE_MS: f64 = 8.64e15;
|
||||||
|
|
||||||
|
/// Number(valeur)
|
||||||
|
pub fn to_number(value: &Value) -> f64 {
|
||||||
|
match value {
|
||||||
|
Value::Null => 0.0,
|
||||||
|
Value::Bool(true) => 1.0,
|
||||||
|
Value::Bool(false) => 0.0,
|
||||||
|
Value::Number(number) => number.as_f64().unwrap_or(f64::NAN),
|
||||||
|
Value::String(text) => string_to_number(text),
|
||||||
|
Value::Array(items) => match items.as_slice() {
|
||||||
|
[] => 0.0,
|
||||||
|
[single] if !single.is_array() && !single.is_object() => match single {
|
||||||
|
Value::Null => 0.0,
|
||||||
|
other => to_number(&Value::String(display(other))),
|
||||||
|
},
|
||||||
|
_ => f64::NAN,
|
||||||
|
},
|
||||||
|
Value::Object(_) => f64::NAN,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display(value: &Value) -> String {
|
||||||
|
match value {
|
||||||
|
Value::String(text) => text.clone(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_to_number(text: &str) -> f64 {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let (sign, unsigned) = match trimmed.as_bytes()[0] {
|
||||||
|
b'-' => (-1.0, &trimmed[1..]),
|
||||||
|
b'+' => (1.0, &trimmed[1..]),
|
||||||
|
_ => (1.0, trimmed),
|
||||||
|
};
|
||||||
|
if unsigned == "Infinity" {
|
||||||
|
return sign * f64::INFINITY;
|
||||||
|
}
|
||||||
|
// Préfixes 0x / 0o / 0b : sans signe en JavaScript
|
||||||
|
if sign == 1.0 && trimmed.len() > 2 && !trimmed.starts_with(['+', '-']) {
|
||||||
|
let radix = match &trimmed[..2] {
|
||||||
|
"0x" | "0X" => Some(16),
|
||||||
|
"0o" | "0O" => Some(8),
|
||||||
|
"0b" | "0B" => Some(2),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(radix) = radix {
|
||||||
|
return u64::from_str_radix(&trimmed[2..], radix)
|
||||||
|
.map(|n| n as f64)
|
||||||
|
.unwrap_or(f64::NAN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let valid = {
|
||||||
|
let bytes = unsigned.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut digits = 0;
|
||||||
|
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||||
|
i += 1;
|
||||||
|
digits += 1;
|
||||||
|
}
|
||||||
|
if i < bytes.len() && bytes[i] == b'.' {
|
||||||
|
i += 1;
|
||||||
|
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||||
|
i += 1;
|
||||||
|
digits += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if digits > 0 && i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
|
||||||
|
i += 1;
|
||||||
|
if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let start = i;
|
||||||
|
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if start == i {
|
||||||
|
return f64::NAN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
digits > 0 && i == bytes.len()
|
||||||
|
};
|
||||||
|
if !valid {
|
||||||
|
return f64::NAN;
|
||||||
|
}
|
||||||
|
unsigned.parse::<f64>().map(|n| sign * n).unwrap_or(f64::NAN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valeur « vraie » en JavaScript (`if (x)`, `x || défaut`, `Boolean(x)`) :
|
||||||
|
/// fausse pour null, false, 0, NaN et la chaîne vide
|
||||||
|
pub fn truthy(value: &Value) -> bool {
|
||||||
|
match value {
|
||||||
|
Value::Null => false,
|
||||||
|
Value::Bool(flag) => *flag,
|
||||||
|
Value::Number(number) => number.as_f64().is_some_and(|n| n != 0.0 && !n.is_nan()),
|
||||||
|
Value::String(text) => !text.is_empty(),
|
||||||
|
Value::Array(_) | Value::Object(_) => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// parseInt(texte, 10) : chiffres de tête, None si aucun (NaN)
|
||||||
|
pub fn parse_int(text: &str) -> Option<f64> {
|
||||||
|
let trimmed = text.trim_start();
|
||||||
|
let (sign, rest) = match trimmed.chars().next() {
|
||||||
|
Some('-') => (-1.0, &trimmed[1..]),
|
||||||
|
Some('+') => (1.0, &trimmed[1..]),
|
||||||
|
_ => (1.0, trimmed),
|
||||||
|
};
|
||||||
|
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||||
|
if digits.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
digits.parse::<f64>().ok().map(|n| sign * n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blancs retirés par String.prototype.trim() (WhiteSpace et LineTerminator) :
|
||||||
|
/// U+FEFF en fait partie, U+0085 non, à l'inverse de str::trim
|
||||||
|
fn is_js_whitespace(c: char) -> bool {
|
||||||
|
matches!(
|
||||||
|
c,
|
||||||
|
'\t' | '\n' | '\u{0B}' | '\u{0C}' | '\r' | ' ' | '\u{A0}' | '\u{1680}' | '\u{2000}'
|
||||||
|
..='\u{200A}' | '\u{2028}' | '\u{2029}' | '\u{202F}' | '\u{205F}' | '\u{3000}' | '\u{FEFF}'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// texte.trim()
|
||||||
|
pub fn trim(text: &str) -> &str {
|
||||||
|
text.trim_matches(is_js_whitespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// String(valeur) : texte tel quel, nombre sans « .0 », tableau joint par « , »,
|
||||||
|
/// objet « [object Object] » (gabarits `${…}`, clés d'objet)
|
||||||
|
pub fn to_js_string(value: &Value) -> String {
|
||||||
|
string_of(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// parseFloat(texte) : plus long préfixe décimal après les blancs de tête, NaN sinon
|
||||||
|
pub fn parse_float(text: &str) -> f64 {
|
||||||
|
let text = text.trim_start_matches(is_js_whitespace);
|
||||||
|
let (sign, unsigned) = match text.as_bytes().first() {
|
||||||
|
Some(b'-') => (-1.0, &text[1..]),
|
||||||
|
Some(b'+') => (1.0, &text[1..]),
|
||||||
|
_ => (1.0, text),
|
||||||
|
};
|
||||||
|
if unsigned.starts_with("Infinity") {
|
||||||
|
return sign * f64::INFINITY;
|
||||||
|
}
|
||||||
|
let bytes = unsigned.as_bytes();
|
||||||
|
let mut end = 0;
|
||||||
|
let mut digits = 0;
|
||||||
|
while end < bytes.len() && bytes[end].is_ascii_digit() {
|
||||||
|
end += 1;
|
||||||
|
digits += 1;
|
||||||
|
}
|
||||||
|
if end < bytes.len() && bytes[end] == b'.' {
|
||||||
|
let mut fraction_end = end + 1;
|
||||||
|
while fraction_end < bytes.len() && bytes[fraction_end].is_ascii_digit() {
|
||||||
|
fraction_end += 1;
|
||||||
|
digits += 1;
|
||||||
|
}
|
||||||
|
end = fraction_end;
|
||||||
|
}
|
||||||
|
if digits == 0 {
|
||||||
|
return f64::NAN;
|
||||||
|
}
|
||||||
|
// Exposant seulement s'il a au moins un chiffre (« 1e » vaut 1)
|
||||||
|
if end < bytes.len() && matches!(bytes[end], b'e' | b'E') {
|
||||||
|
let mut exponent_end = end + 1;
|
||||||
|
if exponent_end < bytes.len() && matches!(bytes[exponent_end], b'+' | b'-') {
|
||||||
|
exponent_end += 1;
|
||||||
|
}
|
||||||
|
let start = exponent_end;
|
||||||
|
while exponent_end < bytes.len() && bytes[exponent_end].is_ascii_digit() {
|
||||||
|
exponent_end += 1;
|
||||||
|
}
|
||||||
|
if exponent_end > start {
|
||||||
|
end = exponent_end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unsigned[..end]
|
||||||
|
.parse::<f64>()
|
||||||
|
.map(|n| sign * n)
|
||||||
|
.unwrap_or(f64::NAN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// encodeURIComponent(texte) : tout sauf A-Z a-z 0-9 - _ . ! ~ * ' ( ) en %XX (UTF-8)
|
||||||
|
pub fn encode_uri_component(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
for byte in text.bytes() {
|
||||||
|
if byte.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&byte) {
|
||||||
|
out.push(byte as char);
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("%{byte:02X}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buffer.from(texte, 'base64') : alphabets standard et « url » acceptés, autres
|
||||||
|
/// caractères ignorés, lecture arrêtée au premier « = »
|
||||||
|
pub fn buffer_from_base64(text: &str) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(text.len() * 3 / 4);
|
||||||
|
let (mut bits, mut count) = (0u32, 0u32);
|
||||||
|
for c in text.chars() {
|
||||||
|
let value = match c {
|
||||||
|
'A'..='Z' => c as u32 - 'A' as u32,
|
||||||
|
'a'..='z' => c as u32 - 'a' as u32 + 26,
|
||||||
|
'0'..='9' => c as u32 - '0' as u32 + 52,
|
||||||
|
'+' | '-' => 62,
|
||||||
|
'/' | '_' => 63,
|
||||||
|
'=' => break,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
bits = (bits << 6) | value;
|
||||||
|
count += 6;
|
||||||
|
if count >= 8 {
|
||||||
|
count -= 8;
|
||||||
|
out.push((bits >> count) as u8);
|
||||||
|
bits &= (1 << count) - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nombre JSON tel que JSON.stringify l'écrirait (entier sans « .0 »)
|
||||||
|
pub fn number_value(number: f64) -> Value {
|
||||||
|
if number.fract() == 0.0 && number.abs() <= 9_007_199_254_740_991.0 {
|
||||||
|
Value::Number(Number::from(number as i64))
|
||||||
|
} else {
|
||||||
|
Number::from_f64(number).map(Value::Number).unwrap_or(Value::Null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// String(nombre) pour les messages
|
||||||
|
pub fn number_to_string(number: f64) -> String {
|
||||||
|
number_string(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// String(nombre) complet (Number.prototype.toString) : -0 → « 0 », notation
|
||||||
|
/// exponentielle au-delà de 1e21 et en deçà de 1e-6, NaN et Infinity
|
||||||
|
pub fn number_string(number: f64) -> String {
|
||||||
|
if number.is_nan() {
|
||||||
|
return "NaN".into();
|
||||||
|
}
|
||||||
|
if number.is_infinite() {
|
||||||
|
return if number > 0.0 { "Infinity" } else { "-Infinity" }.into();
|
||||||
|
}
|
||||||
|
if number == 0.0 {
|
||||||
|
return "0".into();
|
||||||
|
}
|
||||||
|
// Chiffres significatifs les plus courts et exposant, comme JavaScript
|
||||||
|
let scientific = format!("{:e}", number.abs());
|
||||||
|
let (mantissa, exponent) = scientific.split_once('e').unwrap_or((&scientific, "0"));
|
||||||
|
let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
|
||||||
|
let k = digits.len() as i32;
|
||||||
|
let n = exponent.parse::<i32>().unwrap_or(0) + 1;
|
||||||
|
let body = if k <= n && n <= 21 {
|
||||||
|
format!("{digits}{}", "0".repeat((n - k) as usize))
|
||||||
|
} else if 0 < n && n <= 21 {
|
||||||
|
format!("{}.{}", &digits[..n as usize], &digits[n as usize..])
|
||||||
|
} else if -6 < n && n <= 0 {
|
||||||
|
format!("0.{}{digits}", "0".repeat((-n) as usize))
|
||||||
|
} else {
|
||||||
|
let sign = if n > 0 { '+' } else { '-' };
|
||||||
|
let (first, rest) = digits.split_at(1);
|
||||||
|
let fraction = if rest.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(".{rest}")
|
||||||
|
};
|
||||||
|
format!("{first}{fraction}e{sign}{}", (n - 1).abs())
|
||||||
|
};
|
||||||
|
if number < 0.0 { format!("-{body}") } else { body }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// String(valeur) d'une valeur JSON (gabarits `${…}`, Array.prototype.join)
|
||||||
|
pub fn string_of(value: &Value) -> String {
|
||||||
|
match value {
|
||||||
|
Value::Null => "null".into(),
|
||||||
|
Value::Bool(flag) => flag.to_string(),
|
||||||
|
Value::Number(number) => number_string(number.as_f64().unwrap_or(f64::NAN)),
|
||||||
|
Value::String(text) => text.clone(),
|
||||||
|
// [a, null, b].join(',') : null vide
|
||||||
|
Value::Array(items) => items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
if item.is_null() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
string_of(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
Value::Object(_) => "[object Object]".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Longueur d'une chaîne en JavaScript (unités UTF-16)
|
||||||
|
pub fn length(text: &str) -> usize {
|
||||||
|
text.encode_utf16().count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// new Date(valeur), None si « Invalid Date »
|
||||||
|
pub fn new_date(value: &Value) -> Option<DateTime<Utc>> {
|
||||||
|
let millis = match value {
|
||||||
|
Value::Null => 0.0,
|
||||||
|
Value::Bool(flag) => f64::from(u8::from(*flag)),
|
||||||
|
Value::Number(number) => number.as_f64()?,
|
||||||
|
Value::String(text) => return parse_date(text),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
from_millis(millis)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_millis(millis: f64) -> Option<DateTime<Utc>> {
|
||||||
|
if !millis.is_finite() || millis.abs() > MAX_DATE_MS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Utc.timestamp_millis_opt(millis.trunc() as i64).single()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Date.parse pour les formats envoyés par l'interface : ISO 8601 (date seule = UTC
|
||||||
|
/// minuit, date et heure sans fuseau = heure locale, donc UTC ici), et « AAAA-MM-JJ HH:MM:SS »
|
||||||
|
pub fn parse_date(text: &str) -> Option<DateTime<Utc>> {
|
||||||
|
let text = text.trim();
|
||||||
|
if let Ok(date) = DateTime::parse_from_rfc3339(text) {
|
||||||
|
return Some(date.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
if let Ok(date) = NaiveDate::parse_from_str(text, "%Y-%m-%d") {
|
||||||
|
return Some(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0)?));
|
||||||
|
}
|
||||||
|
for format in [
|
||||||
|
"%Y-%m-%dT%H:%M:%S%.f",
|
||||||
|
"%Y-%m-%dT%H:%M",
|
||||||
|
"%Y-%m-%d %H:%M:%S%.f",
|
||||||
|
"%Y-%m-%d %H:%M",
|
||||||
|
] {
|
||||||
|
if let Ok(naive) = NaiveDateTime::parse_from_str(text, format) {
|
||||||
|
return Some(Utc.from_utc_datetime(&naive));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for format in [
|
||||||
|
"%Y-%m-%dT%H:%M:%S%.f%:z",
|
||||||
|
"%Y-%m-%dT%H:%M%:z",
|
||||||
|
"%Y-%m-%dT%H:%M:%S%.fZ",
|
||||||
|
"%Y-%m-%dT%H:%MZ",
|
||||||
|
] {
|
||||||
|
if let Ok(date) = DateTime::parse_from_str(text, format) {
|
||||||
|
return Some(date.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
if let Ok(naive) = NaiveDateTime::parse_from_str(text, format) {
|
||||||
|
return Some(Utc.from_utc_datetime(&naive));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(date) = NaiveDate::parse_from_str(text, "%Y-%m") {
|
||||||
|
return Some(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0)?));
|
||||||
|
}
|
||||||
|
DateTime::parse_from_rfc2822(text)
|
||||||
|
.ok()
|
||||||
|
.map(|date| date.with_timezone(&Utc))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// date.toISOString()
|
||||||
|
pub fn iso(date: &DateTime<Utc>) -> String {
|
||||||
|
date.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn number_comme_javascript() {
|
||||||
|
assert_eq!(to_number(&json!("")), 0.0);
|
||||||
|
assert_eq!(to_number(&json!(" 12 ")), 12.0);
|
||||||
|
assert_eq!(to_number(&json!("1e3")), 1000.0);
|
||||||
|
assert_eq!(to_number(&json!("0x1A")), 26.0);
|
||||||
|
assert!(to_number(&json!("12abc")).is_nan());
|
||||||
|
assert!(to_number(&json!("abc")).is_nan());
|
||||||
|
assert_eq!(to_number(&json!(null)), 0.0);
|
||||||
|
assert_eq!(to_number(&json!(true)), 1.0);
|
||||||
|
assert_eq!(to_number(&json!([])), 0.0);
|
||||||
|
assert_eq!(to_number(&json!(["7"])), 7.0);
|
||||||
|
assert!(to_number(&json!({})).is_nan());
|
||||||
|
assert_eq!(to_number(&json!("-3.5")), -3.5);
|
||||||
|
assert_eq!(to_number(&json!(".5")), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verite_comme_javascript() {
|
||||||
|
for falsy in [json!(null), json!(false), json!(0), json!(0.0), json!("")] {
|
||||||
|
assert!(!truthy(&falsy), "{falsy}");
|
||||||
|
}
|
||||||
|
for truthy_value in [json!(true), json!(-1), json!("0"), json!([]), json!({})] {
|
||||||
|
assert!(truthy(&truthy_value), "{truthy_value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_int_comme_javascript() {
|
||||||
|
assert_eq!(parse_int("12abc"), Some(12.0));
|
||||||
|
assert_eq!(parse_int(" -4"), Some(-4.0));
|
||||||
|
assert_eq!(parse_int("abc"), None);
|
||||||
|
assert_eq!(parse_int(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dates_comme_javascript() {
|
||||||
|
assert_eq!(
|
||||||
|
iso(&new_date(&json!("2026-10-15")).unwrap()),
|
||||||
|
"2026-10-15T00:00:00.000Z"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
iso(&new_date(&json!("2026-10-15T08:30")).unwrap()),
|
||||||
|
"2026-10-15T08:30:00.000Z"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
iso(&new_date(&json!("2026-10-15T08:30:00.123+02:00")).unwrap()),
|
||||||
|
"2026-10-15T06:30:00.123Z"
|
||||||
|
);
|
||||||
|
assert_eq!(iso(&new_date(&json!(null)).unwrap()), "1970-01-01T00:00:00.000Z");
|
||||||
|
assert!(new_date(&json!("pas une date")).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn string_de_nombres_comme_javascript() {
|
||||||
|
// Valeurs de String(n) dans Node
|
||||||
|
for (number, expected) in [
|
||||||
|
(0.0, "0"),
|
||||||
|
(-0.0, "0"),
|
||||||
|
(2026.0, "2026"),
|
||||||
|
(-3.0, "-3"),
|
||||||
|
(18.3, "18.3"),
|
||||||
|
(0.1 + 0.2, "0.30000000000000004"),
|
||||||
|
(1.2291666666666667, "1.2291666666666667"),
|
||||||
|
(123456789012345680000.0, "123456789012345680000"),
|
||||||
|
(1e21, "1e+21"),
|
||||||
|
(1.5e300, "1.5e+300"),
|
||||||
|
(0.000001, "0.000001"),
|
||||||
|
(1e-7, "1e-7"),
|
||||||
|
(-2.5e-8, "-2.5e-8"),
|
||||||
|
(f64::NAN, "NaN"),
|
||||||
|
(f64::NEG_INFINITY, "-Infinity"),
|
||||||
|
] {
|
||||||
|
assert_eq!(number_string(number), expected, "{number}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn string_et_verite_des_valeurs_json() {
|
||||||
|
assert_eq!(string_of(&json!([1, null, "a", [2, 3]])), "1,,a,2,3");
|
||||||
|
assert_eq!(string_of(&json!({ "a": 1 })), "[object Object]");
|
||||||
|
assert_eq!(string_of(&json!(null)), "null");
|
||||||
|
assert_eq!(string_of(&json!(48.5)), "48.5");
|
||||||
|
assert!(!truthy(&json!(0)) && !truthy(&json!("")) && !truthy(&json!(null)));
|
||||||
|
assert!(truthy(&json!("0")) && truthy(&json!([])) && truthy(&json!({})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_float_comme_javascript() {
|
||||||
|
assert_eq!(parse_float("18.3"), 18.3);
|
||||||
|
assert_eq!(parse_float(" -2.50abc"), -2.5);
|
||||||
|
assert_eq!(parse_float(".5"), 0.5);
|
||||||
|
assert_eq!(parse_float("5."), 5.0);
|
||||||
|
assert_eq!(parse_float("1e3x"), 1000.0);
|
||||||
|
assert_eq!(parse_float("1e"), 1.0);
|
||||||
|
assert_eq!(parse_float("-Infinityx"), f64::NEG_INFINITY);
|
||||||
|
assert!(parse_float("abc").is_nan());
|
||||||
|
assert!(parse_float(".").is_nan());
|
||||||
|
assert!(parse_float("").is_nan());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_float_valeurs_de_node() {
|
||||||
|
// Valeurs relevées avec parseFloat() dans Node 22
|
||||||
|
assert_eq!(parse_float("1250.5"), 1250.5);
|
||||||
|
assert_eq!(parse_float(" 12.5abc"), 12.5);
|
||||||
|
assert_eq!(parse_float("1 234,56"), 1.0);
|
||||||
|
assert!(parse_float("abc").is_nan());
|
||||||
|
assert!(parse_float("").is_nan());
|
||||||
|
assert_eq!(parse_float(".5"), 0.5);
|
||||||
|
assert_eq!(parse_float("-Infinity x"), f64::NEG_INFINITY);
|
||||||
|
assert_eq!(parse_float("1e3"), 1000.0);
|
||||||
|
assert_eq!(parse_float("0x10"), 0.0);
|
||||||
|
assert_eq!(parse_float("1e"), 1.0);
|
||||||
|
assert_eq!(parse_float("+3."), 3.0);
|
||||||
|
assert_eq!(parse_float("1.2.3"), 1.2);
|
||||||
|
assert_eq!(parse_float("\u{a0} 7"), 7.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chaines_comme_javascript() {
|
||||||
|
assert_eq!(trim(" \u{a0}\u{feff}x\u{85}"), "x\u{85}");
|
||||||
|
assert_eq!(to_js_string(&json!(1.0)), "1");
|
||||||
|
assert_eq!(to_js_string(&json!([1, null, "a"])), "1,,a");
|
||||||
|
assert_eq!(to_js_string(&json!({})), "[object Object]");
|
||||||
|
assert_eq!(
|
||||||
|
encode_uri_component("FAC 2001/é?&=#+()!*'~-_."),
|
||||||
|
"FAC%202001%2F%C3%A9%3F%26%3D%23%2B()!*'~-_."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base64_comme_buffer_from() {
|
||||||
|
// Buffer.from(texte, 'base64').toString('hex') dans Node 22
|
||||||
|
let hex = |text: &str| {
|
||||||
|
buffer_from_base64(text)
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect::<String>()
|
||||||
|
};
|
||||||
|
assert_eq!(hex("ab!cd"), "69b71d");
|
||||||
|
assert_eq!(hex("QU JD"), "414243");
|
||||||
|
assert_eq!(hex("Q=UJD"), "");
|
||||||
|
assert_eq!(hex("QUJ=D"), "4142");
|
||||||
|
assert_eq!(hex("QU-_"), "414fbf");
|
||||||
|
assert_eq!(hex("QUJDRA==x"), "41424344");
|
||||||
|
assert_eq!(hex("Q\u{e9}UJD"), "414243");
|
||||||
|
assert_eq!(buffer_from_base64("JVBE Ri0x\nLjQ=").len(), 8);
|
||||||
|
assert_eq!(buffer_from_base64("a").len(), 0);
|
||||||
|
assert_eq!(buffer_from_base64("AA=A").len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn longueur_utf16() {
|
||||||
|
assert_eq!(length("été"), 3);
|
||||||
|
assert_eq!(length("😀"), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// Formats JSON de l'API Node, à reproduire à l'identique.
|
||||||
|
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde::Serializer;
|
||||||
|
|
||||||
|
/// Colonne timestamp lue par Drizzle : Date JavaScript, « 2026-10-15T08:30:00.000Z »
|
||||||
|
pub fn ts<S: Serializer>(value: &NaiveDateTime, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&format_ts(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ts_opt<S: Serializer>(value: &Option<NaiveDateTime>, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
match value {
|
||||||
|
Some(value) => ts(value, serializer),
|
||||||
|
None => serializer.serialize_none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_ts(value: &NaiveDateTime) -> String {
|
||||||
|
value.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valeur brute du pilote PostgreSQL (colonnes lues par sql`…` dans Drizzle) :
|
||||||
|
/// « 2026-10-15 08:30:00 », fraction de seconde sans zéros de fin
|
||||||
|
pub fn pg_text<S: Serializer>(value: &NaiveDateTime, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&format_pg_text(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pg_text_opt<S: Serializer>(value: &Option<NaiveDateTime>, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
match value {
|
||||||
|
Some(value) => pg_text(value, serializer),
|
||||||
|
None => serializer.serialize_none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_pg_text(value: &NaiveDateTime) -> String {
|
||||||
|
let base = value.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||||
|
let micros = value.and_utc().timestamp_subsec_micros();
|
||||||
|
if micros == 0 {
|
||||||
|
base
|
||||||
|
} else {
|
||||||
|
format!("{base}.{}", format!("{micros:06}").trim_end_matches('0'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colonne numeric(…, ÉCHELLE) lue par node-pg : le texte de PostgreSQL ("12.50").
|
||||||
|
/// sqlx lit zéro sans son échelle (0.00 devient 0) : elle est rétablie ici.
|
||||||
|
pub fn numeric<const SCALE: u32, S: Serializer>(value: &Decimal, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let mut value = *value;
|
||||||
|
if value.scale() < SCALE {
|
||||||
|
value.rescale(SCALE);
|
||||||
|
}
|
||||||
|
serializer.serialize_str(&value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn numeric_opt<const SCALE: u32, S: Serializer>(
|
||||||
|
value: &Option<Decimal>,
|
||||||
|
serializer: S,
|
||||||
|
) -> Result<S::Ok, S::Error> {
|
||||||
|
match value {
|
||||||
|
Some(value) => numeric::<SCALE, S>(value, serializer),
|
||||||
|
None => serializer.serialize_none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Champs d'une ligne, pour la compléter comme `{ ...ligne, group, creator }` en
|
||||||
|
/// JavaScript (les clés ajoutées viennent après celles de la ligne)
|
||||||
|
pub fn spread<T: serde::Serialize>(row: &T) -> serde_json::Map<String, serde_json::Value> {
|
||||||
|
match serde_json::to_value(row) {
|
||||||
|
Ok(serde_json::Value::Object(fields)) => fields,
|
||||||
|
_ => serde_json::Map::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// stripSmtpPassword (server/sanitize.ts), appliqué par Node à toute réponse /api :
|
||||||
|
/// une clé smtpPassword ou smtp_password devient smtpPasswordSet: Boolean(valeur),
|
||||||
|
/// à la même place. À appliquer aux réponses qui joignent la ligne complète d'un
|
||||||
|
/// magasin (getDlcProduct, getSavTicket…).
|
||||||
|
pub fn strip_smtp_password(value: serde_json::Value) -> serde_json::Value {
|
||||||
|
strip_at_depth(value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_at_depth(value: serde_json::Value, depth: usize) -> serde_json::Value {
|
||||||
|
use serde_json::Value;
|
||||||
|
// Même profondeur maximale que Node
|
||||||
|
if depth > 8 {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
match value {
|
||||||
|
Value::Array(items) => Value::Array(
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| strip_at_depth(item, depth + 1))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
Value::Object(entries) => Value::Object(
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, entry)| {
|
||||||
|
if key == "smtpPassword" || key == "smtp_password" {
|
||||||
|
(
|
||||||
|
"smtpPasswordSet".to_string(),
|
||||||
|
Value::Bool(crate::js::truthy(&entry)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(key, strip_at_depth(entry, depth + 1))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn montants_avec_l_echelle_de_la_colonne() {
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct Row {
|
||||||
|
#[serde(serialize_with = "numeric_opt::<2, _>")]
|
||||||
|
amount: Option<Decimal>,
|
||||||
|
}
|
||||||
|
let text = |amount| serde_json::to_string(&Row { amount }).unwrap();
|
||||||
|
assert_eq!(text(Some(Decimal::ZERO)), r#"{"amount":"0.00"}"#);
|
||||||
|
assert_eq!(text(Some(Decimal::new(1250, 2))), r#"{"amount":"12.50"}"#);
|
||||||
|
assert_eq!(text(None), r#"{"amount":null}"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mot_de_passe_smtp_masque() {
|
||||||
|
let group =
|
||||||
|
serde_json::json!({ "id": 1, "smtpUser": "u", "smtpPassword": "enc:v1:x", "name": "Nancy" });
|
||||||
|
let stripped = strip_smtp_password(serde_json::json!([{ "group": group }, { "smtp_password": "" }]));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&stripped).unwrap(),
|
||||||
|
r#"[{"group":{"id":1,"smtpUser":"u","smtpPasswordSet":true,"name":"Nancy"}},{"smtpPasswordSet":false}]"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats() {
|
||||||
|
let date = NaiveDate::from_ymd_opt(2026, 10, 15)
|
||||||
|
.unwrap()
|
||||||
|
.and_hms_micro_opt(8, 30, 0, 123_400)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(format_ts(&date), "2026-10-15T08:30:00.123Z");
|
||||||
|
assert_eq!(format_pg_text(&date), "2026-10-15 08:30:00.1234");
|
||||||
|
let whole = NaiveDate::from_ymd_opt(2026, 10, 15)
|
||||||
|
.unwrap()
|
||||||
|
.and_hms_opt(8, 0, 0)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(format_pg_text(&whole), "2026-10-15 08:00:00");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Serveur LogiFlow : l'API (router.rs), l'interface construite par Vite
|
||||||
|
// (static_files.rs), les migrations de démarrage et les tâches de fond.
|
||||||
|
// Il a remplacé le serveur Node (docs/PLAN-MIGRATION-RUST.md).
|
||||||
|
|
||||||
|
mod access;
|
||||||
|
mod auth;
|
||||||
|
mod clock;
|
||||||
|
mod config;
|
||||||
|
mod crypto;
|
||||||
|
mod db;
|
||||||
|
mod error;
|
||||||
|
mod express_routes;
|
||||||
|
mod http;
|
||||||
|
mod jobs;
|
||||||
|
mod js;
|
||||||
|
mod json;
|
||||||
|
mod modules;
|
||||||
|
mod origin;
|
||||||
|
mod projections;
|
||||||
|
mod request;
|
||||||
|
mod router;
|
||||||
|
mod schema;
|
||||||
|
mod services;
|
||||||
|
mod startup;
|
||||||
|
mod static_files;
|
||||||
|
mod zod;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub config: Arc<Config>,
|
||||||
|
pub db: sqlx::PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
// Pas de couleurs dans les journaux Docker
|
||||||
|
.with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout()))
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::try_from_env("RUST_LOG").unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = Config::from_env()?;
|
||||||
|
// Connexion paresseuse : le serveur démarre même si la base n'est pas encore prête
|
||||||
|
let db = PgPoolOptions::new()
|
||||||
|
.max_connections(20)
|
||||||
|
.acquire_timeout(Duration::from_secs(15))
|
||||||
|
.idle_timeout(Duration::from_secs(30))
|
||||||
|
.connect_lazy(&config.database_url)?;
|
||||||
|
|
||||||
|
// Comme Node : migrations avant d'accepter des requêtes, puis compte admin,
|
||||||
|
// index en arrière-plan et tâches de fond
|
||||||
|
startup::run_migrations(&db, &config.encryption_key).await;
|
||||||
|
jobs::ensure_default_admin(&db).await;
|
||||||
|
startup::spawn_performance_indexes(db.clone());
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: Arc::new(config),
|
||||||
|
db,
|
||||||
|
};
|
||||||
|
let port = state.config.port;
|
||||||
|
tracing::info!(port, "LogiFlow démarre");
|
||||||
|
|
||||||
|
jobs::spawn(state.clone());
|
||||||
|
let app = router::build(state);
|
||||||
|
let listener = tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))).await?;
|
||||||
|
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
};
|
||||||
|
let terminate = async {
|
||||||
|
if let Ok(mut signal) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||||
|
signal.recv().await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = terminate => {},
|
||||||
|
}
|
||||||
|
tracing::info!("arrêt demandé");
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
// Administration : diagnostic du schéma (GET /api/debug/log-schema et
|
||||||
|
// /api/debug/download-schema, réservés à l'administrateur et à la production),
|
||||||
|
// exécution de SQL (sql.rs) et adresses de secours (emergency.rs : migration
|
||||||
|
// d'urgence, réinitialisation du compte admin).
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{HeaderValue, StatusCode, header};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::{Json, Router, routing::get, routing::post};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::error::{ApiResult, AppError};
|
||||||
|
use crate::http::express_etag;
|
||||||
|
use crate::js;
|
||||||
|
|
||||||
|
mod emergency;
|
||||||
|
mod sql;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/debug/log-schema", get(log_schema))
|
||||||
|
.route("/api/debug/download-schema", get(download_schema))
|
||||||
|
.route("/api/admin/execute-sql", post(sql::execute_sql))
|
||||||
|
.route(
|
||||||
|
"/api/admin/emergency-migration",
|
||||||
|
post(emergency::emergency_migration),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/emergency-admin-reset",
|
||||||
|
post(emergency::emergency_admin_reset),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RULE: &str = "===============================================";
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Column {
|
||||||
|
column_name: String,
|
||||||
|
data_type: String,
|
||||||
|
character_maximum_length: Option<i32>,
|
||||||
|
is_nullable: String,
|
||||||
|
column_default: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Table {
|
||||||
|
name: String,
|
||||||
|
columns: Vec<Column>,
|
||||||
|
/// Nombre de lignes, ou message de l'erreur de comptage
|
||||||
|
count: Result<i64, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ForeignKey {
|
||||||
|
table_name: String,
|
||||||
|
column_name: String,
|
||||||
|
foreign_table_name: String,
|
||||||
|
foreign_column_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tables du schéma public, leurs colonnes et leur nombre de lignes, puis les
|
||||||
|
/// clés étrangères : mêmes requêtes que Node
|
||||||
|
async fn scan(db: &sqlx::PgPool) -> Result<(Vec<Table>, Vec<ForeignKey>), sqlx::Error> {
|
||||||
|
let names: Vec<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT table_name::text
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
ORDER BY table_name",
|
||||||
|
)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
let mut tables = Vec::with_capacity(names.len());
|
||||||
|
for (name,) in names {
|
||||||
|
let columns: Vec<Column> = sqlx::query_as(
|
||||||
|
"SELECT column_name::text, data_type::text, character_maximum_length::int,
|
||||||
|
is_nullable::text, column_default::text
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = $1
|
||||||
|
ORDER BY ordinal_position",
|
||||||
|
)
|
||||||
|
.bind(&name)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
let count = if columns.is_empty() {
|
||||||
|
Ok(0)
|
||||||
|
} else {
|
||||||
|
// Nom lu dans le catalogue, entre guillemets comme dans Node
|
||||||
|
sqlx::query_as::<_, (i64,)>(&format!(
|
||||||
|
"SELECT COUNT(*) AS total FROM \"{}\"",
|
||||||
|
name.replace('"', "\"\"")
|
||||||
|
))
|
||||||
|
.fetch_one(db)
|
||||||
|
.await
|
||||||
|
.map(|(total,)| total)
|
||||||
|
.map_err(|error| match error {
|
||||||
|
sqlx::Error::Database(database) => database.message().to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
tables.push(Table { name, columns, count });
|
||||||
|
}
|
||||||
|
let foreign_keys: Vec<ForeignKey> = sqlx::query_as(
|
||||||
|
"SELECT
|
||||||
|
tc.table_name::text,
|
||||||
|
kcu.column_name::text,
|
||||||
|
ccu.table_name::text AS foreign_table_name,
|
||||||
|
ccu.column_name::text AS foreign_column_name,
|
||||||
|
tc.constraint_name::text
|
||||||
|
FROM information_schema.table_constraints AS tc
|
||||||
|
JOIN information_schema.key_column_usage AS kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
AND tc.table_schema = kcu.table_schema
|
||||||
|
JOIN information_schema.constraint_column_usage AS ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
AND ccu.table_schema = tc.table_schema
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc.table_schema = 'public'
|
||||||
|
ORDER BY tc.table_name, kcu.column_name",
|
||||||
|
)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
Ok((tables, foreign_keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ` 01. nom_de_colonne : type(255) NULL DEFAULT …`
|
||||||
|
fn column_line(index: usize, column: &Column) -> String {
|
||||||
|
let padding = 25usize.saturating_sub(js::length(&column.column_name));
|
||||||
|
let max_length = match column.character_maximum_length {
|
||||||
|
Some(length) if length != 0 => format!("({length})"),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let nullable = if column.is_nullable == "YES" {
|
||||||
|
"NULL"
|
||||||
|
} else {
|
||||||
|
"NOT NULL"
|
||||||
|
};
|
||||||
|
let default = match column.column_default.as_deref() {
|
||||||
|
Some(default) if !default.is_empty() => format!(" DEFAULT {default}"),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
" {:02}. {}{} : {}{max_length} {nullable}{default}",
|
||||||
|
index + 1,
|
||||||
|
column.column_name,
|
||||||
|
" ".repeat(padding),
|
||||||
|
column.data_type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_line(count: &Result<i64, String>) -> String {
|
||||||
|
match count {
|
||||||
|
Ok(total) => format!(" 📈 Nombre d'enregistrements: {total}"),
|
||||||
|
Err(message) => format!(" ⚠️ Impossible de compter les enregistrements: {message}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn foreign_key_line(fk: &ForeignKey) -> String {
|
||||||
|
format!(
|
||||||
|
" {}.{} → {}.{}",
|
||||||
|
fk.table_name, fk.column_name, fk.foreign_table_name, fk.foreign_column_name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_access(user: &CurrentUser, state: &AppState, denied: &str) -> Result<(), AppError> {
|
||||||
|
if !user.is_admin() {
|
||||||
|
return Err(AppError::forbidden(denied));
|
||||||
|
}
|
||||||
|
if state.config.environment != "production" {
|
||||||
|
return Err(AppError::with_body(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
json!({ "error": "Cette route fonctionne uniquement en production sur votre serveur privé" }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_error(error: sqlx::Error) -> AppError {
|
||||||
|
tracing::error!(%error, "scan du schéma");
|
||||||
|
let message = match &error {
|
||||||
|
sqlx::Error::Database(database) => database.message().to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
AppError::with_body(StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": message }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Écrit le schéma dans le journal du serveur et renvoie un résumé
|
||||||
|
async fn log_schema(State(state): State<AppState>, user: CurrentUser) -> ApiResult {
|
||||||
|
check_access(
|
||||||
|
&user,
|
||||||
|
&state,
|
||||||
|
"Accès refusé. Seuls les administrateurs peuvent utiliser cette route de debug.",
|
||||||
|
)?;
|
||||||
|
let timestamp = crate::modules::now_iso();
|
||||||
|
let (tables, foreign_keys) = scan(&state.db).await.map_err(scan_error)?;
|
||||||
|
|
||||||
|
let mut log = format!(
|
||||||
|
"\n🔍 ===== DÉBUT SCAN SCHÉMA BASE DE DONNÉES =====\n⏰ Timestamp: {timestamp}\n\n📊 TOTAL DES TABLES TROUVÉES: {}\n==========================================\n",
|
||||||
|
tables.len()
|
||||||
|
);
|
||||||
|
for table in &tables {
|
||||||
|
log.push_str(&format!(
|
||||||
|
"\n🔸 TABLE: {}\n{}\n",
|
||||||
|
table.name.to_uppercase(),
|
||||||
|
"=".repeat(js::length(&table.name) + 8)
|
||||||
|
));
|
||||||
|
if !table.columns.is_empty() {
|
||||||
|
for (index, column) in table.columns.iter().enumerate() {
|
||||||
|
log.push_str(&column_line(index, column));
|
||||||
|
log.push('\n');
|
||||||
|
}
|
||||||
|
log.push_str(&count_line(&table.count));
|
||||||
|
log.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.push_str("\n🔗 CONTRAINTES DE CLÉS ÉTRANGÈRES:\n===================================\n");
|
||||||
|
if foreign_keys.is_empty() {
|
||||||
|
log.push_str(" Aucune contrainte de clé étrangère trouvée\n");
|
||||||
|
}
|
||||||
|
for fk in &foreign_keys {
|
||||||
|
log.push_str(&foreign_key_line(fk));
|
||||||
|
log.push('\n');
|
||||||
|
}
|
||||||
|
log.push_str("\n🏁 ===== FIN SCAN SCHÉMA BASE DE DONNÉES =====\n");
|
||||||
|
tracing::info!("{log}");
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Schéma loggé avec succès dans les logs du serveur",
|
||||||
|
"totalTables": tables.len(),
|
||||||
|
"totalForeignKeys": foreign_keys.len(),
|
||||||
|
"timestamp": crate::modules::now_iso(),
|
||||||
|
"downloadUrl": "/api/debug/download-schema",
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rapport texte du schéma, en pièce jointe logiflow-schema-AAAA-MM-JJ.txt
|
||||||
|
async fn download_schema(State(state): State<AppState>, user: CurrentUser) -> ApiResult {
|
||||||
|
check_access(
|
||||||
|
&user,
|
||||||
|
&state,
|
||||||
|
"Accès refusé. Seuls les administrateurs peuvent télécharger le rapport de schéma.",
|
||||||
|
)?;
|
||||||
|
let timestamp = crate::modules::now_iso();
|
||||||
|
let mut report = format!(
|
||||||
|
"LOGIFLOW - RAPPORT SCHÉMA BASE DE DONNÉES PRODUCTION\n{RULE}\n\nGÉNÉRÉ LE : {timestamp}\nSERVEUR : Production privé LogiFlow\nENVIRONNEMENT : PostgreSQL Docker\nUTILISATEUR : {}\n\n{RULE}\nRÉSUMÉ DU SCAN\n{RULE}\n\n",
|
||||||
|
user.user.username
|
||||||
|
);
|
||||||
|
let (tables, foreign_keys) = scan(&state.db).await.map_err(scan_error)?;
|
||||||
|
|
||||||
|
report.push_str(&format!(
|
||||||
|
"TOTAL DES TABLES TROUVÉES: {}\n==========================================\n\n",
|
||||||
|
tables.len()
|
||||||
|
));
|
||||||
|
for table in &tables {
|
||||||
|
report.push_str(&format!(
|
||||||
|
"TABLE: {}\n{}\n\n",
|
||||||
|
table.name.to_uppercase(),
|
||||||
|
"=".repeat(js::length(&table.name) + 6)
|
||||||
|
));
|
||||||
|
if !table.columns.is_empty() {
|
||||||
|
for (index, column) in table.columns.iter().enumerate() {
|
||||||
|
report.push_str(&column_line(index, column));
|
||||||
|
report.push('\n');
|
||||||
|
}
|
||||||
|
report.push_str(&count_line(&table.count));
|
||||||
|
report.push_str("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.push_str("CONTRAINTES DE CLÉS ÉTRANGÈRES:\n===================================\n\n");
|
||||||
|
if foreign_keys.is_empty() {
|
||||||
|
report.push_str(" Aucune contrainte de clé étrangère trouvée\n");
|
||||||
|
}
|
||||||
|
for fk in &foreign_keys {
|
||||||
|
report.push_str(&foreign_key_line(fk));
|
||||||
|
report.push('\n');
|
||||||
|
}
|
||||||
|
report.push_str(&format!("\n{RULE}\nFIN DU RAPPORT - {timestamp}\n{RULE}"));
|
||||||
|
|
||||||
|
let filename = format!("logiflow-schema-{}.txt", &crate::modules::now_iso()[..10]);
|
||||||
|
// res.send calcule l'ETag sur le corps
|
||||||
|
let etag = express_etag(report.as_bytes());
|
||||||
|
let mut response = report.into_response();
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||||
|
);
|
||||||
|
if let Ok(value) = HeaderValue::from_str(&format!("attachment; filename=\"{filename}\"")) {
|
||||||
|
headers.insert(header::CONTENT_DISPOSITION, value);
|
||||||
|
}
|
||||||
|
if let Ok(value) = HeaderValue::from_str(&etag) {
|
||||||
|
headers.insert(header::ETAG, value);
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ligne_de_colonne_comme_node() {
|
||||||
|
let column = Column {
|
||||||
|
column_name: "smtp_password".into(),
|
||||||
|
data_type: "character varying".into(),
|
||||||
|
character_maximum_length: Some(255),
|
||||||
|
is_nullable: "YES".into(),
|
||||||
|
column_default: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
column_line(21, &column),
|
||||||
|
" 22. smtp_password : character varying(255) NULL"
|
||||||
|
);
|
||||||
|
let id = Column {
|
||||||
|
column_name: "id".into(),
|
||||||
|
data_type: "integer".into(),
|
||||||
|
character_maximum_length: None,
|
||||||
|
is_nullable: "NO".into(),
|
||||||
|
column_default: Some("nextval('groups_id_seq'::regclass)".into()),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
column_line(0, &id),
|
||||||
|
" 01. id : integer NOT NULL DEFAULT nextval('groups_id_seq'::regclass)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Adresses de secours :
|
||||||
|
// - POST /api/admin/emergency-migration (server/routes.ts, admin) : relance les
|
||||||
|
// migrations de démarrage (startup.rs), puis la création des index en
|
||||||
|
// arrière-plan ; les erreurs sont journalisées, la réponse est toujours un succès ;
|
||||||
|
// - POST /api/emergency-admin-reset (server/index.production.ts, sans session) :
|
||||||
|
// remet le mot de passe « admin » au compte admin si le corps porte le secret
|
||||||
|
// EMERGENCY_SECRET de l'environnement ; l'adresse n'existe pas (404) sans lui.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::db::Table;
|
||||||
|
use crate::error::{ApiResult, AppError};
|
||||||
|
use crate::modules::now_iso;
|
||||||
|
use crate::modules::session_routes::hash_password;
|
||||||
|
use crate::schema::UsersRow;
|
||||||
|
use crate::zod::Parsed;
|
||||||
|
|
||||||
|
pub async fn emergency_migration(State(state): State<AppState>, user: CurrentUser) -> ApiResult {
|
||||||
|
if !user.is_admin() {
|
||||||
|
return Err(AppError::forbidden("Insufficient permissions"));
|
||||||
|
}
|
||||||
|
tracing::warn!(user = user.id(), "migration d'urgence demandée");
|
||||||
|
crate::startup::run_migrations(&state.db, &state.config.encryption_key).await;
|
||||||
|
crate::startup::spawn_performance_indexes(state.db.clone());
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Emergency migration executed successfully",
|
||||||
|
"timestamp": now_iso(),
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_body(status: StatusCode, body: Value) -> AppError {
|
||||||
|
AppError::with_body(status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn emergency_admin_reset(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
crate::request::JsonBody(body): crate::request::JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
let secret = std::env::var("EMERGENCY_SECRET").unwrap_or_default();
|
||||||
|
if secret.is_empty() {
|
||||||
|
return Err(error_body(StatusCode::NOT_FOUND, json!({ "error": "Not found" })));
|
||||||
|
}
|
||||||
|
if body.get("secret").and_then(Value::as_str) != Some(secret.as_str()) {
|
||||||
|
tracing::warn!("réinitialisation du compte admin avec un secret invalide");
|
||||||
|
return Err(error_body(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
json!({ "error": "Invalid emergency secret" }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tracing::warn!("réinitialisation d'urgence du mot de passe admin");
|
||||||
|
let failed = |error: sqlx::Error| {
|
||||||
|
error_body(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "Reset failed", "details": crate::services::nocodb::database_message(&error) }),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// storage.getUserByUsername('admin')
|
||||||
|
let admin: Option<UsersRow> = sqlx::query_as(&UsersRow::select("WHERE username = $1"))
|
||||||
|
.bind("admin")
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(failed)?;
|
||||||
|
let Some(admin) = admin else {
|
||||||
|
return Err(error_body(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
json!({ "error": "Admin user not found" }),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let mut data = Parsed::new();
|
||||||
|
data.insert("password".into(), json!(hash_password("admin".to_string()).await));
|
||||||
|
data.insert("passwordChanged".into(), json!(false));
|
||||||
|
crate::modules::users::update_user(&state.db, &admin.id, &data)
|
||||||
|
.await
|
||||||
|
.map_err(failed)?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Admin password reset to admin/admin",
|
||||||
|
"adminId": admin.id,
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
// POST /api/admin/execute-sql (server/routes.ts) : SQL saisi par l'administrateur
|
||||||
|
// dans la page SQL de l'interface (SQLExecutor.tsx), exécuté tel quel, comme
|
||||||
|
// db.execute(sql.raw(…)) de Drizzle sur node-pg : protocole simple (plusieurs
|
||||||
|
// instructions possibles, résultats en texte), lignes converties par les
|
||||||
|
// analyseurs de node-pg (pg-types), sauf dates, horodatages et intervalles que
|
||||||
|
// Drizzle laisse en texte.
|
||||||
|
//
|
||||||
|
// Faille connue, reprise telle quelle (audit SQL-01) : aucune confirmation ni
|
||||||
|
// restriction des instructions, l'administrateur exécute ce qu'il veut.
|
||||||
|
//
|
||||||
|
// Approximations :
|
||||||
|
// - rowCount : node-pg lit le nombre de l'étiquette de fin de commande
|
||||||
|
// (« UPDATE 3 »), null quand elle n'en porte pas (« CREATE TABLE ») ; sqlx ne
|
||||||
|
// rend que ce nombre (0 sans nombre), la commande est donc reconnue à son
|
||||||
|
// premier mot quand il vaut 0 ;
|
||||||
|
// - tableaux d'intervalles : laissés en texte (objets PostgresInterval dans Node).
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use chrono::{NaiveDate, TimeZone, Utc};
|
||||||
|
use futures_util::TryStreamExt;
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use sqlx::postgres::PgRow;
|
||||||
|
use sqlx::{Column, Either, Row, ValueRef};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::error::{ApiResult, AppError};
|
||||||
|
use crate::js;
|
||||||
|
use crate::request::JsonBody;
|
||||||
|
|
||||||
|
fn error_body(status: StatusCode, body: Value) -> AppError {
|
||||||
|
AppError::with_body(status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Exécution ----------
|
||||||
|
|
||||||
|
/// Ce que node-pg rend : un résultat par instruction terminée (tableau de
|
||||||
|
/// résultats au-delà d'une), les lignes de l'unique instruction
|
||||||
|
struct Outcome {
|
||||||
|
/// rows_affected de chaque fin de commande, dans l'ordre
|
||||||
|
completions: Vec<u64>,
|
||||||
|
rows: Vec<Map<String, Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(db: &sqlx::PgPool, sql: &str) -> Result<Outcome, sqlx::Error> {
|
||||||
|
let mut stream = sqlx::raw_sql(sql).fetch_many(db);
|
||||||
|
let mut outcome = Outcome {
|
||||||
|
completions: Vec::new(),
|
||||||
|
rows: Vec::new(),
|
||||||
|
};
|
||||||
|
while let Some(item) = stream.try_next().await? {
|
||||||
|
match item {
|
||||||
|
Either::Left(done) => outcome.completions.push(done.rows_affected()),
|
||||||
|
Either::Right(row) => outcome.rows.push(row_json(&row)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ligne en objet : une colonne de même nom remplace la précédente, à sa place
|
||||||
|
fn row_json(row: &PgRow) -> Map<String, Value> {
|
||||||
|
let mut out = Map::new();
|
||||||
|
for column in row.columns() {
|
||||||
|
let value = match row.try_get_raw(column.ordinal()) {
|
||||||
|
Ok(raw) if !raw.is_null() => {
|
||||||
|
let oid = raw.type_info().oid().map_or(0, |oid| oid.0);
|
||||||
|
match raw.as_str() {
|
||||||
|
Ok(text) => node_pg_value(oid, text),
|
||||||
|
Err(_) => Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Value::Null,
|
||||||
|
};
|
||||||
|
out.insert(column.name().to_string(), value);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Premier mot d'une instruction, commentaires et parenthèses ouvrantes passés
|
||||||
|
fn first_keyword(sql: &str) -> String {
|
||||||
|
let mut rest = sql;
|
||||||
|
loop {
|
||||||
|
rest = rest.trim_start_matches(|c: char| c.is_whitespace() || c == '(');
|
||||||
|
if let Some(after) = rest.strip_prefix("--") {
|
||||||
|
rest = after.split_once('\n').map_or("", |(_, next)| next);
|
||||||
|
} else if let Some(after) = rest.strip_prefix("/*") {
|
||||||
|
rest = after.split_once("*/").map_or("", |(_, next)| next);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest.chars()
|
||||||
|
.take_while(|c| c.is_ascii_alphabetic())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_ascii_uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// L'étiquette de fin de commande porte-t-elle un nombre ? (« SELECT n »,
|
||||||
|
/// « INSERT 0 n », … ; CREATE TABLE … AS et CREATE MATERIALIZED VIEW rendent
|
||||||
|
/// « SELECT n »)
|
||||||
|
fn tag_has_count(sql: &str) -> bool {
|
||||||
|
const COUNTED: &[&str] = &[
|
||||||
|
"SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "MOVE", "FETCH", "COPY", "VALUES", "TABLE", "WITH",
|
||||||
|
];
|
||||||
|
let keyword = first_keyword(sql);
|
||||||
|
if COUNTED.contains(&keyword.as_str()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if keyword != "CREATE" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let words: Vec<String> = sql
|
||||||
|
.split(|c: char| c.is_whitespace() || c == '(' || c == ')')
|
||||||
|
.filter(|word| !word.is_empty())
|
||||||
|
.map(str::to_ascii_uppercase)
|
||||||
|
.collect();
|
||||||
|
let Some(as_index) = words.iter().position(|word| word == "AS") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let before = &words[..as_index];
|
||||||
|
let creates_table = before
|
||||||
|
.iter()
|
||||||
|
.any(|word| word == "TABLE" || word == "MATERIALIZED");
|
||||||
|
let query_follows = words
|
||||||
|
.get(as_index + 1)
|
||||||
|
.is_some_and(|word| ["SELECT", "VALUES", "TABLE", "WITH", "EXECUTE"].contains(&word.as_str()));
|
||||||
|
creates_table && query_follows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rowCount d'une instruction seule : null si l'étiquette n'a pas de nombre
|
||||||
|
fn row_count(sql: &str, rows_affected: u64) -> Value {
|
||||||
|
if rows_affected > 0 || tag_has_count(sql) {
|
||||||
|
json!(rows_affected)
|
||||||
|
} else {
|
||||||
|
Value::Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_sql(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
if !user.is_admin() {
|
||||||
|
return Err(error_body(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
json!({ "error": "Accès refusé - Admin uniquement" }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(sql) = body
|
||||||
|
.get("sql")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|sql| !sql.is_empty())
|
||||||
|
else {
|
||||||
|
return Err(error_body(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
json!({ "error": "SQL query requis" }),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
tracing::info!(user = user.id(), "exécution SQL de l'administrateur");
|
||||||
|
let mut logs = vec!["🔄 Exécution SQL démarrée...".to_string()];
|
||||||
|
|
||||||
|
let outcome = match run(&state.db, sql).await {
|
||||||
|
Ok(outcome) => outcome,
|
||||||
|
Err(error) => {
|
||||||
|
let message = crate::services::nocodb::database_message(&error);
|
||||||
|
logs.push(format!("❌ Erreur SQL: {message}"));
|
||||||
|
return Err(error_body(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": format!("Erreur SQL: {message}"), "logs": logs }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logs.push("✅ SQL exécuté avec succès".to_string());
|
||||||
|
// Plusieurs instructions : node-pg rend un tableau de résultats, sans rows ni rowCount
|
||||||
|
if outcome.completions.len() > 1 {
|
||||||
|
logs.push("📊 Nombre de lignes affectées: 0".to_string());
|
||||||
|
return Ok(Json(json!({ "success": true, "logs": logs })).into_response());
|
||||||
|
}
|
||||||
|
// Requête vide (blancs seuls) : aucun nombre
|
||||||
|
let count = outcome
|
||||||
|
.completions
|
||||||
|
.first()
|
||||||
|
.map_or(Value::Null, |rows_affected| row_count(sql, *rows_affected));
|
||||||
|
logs.push(format!(
|
||||||
|
"📊 Nombre de lignes affectées: {}",
|
||||||
|
count.as_u64().unwrap_or(0)
|
||||||
|
));
|
||||||
|
let rows: Vec<Value> = outcome.rows.into_iter().map(Value::Object).collect();
|
||||||
|
if !rows.is_empty() {
|
||||||
|
logs.push(format!("📋 Nombre de lignes retournées: {}", rows.len()));
|
||||||
|
// JSON.stringify(lignes, null, 2)
|
||||||
|
if rows.len() <= 10 {
|
||||||
|
logs.push(format!(
|
||||||
|
"📋 Résultats: {}",
|
||||||
|
serde_json::to_string_pretty(&rows).unwrap_or_default()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
logs.push(format!(
|
||||||
|
"📋 Échantillon (10 premières lignes): {}",
|
||||||
|
serde_json::to_string_pretty(&rows[..10]).unwrap_or_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "success": true, "logs": logs, "results": rows, "rowCount": count })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Valeurs, au comportement de node-pg (pg-types) ----------
|
||||||
|
|
||||||
|
/// Valeur texte d'une colonne selon son type (OID PostgreSQL)
|
||||||
|
fn node_pg_value(oid: u32, text: &str) -> Value {
|
||||||
|
match oid {
|
||||||
|
16 => json!(parse_bool(text)),
|
||||||
|
17 => buffer_json(&parse_bytea(text)),
|
||||||
|
// int2, int4, oid : parseInt
|
||||||
|
21 | 23 | 26 => int_value(text),
|
||||||
|
// float4, float8 : parseFloat
|
||||||
|
700 | 701 => js::number_value(js::parse_float(text)),
|
||||||
|
// json, jsonb
|
||||||
|
114 | 3802 => serde_json::from_str(text).unwrap_or(Value::Null),
|
||||||
|
600 => parse_point(text),
|
||||||
|
718 => parse_circle(text),
|
||||||
|
// Tableaux
|
||||||
|
1000 => parse_array(text, &|entry| json!(parse_bool(entry))),
|
||||||
|
1001 => parse_array(text, &|entry| buffer_json(&parse_bytea(entry))),
|
||||||
|
1005 | 1007 | 1028 => parse_array(text, &int_value),
|
||||||
|
1016 => parse_array(text, &|entry| json!(js::trim(entry))),
|
||||||
|
1017 => parse_array(text, &parse_point),
|
||||||
|
1021 | 1022 | 1231 => parse_array(text, &|entry| js::number_value(js::parse_float(entry))),
|
||||||
|
651 | 791 | 1008 | 1009 | 1014 | 1015 | 1040 | 1041 | 1183 | 1270 | 2951 | 3907 => {
|
||||||
|
parse_array(text, &|entry| json!(entry))
|
||||||
|
}
|
||||||
|
1115 | 1182 | 1185 => parse_array(text, &parse_date),
|
||||||
|
199 | 3807 => parse_array(text, &|entry| serde_json::from_str(entry).unwrap_or(Value::Null)),
|
||||||
|
// int8, numeric, texte, dates, horodatages, intervalles… : le texte tel quel
|
||||||
|
_ => json!(text),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// parseInt(texte, 10), NaN écrit null
|
||||||
|
fn int_value(text: &str) -> Value {
|
||||||
|
js::number_value(js::parse_int(text).unwrap_or(f64::NAN))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bool(text: &str) -> bool {
|
||||||
|
matches!(text, "TRUE" | "t" | "true" | "y" | "yes" | "on" | "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// postgres-bytea : format hexadécimal (\x…) ou échappé (\ooo, \\)
|
||||||
|
fn parse_bytea(text: &str) -> Vec<u8> {
|
||||||
|
if let Some(hex) = text.strip_prefix("\\x") {
|
||||||
|
return js_hex(hex);
|
||||||
|
}
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
if chars[i] != '\\' {
|
||||||
|
out.push(chars[i] as u32 as u8);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let octal: String = chars[i + 1..].iter().take(3).collect();
|
||||||
|
if octal.len() == 3 && octal.chars().all(|c| ('0'..='7').contains(&c)) {
|
||||||
|
out.push(u8::from_str_radix(&octal, 8).unwrap_or_default());
|
||||||
|
i += 4;
|
||||||
|
} else {
|
||||||
|
let mut backslashes = 1;
|
||||||
|
while i + backslashes < chars.len() && chars[i + backslashes] == '\\' {
|
||||||
|
backslashes += 1;
|
||||||
|
}
|
||||||
|
out.extend(std::iter::repeat_n(b'\\', backslashes / 2));
|
||||||
|
i += (backslashes / 2) * 2;
|
||||||
|
if backslashes == 1 {
|
||||||
|
// Barre isolée : JavaScript n'avance pas (boucle infinie évitée ici)
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buffer.from(texte, 'hex') : paires lues jusqu'au premier caractère invalide
|
||||||
|
fn js_hex(text: &str) -> Vec<u8> {
|
||||||
|
let digit = |c: u8| (c as char).to_digit(16).map(|d| d as u8);
|
||||||
|
let mut out = Vec::with_capacity(text.len() / 2);
|
||||||
|
for pair in text.as_bytes().chunks_exact(2) {
|
||||||
|
match (digit(pair[0]), digit(pair[1])) {
|
||||||
|
(Some(high), Some(low)) => out.push((high << 4) | low),
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON d'un Buffer : { type: "Buffer", data: [octets] }
|
||||||
|
fn buffer_json(bytes: &[u8]) -> Value {
|
||||||
|
json!({ "type": "Buffer", "data": bytes })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// parsePoint : « (x,y) » → { x, y }
|
||||||
|
fn parse_point(text: &str) -> Value {
|
||||||
|
if !text.starts_with('(') {
|
||||||
|
return Value::Null;
|
||||||
|
}
|
||||||
|
let inner = text.get(1..text.len().saturating_sub(1)).unwrap_or_default();
|
||||||
|
let mut values = inner.split(',');
|
||||||
|
let x = js::parse_float(values.next().unwrap_or_default());
|
||||||
|
let y = values.next().map_or(f64::NAN, js::parse_float);
|
||||||
|
json!({ "x": js::number_value(x), "y": js::number_value(y) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// parseCircle : « <(x,y),r> » → { x, y, radius }
|
||||||
|
fn parse_circle(text: &str) -> Value {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
if chars.first() != Some(&'<') && chars.get(1) != Some(&'(') {
|
||||||
|
return Value::Null;
|
||||||
|
}
|
||||||
|
let mut point = String::from("(");
|
||||||
|
let mut radius = String::new();
|
||||||
|
let mut point_parsed = false;
|
||||||
|
for &c in chars.iter().take(chars.len().saturating_sub(1)).skip(2) {
|
||||||
|
if !point_parsed {
|
||||||
|
point.push(c);
|
||||||
|
}
|
||||||
|
if c == ')' {
|
||||||
|
point_parsed = true;
|
||||||
|
continue;
|
||||||
|
} else if !point_parsed || c == ',' {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
radius.push(c);
|
||||||
|
}
|
||||||
|
let mut result = parse_point(&point);
|
||||||
|
if let Some(object) = result.as_object_mut() {
|
||||||
|
object.insert("radius".into(), js::number_value(js::parse_float(&radius)));
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// postgres-date : Date JavaScript (heure locale du serveur Node : UTC), écrite en
|
||||||
|
/// ISO ; infinity et date illisible écrites null
|
||||||
|
fn parse_date(text: &str) -> Value {
|
||||||
|
let (date_part, rest) = text.split_once(' ').unwrap_or((text, ""));
|
||||||
|
let mut fields = date_part.splitn(3, '-');
|
||||||
|
let (Some(year), Some(month), Some(day)) = (fields.next(), fields.next(), fields.next()) else {
|
||||||
|
return Value::Null;
|
||||||
|
};
|
||||||
|
let (Ok(year), Ok(month), Ok(day)) = (year.parse::<i32>(), month.parse::<u32>(), day.parse::<u32>())
|
||||||
|
else {
|
||||||
|
return Value::Null;
|
||||||
|
};
|
||||||
|
let Some(date) = NaiveDate::from_ymd_opt(year, month, day) else {
|
||||||
|
return Value::Null;
|
||||||
|
};
|
||||||
|
// Heure, fraction (tronquée à la milliseconde) et décalage éventuel
|
||||||
|
let (time, offset) = match rest.find(['+', '-', 'Z']) {
|
||||||
|
Some(index) => (&rest[..index], Some(&rest[index..])),
|
||||||
|
None => (rest, None),
|
||||||
|
};
|
||||||
|
let mut millis = 0;
|
||||||
|
let naive = if time.is_empty() {
|
||||||
|
date.and_hms_opt(0, 0, 0)
|
||||||
|
} else {
|
||||||
|
let (clock, fraction) = time.split_once('.').unwrap_or((time, ""));
|
||||||
|
let mut parts = clock.split(':').map(|part| part.parse::<u32>().ok());
|
||||||
|
let (Some(Some(hour)), Some(Some(minute)), Some(Some(second))) =
|
||||||
|
(parts.next(), parts.next(), parts.next())
|
||||||
|
else {
|
||||||
|
return Value::Null;
|
||||||
|
};
|
||||||
|
millis = format!("{fraction:0<3}")[..3].parse::<i64>().unwrap_or(0);
|
||||||
|
date.and_hms_opt(hour, minute, second)
|
||||||
|
};
|
||||||
|
let Some(naive) = naive else {
|
||||||
|
return Value::Null;
|
||||||
|
};
|
||||||
|
let mut instant = Utc.from_utc_datetime(&naive) + chrono::Duration::milliseconds(millis);
|
||||||
|
if let Some(offset) = offset.filter(|offset| !offset.starts_with('Z')) {
|
||||||
|
let sign = if offset.starts_with('-') { -1 } else { 1 };
|
||||||
|
let mut units = offset[1..]
|
||||||
|
.split(':')
|
||||||
|
.map(|unit| unit.parse::<i64>().unwrap_or(0));
|
||||||
|
let seconds =
|
||||||
|
units.next().unwrap_or(0) * 3600 + units.next().unwrap_or(0) * 60 + units.next().unwrap_or(0);
|
||||||
|
instant -= chrono::Duration::seconds(sign * seconds);
|
||||||
|
}
|
||||||
|
json!(js::iso(&instant))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tableau PostgreSQL ({a,"b c",NULL,{d}}) au comportement de postgres-array :
|
||||||
|
/// `transform` appliqué à chaque élément non NULL
|
||||||
|
fn parse_array(text: &str, transform: &dyn Fn(&str) -> Value) -> Value {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
Value::Array(ArrayParser::new(&chars, transform).parse(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ArrayParser<'a> {
|
||||||
|
source: &'a [char],
|
||||||
|
transform: &'a dyn Fn(&str) -> Value,
|
||||||
|
position: usize,
|
||||||
|
entries: Vec<Value>,
|
||||||
|
recorded: Vec<char>,
|
||||||
|
dimension: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ArrayParser<'a> {
|
||||||
|
fn new(source: &'a [char], transform: &'a dyn Fn(&str) -> Value) -> Self {
|
||||||
|
Self {
|
||||||
|
source,
|
||||||
|
transform,
|
||||||
|
position: 0,
|
||||||
|
entries: Vec::new(),
|
||||||
|
recorded: Vec::new(),
|
||||||
|
dimension: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_character(&mut self) -> (Option<char>, bool) {
|
||||||
|
let character = self.source.get(self.position).copied();
|
||||||
|
self.position += 1;
|
||||||
|
if character == Some('\\') {
|
||||||
|
let escaped = self.source.get(self.position).copied();
|
||||||
|
self.position += 1;
|
||||||
|
return (escaped, true);
|
||||||
|
}
|
||||||
|
(character, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_entry(&mut self, include_empty: bool) {
|
||||||
|
if !self.recorded.is_empty() || include_empty {
|
||||||
|
let entry: String = self.recorded.drain(..).collect();
|
||||||
|
let value = if entry == "NULL" && !include_empty {
|
||||||
|
Value::Null
|
||||||
|
} else {
|
||||||
|
(self.transform)(&entry)
|
||||||
|
};
|
||||||
|
self.entries.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(mut self, nested: bool) -> Vec<Value> {
|
||||||
|
self.parse_with_position(nested).0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_with_position(&mut self, nested: bool) -> (Vec<Value>, usize) {
|
||||||
|
let mut quote = false;
|
||||||
|
// Décoration de dimensions ([1:2]={…})
|
||||||
|
if self.source.first() == Some(&'[') {
|
||||||
|
while self.position < self.source.len() {
|
||||||
|
if self.next_character().0 == Some('=') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while self.position < self.source.len() {
|
||||||
|
let (character, escaped) = self.next_character();
|
||||||
|
match character {
|
||||||
|
Some('{') if !quote => {
|
||||||
|
self.dimension += 1;
|
||||||
|
if self.dimension > 1 {
|
||||||
|
let mut parser = ArrayParser::new(&self.source[self.position - 1..], self.transform);
|
||||||
|
let (nested_entries, consumed) = parser.parse_with_position(true);
|
||||||
|
self.entries.push(Value::Array(nested_entries));
|
||||||
|
self.position = (self.position + consumed).saturating_sub(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some('}') if !quote => {
|
||||||
|
self.dimension -= 1;
|
||||||
|
if self.dimension == 0 {
|
||||||
|
self.new_entry(false);
|
||||||
|
if nested {
|
||||||
|
return (std::mem::take(&mut self.entries), self.position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some('"') if !escaped => {
|
||||||
|
if quote {
|
||||||
|
self.new_entry(true);
|
||||||
|
}
|
||||||
|
quote = !quote;
|
||||||
|
}
|
||||||
|
Some(',') if !quote => self.new_entry(false),
|
||||||
|
Some(other) => self.recorded.push(other),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(std::mem::take(&mut self.entries), self.position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valeurs_comme_node_pg() {
|
||||||
|
// Résultats de node-pg (pg-types 2.2) pour ces textes
|
||||||
|
assert_eq!(node_pg_value(23, "12"), json!(12));
|
||||||
|
assert_eq!(node_pg_value(20, "12345678901"), json!("12345678901"));
|
||||||
|
assert_eq!(node_pg_value(701, "2"), json!(2));
|
||||||
|
assert_eq!(node_pg_value(701, "NaN"), Value::Null);
|
||||||
|
assert_eq!(node_pg_value(1700, "1.50"), json!("1.50"));
|
||||||
|
assert_eq!(node_pg_value(16, "f"), json!(false));
|
||||||
|
assert_eq!(
|
||||||
|
node_pg_value(17, "\\x78ff"),
|
||||||
|
json!({ "type": "Buffer", "data": [120, 255] })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
node_pg_value(1114, "2026-10-15 10:00:00.5"),
|
||||||
|
json!("2026-10-15 10:00:00.5")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
node_pg_value(3802, r#"{"a": [1, "b"]}"#),
|
||||||
|
json!({ "a": [1, "b"] })
|
||||||
|
);
|
||||||
|
assert_eq!(node_pg_value(600, "(1.5,-2)"), json!({ "x": 1.5, "y": -2 }));
|
||||||
|
assert_eq!(
|
||||||
|
node_pg_value(718, "<(1,2),3>"),
|
||||||
|
json!({ "x": 1, "y": 2, "radius": 3 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableaux_comme_postgres_array() {
|
||||||
|
assert_eq!(node_pg_value(1007, "{1,2,NULL}"), json!([1, 2, null]));
|
||||||
|
assert_eq!(
|
||||||
|
node_pg_value(1009, r#"{a,"b c","NULL",NULL,"x\"y"}"#),
|
||||||
|
json!(["a", "b c", "NULL", null, "x\"y"])
|
||||||
|
);
|
||||||
|
assert_eq!(node_pg_value(1007, "{{1,2},{3,4}}"), json!([[1, 2], [3, 4]]));
|
||||||
|
assert_eq!(node_pg_value(1009, "{}"), json!([]));
|
||||||
|
assert_eq!(node_pg_value(1016, "{1, 2}"), json!(["1", "2"]));
|
||||||
|
assert_eq!(node_pg_value(1009, "[0:1]={a,b}"), json!(["a", "b"]));
|
||||||
|
assert_eq!(
|
||||||
|
node_pg_value(1182, "{2026-10-15,infinity}"),
|
||||||
|
json!(["2026-10-15T00:00:00.000Z", null])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
node_pg_value(1185, "{\"2026-01-01 08:00:00.123456+02\"}"),
|
||||||
|
json!(["2026-01-01T06:00:00.123Z"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nombre_de_lignes_de_l_etiquette() {
|
||||||
|
assert_eq!(row_count("UPDATE groups SET x = 1 WHERE false", 0), json!(0));
|
||||||
|
assert_eq!(row_count(" -- commentaire\n(SELECT 1 WHERE false)", 0), json!(0));
|
||||||
|
assert_eq!(row_count("ANALYZE groups", 0), Value::Null);
|
||||||
|
assert_eq!(row_count("CREATE TABLE t (x int)", 0), Value::Null);
|
||||||
|
assert_eq!(row_count("create table t as select 1 where false", 0), json!(0));
|
||||||
|
assert_eq!(row_count("CREATE VIEW v AS SELECT 1", 0), Value::Null);
|
||||||
|
assert_eq!(
|
||||||
|
row_count("CREATE TABLE t (id int GENERATED ALWAYS AS IDENTITY)", 0),
|
||||||
|
Value::Null
|
||||||
|
);
|
||||||
|
assert_eq!(row_count("SHOW timezone", 0), Value::Null);
|
||||||
|
assert_eq!(row_count("DELETE FROM t", 4), json!(4));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
// Statistiques détaillées : /api/analytics/summary, /timeseries, /by-supplier,
|
||||||
|
// /by-store, /export (server/routes.ts, « Analytics routes » ; storage
|
||||||
|
// getAnalyticsSummary, getAnalyticsTimeseries, getAnalyticsBySupplier,
|
||||||
|
// getAnalyticsByStore).
|
||||||
|
//
|
||||||
|
// Node assemble une partie de ces requêtes en texte (timeseries, by-store) :
|
||||||
|
// elles sont ici paramétrées, avec les mêmes résultats. Dans ces requêtes en
|
||||||
|
// texte, un identifiant décimal ou hors limites est comparé sans erreur (il ne
|
||||||
|
// correspond à rien) et seul un NaN ou un Infinity fait échouer la requête ;
|
||||||
|
// dans les requêtes de Drizzle, tout identifiant que PostgreSQL refuse comme
|
||||||
|
// integer la fait échouer.
|
||||||
|
//
|
||||||
|
// Comme Node aujourd'hui : « Montant total » additionne BL et factures (ANA-04),
|
||||||
|
// /by-store ignore les magasins de l'utilisateur (ANA-05), une liste de
|
||||||
|
// magasins vidée par le filtre des droits n'en filtre plus aucun, et un compte
|
||||||
|
// sans magasin voit tout (ANA-06) ; le directeur voit tous les magasins ; le
|
||||||
|
// taux de rapprochement vaut null (NaN) quand il n'y a aucune livraison.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{HeaderValue, StatusCode, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::{Json, Router, routing::get};
|
||||||
|
use chrono::{DateTime, Datelike, Utc};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use sqlx::postgres::PgArguments;
|
||||||
|
use sqlx::{Arguments, PgPool, Postgres};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::db::js_int4;
|
||||||
|
use crate::error::{ApiResult, AppError};
|
||||||
|
use crate::js;
|
||||||
|
use crate::request::JsQuery;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/analytics/summary", get(summary))
|
||||||
|
.route("/api/analytics/timeseries", get(timeseries))
|
||||||
|
.route("/api/analytics/by-supplier", get(by_supplier))
|
||||||
|
.route("/api/analytics/by-store", get(by_store))
|
||||||
|
.route("/api/analytics/export", get(export))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Filtres ----------
|
||||||
|
|
||||||
|
/// Date du filtre : `new Date(texte)`, None si « Invalid Date »
|
||||||
|
type FilterDate = Option<DateTime<Utc>>;
|
||||||
|
|
||||||
|
/// Filtres de l'adresse, tels que Node les lit
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Filters {
|
||||||
|
start_date: Option<FilterDate>,
|
||||||
|
end_date: Option<FilterDate>,
|
||||||
|
/// split(',').map(Number)
|
||||||
|
supplier_ids: Option<Vec<f64>>,
|
||||||
|
group_ids: Option<Vec<f64>>,
|
||||||
|
status: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Échec d'une requête : Node le transforme en 500 (ou en liste vide pour by-store)
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Failure;
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for Failure {
|
||||||
|
fn from(error: sqlx::Error) -> Self {
|
||||||
|
tracing::error!(%error, "statistiques détaillées");
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Filters {
|
||||||
|
fn read(query: &JsQuery) -> Self {
|
||||||
|
let date = |key: &str| query.truthy(key).map(|text| js::new_date(&json!(text)));
|
||||||
|
let numbers = |key: &str| {
|
||||||
|
query
|
||||||
|
.truthy(key)
|
||||||
|
.map(|text| text.split(',').map(|part| js::to_number(&json!(part))).collect())
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
start_date: date("startDate"),
|
||||||
|
end_date: date("endDate"),
|
||||||
|
supplier_ids: numbers("supplierIds"),
|
||||||
|
group_ids: numbers("groupIds"),
|
||||||
|
status: query
|
||||||
|
.truthy("status")
|
||||||
|
.map(|text| text.split(',').map(str::to_string).collect()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hors admin et directeur : seulement les magasins de l'utilisateur (la
|
||||||
|
/// liste filtrée peut devenir vide, et alors ne filtre plus rien)
|
||||||
|
fn restrict_to(mut self, user: &CurrentUser) -> Self {
|
||||||
|
if user.role() != "admin" && user.role() != "directeur" {
|
||||||
|
let own: Vec<f64> = user.group_ids().into_iter().map(f64::from).collect();
|
||||||
|
self.group_ids = Some(match self.group_ids {
|
||||||
|
Some(requested) => requested.into_iter().filter(|id| own.contains(id)).collect(),
|
||||||
|
None => own,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// date.toISOString().split('T')[0] : Err pour « Invalid Date » (RangeError)
|
||||||
|
fn day(date: &Option<FilterDate>) -> Result<Option<String>, Failure> {
|
||||||
|
match date {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(None) => Err(Failure),
|
||||||
|
Some(Some(date)) => Ok(Some(iso_day(date))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn days(&self) -> Result<(Option<String>, Option<String>), Failure> {
|
||||||
|
Ok((Self::day(&self.start_date)?, Self::day(&self.end_date)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partie date de toISOString (années hors 0..9999 sur 6 chiffres signés)
|
||||||
|
fn iso_day(date: &DateTime<Utc>) -> String {
|
||||||
|
let year = date.year();
|
||||||
|
let year = if (0..=9999).contains(&year) {
|
||||||
|
format!("{year:04}")
|
||||||
|
} else {
|
||||||
|
format!("{}{:06}", if year < 0 { '-' } else { '+' }, year.abs())
|
||||||
|
};
|
||||||
|
format!("{year}-{}", date.format("%m-%d"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liste non vide (filters.x?.length)
|
||||||
|
fn non_empty<T>(list: &Option<Vec<T>>) -> Option<&[T]> {
|
||||||
|
list.as_deref().filter(|list| !list.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Conditions WHERE paramétrées
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Conditions {
|
||||||
|
clauses: Vec<String>,
|
||||||
|
arguments: PgArguments,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Conditions {
|
||||||
|
fn placeholder(&self) -> String {
|
||||||
|
format!("${}", self.arguments.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// colonne op $n::type (date en texte, convertie par PostgreSQL)
|
||||||
|
fn compare(&mut self, column: &str, operator: &str, value: &str, sql_type: &str) -> Result<(), Failure> {
|
||||||
|
self.arguments.add(value).map_err(sqlx::Error::Encode)?;
|
||||||
|
self.clauses
|
||||||
|
.push(format!("{column} {operator} {}::{sql_type}", self.placeholder()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inArray de Drizzle sur une colonne integer : chaque nombre doit être un entier
|
||||||
|
fn integers(&mut self, column: &str, ids: &[f64]) -> Result<(), Failure> {
|
||||||
|
let mut placeholders = Vec::new();
|
||||||
|
for id in ids {
|
||||||
|
let id = js_int4(*id).ok_or(Failure)?;
|
||||||
|
self.arguments.add(id).map_err(sqlx::Error::Encode)?;
|
||||||
|
placeholders.push(self.placeholder());
|
||||||
|
}
|
||||||
|
self.clauses
|
||||||
|
.push(format!("{column} IN ({})", placeholders.join(", ")));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `colonne IN (${ids.join(',')})` écrit dans le texte SQL par Node : un NaN ou
|
||||||
|
/// un Infinity est une erreur SQL, les autres nombres sont comparés sans erreur
|
||||||
|
fn literal_integers(&mut self, column: &str, ids: &[f64]) -> Result<(), Failure> {
|
||||||
|
if ids.iter().any(|id| !id.is_finite()) {
|
||||||
|
return Err(Failure);
|
||||||
|
}
|
||||||
|
let matching: Vec<i32> = ids.iter().filter_map(|id| js_int4(*id)).collect();
|
||||||
|
if matching.is_empty() {
|
||||||
|
self.clauses.push("FALSE".into());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut placeholders = Vec::new();
|
||||||
|
for id in matching {
|
||||||
|
self.arguments.add(id).map_err(sqlx::Error::Encode)?;
|
||||||
|
placeholders.push(self.placeholder());
|
||||||
|
}
|
||||||
|
self.clauses
|
||||||
|
.push(format!("{column} IN ({})", placeholders.join(", ")));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn texts(&mut self, column: &str, values: &[String]) -> Result<(), Failure> {
|
||||||
|
let mut placeholders = Vec::new();
|
||||||
|
for value in values {
|
||||||
|
self.arguments.add(value.as_str()).map_err(sqlx::Error::Encode)?;
|
||||||
|
placeholders.push(self.placeholder());
|
||||||
|
}
|
||||||
|
self.clauses
|
||||||
|
.push(format!("{column} IN ({})", placeholders.join(", ")));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn where_clause(&self) -> String {
|
||||||
|
if self.clauses.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("WHERE ({})", self.clauses.join(" AND "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number(texte numeric) ; NaN → 0 (Number(x) || 0)
|
||||||
|
fn number_or_zero(text: Option<&str>) -> f64 {
|
||||||
|
let number = text.map_or(0.0, |text| js::to_number(&json!(text)));
|
||||||
|
if number.is_nan() { 0.0 } else { number }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Lignes rendues (JSON ou CSV) ----------
|
||||||
|
|
||||||
|
/// Valeur d'une ligne : nombre JavaScript, texte, ou objet sérialisé tel quel
|
||||||
|
enum Cell {
|
||||||
|
Number(f64),
|
||||||
|
Text(String),
|
||||||
|
Json(Value),
|
||||||
|
}
|
||||||
|
|
||||||
|
type Row = Vec<(&'static str, Cell)>;
|
||||||
|
|
||||||
|
impl Cell {
|
||||||
|
fn json(&self) -> Value {
|
||||||
|
match self {
|
||||||
|
Self::Number(number) => js::number_value(*number),
|
||||||
|
Self::Text(text) => json!(text),
|
||||||
|
Self::Json(value) => value.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valeur dans le CSV : typeof val === 'object' ? JSON.stringify(val) : val
|
||||||
|
fn csv(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Number(number) => js::number_string(*number),
|
||||||
|
Self::Text(text) => text.clone(),
|
||||||
|
Self::Json(value) => value.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_json(row: &Row) -> Value {
|
||||||
|
Value::Object(
|
||||||
|
row.iter()
|
||||||
|
.map(|(key, cell)| ((*key).to_string(), cell.json()))
|
||||||
|
.collect::<Map<_, _>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rows_json(rows: &[Row]) -> Value {
|
||||||
|
Value::Array(rows.iter().map(row_json).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// En-têtes puis une ligne par élément, valeurs séparées par des virgules sans guillemets
|
||||||
|
fn to_csv(rows: &[Row]) -> String {
|
||||||
|
let headers: Vec<&str> = rows[0].iter().map(|(key, _)| *key).collect();
|
||||||
|
let lines: Vec<String> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
row.iter()
|
||||||
|
.map(|(_, cell)| cell.csv())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!("{}\n{}", headers.join(","), lines.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Requêtes ----------
|
||||||
|
|
||||||
|
/// getAnalyticsSummary
|
||||||
|
async fn summary_row(db: &PgPool, filters: &Filters) -> Result<Row, Failure> {
|
||||||
|
let (start, end) = filters.days()?;
|
||||||
|
let conditions = |date_column: &str, prefix: &str| -> Result<Conditions, Failure> {
|
||||||
|
let mut conditions = Conditions::default();
|
||||||
|
if let Some(start) = &start {
|
||||||
|
conditions.compare(&format!("{prefix}.{date_column}"), ">=", start, "date")?;
|
||||||
|
}
|
||||||
|
if let Some(end) = &end {
|
||||||
|
conditions.compare(&format!("{prefix}.{date_column}"), "<=", end, "date")?;
|
||||||
|
}
|
||||||
|
if let Some(ids) = non_empty(&filters.supplier_ids) {
|
||||||
|
conditions.integers(&format!("{prefix}.supplier_id"), ids)?;
|
||||||
|
}
|
||||||
|
if let Some(ids) = non_empty(&filters.group_ids) {
|
||||||
|
conditions.integers(&format!("{prefix}.group_id"), ids)?;
|
||||||
|
}
|
||||||
|
if let Some(status) = non_empty(&filters.status) {
|
||||||
|
conditions.texts(&format!("{prefix}.status"), status)?;
|
||||||
|
}
|
||||||
|
Ok(conditions)
|
||||||
|
};
|
||||||
|
let orders = conditions("planned_date", "orders")?;
|
||||||
|
let deliveries = conditions("scheduled_date", "deliveries")?;
|
||||||
|
let suppliers = conditions("scheduled_date", "deliveries")?;
|
||||||
|
|
||||||
|
// Magasins les plus actifs : seul le filtre des magasins s'applique
|
||||||
|
let mut stores = PgArguments::default();
|
||||||
|
let store_ids = non_empty(&filters.group_ids);
|
||||||
|
let mut store_list = || -> Result<String, Failure> {
|
||||||
|
let mut placeholders = Vec::new();
|
||||||
|
for id in store_ids.unwrap_or_default() {
|
||||||
|
stores
|
||||||
|
.add(js_int4(*id).ok_or(Failure)?)
|
||||||
|
.map_err(sqlx::Error::Encode)?;
|
||||||
|
placeholders.push(format!("${}", stores.len()));
|
||||||
|
}
|
||||||
|
Ok(format!("({})", placeholders.join(", ")))
|
||||||
|
};
|
||||||
|
let (orders_filter, deliveries_filter, stores_filter) = if store_ids.is_some() {
|
||||||
|
(
|
||||||
|
format!("WHERE group_id IN {}", store_list()?),
|
||||||
|
format!("WHERE group_id IN {}", store_list()?),
|
||||||
|
format!("WHERE g.id IN {}", store_list()?),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Default::default()
|
||||||
|
};
|
||||||
|
let stores_sql = format!(
|
||||||
|
"SELECT g.id AS id, g.name AS name, COALESCE(o.count, 0) AS orders, COALESCE(d.count, 0) AS deliveries
|
||||||
|
FROM groups g
|
||||||
|
LEFT JOIN (SELECT group_id, COUNT(*) AS count FROM orders {orders_filter} GROUP BY group_id) o ON o.group_id = g.id
|
||||||
|
LEFT JOIN (SELECT group_id, COUNT(*) AS count FROM deliveries {deliveries_filter} GROUP BY group_id) d ON d.group_id = g.id
|
||||||
|
{stores_filter}
|
||||||
|
ORDER BY COALESCE(o.count, 0) + COALESCE(d.count, 0) DESC
|
||||||
|
LIMIT 5"
|
||||||
|
);
|
||||||
|
let orders_sql = format!("SELECT COUNT(*) FROM orders {}", orders.where_clause());
|
||||||
|
let deliveries_sql = format!(
|
||||||
|
"SELECT COUNT(*),
|
||||||
|
(COALESCE(SUM(CAST(bl_amount AS NUMERIC)), 0) + COALESCE(SUM(CAST(invoice_amount AS NUMERIC)), 0))::text,
|
||||||
|
COUNT(CASE WHEN reconciled = true THEN 1 END),
|
||||||
|
(AVG(EXTRACT(EPOCH FROM (delivered_date - scheduled_date)) / 86400))::text
|
||||||
|
FROM deliveries {}",
|
||||||
|
deliveries.where_clause()
|
||||||
|
);
|
||||||
|
let suppliers_sql = format!(
|
||||||
|
"SELECT suppliers.id, suppliers.name, COUNT(deliveries.id),
|
||||||
|
COALESCE(SUM(CAST(deliveries.bl_amount AS NUMERIC)), 0)::text
|
||||||
|
FROM deliveries
|
||||||
|
INNER JOIN suppliers ON deliveries.supplier_id = suppliers.id
|
||||||
|
{}
|
||||||
|
GROUP BY suppliers.id, suppliers.name
|
||||||
|
ORDER BY COUNT(deliveries.id) DESC
|
||||||
|
LIMIT 5",
|
||||||
|
suppliers.where_clause()
|
||||||
|
);
|
||||||
|
// Promise.all : les quatre requêtes vont à leur terme, la première erreur l'emporte
|
||||||
|
let (orders, deliveries, top_suppliers, top_stores) = tokio::join!(
|
||||||
|
sqlx::query_as_with::<Postgres, (i64,), _>(&orders_sql, orders.arguments).fetch_one(db),
|
||||||
|
sqlx::query_as_with::<Postgres, (i64, String, i64, Option<String>), _>(
|
||||||
|
&deliveries_sql,
|
||||||
|
deliveries.arguments
|
||||||
|
)
|
||||||
|
.fetch_one(db),
|
||||||
|
sqlx::query_as_with::<Postgres, (i32, String, i64, String), _>(&suppliers_sql, suppliers.arguments)
|
||||||
|
.persistent(false)
|
||||||
|
.fetch_all(db),
|
||||||
|
sqlx::query_as_with::<Postgres, (i32, String, i64, i64), _>(&stores_sql, stores)
|
||||||
|
.persistent(false)
|
||||||
|
.fetch_all(db),
|
||||||
|
);
|
||||||
|
let ((total_orders,), (count, amount, reconciled, average), top_suppliers, top_stores) =
|
||||||
|
(orders?, deliveries?, top_suppliers?, top_stores?);
|
||||||
|
|
||||||
|
let top_suppliers: Vec<Value> = top_suppliers
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, name, count, amount)| {
|
||||||
|
json!({ "id": id, "name": name, "count": count, "amount": js::number_value(js::to_number(&json!(amount))) })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let top_stores: Vec<Value> = top_stores
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, name, orders, deliveries)| json!({ "id": id, "name": name, "orders": orders, "deliveries": deliveries }))
|
||||||
|
.collect();
|
||||||
|
// deliveryStats.count est un texte (bigint), toujours « vrai » : 0 livraison → NaN
|
||||||
|
let reconciliation_rate = reconciled as f64 / count as f64 * 100.0;
|
||||||
|
Ok(vec![
|
||||||
|
("totalOrders", Cell::Number(total_orders as f64)),
|
||||||
|
("totalDeliveries", Cell::Number(count as f64)),
|
||||||
|
("reconciliationRate", Cell::Number(reconciliation_rate)),
|
||||||
|
("totalAmount", Cell::Number(number_or_zero(Some(&amount)))),
|
||||||
|
(
|
||||||
|
"avgDeliveryDelay",
|
||||||
|
Cell::Number(number_or_zero(average.as_deref())),
|
||||||
|
),
|
||||||
|
("topSuppliers", Cell::Json(Value::Array(top_suppliers))),
|
||||||
|
("topStores", Cell::Json(Value::Array(top_stores))),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// getAnalyticsTimeseries : commandes et livraisons par jour, semaine ou mois
|
||||||
|
async fn timeseries_rows(db: &PgPool, filters: &Filters, granularity: &str) -> Result<Vec<Row>, Failure> {
|
||||||
|
let format = match granularity {
|
||||||
|
"day" => "YYYY-MM-DD",
|
||||||
|
"week" => "YYYY-IW",
|
||||||
|
_ => "YYYY-MM",
|
||||||
|
};
|
||||||
|
let (start, end) = filters.days()?;
|
||||||
|
let series = |table: &str, date_column: &str| -> Result<(String, Conditions), Failure> {
|
||||||
|
let mut conditions = Conditions::default();
|
||||||
|
conditions.arguments.add(format).map_err(sqlx::Error::Encode)?;
|
||||||
|
if let Some(start) = &start {
|
||||||
|
conditions.compare(date_column, ">=", start, "date")?;
|
||||||
|
}
|
||||||
|
if let Some(end) = &end {
|
||||||
|
conditions.compare(date_column, "<=", end, "date")?;
|
||||||
|
}
|
||||||
|
if let Some(ids) = non_empty(&filters.supplier_ids) {
|
||||||
|
conditions.literal_integers("supplier_id", ids)?;
|
||||||
|
}
|
||||||
|
if let Some(ids) = non_empty(&filters.group_ids) {
|
||||||
|
conditions.literal_integers("group_id", ids)?;
|
||||||
|
}
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT TO_CHAR({date_column}, $1) AS date, COUNT(*) AS count FROM {table} {} GROUP BY 1 ORDER BY 1",
|
||||||
|
conditions.where_clause()
|
||||||
|
);
|
||||||
|
Ok((sql, conditions))
|
||||||
|
};
|
||||||
|
let (orders_sql, orders) = series("orders", "planned_date")?;
|
||||||
|
let (deliveries_sql, deliveries) = series("deliveries", "scheduled_date")?;
|
||||||
|
let (order_rows, delivery_rows) = tokio::join!(
|
||||||
|
sqlx::query_as_with::<Postgres, (String, i64), _>(&orders_sql, orders.arguments).fetch_all(db),
|
||||||
|
sqlx::query_as_with::<Postgres, (String, i64), _>(&deliveries_sql, deliveries.arguments)
|
||||||
|
.fetch_all(db),
|
||||||
|
);
|
||||||
|
let (order_rows, delivery_rows) = (order_rows?, delivery_rows?);
|
||||||
|
|
||||||
|
// Map date → { orders, deliveries } triée par date (localeCompare sur des
|
||||||
|
// chiffres et des tirets : ordre des octets, celui de BTreeMap)
|
||||||
|
let mut series: BTreeMap<String, (i64, i64)> = BTreeMap::new();
|
||||||
|
for (date, count) in order_rows {
|
||||||
|
series.insert(date, (count, 0));
|
||||||
|
}
|
||||||
|
for (date, count) in delivery_rows {
|
||||||
|
series.entry(date).or_default().1 = count;
|
||||||
|
}
|
||||||
|
Ok(series
|
||||||
|
.into_iter()
|
||||||
|
.map(|(date, (orders, deliveries))| {
|
||||||
|
vec![
|
||||||
|
("date", Cell::Text(date)),
|
||||||
|
("orders", Cell::Number(orders as f64)),
|
||||||
|
("deliveries", Cell::Number(deliveries as f64)),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// getAnalyticsBySupplier : livraisons et montant des BL par fournisseur
|
||||||
|
async fn supplier_rows(db: &PgPool, filters: &Filters) -> Result<Vec<Row>, Failure> {
|
||||||
|
let (start, end) = filters.days()?;
|
||||||
|
let mut conditions = Conditions::default();
|
||||||
|
if let Some(start) = &start {
|
||||||
|
conditions.compare("deliveries.scheduled_date", ">=", start, "date")?;
|
||||||
|
}
|
||||||
|
if let Some(end) = &end {
|
||||||
|
conditions.compare("deliveries.scheduled_date", "<=", end, "date")?;
|
||||||
|
}
|
||||||
|
if let Some(ids) = non_empty(&filters.group_ids) {
|
||||||
|
conditions.integers("deliveries.group_id", ids)?;
|
||||||
|
}
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT suppliers.id, suppliers.name, COUNT(deliveries.id),
|
||||||
|
COALESCE(SUM(CAST(deliveries.bl_amount AS NUMERIC)), 0)::text
|
||||||
|
FROM deliveries
|
||||||
|
INNER JOIN suppliers ON deliveries.supplier_id = suppliers.id
|
||||||
|
{}
|
||||||
|
GROUP BY suppliers.id, suppliers.name
|
||||||
|
ORDER BY COUNT(deliveries.id) DESC",
|
||||||
|
conditions.where_clause()
|
||||||
|
);
|
||||||
|
let rows: Vec<(i32, String, i64, String)> = sqlx::query_as_with(&sql, conditions.arguments)
|
||||||
|
.persistent(false)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, name, deliveries, amount)| {
|
||||||
|
vec![
|
||||||
|
("supplierId", Cell::Number(f64::from(id))),
|
||||||
|
("supplierName", Cell::Text(name)),
|
||||||
|
("deliveries", Cell::Number(deliveries as f64)),
|
||||||
|
("amount", Cell::Number(js::to_number(&json!(amount)))),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// getAnalyticsByStore : commandes et livraisons par magasin ; toute erreur
|
||||||
|
/// rend une liste vide
|
||||||
|
async fn store_rows(db: &PgPool, filters: &Filters) -> Vec<Row> {
|
||||||
|
store_rows_or_fail(db, filters).await.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_rows_or_fail(db: &PgPool, filters: &Filters) -> Result<Vec<Row>, Failure> {
|
||||||
|
let (start, end) = filters.days()?;
|
||||||
|
let mut conditions = Conditions::default();
|
||||||
|
let mut orders = Vec::new();
|
||||||
|
let mut deliveries = Vec::new();
|
||||||
|
if let Some(start) = &start {
|
||||||
|
conditions.compare("planned_date", ">=", start, "date")?;
|
||||||
|
orders.push(conditions.clauses.pop().unwrap_or_default());
|
||||||
|
conditions.compare("scheduled_date", ">=", start, "date")?;
|
||||||
|
deliveries.push(conditions.clauses.pop().unwrap_or_default());
|
||||||
|
}
|
||||||
|
if let Some(end) = &end {
|
||||||
|
conditions.compare("planned_date", "<=", end, "date")?;
|
||||||
|
orders.push(conditions.clauses.pop().unwrap_or_default());
|
||||||
|
conditions.compare("scheduled_date", "<=", end, "date")?;
|
||||||
|
deliveries.push(conditions.clauses.pop().unwrap_or_default());
|
||||||
|
}
|
||||||
|
if let Some(ids) = non_empty(&filters.supplier_ids) {
|
||||||
|
conditions.literal_integers("supplier_id", ids)?;
|
||||||
|
let clause = conditions.clauses.pop().unwrap_or_default();
|
||||||
|
// Même liste pour les deux sous-requêtes (paramètres réutilisés)
|
||||||
|
orders.push(clause.clone());
|
||||||
|
deliveries.push(clause);
|
||||||
|
}
|
||||||
|
let and =
|
||||||
|
|clauses: &[String]| -> String { clauses.iter().map(|clause| format!(" AND {clause}")).collect() };
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT g.id AS \"groupId\", g.name AS \"storeName\", COALESCE(o.count, 0) AS orders, COALESCE(d.count, 0) AS deliveries
|
||||||
|
FROM groups g
|
||||||
|
LEFT JOIN (SELECT group_id, COUNT(*) AS count FROM orders WHERE 1=1{} GROUP BY group_id) o ON g.id = o.group_id
|
||||||
|
LEFT JOIN (SELECT group_id, COUNT(*) AS count FROM deliveries WHERE 1=1{} GROUP BY group_id) d ON g.id = d.group_id
|
||||||
|
ORDER BY (COALESCE(o.count, 0) + COALESCE(d.count, 0)) DESC",
|
||||||
|
and(&orders),
|
||||||
|
and(&deliveries)
|
||||||
|
);
|
||||||
|
let rows: Vec<(i32, String, i64, i64)> = sqlx::query_as_with(&sql, conditions.arguments)
|
||||||
|
.persistent(false)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, name, orders, deliveries)| {
|
||||||
|
vec![
|
||||||
|
("groupId", Cell::Number(f64::from(id))),
|
||||||
|
("storeName", Cell::Text(name)),
|
||||||
|
("orders", Cell::Number(orders as f64)),
|
||||||
|
("deliveries", Cell::Number(deliveries as f64)),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Adresses ----------
|
||||||
|
|
||||||
|
fn failed(message: &str) -> AppError {
|
||||||
|
AppError::internal(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn summary(State(state): State<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
let filters = Filters::read(&query).restrict_to(&user);
|
||||||
|
let row = summary_row(&state.db, &filters)
|
||||||
|
.await
|
||||||
|
.map_err(|_| failed("Failed to fetch analytics summary"))?;
|
||||||
|
Ok(Json(row_json(&row)).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn timeseries(State(state): State<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
let filters = Filters {
|
||||||
|
status: None,
|
||||||
|
..Filters::read(&query)
|
||||||
|
}
|
||||||
|
.restrict_to(&user);
|
||||||
|
let granularity = query.truthy("granularity").unwrap_or("day");
|
||||||
|
let rows = timeseries_rows(&state.db, &filters, granularity)
|
||||||
|
.await
|
||||||
|
.map_err(|_| failed("Failed to fetch analytics timeseries"))?;
|
||||||
|
Ok(Json(rows_json(&rows)).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn by_supplier(State(state): State<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
let filters = Filters {
|
||||||
|
supplier_ids: None,
|
||||||
|
status: None,
|
||||||
|
..Filters::read(&query)
|
||||||
|
}
|
||||||
|
.restrict_to(&user);
|
||||||
|
let rows = supplier_rows(&state.db, &filters)
|
||||||
|
.await
|
||||||
|
.map_err(|_| failed("Failed to fetch analytics by supplier"))?;
|
||||||
|
Ok(Json(rows_json(&rows)).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn by_store(State(state): State<AppState>, _user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
// Ni les magasins de l'utilisateur ni ?groupIds (ANA-05)
|
||||||
|
let filters = Filters::read(&query);
|
||||||
|
Ok(Json(rows_json(&store_rows(&state.db, &filters).await)).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caractères refusés par Node dans un en-tête (checkInvalidHeaderChar)
|
||||||
|
fn header_bytes(value: &str) -> Option<Vec<u8>> {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c as u32 {
|
||||||
|
0x09 | 0x20..=0x7e | 0x80..=0xff => Some(c as u32 as u8),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn export(State(state): State<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to export analytics";
|
||||||
|
let kind = query.truthy("type").unwrap_or("summary");
|
||||||
|
let filters = Filters::read(&query).restrict_to(&user);
|
||||||
|
let filename = format!(
|
||||||
|
"analytics_{kind}_{}.csv",
|
||||||
|
crate::clock::today().format("%Y-%m-%d")
|
||||||
|
);
|
||||||
|
let rows = match kind {
|
||||||
|
"timeseries" => timeseries_rows(&state.db, &filters, "day").await,
|
||||||
|
"suppliers" => supplier_rows(&state.db, &filters).await,
|
||||||
|
"stores" => Ok(store_rows(&state.db, &filters).await),
|
||||||
|
_ => summary_row(&state.db, &filters).await.map(|row| vec![row]),
|
||||||
|
}
|
||||||
|
.map_err(|_| failed(FAILED))?;
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Err(AppError::not_found("No data to export"));
|
||||||
|
}
|
||||||
|
let csv = to_csv(&rows);
|
||||||
|
let csv_type = HeaderValue::from_static("text/csv; charset=utf-8");
|
||||||
|
// Nom de fichier invalide pour un en-tête : Node échoue après avoir posé le
|
||||||
|
// type CSV, et l'erreur JSON part avec ce type
|
||||||
|
let disposition = header_bytes(&format!("attachment; filename=\"{filename}\""))
|
||||||
|
.and_then(|bytes| HeaderValue::from_bytes(&bytes).ok());
|
||||||
|
let Some(disposition) = disposition else {
|
||||||
|
let body = json!({ "message": FAILED }).to_string();
|
||||||
|
return Ok((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
[(header::CONTENT_TYPE, csv_type)],
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
};
|
||||||
|
let etag = HeaderValue::from_str(&crate::http::express_etag(csv.as_bytes())).ok();
|
||||||
|
let mut response: Response = (
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, csv_type),
|
||||||
|
(header::CONTENT_DISPOSITION, disposition),
|
||||||
|
],
|
||||||
|
csv,
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
if let Some(etag) = etag {
|
||||||
|
response.headers_mut().insert(header::ETAG, etag);
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csv_comme_node() {
|
||||||
|
// Valeurs produites par la conversion CSV de /api/analytics/export dans Node
|
||||||
|
let summary = vec![vec![
|
||||||
|
("totalOrders", Cell::Number(9.0)),
|
||||||
|
("reconciliationRate", Cell::Number(f64::NAN)),
|
||||||
|
("avgDeliveryDelay", Cell::Number(1.2291666666666667)),
|
||||||
|
(
|
||||||
|
"topSuppliers",
|
||||||
|
Cell::Json(json!([{ "id": 4, "name": "Jardin & Co", "count": 2, "amount": 450.25 }])),
|
||||||
|
),
|
||||||
|
]];
|
||||||
|
assert_eq!(
|
||||||
|
to_csv(&summary),
|
||||||
|
"totalOrders,reconciliationRate,avgDeliveryDelay,topSuppliers\n9,NaN,1.2291666666666667,[{\"id\":4,\"name\":\"Jardin & Co\",\"count\":2,\"amount\":450.25}]"
|
||||||
|
);
|
||||||
|
// NaN en JSON : null
|
||||||
|
assert_eq!(row_json(&summary[0])["reconciliationRate"], Value::Null);
|
||||||
|
let rows = vec![
|
||||||
|
vec![
|
||||||
|
("date", Cell::Text("2026-10-01".into())),
|
||||||
|
("orders", Cell::Number(2.0)),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
("date", Cell::Text("2026-10-02".into())),
|
||||||
|
("orders", Cell::Number(0.0)),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
assert_eq!(to_csv(&rows), "date,orders\n2026-10-01,2\n2026-10-02,0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn listes_d_identifiants_comme_le_texte_sql_de_node() {
|
||||||
|
let mut conditions = Conditions::default();
|
||||||
|
conditions
|
||||||
|
.literal_integers("supplier_id", &[1.5, 2.0, 99999999999.0])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(conditions.clauses, vec!["supplier_id IN ($1)"]);
|
||||||
|
conditions.literal_integers("group_id", &[1.5]).unwrap();
|
||||||
|
assert_eq!(conditions.clauses[1], "FALSE");
|
||||||
|
assert!(conditions.literal_integers("group_id", &[1.0, f64::NAN]).is_err());
|
||||||
|
assert!(Conditions::default().integers("group_id", &[1.5]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dates_des_filtres() {
|
||||||
|
let date = |text: &str| js::new_date(&json!(text)).unwrap();
|
||||||
|
assert_eq!(iso_day(&date("2026-09-15T23:30:00.000+02:00")), "2026-09-15");
|
||||||
|
assert_eq!(iso_day(&date("2026-09-15T22:30:00.000-02:00")), "2026-09-16");
|
||||||
|
assert_eq!(header_bytes("é€"), None);
|
||||||
|
assert_eq!(header_bytes("résumé").unwrap().len(), 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
// Informations du tableau de bord : /api/announcements (server/routes.ts,
|
||||||
|
// « Announcement routes », branche production ; table dashboard_messages).
|
||||||
|
// Les replis de Node sur erreur SQL passent par server/announcementStorage.ts
|
||||||
|
// (AnnouncementDatabaseStorage) : ils sont reproduits.
|
||||||
|
// Comme Node aujourd'hui : un non-admin voit les informations de tous les
|
||||||
|
// magasins (DASH-13), et un magasin illisible (?storeId=abc) fait retomber sur
|
||||||
|
// toutes les informations, sans limite, avec l'auteur sous une autre forme.
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::{Json, Router, routing::get, routing::put};
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::db::{self, parse_int4};
|
||||||
|
use crate::error::{ApiResult, AppError, OrInternal};
|
||||||
|
use crate::js::truthy;
|
||||||
|
use crate::request::{JsQuery, JsonBody};
|
||||||
|
use crate::schema::DashboardMessagesRow;
|
||||||
|
use crate::zod::Object;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/announcements", get(list).post(create))
|
||||||
|
.route("/api/announcements/{id}", put(update).delete(remove))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// insertAnnouncementSchema (insertDashboardMessageSchema)
|
||||||
|
fn insert_schema() -> Object {
|
||||||
|
DashboardMessagesRow::insert_schema().omit(&["id", "createdAt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Information lue avec son auteur (users.username = created_by) et son magasin
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct MessageRow {
|
||||||
|
id: i32,
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
#[sqlx(rename = "type")]
|
||||||
|
type_: String,
|
||||||
|
store_id: Option<i32>,
|
||||||
|
created_by: String,
|
||||||
|
created_at: Option<NaiveDateTime>,
|
||||||
|
author_id: Option<String>,
|
||||||
|
author_username: Option<String>,
|
||||||
|
author_first_name: Option<String>,
|
||||||
|
author_last_name: Option<String>,
|
||||||
|
author_name: Option<String>,
|
||||||
|
group_id: Option<i32>,
|
||||||
|
group_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGE_COLUMNS: &str = "dashboard_messages.id, dashboard_messages.title, dashboard_messages.content,
|
||||||
|
dashboard_messages.type, dashboard_messages.store_id, dashboard_messages.created_by,
|
||||||
|
dashboard_messages.created_at, users.id AS author_id, users.username AS author_username,
|
||||||
|
users.first_name AS author_first_name, users.last_name AS author_last_name,
|
||||||
|
users.name AS author_name, groups.id AS group_id, groups.name AS group_name";
|
||||||
|
|
||||||
|
/// Liste de la branche production : 5 dernières, filtrées par magasin pour l'admin
|
||||||
|
fn latest_sql(filtered: bool) -> String {
|
||||||
|
let filter = if filtered {
|
||||||
|
"WHERE (dashboard_messages.store_id = $1 OR dashboard_messages.store_id IS NULL)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"SELECT {MESSAGE_COLUMNS}
|
||||||
|
FROM dashboard_messages
|
||||||
|
LEFT JOIN users ON users.username = dashboard_messages.created_by
|
||||||
|
LEFT JOIN groups ON groups.id = dashboard_messages.store_id
|
||||||
|
{filter}
|
||||||
|
ORDER BY dashboard_messages.created_at DESC LIMIT 5"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AnnouncementDatabaseStorage.getAnnouncements() : toutes, sans limite
|
||||||
|
fn stored_sql() -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT {MESSAGE_COLUMNS}
|
||||||
|
FROM dashboard_messages
|
||||||
|
LEFT JOIN users ON dashboard_messages.created_by = users.username
|
||||||
|
LEFT JOIN groups ON dashboard_messages.store_id = groups.id
|
||||||
|
ORDER BY dashboard_messages.created_at DESC"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageRow {
|
||||||
|
/// Champs de dashboard_messages, dans l'ordre de la table
|
||||||
|
fn message(&self) -> Map<String, Value> {
|
||||||
|
let mut message = Map::new();
|
||||||
|
message.insert("id".into(), json!(self.id));
|
||||||
|
message.insert("title".into(), json!(self.title));
|
||||||
|
message.insert("content".into(), json!(self.content));
|
||||||
|
message.insert("type".into(), json!(self.type_));
|
||||||
|
message.insert("storeId".into(), json!(self.store_id));
|
||||||
|
message.insert("createdBy".into(), json!(self.created_by));
|
||||||
|
let created_at = self.created_at.as_ref().map(crate::json::format_ts);
|
||||||
|
message.insert("createdAt".into(), json!(created_at));
|
||||||
|
message
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forme de la liste (branche production de GET /api/announcements)
|
||||||
|
fn listed(&self) -> Value {
|
||||||
|
let mut message = self.message();
|
||||||
|
message.insert("author".into(), self.author());
|
||||||
|
// message.storeId && groupId ? { id, name } : null
|
||||||
|
let group = match (self.store_id, self.group_id) {
|
||||||
|
(Some(store_id), Some(group_id)) if store_id != 0 && group_id != 0 => {
|
||||||
|
json!({ "id": group_id, "name": self.group_name })
|
||||||
|
}
|
||||||
|
_ => Value::Null,
|
||||||
|
};
|
||||||
|
message.insert("group".into(), group);
|
||||||
|
Value::Object(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auteur affiché : nom d'affichage, sinon prénom et nom, sinon identifiant ;
|
||||||
|
/// « Utilisateur Inconnu » si le compte n'existe plus
|
||||||
|
fn author(&self) -> Value {
|
||||||
|
let Some(author_id) = self.author_id.as_deref().filter(|id| !id.is_empty()) else {
|
||||||
|
return json!({
|
||||||
|
"id": self.created_by,
|
||||||
|
"firstName": "Utilisateur",
|
||||||
|
"lastName": "Inconnu",
|
||||||
|
"username": self.created_by,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let non_empty = |name: &Option<String>| name.clone().filter(|name| !name.is_empty());
|
||||||
|
let username = self.author_username.clone().unwrap_or_default();
|
||||||
|
let (first_name, last_name) = (
|
||||||
|
non_empty(&self.author_first_name),
|
||||||
|
non_empty(&self.author_last_name),
|
||||||
|
);
|
||||||
|
let full_name = match (&first_name, &last_name) {
|
||||||
|
(Some(first), Some(last)) => Some(format!("{first} {last}")),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let display_name = non_empty(&self.author_name)
|
||||||
|
.or(full_name)
|
||||||
|
.unwrap_or_else(|| username.clone());
|
||||||
|
let mut words = display_name.split(' ');
|
||||||
|
let first_word = words.next().map(str::to_string).filter(|word| !word.is_empty());
|
||||||
|
let other_words = Some(words.collect::<Vec<_>>().join(" ")).filter(|words| !words.is_empty());
|
||||||
|
json!({
|
||||||
|
"id": author_id,
|
||||||
|
"firstName": first_name.or(first_word).unwrap_or_else(|| username.clone()),
|
||||||
|
"lastName": last_name.or(other_words).unwrap_or_default(),
|
||||||
|
"username": username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forme de storage.getAnnouncements() : auteur et magasin joints tels quels
|
||||||
|
/// (null quand la jointure ne trouve rien, comme Drizzle)
|
||||||
|
fn stored(&self) -> Value {
|
||||||
|
let mut message = self.message();
|
||||||
|
let author = match &self.author_id {
|
||||||
|
Some(id) => json!({
|
||||||
|
"id": id,
|
||||||
|
"firstName": self.author_first_name,
|
||||||
|
"lastName": self.author_last_name,
|
||||||
|
"username": self.author_username,
|
||||||
|
}),
|
||||||
|
None => Value::Null,
|
||||||
|
};
|
||||||
|
let group = match self.group_id {
|
||||||
|
Some(id) => json!({ "id": id, "name": self.group_name }),
|
||||||
|
None => Value::Null,
|
||||||
|
};
|
||||||
|
message.insert("author".into(), author);
|
||||||
|
message.insert("group".into(), group);
|
||||||
|
Value::Object(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// None quand la requête de Node échoue (magasin illisible, base indisponible)
|
||||||
|
async fn latest(db: &PgPool, store_filter: Option<&str>) -> Option<Vec<MessageRow>> {
|
||||||
|
let result = match store_filter {
|
||||||
|
None => {
|
||||||
|
sqlx::query_as(&latest_sql(false))
|
||||||
|
.persistent(false)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Some(raw) => {
|
||||||
|
// parseInt illisible ou hors limites : PostgreSQL refuse le paramètre
|
||||||
|
let store_id = parse_int4(raw)?;
|
||||||
|
sqlx::query_as(&latest_sql(true))
|
||||||
|
.bind(store_id)
|
||||||
|
.persistent(false)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
result
|
||||||
|
.map_err(|error| tracing::warn!(%error, "informations : repli sur la lecture complète"))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(State(state): State<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
// Admin avec ?storeId : informations de ce magasin et informations globales
|
||||||
|
let store_filter = if user.is_admin() {
|
||||||
|
query.truthy("storeId")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let announcements: Vec<Value> = match latest(&state.db, store_filter).await {
|
||||||
|
Some(rows) => rows.iter().map(MessageRow::listed).collect(),
|
||||||
|
None => {
|
||||||
|
let rows: Vec<MessageRow> = sqlx::query_as(&stored_sql())
|
||||||
|
.persistent(false)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.or_500("Failed to fetch announcements")?;
|
||||||
|
rows.iter().map(MessageRow::stored).collect()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Json(announcements).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(State(state): State<AppState>, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to create announcement";
|
||||||
|
if !user.is_admin() {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"Only administrators can create announcements",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Champs repris un par un du corps : type « info » et magasin null à défaut
|
||||||
|
let mut candidate = Map::new();
|
||||||
|
for key in ["title", "content"] {
|
||||||
|
if let Some(value) = body.get(key) {
|
||||||
|
candidate.insert(key.into(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let given = |key: &str| body.get(key).filter(|value| truthy(value)).cloned();
|
||||||
|
candidate.insert("type".into(), given("type").unwrap_or_else(|| json!("info")));
|
||||||
|
candidate.insert("storeId".into(), given("storeId").unwrap_or(Value::Null));
|
||||||
|
candidate.insert("createdBy".into(), json!(user.user.username));
|
||||||
|
let data = insert_schema()
|
||||||
|
.parse(&Value::Object(candidate))
|
||||||
|
.map_err(|issues| AppError::validation("Validation error", issues))?;
|
||||||
|
|
||||||
|
let created: DashboardMessagesRow = match db::insert(&state.db, &data).await {
|
||||||
|
Ok(row) => row,
|
||||||
|
Err(error) => {
|
||||||
|
// Repli de Node : storage.createAnnouncement refait l'insertion et
|
||||||
|
// rend la ligne seule
|
||||||
|
tracing::warn!(%error, "information : nouvel essai d'enregistrement");
|
||||||
|
let row: DashboardMessagesRow = db::insert(&state.db, &data).await.or_500(FAILED)?;
|
||||||
|
return Ok((StatusCode::CREATED, Json(row)).into_response());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut announcement = match serde_json::to_value(created).or_500(FAILED)? {
|
||||||
|
Value::Object(fields) => fields,
|
||||||
|
_ => Map::new(),
|
||||||
|
};
|
||||||
|
let author =
|
||||||
|
json!({ "id": user.id(), "firstName": user.user.first_name, "lastName": user.user.last_name });
|
||||||
|
announcement.insert("author".into(), author);
|
||||||
|
announcement.insert("group".into(), Value::Null);
|
||||||
|
Ok((StatusCode::CREATED, Json(announcement)).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to update announcement";
|
||||||
|
if !user.is_admin() {
|
||||||
|
return Err(AppError::forbidden("Only administrators can edit announcements"));
|
||||||
|
}
|
||||||
|
// Identifiant illisible : la lecture de l'information échoue côté Node
|
||||||
|
let id = parse_int4(&id).ok_or_else(|| AppError::internal(FAILED))?;
|
||||||
|
let existing: Option<(i32,)> = sqlx::query_as("SELECT id FROM dashboard_messages WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
if existing.is_none() {
|
||||||
|
return Err(AppError::not_found("Announcement not found"));
|
||||||
|
}
|
||||||
|
let data = insert_schema()
|
||||||
|
.partial()
|
||||||
|
.parse(&body)
|
||||||
|
.map_err(|issues| AppError::validation("Validation error", issues))?;
|
||||||
|
// Corps vide après validation : Drizzle refuse (« No values to set »)
|
||||||
|
let updated = db::update_by_id::<DashboardMessagesRow>(&state.db, id, &data, false)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?
|
||||||
|
.ok_or_else(|| AppError::internal(FAILED))?;
|
||||||
|
Ok(Json(updated).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to delete announcement";
|
||||||
|
if !user.is_admin() {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"Only administrators can delete announcements",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let id = parse_int4(&id).ok_or_else(|| AppError::internal(FAILED))?;
|
||||||
|
// Repli de Node sur erreur : storage.deleteAnnouncement refait la suppression
|
||||||
|
let deleted = match db::delete_by_id::<DashboardMessagesRow>(&state.db, id).await {
|
||||||
|
Ok(count) => count,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "information : nouvel essai de suppression");
|
||||||
|
db::delete_by_id::<DashboardMessagesRow>(&state.db, id)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if deleted == 0 {
|
||||||
|
return Err(AppError::not_found("Announcement not found"));
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "message": "Announcement deleted successfully" })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn row(name: Option<&str>, first: Option<&str>, last: Option<&str>) -> MessageRow {
|
||||||
|
MessageRow {
|
||||||
|
id: 1,
|
||||||
|
title: "t".into(),
|
||||||
|
content: "c".into(),
|
||||||
|
type_: "info".into(),
|
||||||
|
store_id: Some(0),
|
||||||
|
created_by: "auteur".into(),
|
||||||
|
created_at: None,
|
||||||
|
author_id: Some("u1".into()),
|
||||||
|
author_username: Some("auteur".into()),
|
||||||
|
author_first_name: first.map(Into::into),
|
||||||
|
author_last_name: last.map(Into::into),
|
||||||
|
author_name: name.map(Into::into),
|
||||||
|
group_id: Some(1),
|
||||||
|
group_name: Some("Nancy".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn names(row: MessageRow) -> (String, String) {
|
||||||
|
let author = row.author();
|
||||||
|
(
|
||||||
|
author["firstName"].as_str().unwrap().to_string(),
|
||||||
|
author["lastName"].as_str().unwrap().to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auteur_affiche_comme_node() {
|
||||||
|
assert_eq!(
|
||||||
|
names(row(None, Some("Alice"), Some("Admin"))),
|
||||||
|
("Alice".into(), "Admin".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
names(row(Some("Équipe Rédaction Nancy"), None, None)),
|
||||||
|
("Équipe".into(), "Rédaction Nancy".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
names(row(None, Some("Paulette"), None)),
|
||||||
|
("Paulette".into(), "".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
names(row(None, None, Some("Martin"))),
|
||||||
|
("auteur".into(), "Martin".into())
|
||||||
|
);
|
||||||
|
let mut missing = row(None, None, None);
|
||||||
|
missing.author_id = None;
|
||||||
|
assert_eq!(missing.author()["lastName"], "Inconnu");
|
||||||
|
assert_eq!(missing.stored()["author"], Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magasin_joint_seulement_si_store_id_vrai() {
|
||||||
|
// storeId 0 : pas de magasin dans la liste, même s'il existe
|
||||||
|
assert_eq!(row(None, None, None).listed()["group"], Value::Null);
|
||||||
|
let mut nancy = row(None, None, None);
|
||||||
|
nancy.store_id = Some(1);
|
||||||
|
assert_eq!(nancy.listed()["group"], json!({ "id": 1, "name": "Nancy" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
// Avoirs : /api/avoirs (server/routes.ts, « Avoir routes » ; storage.ts getAvoirs à
|
||||||
|
// updateAvoirNocodbVerification). La création et le passage au statut « Reçu »
|
||||||
|
// envoient l'avoir au webhook du magasin (node-fetch, sans délai maximal) ; s'il
|
||||||
|
// répond 2xx, l'avoir est marqué « webhook envoyé ».
|
||||||
|
//
|
||||||
|
// Défauts de Node reproduits :
|
||||||
|
// - GET /api/avoirs : un directeur voit les avoirs de tous les magasins, et un
|
||||||
|
// compte sans magasin, sans storeId, voit tous les avoirs (liste de magasins vide
|
||||||
|
// = aucun filtre) (AVOIRS-API-08) ;
|
||||||
|
// - PUT : montant, référence et commentaire absents du corps sont effacés
|
||||||
|
// (AVOIRS-API-06) ; magasin, fournisseur et créateur modifiables sans contrôle,
|
||||||
|
// ni règle de rôle pour « Reçu » ni protection des avoirs validés (AVOIRS-API-07) ;
|
||||||
|
// - webhook-status et nocodb-verification ne contrôlent pas le magasin (directeur
|
||||||
|
// compris) et acceptent un identifiant inexistant (AVOIRS-API-08) ;
|
||||||
|
// - le webhook porte un nom de fournisseur factice (AVOIRS-API-05) ;
|
||||||
|
// - GET /:id joint la ligne complète du magasin (mot de passe SMTP remplacé par
|
||||||
|
// smtpPasswordSet), la liste sa projection de rapprochement.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::routing::{get, post, put};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::access::has_permission;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::db::{self, Table};
|
||||||
|
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||||
|
use crate::js;
|
||||||
|
use crate::json::{spread, strip_smtp_password};
|
||||||
|
use crate::modules::bap::node_fetch_post;
|
||||||
|
use crate::modules::now_iso;
|
||||||
|
use crate::projections::{GroupReconciliation, UserSummary};
|
||||||
|
use crate::request::{JsQuery, JsonBody, int_id};
|
||||||
|
use crate::schema::{AvoirsRow, GroupsRow, SuppliersRow};
|
||||||
|
use crate::services::invoice_verification as verification;
|
||||||
|
use crate::services::nocodb::{database_message, not_a_string_error, truthy};
|
||||||
|
use crate::zod::{Field, Object, Parsed};
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/avoirs", get(list).post(create))
|
||||||
|
.route("/api/avoirs/{id}", get(show).put(update).delete(remove))
|
||||||
|
.route("/api/avoirs/{id}/verify-invoice", post(verify_invoice))
|
||||||
|
.route("/api/avoirs/{id}/webhook-status", put(webhook_status))
|
||||||
|
.route("/api/avoirs/{id}/nocodb-verification", put(nocodb_verification))
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUSES: &[&str] = &["En attente de demande", "Demandé", "Reçu"];
|
||||||
|
const RECEIVED: &str = "Reçu";
|
||||||
|
|
||||||
|
/// insertAvoirSchema (shared/schema.ts)
|
||||||
|
fn avoir_schema() -> Object {
|
||||||
|
AvoirsRow::insert_schema()
|
||||||
|
.omit(&[
|
||||||
|
"id",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
"webhookSent",
|
||||||
|
"nocodbVerified",
|
||||||
|
"nocodbVerifiedAt",
|
||||||
|
"processedAt",
|
||||||
|
])
|
||||||
|
.extend(vec![
|
||||||
|
Field::enumeration("status", STATUSES).default(json!("En attente de demande")),
|
||||||
|
Field::coerce_number("amount").optional().nullable(),
|
||||||
|
Field::string("invoiceReference").optional(),
|
||||||
|
Field::string("comment").optional(),
|
||||||
|
Field::boolean("commercialProcessed").default(json!(false)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin et directeur voient tous les magasins ; les autres, les leurs
|
||||||
|
fn sees_all_stores(user: &CurrentUser) -> bool {
|
||||||
|
["admin", "directeur"].contains(&user.role())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// storage.getAvoir : l'avoir de l'identifiant de l'adresse (parseInt, envoyé en
|
||||||
|
/// texte : un identifiant illisible fait échouer la requête, comme dans Node)
|
||||||
|
async fn find_avoir(db: &PgPool, raw_id: &str) -> Result<Option<AvoirsRow>, sqlx::Error> {
|
||||||
|
let id = js::parse_int(raw_id).map_or_else(|| "NaN".to_string(), js::number_to_string);
|
||||||
|
sqlx::query_as(&AvoirsRow::select("WHERE id = $1::int4"))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avoir existant, sinon 404 ; une erreur de la base donne le 500 de l'adresse
|
||||||
|
async fn existing_avoir(db: &PgPool, raw_id: &str, failed: &str) -> Result<AvoirsRow, AppError> {
|
||||||
|
find_avoir(db, raw_id)
|
||||||
|
.await
|
||||||
|
.or_500(failed)?
|
||||||
|
.ok_or_else(|| AppError::not_found("Avoir not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hors admin et directeur, l'avoir doit être d'un magasin de l'utilisateur
|
||||||
|
fn check_store(user: &CurrentUser, avoir: &AvoirsRow, denied: &str) -> Result<(), AppError> {
|
||||||
|
if sees_all_stores(user) || user.group_ids().contains(&avoir.group_id) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::forbidden(denied))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Lecture ----------
|
||||||
|
|
||||||
|
/// Magasins dont les avoirs sont lus
|
||||||
|
enum Scope {
|
||||||
|
/// Aucun filtre
|
||||||
|
All,
|
||||||
|
Stores(Vec<i32>),
|
||||||
|
/// Magasin demandé hors des siens : [] sans requête
|
||||||
|
Nothing,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// storeId de l'adresse : admin et directeur, ce magasin ; les autres, ce magasin
|
||||||
|
/// s'il est l'un des leurs, sinon rien ; sans storeId, les leurs (aucun pour un
|
||||||
|
/// compte sans magasin, donc tous les avoirs)
|
||||||
|
fn scope(user: &CurrentUser, query: &JsQuery) -> Result<Scope, AppError> {
|
||||||
|
let store = query.truthy("storeId");
|
||||||
|
if sees_all_stores(user) {
|
||||||
|
return Ok(match store {
|
||||||
|
Some(store) => Scope::Stores(vec![int_id(store, "Failed to fetch avoirs")?]),
|
||||||
|
None => Scope::All,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mine = user.group_ids();
|
||||||
|
Ok(match store {
|
||||||
|
None if mine.is_empty() => Scope::All,
|
||||||
|
None => Scope::Stores(mine),
|
||||||
|
Some(store) => match js::parse_int(store) {
|
||||||
|
Some(requested) if mine.iter().any(|id| f64::from(*id) == requested) => {
|
||||||
|
Scope::Stores(vec![requested as i32])
|
||||||
|
}
|
||||||
|
_ => Scope::Nothing,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fiches jointes à des avoirs, par identifiant (seconde moitié des leftJoin)
|
||||||
|
struct Relations {
|
||||||
|
suppliers: HashMap<i32, SuppliersRow>,
|
||||||
|
groups: HashMap<i32, GroupReconciliation>,
|
||||||
|
creators: HashMap<String, UserSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Relations {
|
||||||
|
async fn load(db: &PgPool, avoirs: &[AvoirsRow]) -> Result<Self, sqlx::Error> {
|
||||||
|
let supplier_ids: Vec<i32> = avoirs.iter().map(|avoir| avoir.supplier_id).collect();
|
||||||
|
let suppliers = db::find_by_ids::<SuppliersRow>(db, &supplier_ids)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|supplier| (supplier.id, supplier))
|
||||||
|
.collect();
|
||||||
|
let group_ids: Vec<i32> = avoirs.iter().map(|avoir| avoir.group_id).collect();
|
||||||
|
let groups: Vec<GroupReconciliation> = sqlx::query_as(&format!(
|
||||||
|
"SELECT {} FROM groups g WHERE g.id = ANY($1)",
|
||||||
|
GroupReconciliation::columns("g")
|
||||||
|
))
|
||||||
|
.bind(&group_ids)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
let creator_ids: Vec<String> = avoirs.iter().map(|avoir| avoir.created_by.clone()).collect();
|
||||||
|
Ok(Self {
|
||||||
|
suppliers,
|
||||||
|
groups: groups.into_iter().map(|group| (group.id, group)).collect(),
|
||||||
|
creators: UserSummary::by_ids(db, &creator_ids).await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// { ...avoir, supplier, group, creator } (null quand la ligne jointe manque)
|
||||||
|
fn attach(&self, avoir: &AvoirsRow) -> Value {
|
||||||
|
let mut entry = spread(avoir);
|
||||||
|
entry.insert("supplier".into(), json!(self.suppliers.get(&avoir.supplier_id)));
|
||||||
|
entry.insert("group".into(), json!(self.groups.get(&avoir.group_id)));
|
||||||
|
entry.insert("creator".into(), json!(self.creators.get(&avoir.created_by)));
|
||||||
|
Value::Object(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(State(state): State<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to fetch avoirs";
|
||||||
|
let group_ids = match scope(&user, &query)? {
|
||||||
|
Scope::Nothing => return Ok(Json(json!([])).into_response()),
|
||||||
|
Scope::All => None,
|
||||||
|
Scope::Stores(ids) => Some(ids),
|
||||||
|
};
|
||||||
|
let avoirs: Vec<AvoirsRow> = sqlx::query_as(&AvoirsRow::select(
|
||||||
|
"WHERE ($1::int4[] IS NULL OR group_id = ANY($1)) ORDER BY created_at DESC",
|
||||||
|
))
|
||||||
|
.bind(group_ids)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
let relations = Relations::load(&state.db, &avoirs).await.or_500(FAILED)?;
|
||||||
|
let out: Vec<Value> = avoirs.iter().map(|avoir| relations.attach(avoir)).collect();
|
||||||
|
Ok(Json(out).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn show(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to fetch avoir";
|
||||||
|
let avoir = existing_avoir(&state.db, &id, FAILED).await?;
|
||||||
|
check_store(&user, &avoir, "Access denied")?;
|
||||||
|
let supplier = db::find_by_id::<SuppliersRow>(&state.db, avoir.supplier_id)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
// getAvoir joint la ligne complète du magasin
|
||||||
|
let group = db::find_by_id::<GroupsRow>(&state.db, avoir.group_id)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
let creators = UserSummary::by_ids(&state.db, std::slice::from_ref(&avoir.created_by))
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
let mut body = spread(&avoir);
|
||||||
|
body.insert("supplier".into(), json!(supplier));
|
||||||
|
body.insert("group".into(), json!(group));
|
||||||
|
body.insert("creator".into(), json!(creators.get(&avoir.created_by)));
|
||||||
|
Ok(Json(strip_smtp_password(Value::Object(body))).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Webhook du magasin ----------
|
||||||
|
|
||||||
|
/// Nom affiché du créateur : « prénom nom » s'il a les deux, sinon l'identifiant
|
||||||
|
fn display_name(user: &CurrentUser) -> String {
|
||||||
|
let account = &user.user;
|
||||||
|
match (account.first_name.as_deref(), account.last_name.as_deref()) {
|
||||||
|
(Some(first), Some(last)) if !first.is_empty() && !last.is_empty() => format!("{first} {last}"),
|
||||||
|
_ => account.username.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// storage.updateAvoirWebhookStatus
|
||||||
|
async fn set_webhook_sent(db: &PgPool, id: i32, sent: Value) -> Result<(), sqlx::Error> {
|
||||||
|
let mut data = Parsed::new();
|
||||||
|
data.insert("webhookSent".into(), db::node_pg_value(&sent));
|
||||||
|
db::update_by_id::<AvoirsRow>(db, id, &data, true)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie l'avoir au webhook de son magasin s'il en a un ; une réponse 2xx marque
|
||||||
|
/// l'avoir « webhook envoyé ». Toute erreur est seulement journalisée, comme Node.
|
||||||
|
async fn notify_store(db: &PgPool, avoir_id: i32, group_id: i32, payload: impl FnOnce(&GroupsRow) -> Value) {
|
||||||
|
let group = match db::find_by_id::<GroupsRow>(db, group_id).await {
|
||||||
|
Ok(Some(group)) => group,
|
||||||
|
Ok(None) => return,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(%error, avoir_id, "webhook avoir : lecture du magasin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(url) = group.webhook_url.clone().filter(|url| !url.is_empty()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let body = payload(&group).to_string();
|
||||||
|
let response = node_fetch_post(
|
||||||
|
&url,
|
||||||
|
|request| request.header("Content-Type", "application/json").body(body),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match response {
|
||||||
|
Ok((status, _)) if status.is_success() => {
|
||||||
|
if let Err(error) = set_webhook_sent(db, avoir_id, Value::Bool(true)).await {
|
||||||
|
tracing::error!(%error, avoir_id, "webhook avoir : marquage");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((status, _)) => tracing::error!(avoir_id, %status, "webhook avoir refusé"),
|
||||||
|
Err(error) => tracing::error!(avoir_id, message = error.message, "webhook avoir en échec"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Champs communs des messages du webhook (createdAt / status, processedAt à part)
|
||||||
|
fn webhook_fields(avoir: &AvoirsRow, supplier_name: &str, group: &GroupsRow) -> Map<String, Value> {
|
||||||
|
let mut fields = Map::new();
|
||||||
|
fields.insert("type".into(), json!("Avoir"));
|
||||||
|
fields.insert("avoirId".into(), json!(avoir.id));
|
||||||
|
let row = spread(avoir);
|
||||||
|
fields.insert(
|
||||||
|
"invoiceReference".into(),
|
||||||
|
row.get("invoiceReference").cloned().unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
fields.insert("amount".into(), row.get("amount").cloned().unwrap_or(Value::Null));
|
||||||
|
fields.insert("supplierName".into(), json!(supplier_name));
|
||||||
|
fields.insert("groupName".into(), json!(group.name));
|
||||||
|
fields.insert("comment".into(), json!(avoir.comment.clone().unwrap_or_default()));
|
||||||
|
fields.insert("commercialProcessed".into(), json!(avoir.commercial_processed));
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Écriture ----------
|
||||||
|
|
||||||
|
async fn create(State(state): State<AppState>, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to create avoir";
|
||||||
|
// { ...req.body, createdBy: user.id }
|
||||||
|
let mut input = body.as_object().cloned().unwrap_or_default();
|
||||||
|
input.insert("createdBy".into(), json!(user.id()));
|
||||||
|
let data = avoir_schema()
|
||||||
|
.parse(&Value::Object(input))
|
||||||
|
.map_err(|issues| AppError::validation("Validation error", issues))?;
|
||||||
|
let group_id = data.get("groupId").and_then(Value::as_f64).unwrap_or_default();
|
||||||
|
if !sees_all_stores(&user) && !user.group_ids().iter().any(|id| f64::from(*id) == group_id) {
|
||||||
|
return Err(AppError::forbidden("Access denied to this group"));
|
||||||
|
}
|
||||||
|
let avoir: AvoirsRow = db::insert(&state.db, &data).await.or_500(FAILED)?;
|
||||||
|
tracing::info!(id = avoir.id, user = user.id(), "avoir créé");
|
||||||
|
|
||||||
|
let created_by = display_name(&user);
|
||||||
|
let row = spread(&avoir);
|
||||||
|
notify_store(&state.db, avoir.id, avoir.group_id, |group| {
|
||||||
|
let mut fields = webhook_fields(&avoir, "Unknown", group);
|
||||||
|
fields.insert("createdBy".into(), json!(created_by));
|
||||||
|
fields.insert(
|
||||||
|
"createdAt".into(),
|
||||||
|
row.get("createdAt").cloned().unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
Value::Object(fields)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
// L'avoir tel qu'inséré, avant le marquage du webhook
|
||||||
|
Ok(Json(avoir).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to update avoir";
|
||||||
|
let existing = existing_avoir(&state.db, &id, FAILED).await?;
|
||||||
|
check_store(&user, &existing, "Access denied")?;
|
||||||
|
let validated = avoir_schema()
|
||||||
|
.partial()
|
||||||
|
.parse(&body)
|
||||||
|
.map_err(|issues| AppError::validation("Validation error", issues))?;
|
||||||
|
// Absents du corps, montant, référence et commentaire sont effacés (AVOIRS-API-06)
|
||||||
|
let mut data = validated.clone();
|
||||||
|
for key in ["amount", "invoiceReference", "comment"] {
|
||||||
|
data.entry(key).or_insert(Value::Null);
|
||||||
|
}
|
||||||
|
let updated = db::update_by_id::<AvoirsRow>(&state.db, existing.id, &data, true)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
tracing::info!(id = existing.id, user = user.id(), "avoir modifié");
|
||||||
|
|
||||||
|
let becomes_received =
|
||||||
|
validated.get("status").and_then(Value::as_str) == Some(RECEIVED) && existing.status != RECEIVED;
|
||||||
|
let Some(updated) = updated else {
|
||||||
|
// Ligne disparue entre-temps : updatedAvoir.groupId lève une TypeError
|
||||||
|
return if becomes_received {
|
||||||
|
Err(AppError::internal(FAILED))
|
||||||
|
} else {
|
||||||
|
Ok(json_or_empty(None::<AvoirsRow>))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if becomes_received {
|
||||||
|
// Magasin 1 par défaut pour l'admin si l'avoir n'en a pas (groupId à 0)
|
||||||
|
let group_id = if updated.group_id == 0 && user.is_admin() {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
updated.group_id
|
||||||
|
};
|
||||||
|
let created_by = display_name(&user);
|
||||||
|
notify_store(&state.db, updated.id, group_id, |group| {
|
||||||
|
let mut fields = webhook_fields(&updated, "Fournisseur", group);
|
||||||
|
fields.insert("status".into(), json!(RECEIVED));
|
||||||
|
fields.insert("createdBy".into(), json!(created_by));
|
||||||
|
fields.insert("processedAt".into(), json!(now_iso()));
|
||||||
|
Value::Object(fields)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Ok(Json(updated).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to delete avoir";
|
||||||
|
let avoir = existing_avoir(&state.db, &id, FAILED).await?;
|
||||||
|
if !sees_all_stores(&user) {
|
||||||
|
return Err(AppError::forbidden("Insufficient permissions to delete avoirs"));
|
||||||
|
}
|
||||||
|
if user.role() == "directeur" && !user.group_ids().contains(&avoir.group_id) {
|
||||||
|
return Err(AppError::forbidden("Access denied"));
|
||||||
|
}
|
||||||
|
db::delete_by_id::<AvoirsRow>(&state.db, avoir.id)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
tracing::info!(id = avoir.id, user = user.id(), "avoir supprimé");
|
||||||
|
Ok(Json(json!({ "message": "Avoir deleted successfully" })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Vérification et statuts ----------
|
||||||
|
|
||||||
|
async fn verify_invoice(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
let failed = |message: String| {
|
||||||
|
AppError::with_body(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "message": "Failed to verify avoir invoice", "error": message }),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let avoir = find_avoir(&state.db, &id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| failed(database_message(&error)))?
|
||||||
|
.ok_or_else(|| AppError::not_found("Avoir not found"))?;
|
||||||
|
if !has_permission(user.role(), "deliveries", "view") {
|
||||||
|
return Err(AppError::forbidden("Insufficient permissions"));
|
||||||
|
}
|
||||||
|
// Ici, seul l'admin voit tous les magasins (directeur compris dans le contrôle)
|
||||||
|
if !user.is_admin() && !user.group_ids().contains(&avoir.group_id) {
|
||||||
|
return Err(AppError::forbidden("Access denied to this group"));
|
||||||
|
}
|
||||||
|
let supplier = db::find_by_id::<SuppliersRow>(&state.db, avoir.supplier_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| failed(database_message(&error)))?;
|
||||||
|
let group = db::find_by_id::<GroupsRow>(&state.db, avoir.group_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| failed(database_message(&error)))?;
|
||||||
|
if supplier.is_none() || group.is_none() {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"Avoir missing supplier or group information",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// !invoiceReference || !invoiceReference.trim()
|
||||||
|
let reference = body.get("invoiceReference");
|
||||||
|
let reference = match reference {
|
||||||
|
Some(value) if truthy(value) => match value.as_str() {
|
||||||
|
Some(text) => text,
|
||||||
|
None => {
|
||||||
|
return Err(failed(not_a_string_error(
|
||||||
|
"invoiceReference",
|
||||||
|
"trim",
|
||||||
|
Some(value),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => return Err(AppError::bad_request("Référence de facture requise")),
|
||||||
|
};
|
||||||
|
if js::trim(reference).is_empty() {
|
||||||
|
return Err(AppError::bad_request("Référence de facture requise"));
|
||||||
|
}
|
||||||
|
let force_refresh = body.get("forceRefresh").is_some_and(truthy);
|
||||||
|
// Les avoirs ne sont jamais « rapprochés » comme les livraisons
|
||||||
|
let result = verification::verify_invoice(&state, reference, avoir.group_id, force_refresh, false).await;
|
||||||
|
Ok(Json(result).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seuls l'admin et le directeur changent l'état d'un avoir (sans contrôle du magasin)
|
||||||
|
fn admin_or_directeur(user: &CurrentUser) -> Result<(), AppError> {
|
||||||
|
if sees_all_stores(user) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::forbidden("Insufficient permissions"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn webhook_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to update webhook status";
|
||||||
|
admin_or_directeur(&user)?;
|
||||||
|
let id = int_id(&id, FAILED)?;
|
||||||
|
// webhookSent absent : seule la date de modification change
|
||||||
|
let mut data = Parsed::new();
|
||||||
|
if let Some(sent) = body.get("webhookSent") {
|
||||||
|
data.insert("webhookSent".into(), db::node_pg_value(sent));
|
||||||
|
}
|
||||||
|
db::update_by_id::<AvoirsRow>(&state.db, id, &data, true)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(Json(json!({ "message": "Webhook status updated successfully" })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn nocodb_verification(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to update verification status";
|
||||||
|
admin_or_directeur(&user)?;
|
||||||
|
let id = int_id(&id, FAILED)?;
|
||||||
|
let verified = body.get("verified");
|
||||||
|
let is_verified = verified.is_some_and(truthy);
|
||||||
|
let mut data = Parsed::new();
|
||||||
|
if let Some(verified) = verified {
|
||||||
|
data.insert("nocodbVerified".into(), db::node_pg_value(verified));
|
||||||
|
}
|
||||||
|
data.insert(
|
||||||
|
"nocodbVerifiedAt".into(),
|
||||||
|
if is_verified {
|
||||||
|
json!(now_iso())
|
||||||
|
} else {
|
||||||
|
Value::Null
|
||||||
|
},
|
||||||
|
);
|
||||||
|
db::update_by_id::<AvoirsRow>(&state.db, id, &data, true)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
|
||||||
|
// Validé : l'entrée du cache de sa facture devient permanente (erreur ignorée)
|
||||||
|
if is_verified {
|
||||||
|
match db::find_by_id::<AvoirsRow>(&state.db, id).await {
|
||||||
|
Ok(Some(avoir)) => {
|
||||||
|
if let Some(reference) = avoir
|
||||||
|
.invoice_reference
|
||||||
|
.as_deref()
|
||||||
|
.filter(|reference| !js::trim(reference).is_empty())
|
||||||
|
{
|
||||||
|
verification::update_cache_as_reconciled(&state.db, reference, avoir.group_id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(error) => tracing::error!(%error, id, "marquage du cache de l'avoir validé"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
id,
|
||||||
|
verified = is_verified,
|
||||||
|
user = user.id(),
|
||||||
|
"vérification NocoDB de l'avoir"
|
||||||
|
);
|
||||||
|
Ok(Json(json!({ "message": "NocoDB verification status updated successfully" })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_de_creation_comme_zod() {
|
||||||
|
let schema = avoir_schema();
|
||||||
|
let data = schema
|
||||||
|
.parse(&json!({ "supplierId": 1, "groupId": 2, "amount": "", "createdBy": "u", "inconnu": 1 }))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
Value::Object(data),
|
||||||
|
json!({ "supplierId": 1, "groupId": 2, "amount": 0, "commercialProcessed": false,
|
||||||
|
"status": "En attente de demande", "createdBy": "u" })
|
||||||
|
);
|
||||||
|
let issues = schema
|
||||||
|
.parse(&json!({ "supplierId": 1, "groupId": 1, "invoiceReference": null, "createdBy": "u" }))
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(issues[0]["path"], json!(["invoiceReference"]));
|
||||||
|
assert_eq!(issues[0]["message"], json!("Expected string, received null"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modification_partielle_sans_valeurs_par_defaut() {
|
||||||
|
let data = avoir_schema()
|
||||||
|
.partial()
|
||||||
|
.parse(&json!({ "status": "Reçu" }))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(Value::Object(data), json!({ "status": "Reçu" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
// Sauvegardes de la base : /api/backups (server/routes.ts, « Backup management
|
||||||
|
// routes », et server/backupService.ts), réservées à l'administrateur.
|
||||||
|
//
|
||||||
|
// pg_dump est lancé sans shell, avec des arguments séparés ; mêmes options, même
|
||||||
|
// nom de fichier, mêmes lignes dans database_backups, dix sauvegardes gardées.
|
||||||
|
//
|
||||||
|
// Écart voulu (audit BACKUP-09) : Node joint le nom reçu dans l'adresse au
|
||||||
|
// dossier des sauvegardes sans le contrôler (« ../ » sort du dossier). Rust
|
||||||
|
// n'accepte qu'un simple nom de fichier (ni « / », ni « \ », ni « . » ou « .. ») ;
|
||||||
|
// sinon la réponse est celle d'une sauvegarde introuvable.
|
||||||
|
//
|
||||||
|
// Non reportées ici : la sauvegarde automatique (vérification horaire, et après
|
||||||
|
// chaque connexion) reste faite par Node jusqu'à la bascule.
|
||||||
|
|
||||||
|
use std::path::{Path as FsPath, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::{Json, Router, routing::delete, routing::get};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use rand::RngCore;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::db::{self, Table};
|
||||||
|
use crate::error::{ApiResult, AppError, OrInternal};
|
||||||
|
use crate::schema::DatabaseBackupsRow;
|
||||||
|
use crate::zod::Parsed;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/backups", get(list).post(create))
|
||||||
|
.route("/api/backups/{filename}", delete(remove))
|
||||||
|
.route("/api/backups/{filename}/download", get(download))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// maxBackups de backupService
|
||||||
|
const MAX_BACKUPS: i64 = 10;
|
||||||
|
|
||||||
|
fn access_denied() -> AppError {
|
||||||
|
AppError::forbidden("Access denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_only(user: &CurrentUser) -> Result<(), AppError> {
|
||||||
|
if user.is_admin() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(access_denied())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple nom de fichier, sans chemin (BACKUP-09)
|
||||||
|
fn is_plain_name(name: &str) -> bool {
|
||||||
|
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// nanoid() : 21 caractères de l'alphabet sûr pour les adresses
|
||||||
|
fn nanoid() -> String {
|
||||||
|
const ALPHABET: &[u8] = b"useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
|
||||||
|
let mut bytes = [0u8; 21];
|
||||||
|
rand::thread_rng().fill_bytes(&mut bytes);
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|byte| ALPHABET[(byte & 63) as usize] as char)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// backup_<type>_<new Date().toISOString() avec « : » et « . » remplacés>.sql
|
||||||
|
fn backup_filename(kind: &str, now: &DateTime<Utc>) -> String {
|
||||||
|
let stamp = crate::js::iso(now).replace([':', '.'], "-");
|
||||||
|
format!("backup_{kind}_{stamp}.sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paramètres de connexion lus dans DATABASE_URL comme new URL() : nom, mot de
|
||||||
|
/// passe et base gardent leur encodage d'origine
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
struct DatabaseConfig {
|
||||||
|
host: String,
|
||||||
|
port: String,
|
||||||
|
database: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn database_config(url: &str) -> Option<DatabaseConfig> {
|
||||||
|
let (_, rest) = url.split_once("://")?;
|
||||||
|
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
|
||||||
|
let (authority, tail) = rest.split_at(authority_end);
|
||||||
|
let (userinfo, host_port) = match authority.rsplit_once('@') {
|
||||||
|
Some((userinfo, host_port)) => (userinfo, host_port),
|
||||||
|
None => ("", authority),
|
||||||
|
};
|
||||||
|
let (username, password) = userinfo.split_once(':').unwrap_or((userinfo, ""));
|
||||||
|
let (host, port) = if host_port.starts_with('[') {
|
||||||
|
let end = host_port.find(']')?;
|
||||||
|
let port = host_port[end + 1..].strip_prefix(':').unwrap_or("");
|
||||||
|
(&host_port[..=end], port)
|
||||||
|
} else {
|
||||||
|
host_port.rsplit_once(':').unwrap_or((host_port, ""))
|
||||||
|
};
|
||||||
|
let path = tail.split(['?', '#']).next().unwrap_or("");
|
||||||
|
Some(DatabaseConfig {
|
||||||
|
host: host.to_string(),
|
||||||
|
port: if port.is_empty() {
|
||||||
|
"5432".into()
|
||||||
|
} else {
|
||||||
|
port.to_string()
|
||||||
|
},
|
||||||
|
database: path.strip_prefix('/').unwrap_or(path).to_string(),
|
||||||
|
username: username.to_string(),
|
||||||
|
password: password.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dossier des sauvegardes, créé au besoin (repli sur /tmp/backups comme
|
||||||
|
/// ensureBackupDirectory au démarrage de Node)
|
||||||
|
async fn backup_dir(state: &AppState) -> PathBuf {
|
||||||
|
let dir = state.config.backup_dir.clone();
|
||||||
|
if tokio::fs::create_dir_all(&dir).await.is_ok() {
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
let fallback = PathBuf::from("/tmp/backups");
|
||||||
|
let _ = tokio::fs::create_dir_all(&fallback).await;
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/// getBackupList : les dix plus récentes ; une erreur donne une liste vide
|
||||||
|
async fn list(State(state): State<AppState>, user: CurrentUser) -> ApiResult {
|
||||||
|
admin_only(&user)?;
|
||||||
|
let backups: Vec<DatabaseBackupsRow> =
|
||||||
|
sqlx::query_as(&DatabaseBackupsRow::select("ORDER BY created_at DESC LIMIT $1"))
|
||||||
|
.bind(MAX_BACKUPS)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|error| {
|
||||||
|
tracing::error!(%error, "liste des sauvegardes");
|
||||||
|
Vec::new()
|
||||||
|
});
|
||||||
|
Ok(Json(backups).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(State(state): State<AppState>, user: CurrentUser) -> ApiResult {
|
||||||
|
admin_only(&user)?;
|
||||||
|
let backup = create_backup(&state, "manual", user.id())
|
||||||
|
.await
|
||||||
|
.or_500("Failed to create backup")?;
|
||||||
|
Ok(Json(backup).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// backupService.createBackup : ligne « creating », pg_dump, puis taille et
|
||||||
|
/// nombre de tables, et ménage des anciennes sauvegardes
|
||||||
|
pub(crate) async fn create_backup(
|
||||||
|
state: &AppState,
|
||||||
|
kind: &str,
|
||||||
|
created_by: &str,
|
||||||
|
) -> anyhow::Result<DatabaseBackupsRow> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = nanoid();
|
||||||
|
let filename = backup_filename(kind, &now);
|
||||||
|
let dir = backup_dir(state).await;
|
||||||
|
let filepath = dir.join(&filename);
|
||||||
|
let label = if kind == "manual" { "Manuel" } else { "Automatique" };
|
||||||
|
|
||||||
|
let mut row = Parsed::new();
|
||||||
|
row.insert("id".into(), Value::String(id.clone()));
|
||||||
|
row.insert("filename".into(), Value::String(filename.clone()));
|
||||||
|
// toLocaleDateString('fr-FR') : JJ/MM/AAAA, date de Paris (décision R5)
|
||||||
|
row.insert(
|
||||||
|
"description".into(),
|
||||||
|
Value::String(format!(
|
||||||
|
"{label} backup du {}",
|
||||||
|
now.with_timezone(&chrono_tz::Europe::Paris).format("%d/%m/%Y")
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
row.insert("size".into(), json!(0));
|
||||||
|
row.insert("createdBy".into(), Value::String(created_by.to_string()));
|
||||||
|
row.insert("tablesCount".into(), json!(0));
|
||||||
|
row.insert("status".into(), json!("creating"));
|
||||||
|
row.insert("backupType".into(), Value::String(kind.to_string()));
|
||||||
|
db::insert::<DatabaseBackupsRow>(&state.db, &row).await?;
|
||||||
|
|
||||||
|
let config = database_config(&state.config.database_url)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("DATABASE_URL illisible"))?;
|
||||||
|
run_pg_dump(&config, &filepath).await?;
|
||||||
|
|
||||||
|
let size = tokio::fs::metadata(&filepath).await?.len() as i64;
|
||||||
|
let (tables_count,): (i32,) = sqlx::query_as(
|
||||||
|
"SELECT count(*)::int AS count
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_type = 'BASE TABLE'
|
||||||
|
AND table_schema NOT IN ('pg_catalog', 'information_schema')",
|
||||||
|
)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
let updated: DatabaseBackupsRow = sqlx::query_as(&format!(
|
||||||
|
"UPDATE database_backups SET size = $1, tables_count = $2, status = 'completed' WHERE id = $3 RETURNING {}",
|
||||||
|
DatabaseBackupsRow::COLUMNS
|
||||||
|
))
|
||||||
|
.bind(size)
|
||||||
|
.bind(tables_count)
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(%filename, size, tables_count, "sauvegarde {kind} créée");
|
||||||
|
|
||||||
|
clean_old_backups(state, &dir).await;
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// pg_dump -h … -p … -U … -d … --no-password --verbose --clean --if-exists
|
||||||
|
/// --create > fichier, sans shell ; mot de passe par PGPASSWORD
|
||||||
|
async fn run_pg_dump(config: &DatabaseConfig, filepath: &FsPath) -> anyhow::Result<()> {
|
||||||
|
let mut command = tokio::process::Command::new("pg_dump");
|
||||||
|
command
|
||||||
|
.args(["-h", &config.host, "-p", &config.port, "-U", &config.username])
|
||||||
|
.args(["-d", &config.database])
|
||||||
|
.args(["--no-password", "--verbose", "--clean", "--if-exists", "--create"])
|
||||||
|
.env("PGPASSWORD", &config.password);
|
||||||
|
run_to_file(command, filepath).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lance la commande, sa sortie standard dans le fichier (créé ou vidé, comme
|
||||||
|
/// « > fichier »), et attend sa fin ; erreur si elle échoue
|
||||||
|
async fn run_to_file(mut command: tokio::process::Command, filepath: &FsPath) -> anyhow::Result<()> {
|
||||||
|
let file = tokio::fs::File::create(filepath).await?.into_std().await;
|
||||||
|
// spawn puis wait_with_output : output() de tokio remplacerait la sortie
|
||||||
|
// standard configurée par un tube
|
||||||
|
let child = command
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::from(file))
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.spawn()?;
|
||||||
|
let result = child.wait_with_output().await?;
|
||||||
|
if !result.status.success() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"{:?} a échoué ({}) : {}",
|
||||||
|
command.as_std().get_program(),
|
||||||
|
result.status,
|
||||||
|
String::from_utf8_lossy(&result.stderr)
|
||||||
|
.lines()
|
||||||
|
.last()
|
||||||
|
.unwrap_or("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cleanOldBackups : au-delà des dix plus récentes, fichier et ligne supprimés ;
|
||||||
|
/// les erreurs sont seulement journalisées
|
||||||
|
async fn clean_old_backups(state: &AppState, dir: &FsPath) {
|
||||||
|
// Toute la liste, triée comme Node, puis tout ce qui suit les dix premières
|
||||||
|
let all: Result<Vec<(String,)>, sqlx::Error> =
|
||||||
|
sqlx::query_as("SELECT filename FROM database_backups ORDER BY created_at DESC")
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await;
|
||||||
|
match all {
|
||||||
|
Ok(all) => {
|
||||||
|
let old = all.get(MAX_BACKUPS as usize..).unwrap_or_default();
|
||||||
|
for (filename,) in old {
|
||||||
|
if let Err(error) = delete_backup(state, dir, filename).await {
|
||||||
|
tracing::error!(%error, %filename, "suppression d'une ancienne sauvegarde");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !old.is_empty() {
|
||||||
|
tracing::info!(count = old.len(), "anciennes sauvegardes supprimées");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => tracing::error!(%error, "ménage des sauvegardes"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// backupService.deleteBackup : la ligne doit exister ; fichier puis lignes supprimés
|
||||||
|
async fn delete_backup(state: &AppState, dir: &FsPath, filename: &str) -> anyhow::Result<()> {
|
||||||
|
let found: Option<(String,)> =
|
||||||
|
sqlx::query_as("SELECT id FROM database_backups WHERE filename = $1 LIMIT 1")
|
||||||
|
.bind(filename)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
if found.is_none() {
|
||||||
|
anyhow::bail!("Backup record not found");
|
||||||
|
}
|
||||||
|
// BACKUP-09 : jamais de fichier hors du dossier des sauvegardes
|
||||||
|
if is_plain_name(filename) {
|
||||||
|
let filepath = dir.join(filename);
|
||||||
|
// fs.existsSync suit les liens symboliques
|
||||||
|
if tokio::fs::metadata(&filepath).await.is_ok() {
|
||||||
|
tokio::fs::remove_file(&filepath).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sqlx::query("DELETE FROM database_backups WHERE filename = $1")
|
||||||
|
.bind(filename)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(filename): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to delete backup";
|
||||||
|
admin_only(&user)?;
|
||||||
|
if !is_plain_name(&filename) {
|
||||||
|
return Err(AppError::internal(FAILED));
|
||||||
|
}
|
||||||
|
let dir = backup_dir(&state).await;
|
||||||
|
delete_backup(&state, &dir, &filename).await.or_500(FAILED)?;
|
||||||
|
Ok(Json(json!({ "message": "Backup deleted successfully" })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// res.download(fichier, nom) : fichier joint, type selon l'extension (paquet
|
||||||
|
/// send), ETag faible taille-date, 304 si le navigateur l'a déjà
|
||||||
|
async fn download(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
method: Method,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(filename): Path<String>,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to download backup";
|
||||||
|
admin_only(&user)?;
|
||||||
|
if !is_plain_name(&filename) {
|
||||||
|
return Err(AppError::internal(FAILED));
|
||||||
|
}
|
||||||
|
let filepath = backup_dir(&state).await.join(&filename);
|
||||||
|
// fs.existsSync : absent → 500 ; dossier ou fichier caché refusé par send → 404
|
||||||
|
let metadata = tokio::fs::metadata(&filepath)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::internal(FAILED))?;
|
||||||
|
let not_found = || AppError::not_found("Backup file not found");
|
||||||
|
if !metadata.is_file() || filename.starts_with('.') {
|
||||||
|
return Err(not_found());
|
||||||
|
}
|
||||||
|
let modified = metadata.modified().map_err(|_| not_found())?;
|
||||||
|
let mtime_ms = modified
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|_| not_found())?
|
||||||
|
.as_millis();
|
||||||
|
let etag = format!("W/\"{:x}-{:x}\"", metadata.len(), mtime_ms);
|
||||||
|
let last_modified = DateTime::<Utc>::from(modified)
|
||||||
|
.format("%a, %d %b %Y %H:%M:%S GMT")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let mut response_headers = HeaderMap::new();
|
||||||
|
response_headers.insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("private, no-cache"),
|
||||||
|
);
|
||||||
|
let disposition = HeaderValue::from_bytes(&content_disposition(&filename)).map_err(|_| not_found())?;
|
||||||
|
response_headers.insert(header::CONTENT_DISPOSITION, disposition);
|
||||||
|
response_headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||||
|
if let Ok(value) = HeaderValue::from_str(&last_modified) {
|
||||||
|
response_headers.insert(header::LAST_MODIFIED, value);
|
||||||
|
}
|
||||||
|
if let Ok(value) = HeaderValue::from_str(&etag) {
|
||||||
|
response_headers.insert(header::ETAG, value);
|
||||||
|
}
|
||||||
|
response_headers.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static(content_type(&filename)),
|
||||||
|
);
|
||||||
|
if is_fresh(&headers, &etag, &modified) {
|
||||||
|
return Ok((StatusCode::NOT_MODIFIED, response_headers).into_response());
|
||||||
|
}
|
||||||
|
response_headers.insert(header::CONTENT_LENGTH, HeaderValue::from(metadata.len()));
|
||||||
|
if method == Method::HEAD {
|
||||||
|
return Ok((StatusCode::OK, response_headers).into_response());
|
||||||
|
}
|
||||||
|
let content = tokio::fs::read(&filepath).await.map_err(|_| not_found())?;
|
||||||
|
Ok((StatusCode::OK, response_headers, Body::from(content)).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type du paquet mime 1.x utilisé par send (sauvegardes : .sql)
|
||||||
|
fn content_type(filename: &str) -> &'static str {
|
||||||
|
match filename
|
||||||
|
.rsplit_once('.')
|
||||||
|
.map(|(_, ext)| ext.to_ascii_lowercase())
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("sql") => "application/x-sql",
|
||||||
|
Some("gz") => "application/gzip",
|
||||||
|
Some("txt") => "text/plain; charset=UTF-8",
|
||||||
|
Some("json") => "application/json; charset=UTF-8",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paquet « fresh » d'Express : If-None-Match, sinon If-Modified-Since
|
||||||
|
fn is_fresh(request: &HeaderMap, etag: &str, modified: &std::time::SystemTime) -> bool {
|
||||||
|
let cache_control = request
|
||||||
|
.get(header::CACHE_CONTROL)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
if cache_control.contains("no-cache") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(none_match) = request.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) {
|
||||||
|
let bare = etag.trim_start_matches("W/");
|
||||||
|
return none_match.trim() == "*"
|
||||||
|
|| none_match
|
||||||
|
.split(',')
|
||||||
|
.any(|candidate| candidate.trim().trim_start_matches("W/") == bare);
|
||||||
|
}
|
||||||
|
if let Some(since) = request
|
||||||
|
.get(header::IF_MODIFIED_SINCE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|since| DateTime::parse_from_rfc2822(since).ok())
|
||||||
|
{
|
||||||
|
return DateTime::<Utc>::from(*modified).timestamp() <= since.timestamp();
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paquet content-disposition : attachment; filename="…" (texte latin-1, « ? »
|
||||||
|
/// pour le reste) et filename*=UTF-8''… quand le nom n'est pas en latin-1 ou
|
||||||
|
/// contient une séquence %XX. Octets latin-1 : la valeur de l'en-tête tel que
|
||||||
|
/// Node l'écrit.
|
||||||
|
fn content_disposition(filename: &str) -> Vec<u8> {
|
||||||
|
let is_latin1_text = filename
|
||||||
|
.chars()
|
||||||
|
.all(|c| matches!(c as u32, 0x20..=0x7e | 0x80..=0xff));
|
||||||
|
// Une unité UTF-16 hors latin-1 devient « ? » (deux pour un caractère astral)
|
||||||
|
let fallback: String = filename
|
||||||
|
.chars()
|
||||||
|
.flat_map(|c| match c as u32 {
|
||||||
|
0x20..=0x7e | 0xa0..=0xff => vec![c],
|
||||||
|
_ => vec!['?'; c.len_utf16()],
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let has_fallback = fallback != filename;
|
||||||
|
let has_hex_escape = filename
|
||||||
|
.as_bytes()
|
||||||
|
.windows(3)
|
||||||
|
.any(|w| w[0] == b'%' && w[1].is_ascii_hexdigit() && w[2].is_ascii_hexdigit());
|
||||||
|
|
||||||
|
let mut out: Vec<u8> = b"attachment".to_vec();
|
||||||
|
if is_latin1_text || has_fallback {
|
||||||
|
let name = if has_fallback { &fallback } else { filename };
|
||||||
|
out.extend_from_slice(b"; filename=\"");
|
||||||
|
for c in name.chars() {
|
||||||
|
if c == '\\' || c == '"' {
|
||||||
|
out.push(b'\\');
|
||||||
|
}
|
||||||
|
// Caractères latin-1 : un octet chacun
|
||||||
|
out.push(c as u32 as u8);
|
||||||
|
}
|
||||||
|
out.push(b'"');
|
||||||
|
}
|
||||||
|
if has_fallback || !is_latin1_text || has_hex_escape {
|
||||||
|
out.extend_from_slice(b"; filename*=UTF-8''");
|
||||||
|
for byte in filename.bytes() {
|
||||||
|
let keep = byte.is_ascii_alphanumeric() || b"-_.!~".contains(&byte);
|
||||||
|
if keep {
|
||||||
|
out.push(byte);
|
||||||
|
} else {
|
||||||
|
out.extend_from_slice(format!("%{byte:02X}").as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::TimeZone;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noms_de_fichier_acceptes() {
|
||||||
|
assert!(is_plain_name("backup_manual_2026-10-15T08-30-00-123Z.sql"));
|
||||||
|
assert!(is_plain_name("été x.sql"));
|
||||||
|
assert!(!is_plain_name("../etc/passwd"));
|
||||||
|
assert!(!is_plain_name(".."));
|
||||||
|
assert!(!is_plain_name("."));
|
||||||
|
assert!(!is_plain_name("a/b.sql"));
|
||||||
|
assert!(!is_plain_name("a\\b.sql"));
|
||||||
|
assert!(!is_plain_name(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nom_et_identifiant_comme_node() {
|
||||||
|
let now = Utc.timestamp_millis_opt(1_792_151_360_449).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
backup_filename("manual", &now),
|
||||||
|
"backup_manual_2026-10-16T11-49-20-449Z.sql"
|
||||||
|
);
|
||||||
|
let id = nanoid();
|
||||||
|
assert_eq!(id.len(), 21);
|
||||||
|
assert!(
|
||||||
|
id.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parametres_de_connexion_comme_new_url() {
|
||||||
|
assert_eq!(
|
||||||
|
database_config("postgres://logiflow_admin:bench@localhost:5432/bench_lot5_droite"),
|
||||||
|
Some(DatabaseConfig {
|
||||||
|
host: "localhost".into(),
|
||||||
|
port: "5432".into(),
|
||||||
|
database: "bench_lot5_droite".into(),
|
||||||
|
username: "logiflow_admin".into(),
|
||||||
|
password: "bench".into(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let config = database_config("postgresql://u:p%40ss@db/logiflow?sslmode=disable").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
config.host.as_str(),
|
||||||
|
config.port.as_str(),
|
||||||
|
config.database.as_str(),
|
||||||
|
config.password.as_str()
|
||||||
|
),
|
||||||
|
("db", "5432", "logiflow", "p%40ss")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sortie_de_la_commande_ecrite_dans_le_fichier() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("logiflow-test-{}", nanoid()));
|
||||||
|
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||||
|
let filepath = dir.join("sortie.sql");
|
||||||
|
let mut command = tokio::process::Command::new("sh");
|
||||||
|
command.args(["-c", "echo '-- dump'; echo 'journal' >&2"]);
|
||||||
|
run_to_file(command, &filepath).await.unwrap();
|
||||||
|
assert_eq!(tokio::fs::read_to_string(&filepath).await.unwrap(), "-- dump\n");
|
||||||
|
|
||||||
|
let mut failing = tokio::process::Command::new("sh");
|
||||||
|
failing.args(["-c", "echo 'connexion refusée' >&2; exit 1"]);
|
||||||
|
let error = run_to_file(failing, &filepath).await.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("connexion refusée"));
|
||||||
|
tokio::fs::remove_dir_all(&dir).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn en_tete_de_piece_jointe_comme_node() {
|
||||||
|
// Valeurs du paquet content-disposition (Node)
|
||||||
|
let header = |name: &str| String::from_utf8_lossy(&content_disposition(name)).to_string();
|
||||||
|
assert_eq!(
|
||||||
|
header("backup_banc_1.sql"),
|
||||||
|
"attachment; filename=\"backup_banc_1.sql\""
|
||||||
|
);
|
||||||
|
assert_eq!(header("a\"b.sql"), "attachment; filename=\"a\\\"b.sql\"");
|
||||||
|
assert_eq!(
|
||||||
|
header("日本.sql"),
|
||||||
|
"attachment; filename=\"??.sql\"; filename*=UTF-8''%E6%97%A5%E6%9C%AC.sql"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
header("100%25.sql"),
|
||||||
|
"attachment; filename=\"100%25.sql\"; filename*=UTF-8''100%2525.sql"
|
||||||
|
);
|
||||||
|
// « é » part en un octet latin-1, comme Node
|
||||||
|
assert_eq!(
|
||||||
|
content_disposition("été.sql"),
|
||||||
|
b"attachment; filename=\"\xe9t\xe9.sql\"".to_vec()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
// Bon à payer (BAP) : configuration du webhook n8n (/api/webhook-bap-config, test
|
||||||
|
// compris) et envoi d'un PDF au webhook (/api/bap/send-webhook), réservés à l'admin
|
||||||
|
// (server/routes.ts). Les appels sortants reprennent les messages d'erreur de
|
||||||
|
// node-fetch, que le serveur Node utilise pour ces adresses.
|
||||||
|
//
|
||||||
|
// Non repris : la configuration par défaut renvoyée par Node quand la table
|
||||||
|
// webhook_bap_config n'existe pas (code 42P01) ; la table existe partout.
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::db::{self, Table};
|
||||||
|
use crate::error::{ApiResult, AppError};
|
||||||
|
use crate::js;
|
||||||
|
use crate::request::JsonBody;
|
||||||
|
use crate::schema::WebhookBapConfigRow;
|
||||||
|
use crate::services::nocodb::{database_message, truthy};
|
||||||
|
use crate::zod::Object;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/webhook-bap-config", get(get_config).post(save_config))
|
||||||
|
.route("/api/webhook-bap-config/test", post(test_webhook))
|
||||||
|
.route("/api/bap/send-webhook", post(send_webhook))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Délai maximal du test du webhook et de l'envoi d'un BAP
|
||||||
|
const TEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
const SEND_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.build()
|
||||||
|
.expect("client HTTP des webhooks")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// insertWebhookBapConfigSchema
|
||||||
|
fn insert_schema() -> Object {
|
||||||
|
WebhookBapConfigRow::insert_schema().omit(&["id", "createdAt", "updatedAt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_body(status: StatusCode, error: &str) -> AppError {
|
||||||
|
AppError::with_body(status, json!({ "error": error }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erreur 500 de Node : { error, details }
|
||||||
|
fn server_error(error: &str, details: &str) -> AppError {
|
||||||
|
AppError::with_body(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": error, "details": details }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_only(user: &CurrentUser) -> Result<(), AppError> {
|
||||||
|
if user.is_admin() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(error_body(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Accès refusé - Admin uniquement",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// storage.getWebhookBapConfig : la première ligne, sans ordre (comme Node)
|
||||||
|
async fn load_config(db: &sqlx::PgPool) -> Result<Option<WebhookBapConfigRow>, sqlx::Error> {
|
||||||
|
sqlx::query_as(&WebhookBapConfigRow::select("LIMIT 1"))
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_config(State(state): State<AppState>, user: CurrentUser) -> ApiResult {
|
||||||
|
admin_only(&user)?;
|
||||||
|
let config = load_config(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|error| server_error("Erreur serveur", &database_message(&error)))?;
|
||||||
|
Ok(Json(config).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
admin_only(&user)?;
|
||||||
|
// Erreur de validation en 500, avec le message de ZodError (les problèmes en JSON indenté)
|
||||||
|
let data = insert_schema().parse(&body).map_err(|issues| {
|
||||||
|
let details = serde_json::to_string_pretty(&issues).unwrap_or_default();
|
||||||
|
server_error("Erreur serveur", &details)
|
||||||
|
})?;
|
||||||
|
let database_error = |error: sqlx::Error| server_error("Erreur serveur", &database_message(&error));
|
||||||
|
let config = match load_config(&state.db).await.map_err(database_error)? {
|
||||||
|
Some(existing) => db::update_by_id::<WebhookBapConfigRow>(&state.db, existing.id, &data, true)
|
||||||
|
.await
|
||||||
|
.map_err(database_error)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
server_error(
|
||||||
|
"Erreur serveur",
|
||||||
|
"Cannot read properties of undefined (reading 'id')",
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
None => db::insert::<WebhookBapConfigRow>(&state.db, &data)
|
||||||
|
.await
|
||||||
|
.map_err(database_error)?,
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
id = config.id,
|
||||||
|
name = config.name,
|
||||||
|
active = config.is_active,
|
||||||
|
"configuration du webhook BAP sauvegardée"
|
||||||
|
);
|
||||||
|
Ok(Json(config).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Appels sortants, au comportement de node-fetch ----------
|
||||||
|
// Partagés avec les avoirs et l'envoi de facture du rapprochement, qui appellent
|
||||||
|
// aussi les webhooks avec node-fetch dans Node.
|
||||||
|
|
||||||
|
/// Échec d'un appel : `aborted` pour un délai dépassé (AbortError)
|
||||||
|
pub(crate) struct FetchError {
|
||||||
|
pub(crate) aborted: bool,
|
||||||
|
pub(crate) message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FetchError {
|
||||||
|
fn new(message: String) -> Self {
|
||||||
|
Self {
|
||||||
|
aborted: false,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cause d'un échec réseau telle que Node la décrit (connect ECONNREFUSED…)
|
||||||
|
async fn network_reason(url: &reqwest::Url, error: &reqwest::Error) -> String {
|
||||||
|
let host = url
|
||||||
|
.host_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim_matches(['[', ']'])
|
||||||
|
.to_string();
|
||||||
|
let port = url.port_or_known_default().unwrap_or(80);
|
||||||
|
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error);
|
||||||
|
while let Some(current) = source {
|
||||||
|
if let Some(io) = current.downcast_ref::<std::io::Error>() {
|
||||||
|
if io.kind() == std::io::ErrorKind::ConnectionRefused {
|
||||||
|
// Adresse réellement jointe : celle que le nom désigne
|
||||||
|
let address = tokio::net::lookup_host((host.as_str(), port))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut addresses| addresses.next())
|
||||||
|
.map_or_else(|| host.clone(), |address| address.ip().to_string());
|
||||||
|
return format!("connect ECONNREFUSED {address}:{port}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = current.to_string();
|
||||||
|
if text.contains("dns error") || text.contains("failed to lookup address") {
|
||||||
|
return format!("getaddrinfo ENOTFOUND {host}");
|
||||||
|
}
|
||||||
|
source = current.source();
|
||||||
|
}
|
||||||
|
error.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// fetch(url, { method: 'POST', body: JSON }) de node-fetch : statut et corps de la
|
||||||
|
/// réponse, ou l'erreur que node-fetch lèverait
|
||||||
|
async fn post_json(
|
||||||
|
raw_url: &str,
|
||||||
|
body: &Value,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<(StatusCode, String), FetchError> {
|
||||||
|
node_fetch_post(
|
||||||
|
raw_url,
|
||||||
|
|request| {
|
||||||
|
request
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(body.to_string())
|
||||||
|
},
|
||||||
|
Some(timeout),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// fetch(url, { method: 'POST', … }) de node-fetch : `prepare` ajoute le corps et
|
||||||
|
/// ses en-têtes ; sans délai maximal si `timeout` est None (fetch sans signal).
|
||||||
|
/// Rend le statut et le corps de la réponse, ou l'erreur que node-fetch lèverait.
|
||||||
|
pub(crate) async fn node_fetch_post(
|
||||||
|
raw_url: &str,
|
||||||
|
prepare: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder,
|
||||||
|
timeout: Option<Duration>,
|
||||||
|
) -> Result<(StatusCode, String), FetchError> {
|
||||||
|
let url = reqwest::Url::parse(raw_url).map_err(|_| FetchError::new("Invalid URL".to_string()))?;
|
||||||
|
if !matches!(url.scheme(), "http" | "https") {
|
||||||
|
return Err(FetchError::new(format!(
|
||||||
|
"node-fetch cannot load {raw_url}. URL scheme \"{}\" is not supported.",
|
||||||
|
url.scheme()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut request = prepare(CLIENT.post(url.clone()));
|
||||||
|
if let Some(timeout) = timeout {
|
||||||
|
request = request.timeout(timeout);
|
||||||
|
}
|
||||||
|
let response = request.send().await;
|
||||||
|
let response = match response {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) if error.is_timeout() => {
|
||||||
|
return Err(FetchError {
|
||||||
|
aborted: true,
|
||||||
|
message: "The operation was aborted.".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let reason = network_reason(&url, &error).await;
|
||||||
|
return Err(FetchError::new(format!(
|
||||||
|
"request to {url} failed, reason: {reason}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|error| FetchError::new(error.to_string()))?;
|
||||||
|
Ok((status, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// response.statusText
|
||||||
|
fn status_text(status: StatusCode) -> &'static str {
|
||||||
|
status.canonical_reason().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// texte.substring(0, 200), en unités UTF-16
|
||||||
|
fn first_200_units(text: &str) -> String {
|
||||||
|
let units: Vec<u16> = text.encode_utf16().take(200).collect();
|
||||||
|
String::from_utf16_lossy(&units)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_webhook(
|
||||||
|
State(_state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
admin_only(&user)?;
|
||||||
|
let Some(webhook_url) = body.get("webhookUrl").filter(|url| truthy(url)) else {
|
||||||
|
return Err(error_body(StatusCode::BAD_REQUEST, "URL webhook requise"));
|
||||||
|
};
|
||||||
|
let payload = json!({
|
||||||
|
"test": true,
|
||||||
|
"timestamp": crate::modules::now_iso(),
|
||||||
|
"message": "Test de connectivité depuis LogiFlow",
|
||||||
|
});
|
||||||
|
match post_json(&js::to_js_string(webhook_url), &payload, TEST_TIMEOUT).await {
|
||||||
|
Ok((status, text)) if status.is_success() => Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"status": status.as_u16(),
|
||||||
|
"message": "Webhook accessible",
|
||||||
|
"response": first_200_units(&text),
|
||||||
|
}))
|
||||||
|
.into_response()),
|
||||||
|
Ok((status, _)) => {
|
||||||
|
let message = format!("HTTP {}: {}", status.as_u16(), status_text(status));
|
||||||
|
Err(server_error(&message, &message))
|
||||||
|
}
|
||||||
|
Err(error) if error.aborted => {
|
||||||
|
Err(server_error("Timeout - Le webhook ne répond pas", &error.message))
|
||||||
|
}
|
||||||
|
Err(error) => Err(server_error(&error.message, &error.message)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buffer.from(pdfBase64, 'base64').length, ou None si Node lève une TypeError
|
||||||
|
fn decoded_length(value: &Value) -> Option<usize> {
|
||||||
|
match value {
|
||||||
|
Value::String(text) => Some(js::buffer_from_base64(text).len()),
|
||||||
|
Value::Array(items) => Some(items.len()),
|
||||||
|
Value::Object(object) => match object.get("length") {
|
||||||
|
Some(Value::Number(length)) => Some(length.as_f64().map_or(0, |n| n.max(0.0) as usize)),
|
||||||
|
Some(_) => Some(0),
|
||||||
|
None => match (object.get("type"), object.get("data")) {
|
||||||
|
(Some(Value::String(kind)), Some(Value::Array(data))) if kind == "Buffer" => Some(data.len()),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_webhook(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
admin_only(&user)?;
|
||||||
|
let field = |name: &str| body.get(name).filter(|value| truthy(value));
|
||||||
|
let (Some(pdf_base64), Some(file_name), Some(recipient)) =
|
||||||
|
(field("pdfBase64"), field("fileName"), field("recipient"))
|
||||||
|
else {
|
||||||
|
return Err(error_body(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Données manquantes: pdfBase64, fileName ou recipient",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if !matches!(recipient.as_str(), Some("Laurie" | "Jeremy")) {
|
||||||
|
return Err(error_body(StatusCode::BAD_REQUEST, "Destinataire invalide"));
|
||||||
|
}
|
||||||
|
let Some(file_name_text) = file_name.as_str() else {
|
||||||
|
let message = "fileName.toLowerCase is not a function";
|
||||||
|
return Err(server_error(message, message));
|
||||||
|
};
|
||||||
|
if !file_name_text.to_lowercase().ends_with(".pdf") {
|
||||||
|
return Err(error_body(StatusCode::BAD_REQUEST, "Le fichier doit être un PDF"));
|
||||||
|
}
|
||||||
|
let Some(file_size) = decoded_length(pdf_base64) else {
|
||||||
|
return Err(error_body(StatusCode::BAD_REQUEST, "Format base64 invalide"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = load_config(&state.db).await.map_err(|error| {
|
||||||
|
let message = database_message(&error);
|
||||||
|
server_error(&message, &message)
|
||||||
|
})?;
|
||||||
|
let Some(config) = config.filter(|config| config.is_active) else {
|
||||||
|
return Err(error_body(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Configuration webhook BAP non disponible",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let payload = json!({
|
||||||
|
"recipient": recipient,
|
||||||
|
"fileName": file_name,
|
||||||
|
"fileSize": file_size,
|
||||||
|
"pdfBase64": pdf_base64,
|
||||||
|
"contentType": "application/pdf",
|
||||||
|
});
|
||||||
|
tracing::info!(
|
||||||
|
recipient = recipient.as_str(),
|
||||||
|
file_size,
|
||||||
|
"envoi du BAP au webhook"
|
||||||
|
);
|
||||||
|
match post_json(&config.webhook_url, &payload, SEND_TIMEOUT).await {
|
||||||
|
Ok((status, text)) if status.is_success() => Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Fichier envoyé avec succès",
|
||||||
|
"webhookResponse": text,
|
||||||
|
}))
|
||||||
|
.into_response()),
|
||||||
|
Ok((status, _)) => {
|
||||||
|
let message = format!("Erreur webhook: {} {}", status.as_u16(), status_text(status));
|
||||||
|
Err(server_error(&message, &message))
|
||||||
|
}
|
||||||
|
Err(error) if error.aborted => Err(server_error(
|
||||||
|
"Timeout - Le traitement a pris trop de temps",
|
||||||
|
&error.message,
|
||||||
|
)),
|
||||||
|
Err(error) => Err(server_error(&error.message, &error.message)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn taille_du_pdf_comme_buffer_from() {
|
||||||
|
assert_eq!(decoded_length(&json!("JVBERi0xLjQ=")), Some(8));
|
||||||
|
assert_eq!(decoded_length(&json!([1, 2, 3])), Some(3));
|
||||||
|
assert_eq!(decoded_length(&json!({ "length": 5 })), Some(5));
|
||||||
|
assert_eq!(decoded_length(&json!({ "length": "x" })), Some(0));
|
||||||
|
assert_eq!(
|
||||||
|
decoded_length(&json!({ "type": "Buffer", "data": [1, 2] })),
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
|
assert_eq!(decoded_length(&json!({})), None);
|
||||||
|
assert_eq!(decoded_length(&json!(12)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extrait_de_reponse_en_unites_utf16() {
|
||||||
|
assert_eq!(first_200_units(&"é".repeat(250)).chars().count(), 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Contacts des magasins : /api/contacts (server/routes.ts, « Contacts routes »).
|
||||||
|
// Comme Node aujourd'hui : un compte sans magasin voit tous les contacts (CONT-02)
|
||||||
|
// et un manager peut modifier ceux d'un autre magasin (CONT-03).
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::{Json, Router, routing::get, routing::put};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::db::{self, Table};
|
||||||
|
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||||
|
use crate::request::{JsQuery, JsonBody, int_id};
|
||||||
|
use crate::schema::ContactsRow;
|
||||||
|
use crate::zod::Object;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/contacts", get(list).post(create))
|
||||||
|
.route("/api/contacts/{id}", put(update).delete(remove))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// insertContactSchema
|
||||||
|
fn insert_schema() -> Object {
|
||||||
|
ContactsRow::insert_schema().omit(&["id", "createdAt", "updatedAt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_write(user: &CurrentUser) -> Result<(), AppError> {
|
||||||
|
if ["admin", "directeur", "manager"].contains(&user.role()) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::forbidden("Insufficient permissions"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(State(state): State<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to fetch contacts";
|
||||||
|
// Hors admin : les magasins de l'utilisateur ; admin : ?groupId s'il est donné
|
||||||
|
let group_ids: Vec<i32> = if !user.is_admin() {
|
||||||
|
user.group_ids()
|
||||||
|
} else if let Some(group_id) = query.truthy("groupId") {
|
||||||
|
vec![int_id(group_id, FAILED)?]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let contacts: Vec<ContactsRow> = if group_ids.is_empty() {
|
||||||
|
sqlx::query_as(&ContactsRow::select("ORDER BY name"))
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as(&ContactsRow::select("WHERE group_id = ANY($1) ORDER BY name"))
|
||||||
|
.bind(&group_ids)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(Json(contacts).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(State(state): State<AppState>, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult {
|
||||||
|
can_write(&user)?;
|
||||||
|
let data = insert_schema()
|
||||||
|
.parse(&body)
|
||||||
|
.map_err(|issues| AppError::validation("Validation failed", issues))?;
|
||||||
|
let contact: ContactsRow = db::insert(&state.db, &data)
|
||||||
|
.await
|
||||||
|
.or_500("Failed to create contact")?;
|
||||||
|
Ok(Json(contact).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to update contact";
|
||||||
|
can_write(&user)?;
|
||||||
|
let id = int_id(&id, FAILED)?;
|
||||||
|
let data = insert_schema()
|
||||||
|
.partial()
|
||||||
|
.parse(&body)
|
||||||
|
.map_err(|_| AppError::internal(FAILED))?;
|
||||||
|
let contact = db::update_by_id::<ContactsRow>(&state.db, id, &data, true)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(json_or_empty(contact))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to delete contact";
|
||||||
|
can_write(&user)?;
|
||||||
|
let id = int_id(&id, FAILED)?;
|
||||||
|
db::delete_by_id::<ContactsRow>(&state.db, id)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(Json(json!({ "message": "Contact deleted successfully" })).into_response())
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// Commandes clients : /api/customer-orders (server/routes.ts, « Customer Orders
|
||||||
|
// routes » et « Client call tracking routes » ; storage.ts getCustomerOrders à
|
||||||
|
// markClientCalled). Comme Node aujourd'hui :
|
||||||
|
// - un compte sans magasin voit les commandes de tous les magasins ;
|
||||||
|
// - création et modification sans schéma zod (insertCustomerOrderSchema n'est pas
|
||||||
|
// appliqué) : les champs connus de la table sont écrits tels quels, les erreurs de
|
||||||
|
// la base deviennent des 500 ;
|
||||||
|
// - la modification ne contrôle que le magasin d'origine de la commande : elle peut
|
||||||
|
// la déplacer vers un autre magasin ;
|
||||||
|
// - les appels à passer ignorent les commandes dont customer_notified est NULL.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::{Json, Router, routing::get, routing::patch, routing::put};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sqlx::{Postgres, QueryBuilder};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::db::{self, columns_of, drizzle_values, node_pg_value};
|
||||||
|
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||||
|
use crate::js;
|
||||||
|
use crate::json::spread;
|
||||||
|
use crate::modules::now_iso;
|
||||||
|
use crate::projections::GroupSummary;
|
||||||
|
use crate::request::{JsQuery, JsonBody, int_id};
|
||||||
|
use crate::schema::{CustomerOrdersRow, SuppliersRow};
|
||||||
|
use crate::zod::Parsed;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/customer-orders", get(list).post(create))
|
||||||
|
.route("/api/customer-orders/pending-calls", get(pending_calls))
|
||||||
|
.route("/api/customer-orders/{id}", put(update).delete(remove))
|
||||||
|
.route("/api/customer-orders/{id}/mark-called", patch(mark_called))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statuts d'une commande arrivée en magasin, client à prévenir
|
||||||
|
const ARRIVED: [&str; 6] = ["Disponible", "disponible", "Arrivé", "arrivé", "Prêt", "prêt"];
|
||||||
|
|
||||||
|
/// Créateur joint par getCustomerOrders (jamais lu : toujours « unknown »)
|
||||||
|
fn unknown_creator(order: &CustomerOrdersRow) -> Value {
|
||||||
|
json!({ "id": order.created_by, "username": "unknown" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commandes de la requête principale (jointures et tri de Node), complétées comme
|
||||||
|
/// { ...commande, supplier, group, creator }
|
||||||
|
async fn with_relations(
|
||||||
|
state: &AppState,
|
||||||
|
sql: &mut QueryBuilder<'_, Postgres>,
|
||||||
|
creator: fn(&CustomerOrdersRow) -> Value,
|
||||||
|
) -> Result<Vec<Value>, sqlx::Error> {
|
||||||
|
let orders: Vec<CustomerOrdersRow> = sql.build_query_as().fetch_all(&state.db).await?;
|
||||||
|
let supplier_ids: Vec<i32> = orders.iter().map(|order| order.supplier_id).collect();
|
||||||
|
let suppliers: HashMap<i32, SuppliersRow> = db::find_by_ids::<SuppliersRow>(&state.db, &supplier_ids)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|supplier| (supplier.id, supplier))
|
||||||
|
.collect();
|
||||||
|
let group_ids: Vec<i32> = orders.iter().map(|order| order.group_id).collect();
|
||||||
|
let groups = GroupSummary::by_ids(&state.db, &group_ids).await?;
|
||||||
|
Ok(orders
|
||||||
|
.iter()
|
||||||
|
.map(|order| {
|
||||||
|
let mut entry = spread(order);
|
||||||
|
entry.insert("supplier".into(), json!(suppliers.get(&order.supplier_id)));
|
||||||
|
entry.insert("group".into(), json!(groups.get(&order.group_id)));
|
||||||
|
entry.insert("creator".into(), creator(order));
|
||||||
|
Value::Object(entry)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_with_joins() -> QueryBuilder<'static, Postgres> {
|
||||||
|
QueryBuilder::new(format!(
|
||||||
|
"SELECT {} FROM customer_orders
|
||||||
|
LEFT JOIN suppliers ON customer_orders.supplier_id = suppliers.id
|
||||||
|
LEFT JOIN groups ON customer_orders.group_id = groups.id",
|
||||||
|
columns_of::<CustomerOrdersRow>("customer_orders")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(State(state): State<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
// Toute erreur : 500 avec une liste vide
|
||||||
|
let failed = || AppError::with_body(StatusCode::INTERNAL_SERVER_ERROR, json!([]));
|
||||||
|
let store = query.truthy("storeId");
|
||||||
|
// Admin : ?storeId ou tous ; hors admin : ?storeId s'il est l'un des siens,
|
||||||
|
// sinon tous les siens (aucun pour un compte sans magasin : toutes les commandes)
|
||||||
|
let group_ids: Vec<i32> = if user.is_admin() {
|
||||||
|
match store {
|
||||||
|
Some(store) => vec![int_id(store, "").map_err(|_| failed())?],
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mine = user.group_ids();
|
||||||
|
match store.and_then(js::parse_int) {
|
||||||
|
Some(requested) if mine.iter().any(|id| f64::from(*id) == requested) => vec![requested as i32],
|
||||||
|
_ => mine,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut sql = select_with_joins();
|
||||||
|
if !group_ids.is_empty() {
|
||||||
|
sql.push(" WHERE customer_orders.group_id = ANY(")
|
||||||
|
.push_bind(group_ids)
|
||||||
|
.push(")");
|
||||||
|
}
|
||||||
|
sql.push(" ORDER BY customer_orders.created_at DESC");
|
||||||
|
let orders = with_relations(&state, &mut sql, unknown_creator)
|
||||||
|
.await
|
||||||
|
.map_err(|_| failed())?;
|
||||||
|
Ok(Json(orders).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(State(state): State<AppState>, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to create customer order";
|
||||||
|
let mut data = body.as_object().cloned().unwrap_or_default();
|
||||||
|
data.insert("createdBy".into(), json!(user.id()));
|
||||||
|
if !user.is_admin() {
|
||||||
|
// Magasin en texte converti par parseInt ; tout autre type ne correspond à aucun magasin
|
||||||
|
let requested = match data.get("groupId") {
|
||||||
|
Some(Value::String(text)) => js::parse_int(text),
|
||||||
|
Some(Value::Number(number)) => number.as_f64(),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if !["manager", "directeur", "employee"].contains(&user.role()) {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"Insufficient permissions to create customer orders",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !requested.is_some_and(|requested| user.group_ids().iter().any(|id| f64::from(*id) == requested)) {
|
||||||
|
return Err(AppError::forbidden("Access denied to this group"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let values = drizzle_values::<CustomerOrdersRow>(&data).map_err(|_| AppError::internal(FAILED))?;
|
||||||
|
let order: CustomerOrdersRow = db::insert(&state.db, &values).await.or_500(FAILED)?;
|
||||||
|
Ok(Json(order).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commande existante et du magasin de l'utilisateur (hors admin)
|
||||||
|
async fn find_for(
|
||||||
|
state: &AppState,
|
||||||
|
user: &CurrentUser,
|
||||||
|
raw_id: &str,
|
||||||
|
failed: &str,
|
||||||
|
denied: &str,
|
||||||
|
) -> Result<CustomerOrdersRow, AppError> {
|
||||||
|
let id = int_id(raw_id, failed)?;
|
||||||
|
let order = db::find_by_id::<CustomerOrdersRow>(&state.db, id)
|
||||||
|
.await
|
||||||
|
.or_500(failed)?
|
||||||
|
.ok_or_else(|| AppError::not_found("Customer order not found"))?;
|
||||||
|
if !user.is_admin() && !user.group_ids().contains(&order.group_id) {
|
||||||
|
return Err(AppError::forbidden(denied));
|
||||||
|
}
|
||||||
|
Ok(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to update customer order";
|
||||||
|
let order = find_for(&state, &user, &id, FAILED, "Access denied").await?;
|
||||||
|
let mut data = body.as_object().cloned().unwrap_or_default();
|
||||||
|
// updatedAt du corps remplacé par new Date()
|
||||||
|
data.remove("updatedAt");
|
||||||
|
let values = drizzle_values::<CustomerOrdersRow>(&data).map_err(|_| AppError::internal(FAILED))?;
|
||||||
|
let updated = db::update_by_id::<CustomerOrdersRow>(&state.db, order.id, &values, true)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(json_or_empty(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to delete customer order";
|
||||||
|
let order = find_for(&state, &user, &id, FAILED, "Access denied").await?;
|
||||||
|
db::delete_by_id::<CustomerOrdersRow>(&state.db, order.id)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(Json(json!({ "message": "Customer order deleted successfully" })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pending_calls(State(state): State<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to fetch pending client calls";
|
||||||
|
if user.role() == "employee" {
|
||||||
|
return Err(AppError::forbidden("Insufficient permissions"));
|
||||||
|
}
|
||||||
|
// Admin : ?storeId s'il est donné ; directeur et manager : leurs magasins
|
||||||
|
let group_ids: Vec<i32> = if user.is_admin() {
|
||||||
|
match query.truthy("storeId") {
|
||||||
|
Some(store) => vec![int_id(store, FAILED)?],
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.group_ids()
|
||||||
|
};
|
||||||
|
let mut sql = select_with_joins();
|
||||||
|
sql.push(" WHERE customer_orders.customer_notified = false AND customer_orders.status = ANY(")
|
||||||
|
.push_bind(ARRIVED.map(String::from).to_vec())
|
||||||
|
.push(")");
|
||||||
|
if !group_ids.is_empty() {
|
||||||
|
sql.push(" AND customer_orders.group_id = ANY(")
|
||||||
|
.push_bind(group_ids)
|
||||||
|
.push(")");
|
||||||
|
}
|
||||||
|
sql.push(" ORDER BY customer_orders.created_at DESC");
|
||||||
|
let orders = with_relations(&state, &mut sql, |_| Value::Null)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(Json(orders).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_called(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to mark client as called";
|
||||||
|
if user.role() == "employee" {
|
||||||
|
return Err(AppError::forbidden("Insufficient permissions"));
|
||||||
|
}
|
||||||
|
let order = find_for(&state, &user, &id, FAILED, "Access denied to this customer order").await?;
|
||||||
|
// comment || null
|
||||||
|
let comment = body
|
||||||
|
.get("comment")
|
||||||
|
.filter(|comment| js::truthy(comment))
|
||||||
|
.map_or(Value::Null, node_pg_value);
|
||||||
|
let mut values = Parsed::new();
|
||||||
|
values.insert("customerNotified".into(), Value::Bool(true));
|
||||||
|
values.insert("notifiedAt".into(), json!(now_iso()));
|
||||||
|
values.insert("notifiedComment".into(), comment);
|
||||||
|
let updated = db::update_by_id::<CustomerOrdersRow>(&state.db, order.id, &values, true)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(json_or_empty(updated))
|
||||||
|
}
|
||||||
@@ -0,0 +1,918 @@
|
|||||||
|
// Livraisons : /api/deliveries (server/routes.ts, « Deliveries routes »,
|
||||||
|
// validation et contrôle ; storage.ts getDeliveries à markDeliveryControlValidated).
|
||||||
|
//
|
||||||
|
// Formes de Node reproduites :
|
||||||
|
// - liste : champs dans l'ordre du select de Drizzle, fournisseur complet, magasin
|
||||||
|
// réduit (groupReconciliationColumns), créateur réduit, commande associée (sans
|
||||||
|
// ses livraisons, absente si elle est d'un autre magasin) et nombre de
|
||||||
|
// commentaires de rapprochement ;
|
||||||
|
// - GET /:id : sans les champs du contrôle, magasin complet (mot de passe SMTP
|
||||||
|
// remplacé par smtpPasswordSet), commande complète (getOrder).
|
||||||
|
//
|
||||||
|
// Effets de bord de Node reproduits, dans le même ordre (mêmes requêtes) :
|
||||||
|
// - lier une commande « pending » la fait passer à « planned » (création et
|
||||||
|
// modification) ; valider une livraison passe sa commande à « delivered » ;
|
||||||
|
// - fournisseur en rapprochement automatique : livraison livrée avec un BL
|
||||||
|
// rapprochée d'office (seconde mise à jour, la réponse reste la première ligne) ;
|
||||||
|
// - changer la référence de facture relit l'échéance dans NocoDB (sans cache) ;
|
||||||
|
// - valider marque « rapprochées » les entrées du cache de l'ancienne référence et
|
||||||
|
// de l'ancien numéro de BL.
|
||||||
|
//
|
||||||
|
// Défauts de Node reproduits :
|
||||||
|
// - un directeur ou un manager sans ?storeId ne voit que son premier magasin ;
|
||||||
|
// - la validation ne contrôle le magasin que pour un manager (un directeur valide
|
||||||
|
// les livraisons de tout magasin) ; le contrôle (PUT /:id/control) ne contrôle
|
||||||
|
// jamais le magasin ;
|
||||||
|
// - PUT accepte groupId, createdBy, reconciled… (schéma d'insertion partiel) : une
|
||||||
|
// livraison peut changer de magasin ; un corps invalide donne un 500 ;
|
||||||
|
// - POST : erreurs de validation rendues en 500 (pas de 400 zod).
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::routing::{get, post, put};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use sqlx::{PgPool, Postgres, QueryBuilder};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::access::has_permission;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::db::{self, columns_of};
|
||||||
|
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||||
|
use crate::js;
|
||||||
|
use crate::json::{spread, strip_smtp_password};
|
||||||
|
use crate::modules::orders::{self, OrderItem, date_range, listed_groups, spread_body};
|
||||||
|
use crate::projections::{GroupReconciliation, UserSummary};
|
||||||
|
use crate::request::{JsQuery, JsonBody, int_id};
|
||||||
|
use crate::schema::{DeliveriesRow, GroupsRow, OrdersRow, SuppliersRow};
|
||||||
|
use crate::services::dates;
|
||||||
|
use crate::services::invoice_verification as verification;
|
||||||
|
use crate::zod::Parsed;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/deliveries", get(list).post(create))
|
||||||
|
.route("/api/deliveries/{id}", get(show).put(update).delete(remove))
|
||||||
|
.route("/api/deliveries/{id}/validate", post(validate))
|
||||||
|
.route("/api/deliveries/{id}/control", put(control))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Champs d'une livraison dans l'ordre du select de getDeliveries
|
||||||
|
const DELIVERY_FIELDS: &[&str] = &[
|
||||||
|
"id",
|
||||||
|
"orderId",
|
||||||
|
"supplierId",
|
||||||
|
"groupId",
|
||||||
|
"scheduledDate",
|
||||||
|
"deliveredDate",
|
||||||
|
"status",
|
||||||
|
"quantity",
|
||||||
|
"unit",
|
||||||
|
"blNumber",
|
||||||
|
"blAmount",
|
||||||
|
"invoiceReference",
|
||||||
|
"invoiceAmount",
|
||||||
|
"invoiceAmountTTC",
|
||||||
|
"dueDate",
|
||||||
|
"reconciled",
|
||||||
|
"validatedAt",
|
||||||
|
"controlValidated",
|
||||||
|
"controlValidatedBy",
|
||||||
|
"controlValidatedAt",
|
||||||
|
"notes",
|
||||||
|
"createdBy",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Statuts acceptés par le filtre ?status de la liste
|
||||||
|
const LISTED_STATUSES: [&str; 3] = ["pending", "planned", "delivered"];
|
||||||
|
|
||||||
|
/// Les champs `keys` d'un objet, dans cet ordre (projection d'un select de Drizzle)
|
||||||
|
pub fn fields_in_order(fields: &Map<String, Value>, keys: &[&str]) -> Map<String, Value> {
|
||||||
|
keys.iter()
|
||||||
|
.filter_map(|key| fields.get(*key).map(|value| (key.to_string(), value.clone())))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// groupReconciliationColumns des magasins `ids`, par identifiant (seconde
|
||||||
|
/// moitié d'un leftJoin(groups))
|
||||||
|
pub async fn group_reconciliations(
|
||||||
|
db: &PgPool,
|
||||||
|
ids: &[i32],
|
||||||
|
) -> Result<HashMap<i32, GroupReconciliation>, sqlx::Error> {
|
||||||
|
let rows: Vec<GroupReconciliation> = sqlx::query_as(&format!(
|
||||||
|
"SELECT {} FROM groups g WHERE g.id = ANY($1)",
|
||||||
|
GroupReconciliation::columns("g")
|
||||||
|
))
|
||||||
|
.bind(ids)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(|group| (group.id, group)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jointures des livraisons (fournisseur, magasin, créateur)
|
||||||
|
const DELIVERY_JOINS: &str = "FROM deliveries
|
||||||
|
LEFT JOIN suppliers ON deliveries.supplier_id = suppliers.id
|
||||||
|
LEFT JOIN groups ON deliveries.group_id = groups.id
|
||||||
|
LEFT JOIN users ON deliveries.created_by = users.id";
|
||||||
|
|
||||||
|
/// Début de la requête jointe de Drizzle : les colonnes de la livraison, et celles
|
||||||
|
/// des tables jointes qui gardent les jointures (même plan que Node, donc même
|
||||||
|
/// ordre des lignes quand il n'y a pas de tri)
|
||||||
|
fn delivery_select() -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT {}, suppliers.id AS joined_supplier_id, groups.id AS joined_group_id, users.id AS joined_user_id {DELIVERY_JOINS}",
|
||||||
|
columns_of::<DeliveriesRow>("deliveries")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Livraison d'une liste, avec ses relations (fournisseur, magasin réduit, créateur)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DeliveryItem {
|
||||||
|
pub row: DeliveriesRow,
|
||||||
|
pub supplier: Option<SuppliersRow>,
|
||||||
|
pub group: Option<GroupReconciliation>,
|
||||||
|
pub creator: Option<UserSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeliveryItem {
|
||||||
|
pub fn to_json(&self) -> Map<String, Value> {
|
||||||
|
let mut out = fields_in_order(&spread(&self.row), DELIVERY_FIELDS);
|
||||||
|
out.insert("supplier".into(), json!(self.supplier));
|
||||||
|
out.insert("group".into(), json!(self.group));
|
||||||
|
out.insert("creator".into(), json!(self.creator));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lignes de la requête jointe, complétées par leurs relations (lues par identifiant)
|
||||||
|
async fn joined_deliveries(
|
||||||
|
db: &PgPool,
|
||||||
|
mut sql: QueryBuilder<'_, Postgres>,
|
||||||
|
) -> Result<Vec<DeliveryItem>, sqlx::Error> {
|
||||||
|
let rows: Vec<DeliveriesRow> = sql.build_query_as().fetch_all(db).await?;
|
||||||
|
let supplier_ids: Vec<i32> = rows.iter().map(|row| row.supplier_id).collect();
|
||||||
|
let suppliers: HashMap<i32, SuppliersRow> = db::find_by_ids::<SuppliersRow>(db, &supplier_ids)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|supplier| (supplier.id, supplier))
|
||||||
|
.collect();
|
||||||
|
let group_ids: Vec<i32> = rows.iter().map(|row| row.group_id).collect();
|
||||||
|
let groups = group_reconciliations(db, &group_ids).await?;
|
||||||
|
let creator_ids: Vec<String> = rows.iter().map(|row| row.created_by.clone()).collect();
|
||||||
|
let creators = UserSummary::by_ids(db, &creator_ids).await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| DeliveryItem {
|
||||||
|
supplier: suppliers.get(&row.supplier_id).cloned(),
|
||||||
|
group: groups.get(&row.group_id).cloned(),
|
||||||
|
creator: creators.get(&row.created_by).cloned(),
|
||||||
|
row,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// loadDeliveriesByOrderIds : livraisons jointes, par commande (dans l'ordre de la requête)
|
||||||
|
pub async fn deliveries_by_order_ids(
|
||||||
|
db: &PgPool,
|
||||||
|
order_ids: &[i32],
|
||||||
|
) -> Result<HashMap<i32, Vec<DeliveryItem>>, sqlx::Error> {
|
||||||
|
let mut by_order: HashMap<i32, Vec<DeliveryItem>> = HashMap::new();
|
||||||
|
if order_ids.is_empty() {
|
||||||
|
return Ok(by_order);
|
||||||
|
}
|
||||||
|
let mut sql = QueryBuilder::new(delivery_select());
|
||||||
|
sql.push(" WHERE deliveries.order_id = ANY(")
|
||||||
|
.push_bind(order_ids.to_vec())
|
||||||
|
.push(")");
|
||||||
|
for item in joined_deliveries(db, sql).await? {
|
||||||
|
if let Some(order_id) = item.row.order_id {
|
||||||
|
by_order.entry(order_id).or_default().push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(by_order)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Livraisons jointes d'une commande (getOrder)
|
||||||
|
pub async fn deliveries_of_order(db: &PgPool, order_id: i32) -> Result<Vec<DeliveryItem>, sqlx::Error> {
|
||||||
|
let mut sql = QueryBuilder::new(delivery_select());
|
||||||
|
sql.push(" WHERE deliveries.order_id = ").push_bind(order_id);
|
||||||
|
joined_deliveries(db, sql).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Livraison de getDeliveries : avec sa commande (même magasin) et son nombre de commentaires
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ListedDelivery {
|
||||||
|
pub item: DeliveryItem,
|
||||||
|
pub order: Option<OrderItem>,
|
||||||
|
pub comments: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListedDelivery {
|
||||||
|
pub fn to_json(&self) -> Value {
|
||||||
|
let mut out = self.item.to_json();
|
||||||
|
if let Some(order) = &self.order {
|
||||||
|
out.insert("order".into(), Value::Object(order.to_json()));
|
||||||
|
}
|
||||||
|
out.insert("reconciliationCommentsCount".into(), json!(self.comments));
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// loadReconciliationCommentCounts
|
||||||
|
async fn comment_counts(db: &PgPool, delivery_ids: &[i32]) -> Result<HashMap<i32, i64>, sqlx::Error> {
|
||||||
|
if delivery_ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
let rows: Vec<(i32, i64)> = sqlx::query_as(
|
||||||
|
"SELECT delivery_id, count(*) FROM reconciliation_comments WHERE delivery_id = ANY($1) GROUP BY delivery_id",
|
||||||
|
)
|
||||||
|
.bind(delivery_ids)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// attachOrdersAndCommentCounts : une commande d'un autre magasin n'est jamais jointe
|
||||||
|
async fn attach_orders_and_counts(
|
||||||
|
db: &PgPool,
|
||||||
|
items: Vec<DeliveryItem>,
|
||||||
|
) -> Result<Vec<ListedDelivery>, sqlx::Error> {
|
||||||
|
let mut order_ids: Vec<i32> = items.iter().filter_map(|item| item.row.order_id).collect();
|
||||||
|
order_ids.sort_unstable();
|
||||||
|
order_ids.dedup();
|
||||||
|
let delivery_ids: Vec<i32> = items.iter().map(|item| item.row.id).collect();
|
||||||
|
let orders = orders::orders_by_ids(db, &order_ids).await?;
|
||||||
|
let counts = comment_counts(db, &delivery_ids).await?;
|
||||||
|
Ok(items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| {
|
||||||
|
let order = item
|
||||||
|
.row
|
||||||
|
.order_id
|
||||||
|
.filter(|id| *id != 0)
|
||||||
|
.and_then(|id| orders.get(&id))
|
||||||
|
.filter(|order| {
|
||||||
|
let same_store = order.row.group_id == item.row.group_id;
|
||||||
|
if !same_store {
|
||||||
|
tracing::error!(
|
||||||
|
delivery = item.row.id,
|
||||||
|
order = order.row.id,
|
||||||
|
"livraison liée à une commande d'un autre magasin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
same_store
|
||||||
|
})
|
||||||
|
.cloned();
|
||||||
|
let comments = counts.get(&item.row.id).copied().unwrap_or(0);
|
||||||
|
ListedDelivery {
|
||||||
|
item,
|
||||||
|
order,
|
||||||
|
comments,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// storage.getDeliveries : livraisons (de ces magasins si la liste n'est pas vide,
|
||||||
|
/// de ce statut s'il est donné), les plus récentes d'abord
|
||||||
|
pub async fn get_deliveries(
|
||||||
|
db: &PgPool,
|
||||||
|
group_ids: &[i32],
|
||||||
|
status: Option<&str>,
|
||||||
|
) -> Result<Vec<ListedDelivery>, sqlx::Error> {
|
||||||
|
let mut sql = QueryBuilder::new(delivery_select());
|
||||||
|
let mut conditions = 0;
|
||||||
|
if !group_ids.is_empty() {
|
||||||
|
sql.push(" WHERE deliveries.group_id = ANY(")
|
||||||
|
.push_bind(group_ids.to_vec())
|
||||||
|
.push(")");
|
||||||
|
conditions += 1;
|
||||||
|
}
|
||||||
|
if let Some(status) = status {
|
||||||
|
sql.push(if conditions == 0 { " WHERE " } else { " AND " })
|
||||||
|
.push("deliveries.status = ")
|
||||||
|
.push_bind(status.to_string());
|
||||||
|
}
|
||||||
|
sql.push(" ORDER BY deliveries.created_at DESC");
|
||||||
|
let items = joined_deliveries(db, sql).await?;
|
||||||
|
attach_orders_and_counts(db, items).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// storage.getDeliveriesByDateRange : date prévue dans la période, la plus tardive d'abord
|
||||||
|
async fn get_deliveries_by_date_range(
|
||||||
|
db: &PgPool,
|
||||||
|
start: &str,
|
||||||
|
end: &str,
|
||||||
|
group_ids: &[i32],
|
||||||
|
) -> Result<Vec<ListedDelivery>, sqlx::Error> {
|
||||||
|
let mut sql = QueryBuilder::new(delivery_select());
|
||||||
|
sql.push(" WHERE deliveries.scheduled_date >= ")
|
||||||
|
.push_bind(start.to_string())
|
||||||
|
.push("::date AND deliveries.scheduled_date <= ")
|
||||||
|
.push_bind(end.to_string())
|
||||||
|
.push("::date");
|
||||||
|
if !group_ids.is_empty() {
|
||||||
|
sql.push(" AND deliveries.group_id = ANY(")
|
||||||
|
.push_bind(group_ids.to_vec())
|
||||||
|
.push(")");
|
||||||
|
}
|
||||||
|
sql.push(" ORDER BY deliveries.scheduled_date DESC");
|
||||||
|
let items = joined_deliveries(db, sql).await?;
|
||||||
|
attach_orders_and_counts(db, items).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Champs de getDelivery : ceux de la liste sans le contrôle
|
||||||
|
const DETAIL_FIELDS: &[&str] = &[
|
||||||
|
"id",
|
||||||
|
"orderId",
|
||||||
|
"supplierId",
|
||||||
|
"groupId",
|
||||||
|
"scheduledDate",
|
||||||
|
"deliveredDate",
|
||||||
|
"status",
|
||||||
|
"quantity",
|
||||||
|
"unit",
|
||||||
|
"blNumber",
|
||||||
|
"blAmount",
|
||||||
|
"invoiceReference",
|
||||||
|
"invoiceAmount",
|
||||||
|
"invoiceAmountTTC",
|
||||||
|
"dueDate",
|
||||||
|
"reconciled",
|
||||||
|
"validatedAt",
|
||||||
|
"notes",
|
||||||
|
"createdBy",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Livraison de getDelivery : magasin complet, créateur, commande complète
|
||||||
|
/// (même magasin) et nombre de commentaires
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DeliveryDetail {
|
||||||
|
pub row: DeliveriesRow,
|
||||||
|
pub supplier: Option<SuppliersRow>,
|
||||||
|
pub group: Option<GroupsRow>,
|
||||||
|
creator: Option<UserSummary>,
|
||||||
|
order: Option<orders::OrderDetail>,
|
||||||
|
comments: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeliveryDetail {
|
||||||
|
/// Réponse de Node (mot de passe SMTP masqué)
|
||||||
|
pub fn to_json(&self) -> Value {
|
||||||
|
let mut out = fields_in_order(&spread(&self.row), DETAIL_FIELDS);
|
||||||
|
out.insert("supplier".into(), json!(self.supplier));
|
||||||
|
out.insert("group".into(), json!(self.group));
|
||||||
|
out.insert("creator".into(), json!(self.creator));
|
||||||
|
if let Some(order) = &self.order {
|
||||||
|
out.insert("order".into(), order.to_json());
|
||||||
|
}
|
||||||
|
out.insert("reconciliationCommentsCount".into(), json!(self.comments));
|
||||||
|
strip_smtp_password(Value::Object(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// storage.getDelivery. Créateur, commande et commentaires sont lus chacun avec
|
||||||
|
/// son repli de Node (null, absente, 0) en cas d'erreur
|
||||||
|
pub async fn get_delivery(db: &PgPool, id: i32) -> Result<Option<DeliveryDetail>, sqlx::Error> {
|
||||||
|
let row = sqlx::query_as::<_, DeliveriesRow>(&format!(
|
||||||
|
"SELECT {}, suppliers.id AS joined_supplier_id, groups.id AS joined_group_id
|
||||||
|
FROM deliveries
|
||||||
|
LEFT JOIN suppliers ON deliveries.supplier_id = suppliers.id
|
||||||
|
LEFT JOIN groups ON deliveries.group_id = groups.id
|
||||||
|
WHERE deliveries.id = $1",
|
||||||
|
columns_of::<DeliveriesRow>("deliveries")
|
||||||
|
))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
let Some(row) = row else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let supplier = db::find_by_id::<SuppliersRow>(db, row.supplier_id).await?;
|
||||||
|
let group = db::find_by_id::<GroupsRow>(db, row.group_id).await?;
|
||||||
|
let creator = UserSummary::by_ids(db, std::slice::from_ref(&row.created_by))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut found| found.remove(&row.created_by));
|
||||||
|
let order = match row.order_id.filter(|order_id| *order_id != 0) {
|
||||||
|
None => None,
|
||||||
|
Some(order_id) => orders::get_order(db, order_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.filter(|order| {
|
||||||
|
let same_store = order.row.group_id == row.group_id;
|
||||||
|
if !same_store {
|
||||||
|
tracing::error!(delivery = id, order = order_id, "commande d'un autre magasin");
|
||||||
|
}
|
||||||
|
same_store
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let comments = comment_counts(db, &[id])
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|counts| counts.get(&id).copied())
|
||||||
|
.unwrap_or(0);
|
||||||
|
Ok(Some(DeliveryDetail {
|
||||||
|
row,
|
||||||
|
supplier,
|
||||||
|
group,
|
||||||
|
creator,
|
||||||
|
order,
|
||||||
|
comments,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// updateDelivery : mise à jour (avec updatedAt) ; une commande liée encore
|
||||||
|
/// « pending » passe à « planned »
|
||||||
|
async fn update_delivery(db: &PgPool, id: i32, data: &Parsed) -> Result<Option<DeliveriesRow>, sqlx::Error> {
|
||||||
|
let updated = db::update_by_id::<DeliveriesRow>(db, id, data, true).await?;
|
||||||
|
if let Some(order_id) = data.get("orderId").filter(|value| js::truthy(value)) {
|
||||||
|
plan_linked_order(db, order_id).await;
|
||||||
|
}
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commande liée à une livraison créée ou modifiée : « pending » devient
|
||||||
|
/// « planned ». Les erreurs sont journalisées, comme Node
|
||||||
|
async fn plan_linked_order(db: &PgPool, order_id: &Value) {
|
||||||
|
let Some(order_id) = order_id.as_i64().and_then(|id| i32::try_from(id).ok()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match db::find_by_id::<OrdersRow>(db, order_id).await {
|
||||||
|
Ok(Some(order)) if order.status == "pending" => {
|
||||||
|
if let Err(error) = orders::set_order_status(db, order_id, "planned").await {
|
||||||
|
tracing::error!(%error, order_id, "commande liée : passage à planned");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => tracing::error!(%error, order_id, "commande liée : lecture"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// { reconciled: true, validatedAt: new Date() } : rapprochement automatique
|
||||||
|
async fn auto_reconcile(db: &PgPool, id: i32) {
|
||||||
|
let mut data = Map::new();
|
||||||
|
data.insert("reconciled".into(), Value::Bool(true));
|
||||||
|
data.insert("validatedAt".into(), Value::String(crate::modules::now_iso()));
|
||||||
|
if let Err(error) = update_delivery(db, id, &data).await {
|
||||||
|
tracing::error!(%error, delivery = id, "rapprochement automatique");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(State(state): State<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to fetch deliveries";
|
||||||
|
let status = query
|
||||||
|
.get("status")
|
||||||
|
.filter(|status| LISTED_STATUSES.contains(status));
|
||||||
|
let Some(group_ids) = listed_groups(&user, &query).map_err(|_| AppError::internal(FAILED))? else {
|
||||||
|
return Ok(Json(json!([])).into_response());
|
||||||
|
};
|
||||||
|
let range = date_range(&query);
|
||||||
|
let mut deliveries = match range {
|
||||||
|
Some((start, end)) => get_deliveries_by_date_range(&state.db, start, end, &group_ids).await,
|
||||||
|
None => get_deliveries(&state.db, &group_ids, status).await,
|
||||||
|
}
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
// La lecture par période ne filtre pas le statut en base
|
||||||
|
if let (Some(status), Some(_)) = (status, range) {
|
||||||
|
deliveries.retain(|delivery| delivery.item.row.status == status);
|
||||||
|
}
|
||||||
|
if query.get("withBL") == Some("true") {
|
||||||
|
deliveries.retain(|delivery| {
|
||||||
|
delivery
|
||||||
|
.item
|
||||||
|
.row
|
||||||
|
.bl_number
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|bl| !bl.is_empty())
|
||||||
|
&& delivery.item.row.status == "delivered"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let out: Vec<Value> = deliveries.iter().map(ListedDelivery::to_json).collect();
|
||||||
|
Ok(Json(out).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hors admin : la livraison doit être d'un magasin de l'utilisateur
|
||||||
|
fn check_group(user: &CurrentUser, group_id: i32) -> Result<(), AppError> {
|
||||||
|
if user.is_admin() || user.group_ids().contains(&group_id) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::forbidden("Access denied"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Livraison existante, ou 404 ; erreur de lecture (identifiant illisible) : 500
|
||||||
|
async fn existing_delivery(db: &PgPool, id: i32, failed: &str) -> Result<DeliveryDetail, AppError> {
|
||||||
|
get_delivery(db, id)
|
||||||
|
.await
|
||||||
|
.or_500(failed)?
|
||||||
|
.ok_or_else(|| AppError::not_found("Delivery not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn show(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to fetch delivery";
|
||||||
|
let delivery = existing_delivery(&state.db, int_id(&id, FAILED)?, FAILED).await?;
|
||||||
|
check_group(&user, delivery.row.group_id)?;
|
||||||
|
Ok(Json(delivery.to_json()).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// insertDeliverySchema : createInsertSchema(deliveries) sans id, createdAt, updatedAt
|
||||||
|
fn delivery_schema() -> crate::zod::Object {
|
||||||
|
DeliveriesRow::insert_schema().omit(&["id", "createdAt", "updatedAt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Montant converti par le PUT : null ou '' → null, sinon valeur.toString()
|
||||||
|
fn amount_text(value: &Value) -> Value {
|
||||||
|
match value {
|
||||||
|
Value::Null => Value::Null,
|
||||||
|
Value::String(text) if text.is_empty() => Value::Null,
|
||||||
|
other => Value::String(js::string_of(other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Horodatage converti par le PUT : null ou '' → null, sinon new Date(valeur).
|
||||||
|
/// Err : date invalide (refusée par z.date(), donc 500)
|
||||||
|
fn timestamp_value(value: &Value) -> Result<Value, ()> {
|
||||||
|
match value {
|
||||||
|
Value::Null => Ok(Value::Null),
|
||||||
|
Value::String(text) if text.is_empty() => Ok(Value::Null),
|
||||||
|
other => dates::js_date(other)
|
||||||
|
.map(|date| Value::String(js::iso(&date)))
|
||||||
|
.ok_or(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transformations du PUT avant la validation, puis insertDeliverySchema.partial().
|
||||||
|
/// Les dates converties en Date contournent z.date() (que du JSON ne satisfait jamais)
|
||||||
|
fn parse_update(body: &Value) -> Result<Parsed, ()> {
|
||||||
|
if !body.is_object() {
|
||||||
|
// { ...tableau } reste un objet : seuls ses indices s'y ajoutent
|
||||||
|
return delivery_schema()
|
||||||
|
.partial()
|
||||||
|
.parse(&Value::Object(spread_body(body)))
|
||||||
|
.map_err(|_| ());
|
||||||
|
}
|
||||||
|
let mut input = spread_body(body);
|
||||||
|
for key in ["blAmount", "invoiceAmount"] {
|
||||||
|
if let Some(value) = input.get_mut(key) {
|
||||||
|
*value = amount_text(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key in ["invoiceReference", "blNumber"] {
|
||||||
|
if let Some(value) = input.get_mut(key).filter(|value| value.as_str() == Some("")) {
|
||||||
|
*value = Value::Null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut dates = Vec::new();
|
||||||
|
for key in ["validatedAt", "dueDate"] {
|
||||||
|
if let Some(value) = input.remove(key) {
|
||||||
|
dates.push((key, timestamp_value(&value)?));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut data = delivery_schema()
|
||||||
|
.partial()
|
||||||
|
.parse(&Value::Object(input))
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
for (key, value) in dates {
|
||||||
|
data.insert(key.to_string(), value);
|
||||||
|
}
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commande liée : elle doit exister et être du même magasin que la livraison
|
||||||
|
async fn check_linked_order(db: &PgPool, order_id: i32, group_id: i32, failed: &str) -> Result<(), AppError> {
|
||||||
|
let order = db::find_by_id::<OrdersRow>(db, order_id)
|
||||||
|
.await
|
||||||
|
.or_500(failed)?
|
||||||
|
.ok_or_else(|| AppError::bad_request("La commande liée n'existe pas"))?;
|
||||||
|
if order.group_id != group_id {
|
||||||
|
return Err(AppError::bad_request(&format!(
|
||||||
|
"Impossible de lier une livraison du magasin {group_id} avec une commande du magasin {}",
|
||||||
|
order.group_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Échéance d'une nouvelle référence de facture, relue dans NocoDB (sans cache) :
|
||||||
|
/// date normalisée, ou null. Err : échéance normalisée que new Date() ne lit pas
|
||||||
|
/// (toISOString échoue côté Node, d'où un 500). Sert aussi à l'API externe
|
||||||
|
/// (lookupDueDate)
|
||||||
|
pub async fn due_date_from_nocodb(
|
||||||
|
state: &AppState,
|
||||||
|
reference: &str,
|
||||||
|
group_id: i32,
|
||||||
|
reconciled: bool,
|
||||||
|
) -> Result<Value, ()> {
|
||||||
|
let result = verification::verify_invoice(state, reference, group_id, true, reconciled).await;
|
||||||
|
if !result.exists {
|
||||||
|
return Ok(Value::Null);
|
||||||
|
}
|
||||||
|
match result.normalized_due_date() {
|
||||||
|
None => Ok(Value::Null),
|
||||||
|
Some(normalized) => dates::parse_js_date(&normalized)
|
||||||
|
.map(|date| Value::String(js::iso(&date)))
|
||||||
|
.ok_or(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to update delivery";
|
||||||
|
let id = int_id(&id, FAILED)?;
|
||||||
|
let delivery = existing_delivery(&state.db, id, FAILED).await?;
|
||||||
|
if !has_permission(user.role(), "deliveries", "edit") {
|
||||||
|
return Err(AppError::forbidden("Insufficient permissions to edit deliveries"));
|
||||||
|
}
|
||||||
|
check_group(&user, delivery.row.group_id)?;
|
||||||
|
let mut data = parse_update(&body).map_err(|_| AppError::internal(FAILED))?;
|
||||||
|
|
||||||
|
if let Some(order_id) = data.get("orderId").and_then(Value::as_i64) {
|
||||||
|
let order_id = i32::try_from(order_id).map_err(|_| AppError::internal(FAILED))?;
|
||||||
|
check_linked_order(&state.db, order_id, delivery.row.group_id, FAILED).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Référence de facture changée : échéance relue dans NocoDB, ou vidée
|
||||||
|
if let Some(reference) = data.get("invoiceReference").cloned() {
|
||||||
|
let current = delivery
|
||||||
|
.row
|
||||||
|
.invoice_reference
|
||||||
|
.clone()
|
||||||
|
.map_or(Value::Null, Value::String);
|
||||||
|
if reference != current {
|
||||||
|
let due_date = match reference.as_str().filter(|text| !js::trim(text).is_empty()) {
|
||||||
|
Some(text) => due_date_from_nocodb(
|
||||||
|
&state,
|
||||||
|
text,
|
||||||
|
delivery.row.group_id,
|
||||||
|
delivery.row.reconciled == Some(true),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::internal(FAILED))?,
|
||||||
|
None => Value::Null,
|
||||||
|
};
|
||||||
|
data.insert("dueDate".into(), due_date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = update_delivery(&state.db, id, &data).await.or_500(FAILED)?;
|
||||||
|
|
||||||
|
// Rapprochement automatique : livraison livrée avec un BL, fournisseur en mode automatique
|
||||||
|
if data.get("status").and_then(Value::as_str) == Some("delivered")
|
||||||
|
|| data.get("blNumber").is_some_and(js::truthy)
|
||||||
|
{
|
||||||
|
if let Some(updated) = &updated {
|
||||||
|
let automatic = if updated.supplier_id == delivery.row.supplier_id && delivery.supplier.is_some()
|
||||||
|
{
|
||||||
|
delivery
|
||||||
|
.supplier
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|supplier| supplier.automatic_reconciliation)
|
||||||
|
} else {
|
||||||
|
db::find_by_id::<SuppliersRow>(&state.db, updated.supplier_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|supplier| supplier.automatic_reconciliation)
|
||||||
|
};
|
||||||
|
let has_bl = updated.bl_number.as_deref().is_some_and(|bl| !bl.is_empty());
|
||||||
|
if automatic == Some(true) && updated.status == "delivered" && has_bl {
|
||||||
|
auto_reconcile(&state.db, id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(json_or_empty(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// parseInt(orderId) de la création : chiffres de tête du texte de la valeur, NaN sinon
|
||||||
|
fn parse_order_id(value: &Value) -> Value {
|
||||||
|
match js::parse_int(&js::string_of(value)) {
|
||||||
|
Some(number) => js::number_value(number),
|
||||||
|
// NaN : refusé par z.number() (« received nan »), comme une valeur non numérique
|
||||||
|
None => Value::String("NaN".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(State(state): State<AppState>, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to create delivery";
|
||||||
|
let mut input = spread_body(&body);
|
||||||
|
if let Some(order_id) = input.get_mut("orderId") {
|
||||||
|
if matches!(order_id.as_str(), Some("none" | "")) {
|
||||||
|
*order_id = Value::Null;
|
||||||
|
} else if js::truthy(order_id) {
|
||||||
|
*order_id = parse_order_id(order_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.insert("createdBy".into(), Value::String(user.id().to_string()));
|
||||||
|
let mut data = delivery_schema()
|
||||||
|
.parse(&Value::Object(input))
|
||||||
|
.map_err(|_| AppError::internal(FAILED))?;
|
||||||
|
let group_id = data.get("groupId").and_then(Value::as_i64);
|
||||||
|
if !user.is_admin() && !user.group_ids().iter().any(|id| Some(i64::from(*id)) == group_id) {
|
||||||
|
return Err(AppError::forbidden("Access denied to this group"));
|
||||||
|
}
|
||||||
|
let order_id = data.get("orderId").filter(|value| js::truthy(value)).cloned();
|
||||||
|
if let Some(order_id) = order_id.as_ref().and_then(Value::as_i64) {
|
||||||
|
let order_id = i32::try_from(order_id).map_err(|_| AppError::internal(FAILED))?;
|
||||||
|
let group_id = group_id
|
||||||
|
.and_then(|id| i32::try_from(id).ok())
|
||||||
|
.ok_or_else(|| AppError::internal(FAILED))?;
|
||||||
|
check_linked_order(&state.db, order_id, group_id, FAILED).await?;
|
||||||
|
}
|
||||||
|
// Statut « planned » par défaut (status || 'planned')
|
||||||
|
if !data.get("status").is_some_and(js::truthy) {
|
||||||
|
data.insert("status".into(), Value::String("planned".into()));
|
||||||
|
}
|
||||||
|
let delivery = db::insert_values_first::<DeliveriesRow>(&state.db, &data)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
if let Some(order_id) = &order_id {
|
||||||
|
plan_linked_order(&state.db, order_id).await;
|
||||||
|
}
|
||||||
|
Ok(Json(delivery).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to delete delivery";
|
||||||
|
let delivery = existing_delivery(&state.db, int_id(&id, FAILED)?, FAILED).await?;
|
||||||
|
if !has_permission(user.role(), "deliveries", "delete") {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"Insufficient permissions to delete deliveries",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
check_group(&user, delivery.row.group_id)?;
|
||||||
|
db::delete_by_id::<DeliveriesRow>(&state.db, delivery.row.id)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(Json(json!({ "message": "Delivery deleted successfully" })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Texte non vide après trim, comme `valeur && valeur.trim()`
|
||||||
|
fn filled(text: &Option<String>) -> Option<&str> {
|
||||||
|
text.as_deref().filter(|text| !js::trim(text).is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to validate delivery";
|
||||||
|
if !has_permission(user.role(), "deliveries", "validate") {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"Insufficient permissions to validate deliveries",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let id = int_id(&id, FAILED)?;
|
||||||
|
let delivery = existing_delivery(&state.db, id, FAILED).await?;
|
||||||
|
// Seul le manager est limité à ses magasins
|
||||||
|
if user.role() == "manager" && !user.group_ids().contains(&delivery.row.group_id) {
|
||||||
|
return Err(AppError::forbidden("Access denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let field = |name: &str| body.as_object().and_then(|fields| fields.get(name));
|
||||||
|
// blNumber.trim() sur une valeur « vraie » qui n'est pas une chaîne : TypeError, 500
|
||||||
|
let bl_number = match field("blNumber") {
|
||||||
|
Some(value) if js::truthy(value) => value.as_str().ok_or_else(|| AppError::internal(FAILED))?,
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
let bl_number = js::trim(bl_number);
|
||||||
|
if bl_number.is_empty() {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"Le numéro de bon de livraison est obligatoire pour valider une livraison",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateDelivery : statut, date de livraison, BL et montant (toString), sans returning
|
||||||
|
let now = crate::modules::now_iso();
|
||||||
|
let mut sql =
|
||||||
|
QueryBuilder::<Postgres>::new("UPDATE deliveries SET status = 'delivered', delivered_date = ");
|
||||||
|
sql.push_bind(now.clone())
|
||||||
|
.push("::timestamp, updated_at = ")
|
||||||
|
.push_bind(now)
|
||||||
|
.push("::timestamp, bl_number = ")
|
||||||
|
.push_bind(bl_number.to_string());
|
||||||
|
if let Some(amount) = field("blAmount").filter(|amount| !amount.is_null() && amount.as_str() != Some(""))
|
||||||
|
{
|
||||||
|
sql.push(", bl_amount = ")
|
||||||
|
.push_bind(js::string_of(amount))
|
||||||
|
.push("::numeric");
|
||||||
|
}
|
||||||
|
sql.push(" WHERE id = ").push_bind(id);
|
||||||
|
sql.build().execute(&state.db).await.or_500(FAILED)?;
|
||||||
|
|
||||||
|
// Fournisseur en rapprochement automatique : rapprochement validé d'office
|
||||||
|
if delivery
|
||||||
|
.supplier
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|supplier| supplier.automatic_reconciliation)
|
||||||
|
== Some(true)
|
||||||
|
{
|
||||||
|
auto_reconcile(&state.db, id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache des factures : entrées de l'ancienne référence et de l'ancien BL rendues permanentes
|
||||||
|
if let Some(reference) = filled(&delivery.row.invoice_reference) {
|
||||||
|
verification::update_cache_as_reconciled(&state.db, reference, delivery.row.group_id).await;
|
||||||
|
}
|
||||||
|
if let Some(bl) = filled(&delivery.row.bl_number) {
|
||||||
|
verification::update_cache_as_reconciled(&state.db, bl, delivery.row.group_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// La commande associée passe à « delivered »
|
||||||
|
if let Some(order_id) = delivery.row.order_id.filter(|order_id| *order_id != 0) {
|
||||||
|
if let Err(error) = orders::set_order_status(&state.db, order_id, "delivered").await {
|
||||||
|
tracing::error!(%error, order_id, "commande de la livraison validée");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "message": "Delivery validated successfully" })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn control(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to validate delivery control";
|
||||||
|
let Some(id) = js::parse_int(&id) else {
|
||||||
|
return Err(AppError::bad_request("Invalid delivery ID"));
|
||||||
|
};
|
||||||
|
let id = db::js_int4(id).ok_or_else(|| AppError::internal(FAILED))?;
|
||||||
|
existing_delivery(&state.db, id, FAILED).await?;
|
||||||
|
if !has_permission(user.role(), "deliveries", "edit") {
|
||||||
|
return Err(AppError::forbidden("Insufficient permissions"));
|
||||||
|
}
|
||||||
|
// Contrôle effectué : sans contrôle du magasin, comme Node
|
||||||
|
let now = crate::modules::now_iso();
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE deliveries SET control_validated = true, control_validated_by = $1, control_validated_at = $2::timestamp, updated_at = $3::timestamp WHERE id = $4",
|
||||||
|
)
|
||||||
|
.bind(user.id())
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(Json(json!({ "message": "Delivery control validated successfully" })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn champs_dans_l_ordre_du_select() {
|
||||||
|
let fields = json!({ "b": 2, "a": 1, "c": 3 });
|
||||||
|
let ordered = fields_in_order(fields.as_object().unwrap(), &["a", "c", "absent", "b"]);
|
||||||
|
assert_eq!(serde_json::to_string(&ordered).unwrap(), r#"{"a":1,"c":3,"b":2}"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transformations_du_put() {
|
||||||
|
let data = parse_update(&json!({
|
||||||
|
"blAmount": 12.5, "invoiceAmount": "", "invoiceReference": "", "blNumber": "BL-1",
|
||||||
|
"dueDate": "2026-10-15", "validatedAt": null, "inconnu": 1
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
Value::Object(data),
|
||||||
|
json!({
|
||||||
|
"blNumber": "BL-1", "blAmount": "12.5", "invoiceReference": null, "invoiceAmount": null,
|
||||||
|
"validatedAt": null, "dueDate": "2026-10-15T00:00:00.000Z"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Montant TTC non converti : z.string() refuse un nombre
|
||||||
|
assert!(parse_update(&json!({ "invoiceAmountTTC": 5 })).is_err());
|
||||||
|
// Date que new Date() ne lit pas, horodatage resté en texte
|
||||||
|
assert!(parse_update(&json!({ "dueDate": "pas une date" })).is_err());
|
||||||
|
assert!(parse_update(&json!({ "deliveredDate": "2026-10-15" })).is_err());
|
||||||
|
// Tableau étalé : objet sans champ du schéma
|
||||||
|
assert!(parse_update(&json!([1])).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn montants_en_texte_comme_tostring() {
|
||||||
|
assert_eq!(amount_text(&json!(1250.5)), json!("1250.5"));
|
||||||
|
assert_eq!(amount_text(&json!(true)), json!("true"));
|
||||||
|
assert_eq!(amount_text(&json!([1, 2])), json!("1,2"));
|
||||||
|
assert_eq!(amount_text(&json!({})), json!("[object Object]"));
|
||||||
|
assert_eq!(amount_text(&json!("")), Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identifiant_de_commande_comme_parseint() {
|
||||||
|
assert_eq!(parse_order_id(&json!("12abc")), json!(12));
|
||||||
|
assert_eq!(parse_order_id(&json!(5.7)), json!(5));
|
||||||
|
assert_eq!(parse_order_id(&json!([3])), json!(3));
|
||||||
|
assert_eq!(parse_order_id(&json!(true)), json!("NaN"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
// Produits DLC : /api/dlc-products (server/routes.ts, « DLC Products routes » ;
|
||||||
|
// storage.ts getDlcProducts à getDlcStats). Comme Node aujourd'hui :
|
||||||
|
// - un compte sans magasin voit les produits et les compteurs de tous les magasins ;
|
||||||
|
// - création et modification sans schéma zod : les champs connus de la table sont
|
||||||
|
// écrits tels quels, les erreurs de la base deviennent des 500 ;
|
||||||
|
// - la modification ne contrôle que le magasin d'origine du produit : elle peut le
|
||||||
|
// déplacer vers un magasin de l'utilisateur ou non, et changer son créateur ;
|
||||||
|
// - GET /:id joint la ligne complète du magasin (adresse, logo, configuration
|
||||||
|
// NocoDB et SMTP ; le mot de passe SMTP remplacé par smtpPasswordSet) ;
|
||||||
|
// - les compteurs sont rendus en texte ("3"), tels que node-pg lit un COUNT.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::{Json, Router, routing::get, routing::post, routing::put};
|
||||||
|
use chrono::{Days, NaiveDate};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sqlx::{Postgres, QueryBuilder};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::db::{self, columns_of, drizzle_values};
|
||||||
|
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||||
|
use crate::js;
|
||||||
|
use crate::json::{spread, strip_smtp_password};
|
||||||
|
use crate::modules::now_iso;
|
||||||
|
use crate::projections::GroupSummary;
|
||||||
|
use crate::request::{JsQuery, JsonBody, int_id};
|
||||||
|
use crate::schema::{DlcProductsRow, GroupsRow, SuppliersRow};
|
||||||
|
use crate::zod::Parsed;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/dlc-products", get(list).post(create))
|
||||||
|
.route("/api/dlc-products/stats", get(stats))
|
||||||
|
.route("/api/dlc-products/{id}", get(show).put(update).delete(remove))
|
||||||
|
.route("/api/dlc-products/{id}/validate", post(validate))
|
||||||
|
.route("/api/dlc-products/{id}/stock-epuise", put(stock_epuise))
|
||||||
|
.route("/api/dlc-products/{id}/restore-stock", put(restore_stock))
|
||||||
|
.route("/api/dlc-products/{id}/mark-processed", put(mark_processed))
|
||||||
|
.route("/api/dlc-products/{id}/unmark-processed", put(unmark_processed))
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALIDATORS: [&str; 3] = ["admin", "directeur", "manager"];
|
||||||
|
const GROUP_DENIED: &str = "Access denied to this group's DLC products";
|
||||||
|
|
||||||
|
/// Jour courant et J+15 (dates UTC, heure du serveur Node)
|
||||||
|
fn today_and_alert() -> (NaiveDate, NaiveDate) {
|
||||||
|
let today = crate::clock::today();
|
||||||
|
(today, today + Days::new(15))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Magasins lus (vide : tous). Admin : ?storeId s'il est donné ; hors admin :
|
||||||
|
/// ?storeId s'il est l'un des siens, sinon tous les siens (aucun pour un compte
|
||||||
|
/// sans magasin, donc tous les produits). Err : ?storeId illisible pour l'admin,
|
||||||
|
/// [NaN] fait échouer la requête de Node.
|
||||||
|
fn scope(user: &CurrentUser, query: &JsQuery) -> Result<Vec<i32>, ()> {
|
||||||
|
let store = query.truthy("storeId");
|
||||||
|
if user.is_admin() {
|
||||||
|
return match store {
|
||||||
|
Some(store) => int_id(store, "").map(|id| vec![id]).map_err(|_| ()),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mine = user.group_ids();
|
||||||
|
match store.and_then(js::parse_int) {
|
||||||
|
Some(requested) if mine.iter().any(|id| f64::from(*id) == requested) => Ok(vec![requested as i32]),
|
||||||
|
_ => Ok(mine),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute « WHERE » ou « AND » avant une condition
|
||||||
|
fn and(sql: &mut QueryBuilder<'_, Postgres>, count: &mut usize) {
|
||||||
|
sql.push(if *count == 0 { " WHERE " } else { " AND " });
|
||||||
|
*count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(State(state): State<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
// Toute erreur : 500 avec une liste vide
|
||||||
|
let failed = || AppError::with_body(StatusCode::INTERNAL_SERVER_ERROR, json!([]));
|
||||||
|
let group_ids = scope(&user, &query).map_err(|_| failed())?;
|
||||||
|
let (today, alert) = today_and_alert();
|
||||||
|
|
||||||
|
let mut sql = QueryBuilder::<Postgres>::new(format!(
|
||||||
|
"SELECT {} FROM dlc_products
|
||||||
|
LEFT JOIN suppliers ON dlc_products.supplier_id = suppliers.id
|
||||||
|
LEFT JOIN groups ON dlc_products.group_id = groups.id",
|
||||||
|
columns_of::<DlcProductsRow>("dlc_products")
|
||||||
|
));
|
||||||
|
let mut count = 0;
|
||||||
|
if !group_ids.is_empty() {
|
||||||
|
and(&mut sql, &mut count);
|
||||||
|
sql.push("dlc_products.group_id = ANY(")
|
||||||
|
.push_bind(group_ids)
|
||||||
|
.push(")");
|
||||||
|
}
|
||||||
|
// Statut calculé d'après la date (les produits traités sortent des alertes)
|
||||||
|
const NOT_PROCESSED: &str =
|
||||||
|
"(dlc_products.processed_until_expiry IS NULL OR dlc_products.processed_until_expiry = false)";
|
||||||
|
match query.truthy("status").filter(|status| *status != "all") {
|
||||||
|
Some("expires_soon") => {
|
||||||
|
and(&mut sql, &mut count);
|
||||||
|
sql.push("(dlc_products.expiry_date > ")
|
||||||
|
.push_bind(today)
|
||||||
|
.push(" AND dlc_products.expiry_date <= ")
|
||||||
|
.push_bind(alert)
|
||||||
|
.push(" AND dlc_products.status <> 'valides' AND ")
|
||||||
|
.push(NOT_PROCESSED)
|
||||||
|
.push(")");
|
||||||
|
}
|
||||||
|
Some("expires") => {
|
||||||
|
and(&mut sql, &mut count);
|
||||||
|
sql.push("(dlc_products.expiry_date <= ")
|
||||||
|
.push_bind(today)
|
||||||
|
.push(" AND dlc_products.status <> 'valides' AND ")
|
||||||
|
.push(NOT_PROCESSED)
|
||||||
|
.push(")");
|
||||||
|
}
|
||||||
|
Some("en_cours") => {
|
||||||
|
and(&mut sql, &mut count);
|
||||||
|
sql.push("(dlc_products.expiry_date > ")
|
||||||
|
.push_bind(alert)
|
||||||
|
.push(" AND dlc_products.status <> 'valides')");
|
||||||
|
}
|
||||||
|
Some("valides") => {
|
||||||
|
and(&mut sql, &mut count);
|
||||||
|
sql.push("dlc_products.status = 'valides'");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
// parseInt(supplierId) : NaN et 0 ne filtrent pas, hors limites fait échouer la requête
|
||||||
|
if let Some(supplier) = query
|
||||||
|
.truthy("supplierId")
|
||||||
|
.filter(|supplier| *supplier != "all")
|
||||||
|
.filter(|supplier| js::parse_int(supplier).is_some_and(|n| n != 0.0))
|
||||||
|
{
|
||||||
|
let supplier = int_id(supplier, "").map_err(|_| failed())?;
|
||||||
|
and(&mut sql, &mut count);
|
||||||
|
sql.push("dlc_products.supplier_id = ").push_bind(supplier);
|
||||||
|
}
|
||||||
|
if let Some(search) = query.truthy("search") {
|
||||||
|
let pattern = format!("%{search}%");
|
||||||
|
and(&mut sql, &mut count);
|
||||||
|
sql.push("(LOWER(dlc_products.product_name) LIKE LOWER(")
|
||||||
|
.push_bind(pattern.clone())
|
||||||
|
.push(") OR LOWER(suppliers.name) LIKE LOWER(")
|
||||||
|
.push_bind(pattern)
|
||||||
|
.push("))");
|
||||||
|
}
|
||||||
|
// Actifs, puis traités, stock épuisé, validés ; à l'intérieur, date croissante
|
||||||
|
sql.push(
|
||||||
|
" ORDER BY CASE
|
||||||
|
WHEN dlc_products.status != 'valides'
|
||||||
|
AND (dlc_products.processed_until_expiry IS NULL OR dlc_products.processed_until_expiry = false)
|
||||||
|
AND (dlc_products.stock_epuise IS NULL OR dlc_products.stock_epuise = false) THEN 1
|
||||||
|
WHEN dlc_products.processed_until_expiry = true THEN 2
|
||||||
|
WHEN dlc_products.stock_epuise = true THEN 3
|
||||||
|
WHEN dlc_products.status = 'valides' THEN 4
|
||||||
|
ELSE 1
|
||||||
|
END, dlc_products.expiry_date ASC",
|
||||||
|
);
|
||||||
|
let products: Vec<DlcProductsRow> = sql
|
||||||
|
.build_query_as()
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| failed())?;
|
||||||
|
|
||||||
|
let supplier_ids: Vec<i32> = products.iter().map(|p| p.supplier_id).collect();
|
||||||
|
let suppliers: HashMap<i32, SuppliersRow> = db::find_by_ids::<SuppliersRow>(&state.db, &supplier_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|_| failed())?
|
||||||
|
.into_iter()
|
||||||
|
.map(|supplier| (supplier.id, supplier))
|
||||||
|
.collect();
|
||||||
|
let group_ids: Vec<i32> = products.iter().map(|p| p.group_id).collect();
|
||||||
|
let groups = GroupSummary::by_ids(&state.db, &group_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|_| failed())?;
|
||||||
|
|
||||||
|
let out: Vec<Value> = products
|
||||||
|
.iter()
|
||||||
|
.map(|product| {
|
||||||
|
with_relations(
|
||||||
|
product,
|
||||||
|
json!(suppliers.get(&product.supplier_id)),
|
||||||
|
json!(groups.get(&product.group_id)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(out).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// { ...produit, dlcDate: expiryDate, supplier, group, creator } de getDlcProducts / getDlcProduct
|
||||||
|
fn with_relations(product: &DlcProductsRow, supplier: Value, group: Value) -> Value {
|
||||||
|
let mut entry = spread(product);
|
||||||
|
let expiry = entry.get("expiryDate").cloned().unwrap_or(Value::Null);
|
||||||
|
entry.insert("dlcDate".into(), expiry);
|
||||||
|
entry.insert("supplier".into(), supplier);
|
||||||
|
entry.insert("group".into(), group);
|
||||||
|
entry.insert(
|
||||||
|
"creator".into(),
|
||||||
|
json!({ "id": product.created_by, "username": "unknown" }),
|
||||||
|
);
|
||||||
|
Value::Object(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stats(State(state): State<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
let failed = || {
|
||||||
|
AppError::with_body(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "active": 0, "expiringSoon": 0, "expired": 0 }),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let group_ids = scope(&user, &query).map_err(|_| failed())?;
|
||||||
|
let (today, alert) = today_and_alert();
|
||||||
|
const ACTIVE_ROW: &str = "status != 'valides'
|
||||||
|
AND (processed_until_expiry IS NULL OR processed_until_expiry = false)
|
||||||
|
AND (stock_epuise IS NULL OR stock_epuise = false)";
|
||||||
|
let mut sql = QueryBuilder::<Postgres>::new("SELECT COUNT(CASE WHEN expiry_date > ");
|
||||||
|
sql.push_bind(today)
|
||||||
|
.push(format!(
|
||||||
|
" AND {ACTIVE_ROW} THEN 1 END), COUNT(CASE WHEN expiry_date BETWEEN "
|
||||||
|
))
|
||||||
|
.push_bind(today)
|
||||||
|
.push(" AND ")
|
||||||
|
.push_bind(alert)
|
||||||
|
.push(format!(
|
||||||
|
" AND {ACTIVE_ROW} THEN 1 END), COUNT(CASE WHEN expiry_date <= "
|
||||||
|
))
|
||||||
|
.push_bind(today)
|
||||||
|
.push(format!(" AND {ACTIVE_ROW} THEN 1 END) FROM dlc_products WHERE "));
|
||||||
|
if group_ids.is_empty() {
|
||||||
|
sql.push("1 = 1");
|
||||||
|
} else {
|
||||||
|
sql.push("group_id = ANY(").push_bind(group_ids).push(")");
|
||||||
|
}
|
||||||
|
let (active, expiring_soon, expired): (i64, i64, i64) = sql
|
||||||
|
.build_query_as()
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| failed())?;
|
||||||
|
// COUNT lu en texte par node-pg (bigint)
|
||||||
|
Ok(Json(json!({
|
||||||
|
"active": active.to_string(),
|
||||||
|
"expiringSoon": expiring_soon.to_string(),
|
||||||
|
"expired": expired.to_string(),
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produit existant et du magasin de l'utilisateur (hors admin)
|
||||||
|
async fn find_for(
|
||||||
|
state: &AppState,
|
||||||
|
user: &CurrentUser,
|
||||||
|
raw_id: &str,
|
||||||
|
failed: &str,
|
||||||
|
denied: &str,
|
||||||
|
) -> Result<DlcProductsRow, AppError> {
|
||||||
|
let id = int_id(raw_id, failed)?;
|
||||||
|
let product = db::find_by_id::<DlcProductsRow>(&state.db, id)
|
||||||
|
.await
|
||||||
|
.or_500(failed)?
|
||||||
|
.ok_or_else(|| AppError::not_found("DLC Product not found"))?;
|
||||||
|
if !user.is_admin() && !user.group_ids().contains(&product.group_id) {
|
||||||
|
return Err(AppError::forbidden(denied));
|
||||||
|
}
|
||||||
|
Ok(product)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn show(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to fetch DLC product";
|
||||||
|
let product = find_for(&state, &user, &id, FAILED, "Access denied").await?;
|
||||||
|
let supplier = db::find_by_id::<SuppliersRow>(&state.db, product.supplier_id)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
let group = db::find_by_id::<GroupsRow>(&state.db, product.group_id)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
let body = with_relations(&product, json!(supplier), strip_smtp_password(json!(group)));
|
||||||
|
Ok(Json(body).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(State(state): State<AppState>, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to create DLC product";
|
||||||
|
let mut data = body.as_object().cloned().unwrap_or_default();
|
||||||
|
data.insert("createdBy".into(), json!(user.id()));
|
||||||
|
// userGroupIds.includes(data.groupId) : un nombre, jamais un texte
|
||||||
|
let allowed = data
|
||||||
|
.get("groupId")
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.is_some_and(|requested| user.group_ids().iter().any(|id| f64::from(*id) == requested));
|
||||||
|
if !user.is_admin() && !allowed {
|
||||||
|
return Err(AppError::forbidden("Access denied to this group"));
|
||||||
|
}
|
||||||
|
let values = drizzle_values::<DlcProductsRow>(&data).map_err(|_| AppError::internal(FAILED))?;
|
||||||
|
let product: DlcProductsRow = db::insert(&state.db, &values).await.or_500(FAILED)?;
|
||||||
|
// { ...produit, dlcDate: new Date(expiryDate) } : minuit UTC
|
||||||
|
let mut entry = spread(&product);
|
||||||
|
entry.insert(
|
||||||
|
"dlcDate".into(),
|
||||||
|
json!(format!(
|
||||||
|
"{}T00:00:00.000Z",
|
||||||
|
product.expiry_date.format("%Y-%m-%d")
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
Ok(Json(entry).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to update DLC product";
|
||||||
|
let product = find_for(&state, &user, &id, FAILED, "Access denied").await?;
|
||||||
|
let mut data = body.as_object().cloned().unwrap_or_default();
|
||||||
|
// updatedAt du corps remplacé par new Date()
|
||||||
|
data.remove("updatedAt");
|
||||||
|
let values = drizzle_values::<DlcProductsRow>(&data).map_err(|_| AppError::internal(FAILED))?;
|
||||||
|
let updated = db::update_by_id::<DlcProductsRow>(&state.db, product.id, &values, true)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(json_or_empty(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to delete DLC product";
|
||||||
|
let product = find_for(&state, &user, &id, FAILED, "Access denied").await?;
|
||||||
|
db::delete_by_id::<DlcProductsRow>(&state.db, product.id)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(Json(json!({ "message": "DLC Product deleted successfully" })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réservé aux rôles qui valident (admin, directeur, manager)
|
||||||
|
fn require_validator(user: &CurrentUser, message: &str) -> Result<(), AppError> {
|
||||||
|
if VALIDATORS.contains(&user.role()) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::forbidden(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mise à jour d'un produit, avec updatedAt ; ligne absente : réponse vide
|
||||||
|
async fn set_fields(state: &AppState, id: i32, fields: Value, failed: &str) -> ApiResult {
|
||||||
|
let values: Parsed = fields.as_object().cloned().unwrap_or_default();
|
||||||
|
let updated = db::update_by_id::<DlcProductsRow>(&state.db, id, &values, true)
|
||||||
|
.await
|
||||||
|
.or_500(failed)?;
|
||||||
|
Ok(json_or_empty(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to validate DLC product";
|
||||||
|
require_validator(&user, "Insufficient permissions to validate DLC products")?;
|
||||||
|
let product = find_for(&state, &user, &id, FAILED, GROUP_DENIED).await?;
|
||||||
|
let fields = json!({ "validatedBy": user.id(), "validatedAt": now_iso(), "status": "valides" });
|
||||||
|
set_fields(&state, product.id, fields, FAILED).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stock_epuise(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to mark DLC product as stock épuisé";
|
||||||
|
let product = find_for(&state, &user, &id, FAILED, GROUP_DENIED).await?;
|
||||||
|
let fields = json!({ "stockEpuise": true, "stockEpuiseBy": user.id(), "stockEpuiseAt": now_iso() });
|
||||||
|
set_fields(&state, product.id, fields, FAILED).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore_stock(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to restore DLC product stock";
|
||||||
|
require_validator(&user, "Insufficient permissions to restore DLC product stock")?;
|
||||||
|
let product = find_for(&state, &user, &id, FAILED, GROUP_DENIED).await?;
|
||||||
|
let fields = json!({ "stockEpuise": false, "stockEpuiseBy": null, "stockEpuiseAt": null });
|
||||||
|
set_fields(&state, product.id, fields, FAILED).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// markDlcProductAsProcessed / unmark : si la mise à jour échoue, Node se replie sur
|
||||||
|
/// la seule date de modification
|
||||||
|
async fn set_processed(state: &AppState, id: i32, fields: Value, failed: &str) -> ApiResult {
|
||||||
|
let values: Parsed = fields.as_object().cloned().unwrap_or_default();
|
||||||
|
let updated = match db::update_by_id::<DlcProductsRow>(&state.db, id, &values, true).await {
|
||||||
|
Ok(updated) => updated,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "repli sur la seule date de modification");
|
||||||
|
db::update_by_id::<DlcProductsRow>(&state.db, id, &Parsed::new(), true)
|
||||||
|
.await
|
||||||
|
.or_500(failed)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(json_or_empty(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_processed(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to mark DLC product as processed";
|
||||||
|
let product = find_for(&state, &user, &id, FAILED, GROUP_DENIED).await?;
|
||||||
|
let fields = json!({ "processedAt": now_iso(), "processedBy": user.id(), "processedUntilExpiry": true });
|
||||||
|
set_processed(&state, product.id, fields, FAILED).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unmark_processed(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to unmark DLC product as processed";
|
||||||
|
require_validator(
|
||||||
|
&user,
|
||||||
|
"Insufficient permissions to unmark DLC product as processed",
|
||||||
|
)?;
|
||||||
|
let product = find_for(&state, &user, &id, FAILED, GROUP_DENIED).await?;
|
||||||
|
let fields = json!({ "processedAt": null, "processedBy": null, "processedUntilExpiry": false });
|
||||||
|
set_processed(&state, product.id, fields, FAILED).await
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,226 @@
|
|||||||
|
// Relais ffnancy : /api/ffnancy/* (server/routes.ts, « Proxy API ffnancy »).
|
||||||
|
// Le navigateur ne peut pas appeler https://api.ffnancy.fr (CORS) : le serveur
|
||||||
|
// relaie la recherche d'articles et les mouvements d'entrée, avec les seuls
|
||||||
|
// paramètres autorisés (renseignés, dans l'ordre de la liste).
|
||||||
|
//
|
||||||
|
// Comme Node (node-fetch) : en-têtes Accept « */* » et User-Agent « node-fetch »,
|
||||||
|
// sans mandataire ; réponse en erreur : son code et { error: 'API ffnancy error' } ;
|
||||||
|
// réponse illisible ou injoignable : 500. Le JSON reçu est relu puis réécrit
|
||||||
|
// comme JSON.parse / JSON.stringify (nombres au format de JavaScript, clés
|
||||||
|
// numériques en tête), mot de passe SMTP masqué comme toute réponse /api.
|
||||||
|
//
|
||||||
|
// Écarts sans effet sur la réponse : pas d'Accept-Encoding (le client HTTP ne
|
||||||
|
// décompresse pas), et un délai maximal de 30 s (node-fetch n'en a pas).
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use axum::http::{HeaderValue, StatusCode, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::error::{ApiResult, AppError};
|
||||||
|
use crate::js;
|
||||||
|
use crate::json::strip_smtp_password;
|
||||||
|
use crate::request::JsQuery;
|
||||||
|
use crate::services::nocodb::array_index;
|
||||||
|
|
||||||
|
const FFNANCY_API: &str = "https://api.ffnancy.fr/api";
|
||||||
|
const ARTICLE_PARAMS: [&str; 7] = ["search", "codein", "ean", "codefou", "actif", "page", "limit"];
|
||||||
|
const ENTRY_PARAMS: [&str; 6] = ["artNoId", "dateDebut", "dateFin", "site", "page", "limit"];
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.user_agent("node-fetch")
|
||||||
|
.timeout(TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.expect("client HTTP ffnancy")
|
||||||
|
});
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/ffnancy/articles", get(articles))
|
||||||
|
.route("/api/ffnancy/mouvements/entrees", get(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adresse relayée : `${base}/${chemin}?${new URLSearchParams(autorisés renseignés)}`
|
||||||
|
pub fn relay_url(path: &str, allowed: &[&str], query: &JsQuery) -> String {
|
||||||
|
let mut params = form_urlencoded::Serializer::new(String::new());
|
||||||
|
for key in allowed {
|
||||||
|
if let Some(value) = query.truthy(key) {
|
||||||
|
params.append_pair(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("{FFNANCY_API}/{path}?{}", params.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn articles(_user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
relay(relay_url("articles", &ARTICLE_PARAMS, &query)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn entries(_user: CurrentUser, query: JsQuery) -> ApiResult {
|
||||||
|
relay(relay_url("mouvements/entrees", &ENTRY_PARAMS, &query)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_body(status: StatusCode, error: &str) -> AppError {
|
||||||
|
AppError::with_body(status, json!({ "error": error }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET de l'adresse : code d'erreur relayé, ou le JSON reçu réécrit comme Node
|
||||||
|
async fn relay(url: String) -> ApiResult {
|
||||||
|
let unreachable = |error: &dyn std::fmt::Display| {
|
||||||
|
tracing::warn!(%error, "appel ffnancy");
|
||||||
|
error_body(StatusCode::INTERNAL_SERVER_ERROR, "Failed to reach API ffnancy")
|
||||||
|
};
|
||||||
|
let response = CLIENT
|
||||||
|
.get(&url)
|
||||||
|
.header(header::ACCEPT, "*/*")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| unreachable(&error))?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(error_body(status, "API ffnancy error"));
|
||||||
|
}
|
||||||
|
let text = response.text().await.map_err(|error| unreachable(&error))?;
|
||||||
|
// TextDecoder de node-fetch : marque d'ordre des octets retirée
|
||||||
|
let value: Value = serde_json::from_str(text.strip_prefix('\u{feff}').unwrap_or(&text))
|
||||||
|
.map_err(|error| unreachable(&error))?;
|
||||||
|
Ok(json_response(js_stringify(&strip_smtp_password(value))))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_response(body: String) -> Response {
|
||||||
|
let mut response = body.into_response();
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("application/json; charset=utf-8"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON.stringify d'une valeur lue par JSON.parse : nombres au format de
|
||||||
|
/// JavaScript (10.0 → 10, 1e21 → 1e+21), clés « indices de tableau » en tête
|
||||||
|
/// par ordre croissant
|
||||||
|
pub fn js_stringify(value: &Value) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
write_js(value, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_js(value: &Value, out: &mut String) {
|
||||||
|
match value {
|
||||||
|
Value::Null => out.push_str("null"),
|
||||||
|
Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),
|
||||||
|
Value::Number(number) => {
|
||||||
|
let number = number.as_f64().unwrap_or(f64::NAN);
|
||||||
|
if number.is_finite() {
|
||||||
|
out.push_str(&js::number_string(number));
|
||||||
|
} else {
|
||||||
|
out.push_str("null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::String(text) => out.push_str(&Value::String(text.clone()).to_string()),
|
||||||
|
Value::Array(items) => {
|
||||||
|
out.push('[');
|
||||||
|
for (index, item) in items.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
out.push(',');
|
||||||
|
}
|
||||||
|
write_js(item, out);
|
||||||
|
}
|
||||||
|
out.push(']');
|
||||||
|
}
|
||||||
|
Value::Object(fields) => {
|
||||||
|
let (mut indexes, others): (Vec<&String>, Vec<&String>) =
|
||||||
|
fields.keys().partition(|key| array_index(key).is_some());
|
||||||
|
indexes.sort_by_key(|key| array_index(key));
|
||||||
|
out.push('{');
|
||||||
|
for (position, key) in indexes.into_iter().chain(others).enumerate() {
|
||||||
|
if position > 0 {
|
||||||
|
out.push(',');
|
||||||
|
}
|
||||||
|
out.push_str(&Value::String(key.clone()).to_string());
|
||||||
|
out.push(':');
|
||||||
|
write_js(&fields[key.as_str()], out);
|
||||||
|
}
|
||||||
|
out.push('}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn query(pairs: &[(&str, &str)]) -> JsQuery {
|
||||||
|
JsQuery(
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.to_string(), value.to_string()))
|
||||||
|
.collect::<HashMap<_, _>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parametres_autorises_dans_l_ordre_de_la_liste() {
|
||||||
|
// Valeurs de `${new URLSearchParams(…)}` dans Node
|
||||||
|
let q = query(&[
|
||||||
|
("limit", "1"),
|
||||||
|
("ean", "3245678901234"),
|
||||||
|
("groupId", "1"),
|
||||||
|
("search", "pot de fleur & co/é*"),
|
||||||
|
("codein", ""),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
relay_url("articles", &ARTICLE_PARAMS, &q),
|
||||||
|
"https://api.ffnancy.fr/api/articles?search=pot+de+fleur+%26+co%2F%C3%A9*&ean=3245678901234&limit=1"
|
||||||
|
);
|
||||||
|
let q = query(&[
|
||||||
|
("dateDebut", "2000-01-01"),
|
||||||
|
("artNoId", "42"),
|
||||||
|
("limit", "1"),
|
||||||
|
("ean", "x"),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
relay_url("mouvements/entrees", &ENTRY_PARAMS, &q),
|
||||||
|
"https://api.ffnancy.fr/api/mouvements/entrees?artNoId=42&dateDebut=2000-01-01&limit=1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sans_parametre() {
|
||||||
|
assert_eq!(
|
||||||
|
relay_url("articles", &ARTICLE_PARAMS, &query(&[])),
|
||||||
|
"https://api.ffnancy.fr/api/articles?"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_reecrit_comme_javascript() {
|
||||||
|
// JSON.stringify(JSON.parse(texte)) dans Node
|
||||||
|
let parsed: Value = serde_json::from_str(
|
||||||
|
r#"{"b":1.0,"10":"x","a":[1e21,0.00001,12345678901234567890,-0.0,"é\u001f"],"2":{"z":true,"0":null},"b":2.50}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
js_stringify(&parsed),
|
||||||
|
r#"{"2":{"0":null,"z":true},"10":"x","b":2.5,"a":[1e+21,0.00001,12345678901234567000,0,"é\u001f"]}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mot_de_passe_smtp_masque() {
|
||||||
|
let value = json!({ "group": { "smtpPassword": "secret" } });
|
||||||
|
assert_eq!(
|
||||||
|
js_stringify(&strip_smtp_password(value)),
|
||||||
|
r#"{"group":{"smtpPasswordSet":true}}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
// Magasins : /api/groups (server/routes.ts, « Groups routes »).
|
||||||
|
//
|
||||||
|
// Le mot de passe SMTP ne sort jamais : comme stripSmtpPassword
|
||||||
|
// (server/sanitize.ts, appliqué par Node à toute réponse JSON de /api), il est
|
||||||
|
// remplacé par smtpPasswordSet, à la même place. Il est chiffré à l'écriture
|
||||||
|
// (crate::crypto, comme storage.createGroup / updateGroup).
|
||||||
|
//
|
||||||
|
// POST /api/groups/:id/test-smtp vérifie la configuration SMTP du magasin
|
||||||
|
// (connexion et authentification, sans envoi) : services::mail.
|
||||||
|
//
|
||||||
|
// Comme Node aujourd'hui : un manager peut créer, modifier et supprimer
|
||||||
|
// n'importe quel magasin, pas seulement le sien.
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::{Json, Router, routing::get, routing::post, routing::put};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::CurrentUser;
|
||||||
|
use crate::crypto;
|
||||||
|
use crate::db::{self, Table};
|
||||||
|
use crate::error::{ApiResult, AppError, OrInternal, empty_json};
|
||||||
|
use crate::request::{JsonBody, int_id};
|
||||||
|
use crate::schema::GroupsRow;
|
||||||
|
use crate::zod::{Field, Object, Parsed};
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/groups", get(list).post(create))
|
||||||
|
.route("/api/groups/{id}", put(update).delete(remove))
|
||||||
|
.route("/api/groups/{id}/test-smtp", post(test_smtp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// insertGroupSchema (shared/schema.ts) : le formulaire envoie "" pour les
|
||||||
|
/// champs vides, d'où les champs texte élargis
|
||||||
|
fn insert_schema() -> Object {
|
||||||
|
GroupsRow::insert_schema()
|
||||||
|
.omit(&["id", "createdAt", "updatedAt"])
|
||||||
|
.extend(vec![
|
||||||
|
Field::string("address").optional().nullable(),
|
||||||
|
Field::string("phone").optional().nullable(),
|
||||||
|
Field::string("logo").optional().nullable(),
|
||||||
|
Field::string("smtpHost").optional().nullable(),
|
||||||
|
Field::coerce_number("smtpPort")
|
||||||
|
.int()
|
||||||
|
.min(1.0)
|
||||||
|
.max(65535.0)
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
Field::string("smtpUser").optional().nullable(),
|
||||||
|
Field::string("smtpPassword").optional().nullable(),
|
||||||
|
Field::union(
|
||||||
|
"smtpSenderEmail",
|
||||||
|
vec![Field::string("").email(), Field::literal("", json!(""))],
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
Field::string("smtpSenderName").optional().nullable(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// stripSmtpPassword : smtpPassword (ou smtp_password) remplacé par
|
||||||
|
/// smtpPasswordSet, à la même place, dans tout l'objet (8 niveaux au plus)
|
||||||
|
pub fn strip_smtp_password(value: Value) -> Value {
|
||||||
|
fn strip(value: Value, depth: usize) -> Value {
|
||||||
|
if depth > 8 {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
match value {
|
||||||
|
Value::Array(items) => {
|
||||||
|
Value::Array(items.into_iter().map(|item| strip(item, depth + 1)).collect())
|
||||||
|
}
|
||||||
|
Value::Object(object) => {
|
||||||
|
let mut out = Map::with_capacity(object.len());
|
||||||
|
for (key, entry) in object {
|
||||||
|
if key == "smtpPassword" || key == "smtp_password" {
|
||||||
|
out.insert("smtpPasswordSet".into(), Value::Bool(js_truthy(&entry)));
|
||||||
|
} else {
|
||||||
|
out.insert(key, strip(entry, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
strip(value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boolean(valeur) de JavaScript
|
||||||
|
fn js_truthy(value: &Value) -> bool {
|
||||||
|
match value {
|
||||||
|
Value::Null => false,
|
||||||
|
Value::Bool(flag) => *flag,
|
||||||
|
Value::Number(number) => number.as_f64().is_some_and(|n| n != 0.0),
|
||||||
|
Value::String(text) => !text.is_empty(),
|
||||||
|
Value::Array(_) | Value::Object(_) => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse JSON d'un magasin complet, sans son mot de passe SMTP
|
||||||
|
fn group_json<T: serde::Serialize>(group: T) -> Response {
|
||||||
|
match serde_json::to_value(group) {
|
||||||
|
Ok(value) => Json(strip_smtp_password(value)).into_response(),
|
||||||
|
Err(_) => AppError::internal("Internal Server Error").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// encryptSecret sur le mot de passe SMTP fourni (vide ou déjà chiffré : inchangé)
|
||||||
|
fn encrypt_smtp_password(data: &mut Parsed, secret: &str) {
|
||||||
|
if let Some(Value::String(password)) = data.get("smtpPassword") {
|
||||||
|
let sealed = crypto::encrypt(password, secret);
|
||||||
|
data.insert("smtpPassword".into(), Value::String(sealed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_write(user: &CurrentUser) -> Result<(), AppError> {
|
||||||
|
if ["admin", "manager"].contains(&user.role()) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::forbidden("Insufficient permissions"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin : tous les magasins (fiches complètes, triées par nom) ; les autres :
|
||||||
|
/// leurs magasins, tels que chargés avec l'utilisateur
|
||||||
|
async fn list(State(state): State<AppState>, user: CurrentUser) -> ApiResult {
|
||||||
|
if user.is_admin() {
|
||||||
|
let groups: Vec<GroupsRow> = sqlx::query_as(&GroupsRow::select("ORDER BY name"))
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.or_500("Failed to fetch groups")?;
|
||||||
|
return Ok(group_json(groups));
|
||||||
|
}
|
||||||
|
let groups: Vec<_> = user
|
||||||
|
.user_groups
|
||||||
|
.iter()
|
||||||
|
.map(|membership| &membership.group)
|
||||||
|
.collect();
|
||||||
|
Ok(Json(groups).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(State(state): State<AppState>, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult {
|
||||||
|
can_write(&user)?;
|
||||||
|
let mut data = insert_schema()
|
||||||
|
.parse(&body)
|
||||||
|
.map_err(|issues| AppError::validation("Validation failed", issues))?;
|
||||||
|
encrypt_smtp_password(&mut data, &state.config.encryption_key);
|
||||||
|
let group: GroupsRow = db::insert(&state.db, &data)
|
||||||
|
.await
|
||||||
|
.or_500("Failed to create group")?;
|
||||||
|
Ok(group_json(group))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
JsonBody(body): JsonBody,
|
||||||
|
) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to update group";
|
||||||
|
can_write(&user)?;
|
||||||
|
let id = int_id(&id, FAILED)?;
|
||||||
|
let mut data = insert_schema()
|
||||||
|
.partial()
|
||||||
|
.parse(&body)
|
||||||
|
.map_err(|_| AppError::internal(FAILED))?;
|
||||||
|
// Le mot de passe n'est jamais renvoyé au client : vide, il reste inchangé
|
||||||
|
if !data.get("smtpPassword").is_some_and(js_truthy) {
|
||||||
|
data.remove("smtpPassword");
|
||||||
|
}
|
||||||
|
encrypt_smtp_password(&mut data, &state.config.encryption_key);
|
||||||
|
let group = db::update_by_id::<GroupsRow>(&state.db, id, &data, true)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(match group {
|
||||||
|
Some(group) => group_json(group),
|
||||||
|
None => empty_json(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
const FAILED: &str = "Failed to delete group";
|
||||||
|
can_write(&user)?;
|
||||||
|
let id = int_id(&id, FAILED)?;
|
||||||
|
// Un magasin encore utilisé (commandes, livraisons…) fait échouer la suppression
|
||||||
|
db::delete_by_id::<GroupsRow>(&state.db, id)
|
||||||
|
.await
|
||||||
|
.or_500(FAILED)?;
|
||||||
|
Ok(Json(json!({ "message": "Group deleted successfully" })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test de la configuration SMTP d'un magasin, sans envoi : { success, message },
|
||||||
|
/// toute erreur (base comprise) en 400 avec son message
|
||||||
|
async fn test_smtp(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||||
|
can_write(&user)?;
|
||||||
|
let failure = |message: &str| {
|
||||||
|
let message = if message.is_empty() {
|
||||||
|
"Impossible de joindre le serveur SMTP"
|
||||||
|
} else {
|
||||||
|
message
|
||||||
|
};
|
||||||
|
AppError::with_body(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
json!({ "success": false, "message": message }),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// parseInt(id) envoyé en texte : un identifiant illisible fait échouer la requête
|
||||||
|
let id = crate::js::parse_int(&id).map_or_else(|| "NaN".to_string(), crate::js::number_to_string);
|
||||||
|
let group = crate::services::nocodb::group_by_text_id(&state.db, Some(&id))
|
||||||
|
.await
|
||||||
|
.map_err(|message| failure(&message))?
|
||||||
|
.ok_or_else(|| AppError::not_found("Magasin introuvable"))?;
|
||||||
|
let missing = crate::services::mail::missing_smtp_fields(&group);
|
||||||
|
if !missing.is_empty() {
|
||||||
|
return Err(failure(&format!(
|
||||||
|
"Configuration incomplète : {}",
|
||||||
|
missing.join(", ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
crate::services::mail::verify_smtp_config(&group, &state.config.encryption_key)
|
||||||
|
.await
|
||||||
|
.map_err(|message| failure(&message))?;
|
||||||
|
Ok(Json(json!({ "success": true, "message": "Connexion au serveur SMTP réussie" })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mot_de_passe_smtp_remplace_a_sa_place() {
|
||||||
|
let value = json!([{ "id": 1, "smtpUser": "u", "smtpPassword": "enc:v1:x", "smtpSenderName": "n" },
|
||||||
|
{ "group": { "smtp_password": "" } }]);
|
||||||
|
assert_eq!(
|
||||||
|
strip_smtp_password(value).to_string(),
|
||||||
|
r#"[{"id":1,"smtpUser":"u","smtpPasswordSet":true,"smtpSenderName":"n"},{"group":{"smtpPasswordSet":false}}]"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn courriel_d_expediteur_vide_ou_valide() {
|
||||||
|
let schema = insert_schema();
|
||||||
|
assert!(
|
||||||
|
schema
|
||||||
|
.parse(&json!({ "name": "X", "smtpSenderEmail": "" }))
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
schema
|
||||||
|
.parse(&json!({ "name": "X", "smtpSenderEmail": null, "smtpPort": "587" }))
|
||||||
|
.is_ok_and(|data| data["smtpPort"] == json!(587))
|
||||||
|
);
|
||||||
|
let issues = schema
|
||||||
|
.parse(&json!({ "name": "X", "smtpSenderEmail": "abc", "smtpPort": "0" }))
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&issues).unwrap(),
|
||||||
|
r#"[{"code":"too_small","minimum":1,"type":"number","inclusive":true,"exact":false,"message":"Number must be greater than or equal to 1","path":["smtpPort"]},{"validation":"email","code":"invalid_string","message":"Invalid email","path":["smtpSenderEmail"]}]"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// GET /api/health (server/routes.ts:118), utilisé par le contrôle de santé Docker.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub async fn health(State(state): State<AppState>) -> Json<Value> {
|
||||||
|
Json(json!({
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": crate::modules::now_iso(),
|
||||||
|
"environment": state.config.environment,
|
||||||
|
"database": "connected",
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Un module par domaine de l'application (lots de docs/PLAN-MIGRATION-RUST.md).
|
||||||
|
pub mod admin;
|
||||||
|
pub mod analytics;
|
||||||
|
pub mod announcements;
|
||||||
|
pub mod avoirs;
|
||||||
|
pub mod backups;
|
||||||
|
pub mod bap;
|
||||||
|
pub mod contacts;
|
||||||
|
pub mod customer_orders;
|
||||||
|
pub mod deliveries;
|
||||||
|
pub mod dlc;
|
||||||
|
pub mod external_api;
|
||||||
|
pub mod ffnancy;
|
||||||
|
pub mod groups;
|
||||||
|
pub mod health;
|
||||||
|
pub mod nocodb_config;
|
||||||
|
pub mod orders;
|
||||||
|
pub mod payment_schedule;
|
||||||
|
pub mod publicities;
|
||||||
|
pub mod reconciliation;
|
||||||
|
pub mod sav;
|
||||||
|
pub mod session_routes;
|
||||||
|
pub mod stats;
|
||||||
|
pub mod suppliers;
|
||||||
|
pub mod tasks;
|
||||||
|
pub mod users;
|
||||||
|
pub mod utilities;
|
||||||
|
pub mod weather;
|
||||||
|
|
||||||
|
/// Instant présent au format de JavaScript : 2026-10-15T08:30:00.000Z
|
||||||
|
pub fn now_iso() -> String {
|
||||||
|
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
|
||||||
|
}
|
||||||
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user