feat(rust) : serveur réécrit en Rust, Node retiré ; Calendrier en tête du menu (#571)

- Serveur Rust (server-rs/, axum + sqlx) : les 148 adresses de l'API,
  l'interface, les migrations de démarrage et les tâches de fond ; même
  comportement que Node (banc : 2027 cas, 0 écart hors écarts déclarés
  USERS-02, RAPPRO-21, R4, R5).
- Node retiré : server/, dépendances serveur, image Docker sans Node
  (51 Mo), docker-compose à un seul service applicatif.
- R4 : écritures d'un autre site refusées (Sec-Fetch-Site / Origin).
- R5 : « aujourd'hui » et sauvegarde automatique à l'heure de Paris.
- R6 : mainteneur dans .github/CODEOWNERS.
- Menu latéral : Accueil puis Calendrier en tête.
- Plan et état : docs/PLAN-MIGRATION-RUST.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ke2qyS8rgacP3ap5iezDbM
This commit is contained in:
LogiFlowandClaude Opus 5.5 authored and GitHub committed 2026-10-15 20:31:14 +02:00
1 parent 6760f43e87
commit 600627c632
146 files changed
+31521 -20331

No files matched your search

+50
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
8f4d799016e6dcd75cbef17ad27193f47b79300a
+299
View File
@@ -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"],
},
];
+24
View File
@@ -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);
+251
View File
@@ -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];
+359
View File
@@ -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"],
},
];
+49
View File
@@ -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"],
},
];
+66
View File
@@ -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: [],
},
];
+438
View File
@@ -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); });
+49
View File
@@ -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",
};
+34
View File
@@ -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];
}
+23
View File
@@ -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"
+47
View File
@@ -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); });
+20
View File
@@ -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
+211
View File
@@ -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); });
+8
View File
@@ -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.
+84
View File
@@ -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]);
}
}
+52
View File
@@ -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`]);
}
+25
View File
@@ -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')`);
}
+63
View File
@@ -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')`);
}
+23
View File
@@ -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')`);
}
+18
View File
@@ -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;
+44
View File
@@ -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)."
+287
View File
@@ -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()))));
});
});
});
}
+49
View File
@@ -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;
}
-23
View File
@@ -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);
}
-163
View File
@@ -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 "$@"
+224
View File
@@ -0,0 +1,224 @@
// 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}")]`);
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);
});