P1 conception: security-by-design layer (Merise 21 entities, Forgejo CI/CD, hardening) (#3)
All checks were successful
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 16s
CI / static-tests (push) Successful in 4s

This commit is contained in:
Corentin JOGUET 2026-06-15 12:16:11 +02:00
parent 822fdc1bc4
commit 32ff6a63ba
35 changed files with 2016 additions and 1026 deletions

View file

@ -66,6 +66,50 @@ CORS_ALLOWED_ORIGIN=https://kiosk.example.com
# argon2id recommande depuis PHP 7.3 pour les nouveaux projets.
PASSWORD_ALGO=argon2id
# Parametres de cout argon2id (password_hash options).
# Defauts alignes sur les recommandations OWASP Password Storage Cheat Sheet
# (memoire >= 19 MiB, >= 2 iterations). 65536 KiB = 64 MiB, marge confortable.
# Ces valeurs servent aussi au hash du PIN equipier (pin_hash, actions sensibles).
ARGON2_MEMORY_COST=65536 # KiB (64 MiB)
ARGON2_TIME_COST=4 # nombre d'iterations
ARGON2_THREADS=1 # parallelisme (1 = portable, deterministe)
# ===================================================================
# Anti brute-force - throttling de connexion (security-by-design)
# ===================================================================
# Deux gardes complementaires (cf. docs/merise/mld.md 4.21 + PROJECT_CONTEXT 19) :
# 1. par compte : colonnes user.failed_login_attempts / user.lockout_until
# 2. par IP source : table login_throttle (entite 21)
# Backoff degressif (pas de lock definitif) : evite le DoS sur compte legitime.
# Compteur par compte : nombre d'echecs avant declenchement du backoff.
ACCOUNT_LOCKOUT_THRESHOLD=5
# Duree de base du backoff (secondes). Croit de facon degressive a chaque
# palier d'echecs supplementaires (ex : 60 -> 300 -> 900).
ACCOUNT_LOCKOUT_BASE_SECONDS=60
ACCOUNT_LOCKOUT_MAX_SECONDS=900 # plafond du backoff (15 min)
# Gate par IP : fenetre glissante et plafond de tentatives sur cette fenetre.
IP_THROTTLE_WINDOW_SECONDS=900 # 15 min
IP_THROTTLE_MAX_ATTEMPTS=20 # par IP sur la fenetre
# PIN equipier pour actions sensibles (annulation, override). Longueur minimale.
STAFF_PIN_MIN_LENGTH=4
# Expiration du token de reinitialisation de mot de passe (secondes).
PASSWORD_RESET_TTL=3600 # 1h
# ===================================================================
# Retention des donnees (RGPD - minimisation et limitation de conservation)
# ===================================================================
# Purges executees par le service cron (docker/cron/crontab).
# Justification documentee : interet legitime / obligations probatoires.
AUDIT_LOG_RETENTION_DAYS=365 # journal d'audit ~12 mois
THROTTLE_PURGE_AFTER_HOURS=24 # login_throttle : lignes sans lockout actif > 24h
ORDER_RETENTION_DAYS=1095 # commandes (historique/stats) ~3 ans
# Purge des sessions expirees : deja geree par le cron */15 (voir crontab).
# ===================================================================
# Upload images produits
# ===================================================================

View file

@ -80,5 +80,3 @@ jobs:
else
echo "PHPUnit skipped: no tests/ + phpunit.xml yet (activates in P2)"
fi
# (CI pipeline validee le 2026-06-15 - test auto-merge)

View file

@ -24,6 +24,17 @@ Remplis les sections, coche ce qui s'applique, supprime ce qui ne sert pas.
- [ ] Docs Merise / dictionnaire a jour si le modele de donnees change
- [ ] Tests ajoutes et passants si du code est touche (unit > integration > e2e)
## Checklist securite (security-by-design)
<!-- Cocher ce qui s'applique ; voir SECURITY.md et PROJECT_CONTEXT section 19. -->
- [ ] Aucun secret commite (CI gitleaks verte) ; `.env` reste gitignore
- [ ] Entrees utilisateur validees ; requetes SQL en prepared statements (anti-injection)
- [ ] Mots de passe / PIN en argon2id ; pas de donnee sensible en clair ni dans les logs
- [ ] Sorties HTML echappees (anti-XSS) ; CSRF gere sur les formulaires d'etat
- [ ] Permissions RBAC verifiees cote serveur pour toute action sensible
- [ ] Impact RGPD evalue si nouvelles donnees personnelles (retention, droit a l'effacement)
## Bloc RNCP impacte
<!-- ex : Bloc 2 Cr 3.b (modelisation), Bloc 1 (accessibilite), Bloc 5 (infra/CI)... -->

55
SECURITY.md Normal file
View file

@ -0,0 +1,55 @@
# Politique de securite - Wakdo
Wakdo est un projet de fin de formation (RNCP 37805) construit en
**security-by-design** : la menace est modelisee avant le code. Ce document
resume la posture, le signalement de vulnerabilites et les garde-fous CI.
## Modele de menace
Le modele STRIDE complet, le registre des risques et la classification des
donnees (4 niveaux) vivent dans `docs/PROJECT_CONTEXT.md` section 19, et le flux
d'authentification durci dans `docs/uml/security-sequence.md`.
## Mesures en place (resume)
| Domaine | Mesure |
|---|---|
| Mots de passe | `password_hash` argon2id (cout configurable, defauts OWASP) |
| Actions sensibles | PIN equipier hashe argon2id (`pin_hash`) |
| Brute-force | double throttle : compteur par compte (`user`) + par IP (`login_throttle`), backoff degressif |
| Sessions | cookies `HttpOnly` + `Secure` + `SameSite=Strict`, regeneration d'ID a la connexion (anti-fixation), idle 4h / absolu 10h |
| Injection | PDO prepared statements exclusivement |
| Upload | validation MIME + taille, stockage hors webroot |
| En-tetes / PHP | `expose_php=Off`, `allow_url_fopen/include=Off`, `cgi.fix_pathinfo=0`, fonctions d'execution systeme desactivees |
| RGPD | retention limitee (audit ~12 mois, throttle 24h, commandes ~3 ans), droit consultation/modif/suppression |
| Secrets | `.env` gitignore, tenu hors de `.git/config` (credential helper lisant `.env`), secret-scan gitleaks en CI |
Les seuils operationnels (couts argon2, lockout, throttle, retention) sont
documentes dans `.env.example`.
## Garde-fous CI (Forgejo Actions)
Chaque PR vers `dev` ou `main` declenche `.forgejo/workflows/ci.yml` :
- **secret-scan** (gitleaks) : empeche un secret d'entrer dans l'historique
- **php-lint** : `php -l` sur tous les fichiers PHP
- **static-tests** : PHPStan + PHPUnit (s'activent quand le code PHP arrive en P2)
La strategie de merge est **PR + auto-merge sur CI verte** (travail solo) : la
PR est obligatoire (trace de gouvernance), le merge se declenche automatiquement
une fois les checks au vert. Voir `scripts/forgejo-pr-automerge.sh` et
`scripts/forgejo-branch-protection.sh`.
## Signaler une vulnerabilite
Projet pedagogique non destine a la production publique. Pour signaler un
probleme de securite : ouvrir une issue sur le depot Forgejo
(`https://git.acadenice.com/AcadeNice/corentin_wakdo`) ou contacter l'auteur.
Merci de ne pas divulguer publiquement un detail exploitable avant correction.
## Perimetre
Couvert : authentification, autorisation (RBAC), gestion de session, validation
d'entree, integrite des donnees de commande, hygiene des secrets.
Hors perimetre : paiement reel (remplace par numero de commande), durcissement
OS de l'hote, securite physique de la borne.

View file

@ -41,8 +41,31 @@ session.cookie_secure = 1
; Persistance inter-container non necessaire : chaque session est liee a une
; instance unique du service wakdo-app (pas de scale horizontal pour ce projet).
; --- Expose_php = Off : ne pas leak la version PHP dans l'entete HTTP ---
; session.gc_maxlifetime : filet de securite cote serveur (l'idle reel est
; pilote par l'appli via SESSION_LIFETIME_IDLE). 4h.
session.gc_maxlifetime = 14400
; IDs de session longs et a forte entropie (anti-prediction/fixation).
session.sid_length = 48
session.sid_bits_per_character = 6
; Pas de cache navigateur sur les pages avec session (anti-fuite via cache).
session.cache_limiter = nocache
; --- Durcissement general (security-by-design, cf. PROJECT_CONTEXT 19) ---
; Expose_php = Off : ne pas leak la version PHP dans l'entete HTTP.
expose_php = Off
; Anti RFI/SSRF : interdire l'ouverture d'URL distantes et leur inclusion.
allow_url_fopen = Off
allow_url_include = Off
; FPM : ne pas deviner le script a partir du PATH_INFO (anti exploitation
; d'upload mal route vers l'interpreteur). Le routage passe par le front controller.
cgi.fix_pathinfo = 0
; Interdire le chargement dynamique d'extensions au runtime.
enable_dl = Off
; Ne pas inclure les arguments dans les stack traces (anti-fuite de secrets).
zend.exception_ignore_args = On
; Desactiver les fonctions d'execution systeme : l'appli n'en a aucun usage
; legitime (anti-RCE en cas d'injection). Les scripts d'ops vivent cote cron/host.
disable_functions = exec,passthru,shell_exec,system,proc_open,popen
; --- OPcache (perf + stabilite) ---
[opcache]

View file

@ -26,12 +26,19 @@ Wakdo est une **borne de commande tactile** pour un restaurant de restauration r
### Acteurs
| Acteur | Role | Interface |
| Acteur | Role RBAC | Interface |
|---|---|---|
| **Client** | Passe sa commande sur la borne | Borne tactile (Bloc 1) |
| **Accueil** | Saisit commandes au **comptoir** (client au guichet) ou au **drive** (client en voiture via intercom + casque equipier), remet les commandes livrees aux clients | Back-office (Bloc 2) |
| **Preparation** | Voit les commandes a preparer triees par heure croissante, les declare "preparees" | Back-office (Bloc 2) |
| **Administration** | CRUD sur donnees (produits, menus, prix, images) + gestion utilisateurs + stats | Back-office (Bloc 2) |
| **Client** | (non authentifie) | Borne tactile (Bloc 1, canal `kiosk`) |
| **Counter** | `counter` | Back-office : saisit les commandes au **comptoir**, les remet au client, peut annuler |
| **Drive** | `drive` | Back-office : saisit les commandes au **drive** (intercom + casque), les remet, peut annuler |
| **Kitchen** | `kitchen` | Back-office : voit la file des commandes `paid` triees par `paid_at` croissant, en **lecture seule** (KDS visuel, aucune transition) |
| **Manager** | `manager` | Back-office : catalogue (create/update), stock/reappro, statistiques |
| **Administration** | `admin` | Back-office : catalogue complet (+ suppressions), gestion utilisateurs, roles et permissions (RBAC), stats |
> Modele v0.2 : 5 roles RBAC (`admin`, `manager`, `kitchen`, `counter`, `drive`)
> + Customer non authentifie. RBAC permission-driven (le code teste une
> permission, pas un nom de role) ; catalogue de 23 permissions fige au seed.
> Voir `docs/merise/dictionary.md` 3.15-3.18 et `docs/uml/use-cases.md`.
### Processus metier cle
@ -46,21 +53,21 @@ Client Borne (Bloc 1) API (Bloc 2) BDD
│ │─POST /api/orders─────▶│───INSERT──────────▶│
│ │◀──────────201─────────│ │
│─recupere au comptoir │ │ │
Preparation voit commande pending
→ declare "preparee"
Accueil voit commande prete
→ declare "livree"
Kitchen voit la file des commandes paid (lecture seule, KDS)
Counter / Drive remettent au client
→ declarent "livree" (geste unique paid -> delivered)
```
### Regles metier (MCT - a modeliser en Merise)
- Un **menu** = burger + accompagnement (frites OU salade) + boisson + sauce
- Les **accompagnements** et **boissons** ont **2 tailles** (normale / grande)
- **Grande taille** = +0,50 € sur le prix de base
- Une **commande** a un **numero** saisi par le client (remplace le paiement dans le cadre de l'exam)
- Statuts commande : `pending` -> `preparing` -> `ready` -> `delivered` (ou `cancelled`)
- Un **menu** = burger fixe + slots a choix (boisson, accompagnement, sauce). Modele relationnel `menu_slot` + `menu_slot_option` (voir `dictionary.md` 3.4-3.5)
- Format **Normal / Maxi** au niveau du menu (deux prix : `price_normal_cents`, `price_maxi_cents`) ; le Maxi agrandit accompagnement + boisson uniquement
- **Personnalisation des ingredients** (retirer = gratuit, ajouter = supplement) sur les sandwichs composes, via le configurateur (`ingredient`, `product_ingredient`, `order_item_modifier`)
- **TVA portee par le produit** (`vat_rate` : 10% defaut, 5,5% contenant conservable), calculee ligne par ligne et snapshotee sur `order_item` (fact-check BOFiP, voir `dictionary.md` note 9)
- Une **commande** a un **numero** saisi par le client, prefixe par canal `K`/`C`/`D` (remplace le paiement dans le cadre de l'exam)
- Statuts commande (machine a **4 etats**) : `pending_payment` -> `paid` -> `delivered` (+ `cancelled`). La transition `pending_payment -> paid` est **atomique** a la creation (saisie du numero = substitut de paiement). `cancelled` est atteignable depuis `pending_payment` et `paid` (pas depuis `delivered`). Plus de `preparing` / `ready` : la cuisine est en lecture seule, la remise est un geste unique
- **Source commande** (trace sur chaque commande) : `kiosk` (borne autonome) | `counter` (comptoir) | `drive` (drive-thru)
- La preparation voit les commandes triees par **heure de livraison croissante** (tous canaux confondus)
- Le canal de prepa (`kitchen`/`counter`/`drive`) voit la file des commandes `paid` triee par `paid_at` **croissant**, filtree par `role_visible_source` (kitchen voit tout ; counter voit kiosk+counter ; drive voit drive)
- **Horaires service** : 10h00 → 01h00 du matin (service continu 15h, pas de fermeture intermediaire)
- **Pas de notion de "session de service" a modeliser** : les equipiers se relaient, chacun se connecte a sa prise de poste et se deconnecte a la fin. Pas de "shift" a tracer dans la BDD (hors scope RNCP)
- **Fenetre de maintenance systeme** : 01h30 → 09h30 (crons lourds, backups, agregations) — evite toute interference avec le service actif
@ -187,8 +194,8 @@ Reseaux :
| TLS | Let's Encrypt via Traefik | auto | `acme.json` existant |
| Conteneurisation | Docker + docker compose | v2 | Cr 7.c |
| Orchestration locale | Makefile | — | Cr 7.b (script) + Cr 7.c.4 (une commande) |
| CI/CD | GitHub Actions | — | Cr 7.d |
| Versioning | Git + GitHub | — | Cr 4.f (collaboration) |
| CI/CD | Forgejo Actions (act_runner auto-heberge) | — | Cr 7.d |
| Versioning | Git + Forgejo auto-heberge (push-mirror GitHub) | — | Cr 4.f (collaboration) |
| Hooks Git | pre-commit + commit-msg | versionnes dans `.githooks/` | Conventional Commits |
---
@ -220,10 +227,11 @@ Reseaux :
**IN scope — Back-office :**
- Authentification sessions securisees (hash bcrypt/argon2, protection CSRF, fixation session) — duree de session adaptee a un poste complet d'equipier (idle timeout 4h, absolute timeout 10h)
- 3 roles RBAC : `admin`, `preparation`, `accueil`
- **Admin** : CRUD categories, produits (nom, description, prix, image, dispo), menus (composition + options), utilisateurs
- **Preparation** : liste commandes a preparer triees par heure livraison croissante, bouton "declarer preparee"
- **Accueil** : saisir commande manuellement (comptoir ou drive-thru via casque/intercom), bouton "declarer livree" ; champ `source` enregistre sur chaque commande (`counter` ou `drive`)
- 5 roles RBAC seed : `admin`, `manager`, `kitchen`, `counter`, `drive` (RBAC permission-driven, 23 permissions figees au seed ; roles personnalises possibles)
- **Admin** : CRUD complet catalogue (+ suppressions), gestion utilisateurs, roles et permissions (RBAC), stats
- **Manager** : catalogue (create/update), stock (reappro + inventaire), statistiques ; pas d'acces utilisateurs ni RBAC
- **Kitchen** : file des commandes `paid` triee par `paid_at` croissant, en **lecture seule** (KDS visuel) ; inventaire
- **Counter** / **Drive** : saisir une commande (comptoir / drive-thru via casque/intercom), bouton "declarer livree" (geste unique `paid -> delivered`), annuler ; `source` auto-tague depuis `role.order_source` ; inventaire
- Upload images produits (validation type MIME + taille + stockage dans volume `wakdo_uploads`)
- Historique commandes par statut
- Stats de base (commandes du jour, CA jour, produits top)
@ -261,9 +269,10 @@ Reseaux :
- `0 3 * * *` — backup BDD quotidien a 03h00 (entre fin service 01h et ouverture 10h)
- `*/15 * * * *` — purge sessions expirees toutes les 15 min (leger, peut tourner en service)
- `30 4 * * *` — agregation stats commandes a 04h30 sur le **jour de service** ecoule (10h J-1 → 01h J)
- **CI GitHub Actions** : lint PHP + PHPUnit sur PR -> dev
- **CD GitHub Actions** : deploy auto sur merge main (SSH + pull + `make rebuild`)
- `.env.example` documente, secrets hors du repo
- **CI Forgejo Actions** (act_runner auto-heberge) : lint PHP + PHPStan + PHPUnit + secret-scan (gitleaks) sur PR -> dev
- **CD Forgejo Actions** : deploy auto sur merge main (SSH + pull + `make rebuild`)
- `.env.example` documente (parametres securite : argon2id, lockout, seuils throttle, retention RGPD), secrets hors du repo
- `php.ini` durci (expose_php off, session cookies httponly/secure/samesite, upload limite)
- Healthcheck Traefik + readiness probes
- Logs centralises (stdout des conteneurs)
- Documentation deploiement + architecture (schemas dans `docs/`)
@ -327,8 +336,8 @@ Reseaux :
| Cr 7.c.3 | App conteneurisee complete | 4 services (web, app, db, cron) |
| Cr 7.c.4 | **Une ligne de commande** | `make init` lance toute la stack + migrate + seed |
| Cr 7.d.1 | Architecture serveur | Traefik reverse + reseaux segmentes documentes |
| Cr 7.d.2 | Tests avant deploy | CI PHPUnit + lint sur PR |
| Cr 7.d.3 | Integration/deploiement continus | GitHub Actions deploy automatique sur merge main |
| Cr 7.d.2 | Tests avant deploy | CI PHPUnit + PHPStan + secret-scan sur PR (Forgejo Actions) |
| Cr 7.d.3 | Integration/deploiement continus | Forgejo Actions deploy automatique sur merge main |
---
@ -344,13 +353,13 @@ main ← production (tag vX.Y.Z sur chaque release)
fix/* ← corrections
refactor/* ← refactos
docs/* ← doc seulement
ci/* ← GitHub Actions
ci/* ← Forgejo Actions
db/* ← migrations / schema BDD
chore/* ← tooling, config
test/* ← ajout de tests
```
Les branches `main` et `dev` sont **protegees** cote GitHub. Pas de commit direct autorise. Hook pre-commit local les bloque egalement.
Les branches `main` et `dev` sont **protegees** cote Forgejo (push direct interdit, force-push bloque, PR obligatoire via l'API `branch_protections`). Hook pre-commit local les bloque egalement.
**Flow :**
1. `git checkout -b feat/menu-composition` (depuis `dev`)
@ -432,9 +441,10 @@ Les branches `main` et `dev` sont **protegees** cote GitHub. Pas de commit direc
| 10 | Service cron dedie | Cr 7.b.3 explicite + realiste prod |
| 11 | Makefile avec `make init` | Cr 7.c.4 + demonstration DevOps |
| 12 | Conventional Commits + hooks | Cr 4.f.x + discipline de versioning |
| 13 | Branches feat/* -> dev -> main | Pipeline propre pour jury, GitHub PR trace |
| 14 | CI/CD GitHub Actions | Cr 7.d explicite dans referentiel |
| 13 | Branches feat/* -> dev -> main | Pipeline propre pour jury, PR tracee (Forgejo, mirror GitHub) |
| 14 | CI/CD Forgejo Actions (act_runner auto-heberge) | Cr 7.d explicite ; forge + CI maitrisees de bout en bout (argument Bloc 5) |
| 15 | RGPD implemente minimal | Cr 3.d.1-4 evaluees meme projet ecole |
| 16 | Security-by-design (threat model STRIDE + classification donnees) | Audit Cr 7.a ; stock en %, throttle brute-force, retention RGPD documentes en amont du code |
---
@ -442,18 +452,18 @@ Les branches `main` et `dev` sont **protegees** cote GitHub. Pas de commit direc
| Phase | Scope | Budget (h) | Deadline intermediaire |
|---|---|---|---|
| **P0 - Setup** | PC, arborescence, Docker, hooks, CI squelette, init Git/GitHub | 20 | Semaine 1 |
| **P1 - Conception Merise** | Dictionnaire, MCD, MCT, MLD, schemas fonctionnels, DDL | 30 | Semaine 3 |
| **P0 - Setup** | PC, arborescence, Docker, hooks, CI squelette, migration Forgejo + act_runner | 22 | Semaine 1 |
| **P1 - Conception Merise + Security-by-design** | Dictionnaire, MCD, MCT, MLD, schemas fonctionnels, DDL, threat model STRIDE + classification donnees + sequence securite | 38 | Semaine 3 |
| **P2 - Back squelette** | POO base (Core, Router, Autoloader, DB), auth + roles | 30 | Semaine 6 |
| **P3 - Back CRUD admin** | Produits, menus, utilisateurs, views | 40 | Semaine 10 |
| **P4 - API REST** | Endpoints + CORS + tests | 20 | Semaine 12 |
| **P5 - Front borne** | Integration maquette, Ajax, accessibilite, responsive | 60 | Semaine 16 |
| **P6 - Tests + finition** | PHPUnit, tests E2E borne, corrections | 25 | Semaine 18 |
| **P7 - DevOps finalisation** | CI/CD deploy auto, crons, docs argumentation | 20 | Semaine 19 |
| **P7 - DevOps finalisation** | Forgejo Actions CI/CD (PHPUnit + PHPStan + secret-scan + deploy auto), crons, SECURITY.md, docs argumentation | 22 | Semaine 19 |
| **P8 - Prep soutenance** | README pour jury, schemas finaux, repetitions, modifs en direct | 15 | Semaine 20 |
| **TOTAL** | | **260** | **Semaine 20 = fin aout 2026** |
| **TOTAL** | | **272** | **Semaine 20 = fin aout 2026** |
Buffer : ~20 h pour imprevus. Cible effective : ~240 h sur 20 semaines = **12 h/semaine**.
Buffer : ~8 h pour imprevus. Cible effective : ~264 h sur 20 semaines = **~13 h/semaine**.
---
@ -481,7 +491,7 @@ Buffer : ~20 h pour imprevus. Cible effective : ~240 h sur 20 semaines = **12 h/
- `docker-compose.yml` commente
- Dockerfiles customs commentes
- `Makefile` avec `make help`
- `.github/workflows/` avec CI + CD
- `.forgejo/workflows/` avec CI (PHPUnit + PHPStan + secret-scan) + CD
- Crontab documente
- Script de backup/restore teste
- Architecture serveur decrite (`docs/architecture/deployment.md`)
@ -675,4 +685,138 @@ Ces regles tiennent lieu de garde-fous pendant toute la duree du projet. Les enf
---
*Document vivant — version 1.1 — 2026-04-24 (ajout section 17 transparence IA). A mettre a jour a chaque decision structurante.*
## 19. Security threat model and data classification
Cette section formalise la couche **security-by-design** ajoutee au modele Merise v0.2
(voir `docs/merise/dictionary.md` note 13, `docs/merise/mlt.md` section 2 pour les regles
transverses RG-T13 a RG-T21). Elle se lit a deux niveaux : un **registre des risques** de
synthese pour une lecture gestion, suivi d'une **analyse STRIDE par element** pour la
profondeur technique, puis une **matrice de classification des donnees** en 4 niveaux. Tous
les claims securite sont rattaches a un mecanisme concret (une regle RG-T, une colonne, une
entite) plutot qu'enonces comme des absolus.
### 19.1 Perimetre et frontieres de confiance
Le systeme expose cinq frontieres de confiance (trust boundaries), correspondant aux points
d'entree analyses ci-dessous :
- **E1 — Borne kiosk (public anonyme)** : `POST /api/orders`, consultation du catalogue.
Aucune authentification ; la borne est anonyme par conception (les commandes kiosk ont
`customer_order.acting_user_id = NULL`). Surface la plus exposee, donc traitee sans
hypothese de confiance sur l'entree.
- **E2 — Back-office admin (staff authentifie, poste partage + PIN par equipier)** : CRUD
catalogue/menus/ingredients, RBAC, gestion utilisateurs, stock, annulation de commande,
stats. Session partagee par poste pour le flux courant ; un PIN par equipier
(`user.pin_hash`) re-autorise l'ensemble sensible (RG-T13).
- **E3 — Surface d'authentification** : login (`AUTHENTICATE_USER`, op 25, `mlt.md` 12.1) et
reinitialisation de mot de passe (`RESET_PASSWORD`, op 28, `mlt.md` 12.3).
- **E4 — Couche donnees / BDD** : acces PDO, requetes preparees (RG-T06), allowlists
(RG-T16/RG-T17), integrite transactionnelle (RG-T08/RG-T11), snapshots immuables (RG-T05).
- **E5 — Stock / inventaire** : decrement de vente, reappro, comptage d'inventaire, avec un
journal append-only `stock_movement` et attribution de l'acteur ; la correction d'inventaire
est PIN-gated (RG-T13, `mlt.md` 9.2) car elle peut masquer de la demarque (shrinkage).
Hors perimetre de cette section : la securite reseau/infra (Traefik, segmentation Docker,
TLS), couverte en section 5 ; le durcissement CORS, couvert en section 5.
### 19.2 Registre des risques (risk register)
Synthese gestion. Likelihood et residual risk sont evalues a dire d'expert pour ce projet
fictif (`[REASONING]`, non quantifies par benchmark) ; chaque mitigation cite une regle RG-T
et/ou une entite reelle du modele.
| # | Actif | Menace | Impact | Likelihood | Mitigation (regle / entite) | Risque residuel |
|---|---|---|---|---|---|---|
| R1 | Recette (cash) sur commande payee | Un equipier annule une commande `paid` pour detourner l'encaissement (fraude interne) | Fort | Moyenne | `CANCEL_ORDER` (`mlt.md` 7.1) PIN-gated (RG-T13) + ecriture `audit_log` dans la meme transaction (RG-T14, RG-T11) ; acteur capture via `audit_log.actor_user_id` | Faible — l'annulation reste possible mais devient nominative et tracee ; dissuasion plus que blocage |
| R2 | `product.price_cents` / `vat_rate` / `role_id` | Falsification via un champ de formulaire injecte (mass-assignment) | Fort | Moyenne | Allowlist de colonnes par operation (RG-T16) sur `UPDATE_PRODUCT` (`mlt.md` 8.2) et `UPDATE_USER` (10.2) ; seules les colonnes autorisees sont bindees | Faible — les champs hors allowlist sont ignores ; un changement de prix reste audite (RG-T14) |
| R3 | Comptes back-office (`user.password_hash`) | Brute-force sur le login staff | Moyen | Haute | Backoff degressif par compte (`user.failed_login_attempts` / `lockout_until`) + par IP (`login_throttle`, entite 21) ; gate avant verification (`mlt.md` 12.1 PRE-3, RG-8) | Faible — ralentissement sans lock indefini ; un service de 15h n'est pas bloque par une saisie maladroite |
| R4 | Vues kiosk et admin (texte stocke) | XSS stocke via `product.name` / `ingredient.name` / `user.first_name` | Moyen | Moyenne | Echappement au rendu (RG-T15) : `htmlspecialchars(..., ENT_QUOTES)` cote admin, injection via `textContent` (pas `innerHTML`) cote kiosk vanilla-JS | Faible — l'echappement reduit le risque d'execution de script injecte |
| R5 | `ingredient.stock_quantity` | Survente (oversell) sous concurrence multi-borne | Moyen | Moyenne | Decrement atomique auto-verrouillant (RG-T20) sans read-gate + disponibilite calculee (RG-T21) ; `stock_quantity` signe, la magnitude de survente est remontee aux managers | Moyen accepte — le systeme ne bloque pas une commande sur le stock ; la survente est mesuree, pas empechee (decision metier) |
| R6 | Commande payee | Double-charge sur retry reseau de `POST /api/orders` | Moyen | Moyenne | Idempotence (RG-T19) : `customer_order.idempotency_key` UNIQUE ; un retry renvoie la commande existante au lieu d'en creer une seconde | Faible — la cle UNIQUE deduplique les rejeux ; depend d'une cle client correctement generee |
| R7 | PII utilisateur (`user.email`/`first_name`/`last_name`) | Demande d'effacement RGPD non honoree, ou rupture de l'integrite referentielle a la suppression | Fort (conformite) | Faible | Anonymisation (`ERASE_USER_PII`, `mlt.md` 10.5) : la ligne est conservee, PII remplacees par un placeholder `anon-<id>@wakdo.invalid`, credentials invalides, `anonymized_at` pose ; `audit_log` retient sa propre fenetre | Faible — effacement et tracabilite coexistent ; les FK (`audit_log.actor_user_id`, `customer_order.acting_user_id`, `stock_movement.user_id`) restent valides |
| R8 | Matrice RBAC (`role_permission`) | Elevation de privilege via modification de role non controlee | Fort | Faible | `MANAGE_RBAC` (`mlt.md` 10.4) PIN-gated (RG-T13) + `audit_log` du diff de permissions (RG-T14, RG-6) ; `role_id` derriere l'allowlist (RG-T16) | Faible — tout gain/perte de capacite est nominatif et trace |
| R9 | `stock_movement` (demarque) | Correction d'inventaire masquant une demarque | Moyen | Moyenne | `INVENTORY_COUNT` (`mlt.md` 9.2) PIN-gated (RG-T13) ; le `user_id` capture par PIN est ecrit dans `stock_movement.user_id` (append-only) | Faible — la correction devient attribuable a une personne meme sur poste partage |
### 19.3 Analyse STRIDE par element
Un bloc par categorie STRIDE, mappe aux controles reels du modele (verifies contre
`mlt.md` section 2).
**Spoofing (usurpation d'identite).** L'authentification back-office repose sur argon2id
(`user.password_hash`, `mlt.md` 12.1 RG-2) avec regeneration de session a la connexion
(`session_regenerate(true)`, RG-3) pour contrer la fixation. Le login est enumeration-safe :
meme erreur generique que l'email existe ou non, avec un `password_verify` leurre pour garder
le timing comparable (RG-2). Sur un poste partage, un PIN par equipier (`user.pin_hash`,
RG-T13) re-authentifie l'acteur reel pour les actions sensibles. La reinitialisation de mot de
passe (`RESET_PASSWORD`, `mlt.md` 12.3) stocke le token hashe (`password_reset_token_hash`),
n'envoie le token brut qu'une seule fois, l'expire a 1h et le rend a usage unique (RG-2/RG-3) ;
la phase requete renvoie une reponse neutre identique que le compte existe ou non (RG-1,
enumeration-safe). La borne kiosk est anonyme
par conception, donc hors perimetre d'usurpation (pas de compte a usurper).
**Tampering (alteration).** L'allowlist de mass-assignment (RG-T16) limite les colonnes
bindees aux champs autorises par operation, protegeant `price_cents`, `vat_rate`, `role_id`,
`is_active`, `status`. La validation cote serveur (RG-T18) re-verifie type, plage, longueur,
appartenance ENUM et existence des FK independamment du client. Les requetes preparees PDO
(RG-T06) traitent les valeurs hors de la chaine SQL, ce qui ferme l'injection SQL par valeur.
Les identifiants SQL dynamiques (colonne et direction d'un `ORDER BY`/`GROUP BY`) sont resolus
contre une allowlist fixe avant construction de la requete (RG-T17), car un identifiant ne
peut pas etre bind comme une valeur.
Les snapshots de commande (`order_item.label_snapshot`, `unit_price_cents_snapshot`,
`vat_rate_snapshot`) sont immuables apres INSERT (RG-T05), preservant l'integrite historique
des commandes placees. La re-validation serveur des modifiers (`mlt.md` 3.3 RG-9) rejette un
`POST` forge ajoutant un ingredient non-`is_addable`.
**Repudiation (deni d'action).** Le journal `audit_log` (entite 20, RG-T14) enregistre les
actions sensibles non-stock avec `actor_user_id` (capture par PIN, RG-T13), `actor_role_id`
(denormalise pour survivre a l'anonymisation), `action_code`, `entity_type`/`entity_id` et un
`summary` non-personnel ; pas d'UPDATE/DELETE applicatif. L'attribution des commandes
comptoir/drive passe par `customer_order.acting_user_id` (`mlt.md` 4.1 RG-5) et celle du stock
par `stock_movement.user_id` (`mlt.md` 9.1/9.2). Les actions stock ne sont pas doublement
journalisees : `stock_movement` (append-only) fournit deja la piste.
**Information disclosure (divulgation).** La matrice de classification (19.4) borne ce qui
sort des logs et des reponses API. Les erreurs d'auth sont generiques (RG-2, pas de
distinction email inconnu / mot de passe faux). L'`audit_log` stocke des **noms de champs**
modifies, pas les valeurs PII (`audit_log.details`, RG-T14). L'attribution de stock
(`stock_movement.user_id`) n'est visible que pour manager/admin ; le staff de ligne voit les
deltas sans l'identite de l'acteur (`mlt.md` 9.3 RG-4). Les credentials (`password_hash`,
`pin_hash`, `password_reset_token_hash`) sont tenus hors logs et hors reponses API.
**Denial of service.** Le throttling de login est degressif (backoff exponentiel plafonne)
plutot qu'un lock indefini, dans les deux dimensions compte (`user.lockout_until`) et IP
(`login_throttle.lockout_until`, `mlt.md` 12.1 RG-8) : une saisie maladroite ne bloque pas une
cuisine en plein service de 15h continu. L'idempotence (RG-T19) absorbe les doubles-soumissions
de retry reseau sur le kiosk anonyme. Le decrement de stock atomique (RG-T20) evite tout
contentieux de verrou (pas de `SELECT ... FOR UPDATE`, pas d'ordre de deadlock).
**Elevation of privilege.** Le RBAC est permission-driven : le code teste une permission, pas
un nom de role (catalogue de 23 permissions fige au seed, `dictionary.md` 3.17). Les
changements de role passent par `MANAGE_RBAC` (`mlt.md` 10.4) PIN-gated (RG-T13) et audites
avec le diff de permissions (RG-T14, RG-6). `role_id` est derriere l'allowlist de
mass-assignment (RG-T16) sur `UPDATE_USER` (10.2). Les permissions sont rechargees depuis la
BDD a chaque verification (`mlt.md` 10.4 RG-3), donc un changement de droits prend effet sans
re-login force.
### 19.4 Matrice de classification des donnees (4 niveaux)
Les 21 entites du modele (`dictionary.md` 3.1-3.21) sont reparties en quatre niveaux. La
classification suit l'entite ; quelques colonnes sont surclassees explicitement (credentials,
PII).
| Niveau | Definition | Entites / colonnes | Regle de manipulation |
|---|---|---|---|
| **RESTRICTED** (secrets / credentials) | Secrets d'authentification ; tenus hors de toute exposition | Colonnes de `user` (14) : `password_hash`, `pin_hash`, `password_reset_token_hash` | Hors logs et hors reponses API ; argon2id ; invalides a l'anonymisation (`mlt.md` 10.5 RG-1) ; exclus de `audit_log.details` qui ne retient que des noms de champs (RG-T14) |
| **CONFIDENTIAL** (PII, RGPD) | Donnees a caractere personnel d'un staff identifiable | Colonnes de `user` (14) : `email`, `first_name`, `last_name` | Sujet a l'anonymisation a l'effacement (`ERASE_USER_PII`, op 27) ; `audit_log` stocke les noms de champs, pas les valeurs ; echappement au rendu (RG-T15) |
| **INTERNAL** (sensible metier) | Donnees d'exploitation, non publiques, a acces restreint par RBAC | `customer_order` (10), `order_item` (11), `order_item_selection` (12), `order_item_modifier` (13), `stock_movement` (19), `audit_log` (20), `login_throttle` (21, contient l'IP source), `role` (15), `permission` (17), `role_permission` (18), `role_visible_source` (16) ; sorties de stats (`READ_STATS`, op 24) | Acces filtre par permission (RG-T03) ; attribution stock visible manager/admin seulement (`mlt.md` 9.3 RG-4) ; integrite par snapshots (RG-T05) et transactions (RG-T08/RG-T11) |
| **PUBLIC** (catalogue, face kiosk) | Donnees servies a la borne anonyme | `category` (1), `product` (2), `menu` (3), `menu_slot` (4), `menu_slot_option` (5), `ingredient` (6, nom + dispo calculee), `product_ingredient` (7), `allergen` (8), `ingredient_allergen` (9) | Lecture publique via `LOAD_CATALOGUE` (op 1) ; ecriture reservee admin/manager (RG-T03) ; texte echappe au rendu (RG-T15) ; disponibilite calculee (RG-T21) |
**Couverture** : 21/21 entites classifiees (9 PUBLIC, 11 INTERNAL incluant les deux entites
security-by-design `audit_log` et `login_throttle`, plus `user` dont les colonnes sont
reparties entre RESTRICTED, CONFIDENTIAL et — pour `is_active`, `role_id`, `last_login_at`,
les compteurs de throttle — INTERNAL). L'entite `user` (14) est la seule a porter trois
niveaux simultanement, d'ou son traitement par colonne.
---
*Document vivant — version 1.3 — 2026-06-15 (drift GitHub -> Forgejo Actions corrige, CI securite PHPStan/secret-scan, planning rechiffre pour la couche security-by-design). A mettre a jour a chaque decision structurante.*

View file

@ -0,0 +1,102 @@
# Forgejo Actions - runner (act_runner)
Prerequis d'infrastructure pour la CI/CD Wakdo. Les workflows vivent dans
`.forgejo/workflows/` (lot D) ; ils ne s'executent que si un `act_runner` est
enregistre et en ligne sur le serveur.
## Pourquoi un runner separe de la stack app
La stack `docker-compose.yml` de Wakdo = runtime applicatif (web, app, db, cron).
Le runner CI est du **tooling** : il se rattache au depot Forgejo, pas a l'app.
On le fait tourner comme service dedie sur l'hote stark (meme lecon que
"gh dans Docker = mauvaise idee", cf. journal session 6). Cela evite que la CI
puisse impacter le runtime, et garde un cycle de vie independant.
## 1. Obtenir le token de registration (action manuelle, niveau admin)
Le token vient de l'instance Forgejo, pas du repo. Dans l'UI Forgejo :
- niveau **repo** : `Settings > Actions > Runners > Create new runner`
- ou niveau **org/instance** : `Site Administration > Actions > Runners`
Recuperer le `REGISTRATION_TOKEN` affiche. Il est a usage unique pour
l'enregistrement (pas a versionner).
## 2. Enregistrer le runner (sur stark)
Setup reel en place (image `simplyforma/forgejo-runner` deja presente sur
l'hote, data dir sous `$HOME` car `/srv` non inscriptible par `corentin`).
Le conteneur tourne sous l'uid de l'hote (`--user`) pour pouvoir ecrire
`.runner` dans le volume monte.
```bash
DATA=/home/corentin/forgejo-runner-wakdo
mkdir -p "$DATA"
docker run --rm \
--user "$(id -u):$(id -g)" \
-v "$DATA":/data --workdir /data \
--entrypoint forgejo-runner \
simplyforma/forgejo-runner:12.10.2 \
register --no-interactive \
--instance https://git.acadenice.com \
--token "<REGISTRATION_TOKEN>" \
--name stark-wakdo \
--labels 'docker:docker://node:20-bookworm,php-ci:docker://php:8.3-cli'
```
L'enregistrement ecrit `$DATA/.runner` (contient le secret du runner - ne pas
versionner, ne pas sortir de l'hote). Runner enregistre le 2026-06-15
(uuid `e4a3dbef-...`, labels `docker` + `php-ci`).
## 3. Lancer le runner en service
```bash
DATA=/home/corentin/forgejo-runner-wakdo
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
docker run -d --restart=always \
--name forgejo-runner-wakdo \
--user "$(id -u):$(id -g)" \
--group-add "$DOCKER_GID" \
-e HOME=/data \
-v "$DATA":/data --workdir /data \
-v /var/run/docker.sock:/var/run/docker.sock \
--entrypoint forgejo-runner \
simplyforma/forgejo-runner:12.10.2 \
daemon
```
Notes :
- `--group-add $DOCKER_GID` : acces au socket Docker pour executer les jobs
dans des conteneurs (sans tourner en root).
- `-e HOME=/data` : evite l'erreur `mkdir /.cache: permission denied` (le cache
server interne ecrit sous `$HOME`).
- Verifier `docker logs forgejo-runner-wakdo` : `declared successfully` +
`[poller] launched`, et `Settings > Actions > Runners` doit montrer `stark-wakdo` **Idle**.
- Prerequis cote depot : **Actions activees** (`Settings > Actions` du depot).
## 4. Labels et usage en workflow
Les jobs ciblent un label via `runs-on`. Pour la CI PHP de Wakdo :
```yaml
jobs:
ci:
runs-on: docker # image par defaut node:20-bookworm
# les etapes installent/php via le conteneur ou une action setup-php
```
## Securite du runner
- Le `.runner` (secret) reste sur l'hote, hors du repo.
- Le socket Docker monte donne un acces privilegie : le runner ne doit executer
que des workflows du depot Wakdo (runner dedie au repo, pas partage).
- Roter le secret = re-enregistrer avec un nouveau token et supprimer l'ancien
runner dans l'UI.
## Lien avec les autres lots
- **Lot C** : ce document + prerequis infra.
- **Lot D** : `.forgejo/workflows/ci.yml` (PHPUnit + PHPStan + secret-scan gitleaks)
et auto-merge des PR sur CI verte (strategie solo dev validee).

View file

@ -1,67 +0,0 @@
<mxfile host="app.diagrams.net" agent="claude-code-byan" type="device">
<diagram name="MCD - Catalogue" id="mcd-catalogue">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="categorie" value="&lt;b&gt;CATEGORIE&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;libelle : VARCHAR (UNIQUE)&lt;br&gt;slug : VARCHAR (UNIQUE)&lt;br&gt;image_path : VARCHAR&lt;br&gt;ordre : SMALLINT&lt;br&gt;est_actif : BOOLEAN" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="440" y="40" width="280" height="160" as="geometry" />
</mxCell>
<mxCell id="produit" value="&lt;b&gt;PRODUIT&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;categorie_id : INT (FK)&lt;br&gt;libelle : VARCHAR&lt;br&gt;description : TEXT&lt;br&gt;prix_ttc_cents : INT&lt;br&gt;image_path : VARCHAR&lt;br&gt;est_disponible : BOOLEAN&lt;br&gt;ordre : SMALLINT" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="60" y="320" width="280" height="200" as="geometry" />
</mxCell>
<mxCell id="menu" value="&lt;b&gt;MENU&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;categorie_id : INT (FK)&lt;br&gt;libelle : VARCHAR&lt;br&gt;description : TEXT&lt;br&gt;prix_ttc_cents : INT&lt;br&gt;image_path : VARCHAR&lt;br&gt;est_disponible : BOOLEAN&lt;br&gt;ordre : SMALLINT" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="820" y="320" width="280" height="200" as="geometry" />
</mxCell>
<mxCell id="menu_produit" value="&lt;b&gt;MENU_PRODUIT&lt;/b&gt; &lt;i&gt;(associative)&lt;/i&gt;&lt;hr&gt;menu_id : INT (PK, FK)&lt;br&gt;produit_id : INT (PK, FK)&lt;br&gt;role : ENUM&lt;br&gt;position : SMALLINT" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="440" y="640" width="280" height="140" as="geometry" />
</mxCell>
<mxCell id="e_cat_prod" value="regroupe" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="categorie" target="produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_cat_prod_a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cat_prod">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_cat_prod_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cat_prod">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_cat_menu" value="regroupe" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="categorie" target="menu">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_cat_menu_a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cat_menu">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_cat_menu_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cat_menu">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_prod_mp" value="fait_partie_de" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="produit" target="menu_produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_prod_mp_a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_prod_mp">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_prod_mp_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_prod_mp">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_menu_mp" value="compose" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="menu" target="menu_produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_menu_mp_a" value="(1,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_menu_mp">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_menu_mp_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_menu_mp">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View file

@ -0,0 +1,51 @@
erDiagram
category {
int id PK
varchar name
varchar slug
varchar image_path
smallint display_order
tinyint is_active
}
product {
int id PK
int category_id FK
varchar name
text description
int price_cents
smallint vat_rate
varchar image_path
tinyint is_available
smallint display_order
}
menu {
int id PK
int category_id FK
int burger_product_id FK
varchar name
text description
int price_normal_cents
int price_maxi_cents
varchar image_path
tinyint is_available
smallint display_order
}
menu_slot {
int id PK
int menu_id FK
varchar name
enum slot_type
tinyint is_required
smallint display_order
}
menu_slot_option {
int menu_slot_id FK
int product_id FK
}
category ||--o{ product : "groups"
category ||--o{ menu : "groups"
menu ||--|| product : "anchors (burger_product_id)"
menu ||--o{ menu_slot : "defines_slot"
menu_slot ||--o{ menu_slot_option : "lists"
product ||--o{ menu_slot_option : "is_eligible_for"

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 119 KiB

View file

@ -1,93 +0,0 @@
<mxfile host="app.diagrams.net" agent="claude-code-byan" type="device">
<diagram name="MCD - Commande" id="mcd-commande">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="commande" value="&lt;b&gt;COMMANDE&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;numero : VARCHAR (UNIQUE)&lt;br&gt;source : ENUM (kiosk|comptoir|drive)&lt;br&gt;mode_consommation : ENUM (sur_place|a_emporter|drive)&lt;br&gt;statut : ENUM&lt;br&gt;total_ht_cents : INT&lt;br&gt;total_tva_cents : INT&lt;br&gt;total_ttc_cents : INT&lt;br&gt;tva_taux_pourmille : SMALLINT&lt;br&gt;paye_a : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="440" y="40" width="320" height="240" as="geometry" />
</mxCell>
<mxCell id="user_stub" value="&lt;b&gt;USER&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;&lt;i&gt;(detail dans RBAC)&lt;/i&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="880" y="40" width="240" height="80" as="geometry" />
</mxCell>
<mxCell id="commande_event" value="&lt;b&gt;COMMANDE_EVENT&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;commande_id : INT (FK)&lt;br&gt;event_type : ENUM&lt;br&gt;from_statut : ENUM (NULL)&lt;br&gt;to_statut : ENUM&lt;br&gt;user_id : INT (FK, NULL)&lt;br&gt;payload : JSON (NULL)&lt;br&gt;created_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="840" y="360" width="280" height="200" as="geometry" />
</mxCell>
<mxCell id="ligne_commande" value="&lt;b&gt;LIGNE_COMMANDE&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;commande_id : INT (FK)&lt;br&gt;type_item : ENUM (produit|menu)&lt;br&gt;produit_id : INT (FK, NULL)&lt;br&gt;menu_id : INT (FK, NULL)&lt;br&gt;libelle_snapshot : VARCHAR&lt;br&gt;prix_unitaire_ttc_cents_snapshot : INT&lt;br&gt;quantite : SMALLINT" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="440" y="360" width="280" height="220" as="geometry" />
</mxCell>
<mxCell id="produit" value="&lt;b&gt;PRODUIT&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;&lt;i&gt;(detail dans Catalogue)&lt;/i&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="80" y="700" width="240" height="80" as="geometry" />
</mxCell>
<mxCell id="menu" value="&lt;b&gt;MENU&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;&lt;i&gt;(detail dans Catalogue)&lt;/i&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="840" y="700" width="240" height="80" as="geometry" />
</mxCell>
<mxCell id="e_cmd_lc" value="contient" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="commande" target="ligne_commande">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_cmd_lc_a" value="(1,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cmd_lc">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_cmd_lc_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cmd_lc">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_lc_prod" value="refere_si_type_produit" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0;dashed=1" edge="1" parent="1" source="ligne_commande" target="produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_lc_prod_a" value="(0,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_lc_prod">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_lc_prod_b" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_lc_prod">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_lc_menu" value="refere_si_type_menu" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0;dashed=1" edge="1" parent="1" source="ligne_commande" target="menu">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_lc_menu_a" value="(0,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_lc_menu">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_lc_menu_b" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_lc_menu">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="note_poly" value="&lt;b&gt;Polymorphisme&lt;/b&gt;&lt;br&gt;Exactement UNE des deux references est non-nulle.&lt;br&gt;Discriminateur : type_item &amp;isin; {produit, menu}.&lt;br&gt;Contrainte CHECK SQL au MLD." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="80" y="360" width="280" height="100" as="geometry" />
</mxCell>
<mxCell id="e_cmd_evt" value="journalise" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="commande" target="commande_event">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_cmd_evt_a" value="(1,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cmd_evt">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_cmd_evt_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cmd_evt">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_user_evt" value="declenche" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0;dashed=1" edge="1" parent="1" source="user_stub" target="commande_event">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_user_evt_a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_user_evt">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_user_evt_b" value="(0,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_user_evt">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="note_audit" value="&lt;b&gt;Journal d'audit (event sourcing)&lt;/b&gt;&lt;br&gt;Append-only : aucun UPDATE / DELETE applicatif.&lt;br&gt;user_id NULL si auto-validation kiosk.&lt;br&gt;ON DELETE CASCADE cote commande_id.&lt;br&gt;ON DELETE SET NULL cote user_id." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="840" y="580" width="280" height="100" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 704 KiB

View file

@ -1,182 +0,0 @@
<mxfile host="app.diagrams.net" agent="claude-code-byan" type="device">
<diagram name="MCD - Global" id="mcd-global">
<mxGraphModel dx="1800" dy="1100" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="categorie" value="&lt;b&gt;CATEGORIE&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="600" y="40" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="produit" value="&lt;b&gt;PRODUIT&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="240" y="220" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="menu_produit" value="&lt;b&gt;MENU_PRODUIT&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=13;align=center;verticalAlign=middle;dashed=1" vertex="1" parent="1">
<mxGeometry x="600" y="220" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="menu" value="&lt;b&gt;MENU&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="960" y="220" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="ligne_commande" value="&lt;b&gt;LIGNE_COMMANDE&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="600" y="400" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="commande" value="&lt;b&gt;COMMANDE&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="600" y="540" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="commande_event" value="&lt;b&gt;COMMANDE_EVENT&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="960" y="540" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="user" value="&lt;b&gt;USER&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="120" y="780" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="role" value="&lt;b&gt;ROLE&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="440" y="780" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="role_permission" value="&lt;b&gt;ROLE_PERMISSION&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=13;align=center;verticalAlign=middle;dashed=1" vertex="1" parent="1">
<mxGeometry x="760" y="780" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="permission" value="&lt;b&gt;PERMISSION&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="1080" y="780" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="e1" value="regroupe" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="categorie" target="produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e1a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e1">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e1b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e1">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e2" value="regroupe" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="categorie" target="menu">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e2a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e2">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e2b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e2">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e3" value="fait_partie_de" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="produit" target="menu_produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e3a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e3">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e3b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e3">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e4" value="compose" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="menu" target="menu_produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e4a" value="(1,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e4">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e4b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e4">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e5" value="contient" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="commande" target="ligne_commande">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e5a" value="(1,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e5">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e5b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e5">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e6" value="refere_si_type_produit" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0;dashed=1" edge="1" parent="1" source="ligne_commande" target="produit">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="120" y="425" />
<mxPoint x="120" y="245" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="e6a" value="(0,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e6">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e6b" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e6">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e7" value="refere_si_type_menu" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0;dashed=1" edge="1" parent="1" source="ligne_commande" target="menu">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1280" y="425" />
<mxPoint x="1280" y="245" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="e7a" value="(0,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e7">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e7b" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e7">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e8" value="a_pour_role" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="user" target="role">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e8a" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e8">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e8b" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e8">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e9" value="possede" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="role" target="role_permission">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e9a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e9">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e9b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e9">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e10" value="assignee_a" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="permission" target="role_permission">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e10a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e10">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e10b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e10">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e11" value="journalise" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="commande" target="commande_event">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e11a" value="(1,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e11">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e11b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e11">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e12" value="declenche" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0;dashed=1" edge="1" parent="1" source="user" target="commande_event">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1280" y="805" />
<mxPoint x="1280" y="565" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="e12a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e12">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e12b" value="(0,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e12">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 363 KiB

View file

@ -0,0 +1,61 @@
erDiagram
product {
int id PK
varchar name
}
ingredient {
int id PK
varchar name
varchar unit
int stock_quantity
int stock_capacity
smallint pack_size
varchar pack_label
smallint low_stock_pct
smallint critical_stock_pct
tinyint is_active
}
product_ingredient {
int product_id FK
int ingredient_id FK
smallint quantity_normal
smallint quantity_maxi
tinyint is_removable
tinyint is_addable
int extra_price_cents
}
allergen {
int id PK
varchar code
varchar name
text description
}
ingredient_allergen {
int ingredient_id FK
int allergen_id FK
}
customer_order {
int id PK
varchar order_number
}
user {
int id PK
varchar email
}
stock_movement {
int id PK
int ingredient_id FK
enum movement_type
int delta
int order_id FK
int user_id FK
varchar note
}
product ||--o{ product_ingredient : "is_composed_of"
ingredient ||--o{ product_ingredient : "appears_in"
ingredient ||--o{ ingredient_allergen : "contains"
allergen ||--o{ ingredient_allergen : "is_present_in"
ingredient ||--o{ stock_movement : "decrements"
customer_order |o--o{ stock_movement : "triggers"
user |o--o{ stock_movement : "logs"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 141 KiB

View file

@ -0,0 +1,67 @@
erDiagram
customer_order {
int id PK
varchar order_number
varchar idempotency_key
enum source
int acting_user_id FK
enum service_mode
enum status
int total_ht_cents
int total_vat_cents
int total_ttc_cents
datetime paid_at
datetime delivered_at
datetime cancelled_at
}
order_item {
int id PK
int order_id FK
enum item_type
int product_id FK
int menu_id FK
enum format
varchar label_snapshot
int unit_price_cents_snapshot
smallint vat_rate_snapshot
smallint quantity
}
order_item_selection {
int id PK
int order_item_id FK
int menu_slot_id FK
int product_id FK
varchar label_snapshot
}
order_item_modifier {
int id PK
int order_item_id FK
int ingredient_id FK
enum action
int extra_price_cents
}
product {
int id PK
varchar name
}
menu {
int id PK
varchar name
}
menu_slot {
int id PK
varchar name
}
ingredient {
int id PK
varchar name
}
customer_order ||--o{ order_item : "contains"
order_item }o--o| product : "references_product"
order_item }o--o| menu : "references_menu"
order_item ||--o{ order_item_selection : "fills_slot"
order_item ||--o{ order_item_modifier : "modifies_ingredient"
menu_slot ||--o{ order_item_selection : "slot_filled_by"
product ||--o{ order_item_selection : "chosen_for_slot"
ingredient ||--o{ order_item_modifier : "modified_by"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 156 KiB

View file

@ -1,57 +0,0 @@
<mxfile host="app.diagrams.net" agent="claude-code-byan" type="device">
<diagram name="MCD - RBAC" id="mcd-rbac">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="user" value="&lt;b&gt;USER&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;email : VARCHAR (UNIQUE, RFC 5321)&lt;br&gt;password_hash : VARCHAR (argon2id)&lt;br&gt;nom : VARCHAR&lt;br&gt;prenom : VARCHAR&lt;br&gt;role_id : INT (FK)&lt;br&gt;est_actif : BOOLEAN&lt;br&gt;last_login_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="60" y="80" width="280" height="200" as="geometry" />
</mxCell>
<mxCell id="role" value="&lt;b&gt;ROLE&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;code : VARCHAR (UNIQUE)&lt;br&gt;libelle : VARCHAR&lt;br&gt;description : TEXT&lt;br&gt;est_actif : BOOLEAN" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="440" y="80" width="280" height="160" as="geometry" />
</mxCell>
<mxCell id="permission" value="&lt;b&gt;PERMISSION&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;code : VARCHAR (UNIQUE, resource.action)&lt;br&gt;libelle : VARCHAR&lt;br&gt;description : TEXT" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="820" y="80" width="280" height="160" as="geometry" />
</mxCell>
<mxCell id="role_permission" value="&lt;b&gt;ROLE_PERMISSION&lt;/b&gt; &lt;i&gt;(associative)&lt;/i&gt;&lt;hr&gt;role_id : INT (PK, FK)&lt;br&gt;permission_id : INT (PK, FK)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="630" y="440" width="300" height="120" as="geometry" />
</mxCell>
<mxCell id="e_user_role" value="a_pour_role" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="user" target="role">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_user_role_a" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_user_role">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_user_role_b" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_user_role">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_role_rp" value="possede" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="role" target="role_permission">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_role_rp_a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_role_rp">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_role_rp_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_role_rp">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_perm_rp" value="assignee_a" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="permission" target="role_permission">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_perm_rp_a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_perm_rp">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_perm_rp_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_perm_rp">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View file

@ -0,0 +1,64 @@
erDiagram
user {
int id PK
varchar email
varchar password_hash
varchar pin_hash
varchar first_name
varchar last_name
int role_id FK
tinyint is_active
datetime last_login_at
smallint failed_login_attempts
datetime lockout_until
datetime anonymized_at
}
role {
int id PK
varchar code
varchar label
text description
varchar default_route
enum order_source
tinyint is_active
}
role_visible_source {
int role_id FK
enum source
}
permission {
int id PK
varchar code
varchar label
text description
}
role_permission {
int role_id FK
int permission_id FK
}
audit_log {
int id PK
int actor_user_id FK
int actor_role_id FK
varchar action_code
varchar entity_type
int entity_id
varchar summary
json details
datetime created_at
}
login_throttle {
int id PK
varchar ip_address UK
smallint failed_attempts
datetime window_started_at
datetime lockout_until
datetime last_attempt_at
}
user }o--|| role : "holds"
role ||--o{ role_visible_source : "sees_source"
role ||--o{ role_permission : "grants"
permission ||--o{ role_permission : "granted_to"
user |o--o{ audit_log : "performs"
role |o--o{ audit_log : "context_of"

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 151 KiB

View file

@ -1,59 +0,0 @@
<mxfile host="app.diagrams.net" agent="claude-code-byan" type="device">
<diagram name="MLD - Catalogue" id="mld-catalogue">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="t_categorie" value="&lt;b&gt;categorie&lt;/b&gt;&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;UK libelle : VARCHAR(80)&lt;br&gt;UK slug : VARCHAR(60)&lt;br&gt;image_path : VARCHAR(255) NULL&lt;br&gt;ordre : SMALLINT UNSIGNED DEFAULT 0&lt;br&gt;est_actif : TINYINT(1) DEFAULT 1&lt;br&gt;created_at : DATETIME&lt;br&gt;updated_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="440" y="40" width="300" height="180" as="geometry" />
</mxCell>
<mxCell id="t_produit" value="&lt;b&gt;produit&lt;/b&gt;&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;FK categorie_id : INT UNSIGNED&lt;br&gt;libelle : VARCHAR(120)&lt;br&gt;description : TEXT NULL&lt;br&gt;prix_ttc_cents : INT UNSIGNED (CHECK &gt; 0)&lt;br&gt;image_path : VARCHAR(255) NULL&lt;br&gt;est_disponible : TINYINT(1) DEFAULT 1&lt;br&gt;ordre : SMALLINT UNSIGNED DEFAULT 0&lt;br&gt;created_at : DATETIME&lt;br&gt;updated_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="40" y="280" width="320" height="220" as="geometry" />
</mxCell>
<mxCell id="t_menu" value="&lt;b&gt;menu&lt;/b&gt;&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;FK categorie_id : INT UNSIGNED&lt;br&gt;libelle : VARCHAR(120)&lt;br&gt;description : TEXT NULL&lt;br&gt;prix_ttc_cents : INT UNSIGNED (CHECK &gt; 0)&lt;br&gt;image_path : VARCHAR(255) NULL&lt;br&gt;est_disponible : TINYINT(1) DEFAULT 1&lt;br&gt;ordre : SMALLINT UNSIGNED DEFAULT 0&lt;br&gt;created_at : DATETIME&lt;br&gt;updated_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="820" y="280" width="320" height="220" as="geometry" />
</mxCell>
<mxCell id="t_menu_produit" value="&lt;b&gt;menu_produit&lt;/b&gt; (jointure)&lt;hr&gt;&lt;u&gt;PK FK menu_id : INT UNSIGNED&lt;/u&gt;&lt;br&gt;&lt;u&gt;PK FK produit_id : INT UNSIGNED&lt;/u&gt;&lt;br&gt;role : ENUM(burger,accompagnement,boisson,sauce,dessert)&lt;br&gt;position : SMALLINT UNSIGNED DEFAULT 0" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="400" y="560" width="380" height="130" as="geometry" />
</mxCell>
<mxCell id="fk_prod_cat" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_produit" target="t_categorie">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_prod_cat_lbl" value="FK ON DELETE RESTRICT" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_prod_cat">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_menu_cat" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_menu" target="t_categorie">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_menu_cat_lbl" value="FK ON DELETE RESTRICT" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_menu_cat">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_mp_menu" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_menu_produit" target="t_menu">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_mp_menu_lbl" value="FK ON DELETE CASCADE" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_mp_menu">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_mp_prod" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_menu_produit" target="t_produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_mp_prod_lbl" value="FK ON DELETE RESTRICT" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_mp_prod">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="legende" value="&lt;b&gt;Legende&lt;/b&gt;&lt;br&gt;&lt;u&gt;PK&lt;/u&gt; : cle primaire&lt;br&gt;FK : cle etrangere (fleche -&gt; table referencee)&lt;br&gt;UK : contrainte unique&lt;br&gt;Bleu = table principale&lt;br&gt;Jaune pointille = table de jointure" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;align=left;verticalAlign=top;fontSize=10;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="280" height="130" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View file

@ -1,78 +0,0 @@
<mxfile host="app.diagrams.net" agent="claude-code-byan" type="device">
<diagram name="MLD - Commande" id="mld-commande">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="t_commande" value="&lt;b&gt;commande&lt;/b&gt;&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;UK numero : VARCHAR(20)&lt;br&gt;source : ENUM(kiosk,comptoir,drive)&lt;br&gt;mode_consommation : ENUM(sur_place,a_emporter,drive)&lt;br&gt;statut : ENUM DEFAULT pending_payment&lt;br&gt;total_ht_cents : INT UNSIGNED&lt;br&gt;total_tva_cents : INT UNSIGNED&lt;br&gt;total_ttc_cents : INT UNSIGNED&lt;br&gt;tva_taux_pourmille : SMALLINT UNSIGNED&lt;br&gt;paye_a : DATETIME NULL&lt;br&gt;created_at : DATETIME&lt;br&gt;updated_at : DATETIME&lt;hr&gt;CHECK (source != drive OR mode = drive)&lt;br&gt;CHECK (ttc = ht + tva)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="500" y="40" width="380" height="290" as="geometry" />
</mxCell>
<mxCell id="t_ligne_commande" value="&lt;b&gt;ligne_commande&lt;/b&gt;&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;FK commande_id : INT UNSIGNED&lt;br&gt;type_item : ENUM(produit,menu)&lt;br&gt;FK produit_id : INT UNSIGNED NULL&lt;br&gt;FK menu_id : INT UNSIGNED NULL&lt;br&gt;libelle_snapshot : VARCHAR(120)&lt;br&gt;prix_unitaire_ttc_cents_snapshot : INT UNSIGNED&lt;br&gt;quantite : SMALLINT UNSIGNED DEFAULT 1&lt;br&gt;created_at : DATETIME&lt;hr&gt;CHECK polymorphisme exclusif" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="40" y="400" width="380" height="220" as="geometry" />
</mxCell>
<mxCell id="t_commande_event" value="&lt;b&gt;commande_event&lt;/b&gt; (append-only)&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;FK commande_id : INT UNSIGNED&lt;br&gt;event_type : ENUM(CREATED,PAID,...)&lt;br&gt;from_statut : ENUM NULL&lt;br&gt;to_statut : ENUM&lt;br&gt;FK user_id : INT UNSIGNED NULL&lt;br&gt;payload : JSON NULL&lt;br&gt;created_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="960" y="400" width="380" height="200" as="geometry" />
</mxCell>
<mxCell id="t_produit_stub" value="&lt;b&gt;produit&lt;/b&gt; &lt;i&gt;(cf. Catalogue)&lt;/i&gt;&lt;hr&gt;&lt;u&gt;PK id&lt;/u&gt;&lt;br&gt;..." style="rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="40" y="680" width="200" height="80" as="geometry" />
</mxCell>
<mxCell id="t_menu_stub" value="&lt;b&gt;menu&lt;/b&gt; &lt;i&gt;(cf. Catalogue)&lt;/i&gt;&lt;hr&gt;&lt;u&gt;PK id&lt;/u&gt;&lt;br&gt;..." style="rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="280" y="680" width="200" height="80" as="geometry" />
</mxCell>
<mxCell id="t_user_stub" value="&lt;b&gt;user&lt;/b&gt; &lt;i&gt;(cf. RBAC)&lt;/i&gt;&lt;hr&gt;&lt;u&gt;PK id&lt;/u&gt;&lt;br&gt;..." style="rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="1140" y="680" width="200" height="80" as="geometry" />
</mxCell>
<mxCell id="fk_lc_cmd" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_ligne_commande" target="t_commande">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_lc_cmd_lbl" value="FK ON DELETE CASCADE" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_lc_cmd">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_lc_prod" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1" edge="1" parent="1" source="t_ligne_commande" target="t_produit_stub">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_lc_prod_lbl" value="FK NULL ON DELETE RESTRICT" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_lc_prod">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_lc_menu" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1" edge="1" parent="1" source="t_ligne_commande" target="t_menu_stub">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_lc_menu_lbl" value="FK NULL ON DELETE RESTRICT" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_lc_menu">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_evt_cmd" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_commande_event" target="t_commande">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_evt_cmd_lbl" value="FK ON DELETE CASCADE" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_evt_cmd">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_evt_user" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1" edge="1" parent="1" source="t_commande_event" target="t_user_stub">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_evt_user_lbl" value="FK NULL ON DELETE SET NULL" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_evt_user">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="note_audit" value="&lt;b&gt;Journal d'audit (event sourcing)&lt;/b&gt;&lt;br&gt;Append-only : aucun UPDATE / DELETE applicatif.&lt;br&gt;3 IDX : (commande_id, created_at), (user_id, created_at), (event_type, created_at).&lt;br&gt;Pattern d'ecriture : transaction qui modifie commande.statut insere aussi une ligne d'event." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=10;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="960" y="620" width="380" height="80" as="geometry" />
</mxCell>
<mxCell id="legende" value="&lt;b&gt;Legende&lt;/b&gt;&lt;br&gt;&lt;u&gt;PK&lt;/u&gt; : cle primaire&lt;br&gt;FK : cle etrangere (fleche -&gt; table referencee)&lt;br&gt;UK : contrainte unique&lt;br&gt;Bleu = table principale&lt;br&gt;Vert = journal d'audit&lt;br&gt;Violet = stub d'un autre sous-domaine&lt;br&gt;Pointille = FK nullable" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;align=left;verticalAlign=top;fontSize=10;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="280" height="150" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View file

@ -1,56 +0,0 @@
<mxfile host="app.diagrams.net" agent="claude-code-byan" type="device">
<diagram name="MLD - RBAC" id="mld-rbac">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="t_user" value="&lt;b&gt;user&lt;/b&gt;&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;UK email : VARCHAR(254)&lt;br&gt;password_hash : VARCHAR(255)&lt;br&gt;nom : VARCHAR(60)&lt;br&gt;prenom : VARCHAR(60)&lt;br&gt;FK role_id : INT UNSIGNED&lt;br&gt;est_actif : TINYINT(1) DEFAULT 1&lt;br&gt;last_login_at : DATETIME NULL&lt;br&gt;created_at : DATETIME&lt;br&gt;updated_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="40" y="60" width="320" height="220" as="geometry" />
</mxCell>
<mxCell id="t_role" value="&lt;b&gt;role&lt;/b&gt;&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;UK code : VARCHAR(40)&lt;br&gt;libelle : VARCHAR(80)&lt;br&gt;description : TEXT NULL&lt;br&gt;est_actif : TINYINT(1) DEFAULT 1&lt;br&gt;created_at : DATETIME&lt;br&gt;updated_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="440" y="60" width="320" height="160" as="geometry" />
</mxCell>
<mxCell id="t_permission" value="&lt;b&gt;permission&lt;/b&gt;&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;UK code : VARCHAR(60) format resource.action&lt;br&gt;libelle : VARCHAR(120)&lt;br&gt;description : TEXT NULL&lt;br&gt;created_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="840" y="60" width="320" height="140" as="geometry" />
</mxCell>
<mxCell id="t_role_permission" value="&lt;b&gt;role_permission&lt;/b&gt; (jointure)&lt;hr&gt;&lt;u&gt;PK FK role_id : INT UNSIGNED&lt;/u&gt;&lt;br&gt;&lt;u&gt;PK FK permission_id : INT UNSIGNED&lt;/u&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="600" y="380" width="320" height="100" as="geometry" />
</mxCell>
<mxCell id="fk_user_role" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_user" target="t_role">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_user_role_lbl" value="FK ON DELETE RESTRICT" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_user_role">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_rp_role" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_role_permission" target="t_role">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_rp_role_lbl" value="FK ON DELETE CASCADE" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_rp_role">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_rp_perm" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_role_permission" target="t_permission">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_rp_perm_lbl" value="FK ON DELETE CASCADE" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_rp_perm">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="note_rbac" value="&lt;b&gt;Modele RBAC&lt;/b&gt;&lt;br&gt;Roles : dynamiques (CRUD admin UI), table principale role.&lt;br&gt;Permissions : statiques (declarees en migration), pas d'updated_at.&lt;br&gt;Mapping role-permission : matrice editable depuis l'UI admin.&lt;br&gt;Voir docs/notes/rbac-roles-permissions.md." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=10;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="40" y="380" width="500" height="100" as="geometry" />
</mxCell>
<mxCell id="legende" value="&lt;b&gt;Legende&lt;/b&gt;&lt;br&gt;&lt;u&gt;PK&lt;/u&gt; : cle primaire (composite si plusieurs lignes soulignees)&lt;br&gt;FK : cle etrangere (fleche -&gt; table referencee)&lt;br&gt;UK : contrainte unique&lt;br&gt;Bleu = table principale&lt;br&gt;Jaune pointille = table de jointure" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;align=left;verticalAlign=top;fontSize=10;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="40" y="540" width="500" height="120" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View file

@ -1,10 +1,10 @@
# Data Dictionary — Wakdo
**Merise phase** : P1 - Conception, step 1 (data dictionary first, mantra #33)
**Version** : v0.2 — prod-like, 19 entities
**Date** : 2026-06-04
**Version** : v0.2 — prod-like, 21 entities (19 prod-like + security-by-design layer, incl. the new `login_throttle` entity)
**Date** : 2026-06-04 (security-by-design additions 2026-06-11)
**Branch** : `feat/p1-conception`
**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7)
**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer in progress (see note 13)
**Author** : BYAN (methodology layer)
---
@ -193,21 +193,32 @@ Elementary ingredient used in product composition. Carries stock data.
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `name` | VARCHAR(120) | NO | — | UNIQUE | e.g., "Sesame Bun", "Cheddar Slice", "Ketchup Portion" |
| `unit` | VARCHAR(40) | NO | — | — | packaging unit label: piece / portion / sachet 1kg / pot / bottle (free-form label, not an ENUM — units vary per ingredient) |
| `stock_quantity` | INT | NO | 0 | CHECK >= 0 | current stock in units. Signed INT to allow negative detection (alert), but business rule enforces >= 0 |
| `stock_quantity` | INT (signed) | NO | 0 | — | current stock in units. Signed INT with no `CHECK >= 0`: it MAY go negative when sales outrun counted stock (oversell magnitude, surfaced to managers). The system does not block an order on stock. |
| `stock_capacity` | INT | NO | — | CHECK > 0 | reference "full" level in units = the 100% used to compute the stock percentage. The `CHECK > 0` also guards the percentage division against divide-by-zero |
| `pack_size` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | units per restocking pack (e.g., 100 for a bag of 100 portions) |
| `pack_label` | VARCHAR(80) | YES | NULL | — | human label of the pack (e.g., "Sac 100 portions") |
| `low_stock_threshold` | SMALLINT UNSIGNED | NO | 0 | CHECK >= 0 | alert threshold: stock_quantity <= this value triggers low-stock indicator |
| `low_stock_pct` | SMALLINT UNSIGNED | NO | 10 | CHECK BETWEEN 0 AND 100 | warning band, percent of capacity: `stock_quantity <= stock_capacity * low_stock_pct/100` triggers the low-stock indicator |
| `critical_stock_pct` | SMALLINT UNSIGNED | NO | 5 | CHECK BETWEEN 0 AND 100 | auto-out-of-stock floor, percent of capacity: `stock_quantity <= stock_capacity * critical_stock_pct/100` makes the product computed out-of-stock |
| `is_active` | TINYINT(1) | NO | 1 | — | deactivate obsolete ingredients without deleting |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit |
| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit |
**Table-level CHECK**: `critical_stock_pct < low_stock_pct` (the critical floor sits below the warning band).
**Stock decrement rule**: at the `paid` transition, each ingredient is decremented by
`product_ingredient.quantity_normal` or `quantity_maxi` (selected by `order_item.format`)
multiplied by `order_item.quantity`, then adjusted by `order_item_modifier` rows. See note 7.
**Restocking rule**: `stock_quantity += N * pack_size` (restocked in full packs).
**Cancellation rule**: stock is re-credited when a `paid` order is cancelled.
**Low-stock alert**: computed at display time (`stock_quantity <= low_stock_threshold`);
no additional stored column.
**Stock model (percentage-based, three bands)**: the absolute alert threshold is replaced by a
percentage model anchored on `stock_capacity` (the 100% reference). The stock percentage is
computed, not stored: `stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. The
`CHECK > 0` on `stock_capacity` guards this division against divide-by-zero. Three bands:
- **Normal** — above the low band: nothing flagged.
- **Low**`stock_quantity <= stock_capacity * low_stock_pct/100`: orderable + manager alert.
The manager either pulls the product via `product.is_available=0`, or restocks to clear the alert.
- **Critical**`stock_quantity <= stock_capacity * critical_stock_pct/100`: the product
auto-goes out-of-stock (computed availability, see rule RG-T21 in `mlt.md`); no extra stored column.
---
@ -271,7 +282,9 @@ Customer transaction: 1 order = 1 validated cart at a point in time.
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `order_number` | VARCHAR(20) | NO | — | UNIQUE | human-readable format: `K`/`C`/`D`-YYYY-MM-DD-NNN. Prefix by channel: K=kiosk, C=counter, D=drive. See note 4. |
| `idempotency_key` | VARCHAR(36) | YES | NULL | UNIQUE | client-generated UUID to deduplicate a retried `POST /api/orders` (anti-double-charge). UNIQUE rejects duplicates; multiple NULLs allowed. Security-by-design, see note 13 |
| `source` | ENUM('kiosk','counter','drive') | NO | — | INDEX | input channel (who entered the order). Values in English, see note 5. |
| `acting_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | back-office staff (counter/drive) who created the order, captured under PIN. NULL for `kiosk` (anonymous). Targeted accountability without forcing per-person login on the kiosk. See note 13 |
| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | — | — | consumption mode, retained for stats/KPI only. No fiscal role (see note 9). `drive` source implies `drive` service_mode (cross-constraint enforced at app layer). |
| `status` | ENUM('pending_payment','paid','delivered','cancelled') | NO | 'pending_payment' | INDEX | 4-state machine: `pending_payment -> paid -> delivered` (+ `cancelled`). See note 6. |
| `total_ht_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | ex-VAT total, snapshot at order validation |
@ -384,6 +397,13 @@ are not authenticated and have no row here.
| `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE RESTRICT | a user cannot exist without a role |
| `is_active` | TINYINT(1) | NO | 1 | — | deactivation without deletion |
| `last_login_at` | DATETIME | YES | NULL | — | useful for audit and dormant account detection |
| `pin_hash` | VARCHAR(255) | YES | NULL | — | argon2id hash of the per-staff PIN that authorises sensitive actions (price/RBAC/user/cancel/inventory). NULL = no PIN set. Security-by-design, see note 13 |
| `failed_login_attempts` | SMALLINT UNSIGNED | NO | 0 | — | consecutive failed logins; drives degressive throttling (note 13) |
| `last_failed_login_at` | DATETIME | YES | NULL | — | timestamp of the last failed login |
| `lockout_until` | DATETIME | YES | NULL | — | end of the current throttling window (degressive backoff, not a hard indefinite lock) |
| `password_reset_token_hash` | VARCHAR(255) | YES | NULL | — | hash of the reset token (not the raw token); NULL when no reset pending |
| `password_reset_expires_at` | DATETIME | YES | NULL | — | expiry of the reset token |
| `anonymized_at` | DATETIME | YES | NULL | — | RGPD tombstone marker: when set, PII columns are nulled/replaced (note 13). The row is kept for referential integrity |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit |
| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit |
@ -392,6 +412,10 @@ are not authenticated and have no row here.
RFC 5321 email length: local-part <= 64, domain <= 255, total <= 254 (including `@`).
VARCHAR(254) is the spec-compliant value.
**PII columns**: `email`, `first_name`, `last_name`. Subject to RGPD anonymisation
(see note 13). `password_hash` and `pin_hash` are credentials, kept out of logs and
API responses.
---
### 3.15 `role`
@ -539,6 +563,59 @@ Append-only audit log of all stock changes per ingredient.
---
### 3.20 `audit_log`
Append-only log of **sensitive back-office actions**, for accountability where it matters
(insider threat, money handling, RBAC changes). Complements `stock_movement` (which is
stock-specific); covers catalogue/price, user, role/permission, and order cancellation events.
Security-by-design addition (see note 13).
| Attribute | Type | NULL | Default | Constraint | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `actor_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | staff who performed the action, captured via PIN for sensitive operations. NULL if not attributable to an individual |
| `actor_role_id` | INT UNSIGNED | YES | NULL | FK -> `role(id)`, ON DELETE SET NULL | role context at action time (denormalised so the trail survives user anonymisation) |
| `action_code` | VARCHAR(60) | NO | — | INDEX | MCT operation / permission code, e.g. `product.update`, `order.cancel`, `role.manage`, `user.deactivate` |
| `entity_type` | VARCHAR(40) | YES | NULL | — | affected table name, e.g. `product`, `customer_order`, `role`, `user` |
| `entity_id` | INT UNSIGNED | YES | NULL | — | PK of the affected row |
| `summary` | VARCHAR(255) | YES | NULL | — | short non-personal description, e.g. "price_cents 880 -> 920", "added permission stock.manage" |
| `details` | JSON | YES | NULL | — | optional before/after diff. For user-targeted actions, stores changed **field names**, not PII values |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | immutable timestamp |
**Immutability**: no UPDATE or DELETE at application layer (same discipline as `stock_movement`).
**Indexes**: `(actor_user_id, created_at)`, `(entity_type, entity_id)`, `(action_code, created_at)`.
**Retention**: own window (~12 months, legitimate-interest / fiscal traceability), decoupled
from user PII lifecycle (note 13). A scheduled purge (cron) removes rows past the window.
**Logged operations** (sensitive set): `UPDATE_PRODUCT` (8.2, incl. price), `DELETE_PRODUCT`
(8.3), `DELETE_MENU` (8.6), `CANCEL_ORDER` (7.1), `RESTOCK` (9.1), `INVENTORY_COUNT` (9.2),
`CREATE_USER` / `UPDATE_USER` / `DEACTIVATE_USER` (10.1-10.3), `MANAGE_RBAC` (10.4).
**Volume**: low (~10-50 sensitive actions/day) — orders of magnitude below `stock_movement`.
---
### 3.21 `login_throttle`
Per-source-IP brute-force throttle. Complements the per-account counter already on `user`
(`failed_login_attempts` / `lockout_until`), one row per source IP. Security-by-design addition
(see note 13).
| Attribute | Type | NULL | Default | Constraint | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `ip_address` | VARCHAR(45) | NO | — | UNIQUE | source IP, one row per IP, upserted; 45 chars holds a full IPv6 literal |
| `failed_attempts` | SMALLINT UNSIGNED | NO | 0 | — | consecutive failed logins from this IP in the current window |
| `window_started_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | start of the current counting window |
| `lockout_until` | DATETIME | YES | NULL | — | end of the degressive backoff window; NULL = not throttled |
| `last_attempt_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | timestamp of the last failed attempt |
**No FK**: an IP is not a modelled entity. Rows are appended/upserted by IP; the window resets
when expired. A daily cron purges rows with no active lockout whose `last_attempt_at` is older
than 24h.
---
## 4. Modeling notes
### Note 1 — Why `INT UNSIGNED` in cents for prices
@ -748,6 +825,62 @@ The 4-state machine combined with 3 phase timestamps provides all KPI data neede
For stock audit, `stock_movement` (entity 3.19) provides the append-only audit trail
where it is genuinely needed (inventory reconciliation).
### Note 13 — Security-by-design data additions (2026-06-11)
These additions extend the prod-like model with a security-by-design layer. They do not
replace any v0.2 decision; they add accountability, auth lifecycle, and abuse resistance.
**Accountability — hybrid shared-account + PIN.** Back-office sessions stay shared per
workstation for the routine flow (a fast-food terminal is shared, `equipiers` rotate). A
per-staff PIN (`user.pin_hash`, argon2id) authorises a defined set of **sensitive actions**
(price/menu edits 8.2/8.3/8.6, order cancellation 7.1, inventory correction 9.2, user
management 10.1-10.3, RBAC 10.4). Those actions write the acting `user_id` into `audit_log`
(3.20). This resolves the circular justification that dropped `commande_event` in v0.1
(events were considered useless because accounts were shared): accountability is recorded
where it matters, at near-zero friction for the routine 95%. `customer_order.acting_user_id`
captures the staff for counter/drive orders taken under PIN; kiosk orders stay anonymous.
**Auth lifecycle.** `password_reset_token_hash` + `password_reset_expires_at` enable a reset
path (the token is stored hashed, the raw token is e-mailed once). Brute-force resistance uses
degressive throttling rather than a hard indefinite lock: `failed_login_attempts` +
`lockout_until` implement an exponential backoff per (account + source IP), so a fat-finger
streak does not lock out a whole kitchen mid-service (15 h continuous). Failed logins are
written to `audit_log`.
**RGPD anonymisation vs audit retention.** `user` PII (`email`, `first_name`, `last_name`)
is subject to the right to erasure (Cr 3.d). Erasure **anonymises** rather than hard-deletes:
the row is kept, `email` becomes a non-identifying unique placeholder (`anon-<id>@wakdo.invalid`,
RFC 2606 reserved domain), names are cleared, `password_hash`/`pin_hash` are invalidated, and
`anonymized_at` is set. The `audit_log` retains its own retention window (~12 months,
legitimate-interest / fiscal traceability) and keeps pointing at the anonymised principal, so
erasure and accountability coexist without breaking referential integrity.
**Abuse resistance on the anonymous kiosk.** `customer_order.idempotency_key` (client UUID,
UNIQUE) deduplicates a retried `POST /api/orders` so a network retry does not create a
duplicate paid order. Stock is decremented with a single atomic statement
(`UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id`): no operation
gates on a stock read, so the row self-locks for the duration of the write — no lost update and
no deadlock-ordering concern. This replaces the earlier pessimistic `SELECT ... FOR UPDATE`
approach (treatment-layer rule, see `mlt.md`); it adds no column here.
**Percentage stock model + computed availability.** `ingredient` carries `stock_capacity` (the
100% reference), `low_stock_pct` (warning band) and `critical_stock_pct` (auto-out-of-stock
floor) — see 3.6. `stock_quantity` is signed and may go negative (oversell magnitude surfaced to
managers); the system does not block an order on stock. Effective product orderability is
computed (rule RG-T21 in `mlt.md`): `product.is_available = 1` AND each non-removable
(`is_removable=0`) ingredient of its `product_ingredient` has
`stock_quantity > stock_capacity * critical_stock_pct/100`. At the critical band a product
auto-goes out-of-stock with no write and no cascade; a manual pull (`product.is_available=0`) is
a hard override; restock above the critical band makes the product orderable again on its own.
**Per-IP brute-force throttle.** `login_throttle` (3.21) tracks `failed_attempts` and
`lockout_until` per source IP (one upserted row per IP), complementing the per-account counter
on `user`. This adds a second throttling dimension so a single IP hammering many accounts is
slowed independently of any one account's counter. A daily cron purges idle, non-locked rows.
References: `docs/notes/revue-alignement-p1.md` §7 (D-decisions), security-by-design impact
map (2026-06-11). Threat model and data-classification matrix: `PROJECT_CONTEXT.md` §19 (to come).
---
## 5. Entity count summary
@ -773,11 +906,20 @@ where it is genuinely needed (inventory reconciliation).
| 17 | `permission` | reference | v0.1 `permission` (translated, catalogue frozen) |
| 18 | `role_permission` | join | v0.1 `role_permission` (unchanged) |
| 19 | `stock_movement` | audit | new — append-only stock audit log |
| 20 | `audit_log` | audit | new (security-by-design) — append-only sensitive-action log |
| 21 | `login_throttle` | security | new (security-by-design) - per-IP brute-force throttle |
**Dropped from v0.1**: `commande_event` (replaced by phase timestamps on `customer_order`),
`menu_produit` (replaced by `menu_slot` + `menu_slot_option` model).
**Total: 19 entities.**
**Total: 21 entities** (19 prod-like v0.2 + `audit_log` and `login_throttle` from the
security-by-design layer).
Security-by-design also adds columns (beyond the two new entities): `user` auth-lifecycle +
`pin_hash` + `anonymized_at` (3.14), `customer_order.acting_user_id` + `idempotency_key` (3.10),
and the percentage stock model on `ingredient` (3.6) — `stock_capacity`, `critical_stock_pct`,
plus the rename of `low_stock_threshold` to `low_stock_pct`. `login_throttle` (3.21) is the 21st
entity. See note 13.
---

View file

@ -1,10 +1,10 @@
# Conceptual Data Model (MCD) — Wakdo
**Merise phase** : P1 - Conception, step 2 (data dictionary first, mantra #33)
**Version** : v0.2 — prod-like, 19 entities
**Date** : 2026-06-04
**Version** : v0.2 — prod-like, 21 entities (19 prod-like + security-by-design layer)
**Date** : 2026-06-04 (security-by-design additions 2026-06-11)
**Branch** : `feat/p1-conception`
**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7)
**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer (audit_log + accountability/auth columns) in progress
**Author** : BYAN (methodology layer)
---
@ -21,7 +21,7 @@ structure: how many X per Y, whether participation is mandatory, whether associa
their own attributes.
**Sources**:
- `docs/merise/dictionary.md` (v0.2 — 19 entities, source of truth for all names, types, ENUMs)
- `docs/merise/dictionary.md` (v0.2 — 21 entities, source of truth for all names, types, ENUMs)
- `docs/notes/revue-alignement-p1.md` §7 (decision table D1-D8 + stock)
- `docs/PROJECT_CONTEXT.md` (business rules: menu composition, order flow, RBAC, service modes)
- `docs/merise/_sources/` (school data: 9 categories, 53 products, 13 menus)
@ -62,7 +62,7 @@ N-N associations that carry their own attributes become **associative entities**
## 3. Decomposition by sub-domain
The 19-entity model is split into 4 sub-domains for readability. Beyond approximately
The 21-entity model is split into 4 sub-domains for readability. Beyond approximately
5 entities, a single flat diagram becomes difficult to read; decomposition is the standard
Merise practice for models of this size.
@ -71,12 +71,21 @@ Merise practice for models of this size.
| Catalogue | category, product, menu, menu_slot, menu_slot_option | 5 |
| Ingredients & Stock | ingredient, product_ingredient, allergen, ingredient_allergen, stock_movement | 5 |
| Order | customer_order, order_item, order_item_selection, order_item_modifier | 4 |
| RBAC | user, role, role_visible_source, permission, role_permission | 5 |
| RBAC & Audit | user, role, role_visible_source, permission, role_permission, audit_log, login_throttle | 7 |
**Note on the absence of a global diagram**: a single 19-entity ER diagram would be
> **Security-by-design layer (2026-06-11)**: `audit_log` (entity 20) is a cross-cutting,
> append-only log of sensitive actions; it is placed in the RBAC & Audit sub-domain because
> its references (`actor_user_id`, `actor_role_id`) are RBAC entities. `login_throttle`
> (entity 21) is a per-source-IP brute-force throttle, keyed by IP and carrying no FK; it sits
> in the same sub-domain because it guards the authentication path. New columns on existing
> entities: `user` auth-lifecycle + `pin_hash` + `anonymized_at`, `customer_order.acting_user_id`
> + `idempotency_key`. See dictionary note 13.
**Note on the absence of a global diagram**: a single 21-entity ER diagram would be
unreadable and unmaintainable. The sub-domain decomposition below is the intentional
structural choice. The `.drawio` source files will be regenerated from this document as the
single reference once the MCD is stabilised (regeneration tracked in `docs/notes/`).
structural choice. Each sub-domain is a Mermaid `erDiagram` (authoritative, rendered
natively) with a portable SVG render in `docs/merise/_diagrams/`; see section 11 for the
sources and regeneration command.
---
@ -174,15 +183,18 @@ erDiagram
varchar name
varchar unit
int stock_quantity
int stock_capacity
smallint pack_size
varchar pack_label
smallint low_stock_threshold
smallint low_stock_pct
smallint critical_stock_pct
tinyint is_active
}
product_ingredient {
int product_id FK
int ingredient_id FK
smallint quantity
smallint quantity_normal
smallint quantity_maxi
tinyint is_removable
tinyint is_addable
int extra_price_cents
@ -238,13 +250,15 @@ erDiagram
### 5.3 Notes on the Ingredients & Stock sub-domain
**`product_ingredient` as an associative entity**: the N-N association between `product` and `ingredient` carries four attributes (`quantity`, `is_removable`, `is_addable`, `extra_price_cents`). It becomes a join table in the MLD with composite PK `(product_id, ingredient_id)`.
**`product_ingredient` as an associative entity**: the N-N association between `product` and `ingredient` carries five attributes (`quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`). It becomes a join table in the MLD with composite PK `(product_id, ingredient_id)`.
**`ingredient_allergen` as a pure join table**: no own attributes. The allergen set for a product is computed at query time by joining `product_ingredient -> ingredient_allergen -> allergen`; no manual per-product entry is needed.
**`stock_movement` immutability**: this table is append-only. No UPDATE or DELETE is permitted at application layer. Corrections are new rows with `movement_type = 'inventory_correction'` and a signed `delta`.
**Low-stock alert**: computed at display time (`stock_quantity <= low_stock_threshold`); no additional stored column.
**Percentage-based stock model**: stock health is anchored on a per-ingredient `stock_capacity` (the 100% reference, `CHECK > 0`). `stock_quantity` is signed and may go negative when sales outrun counted stock; the system does not block an order on a low stock read. `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` is computed, not stored. Two percentage thresholds drive a three-band behaviour: `low_stock_pct` (warning band, default 10%) and `critical_stock_pct` (auto-out-of-stock floor, default 5%), with the table-level invariant `critical_stock_pct < low_stock_pct`. Above the low band is normal; between critical and low the product stays orderable and a manager alert is raised (the manager either pulls the product via `product.is_available = 0` or restocks to clear the alert); at or below the critical band the product auto-goes out-of-stock (computed, see below).
**Computed product availability (rule RG-T21, see `mlt.md`)**: effective orderability is derived, not stored. A product is orderable when `product.is_available = 1` AND each non-removable (`is_removable = 0`) ingredient in its `product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct / 100`. A required ingredient reaching the critical band makes the product auto-out-of-stock with no write and no cascade; a manual pull (`product.is_available = 0`) is a hard override; restock above the critical band makes the product orderable again on its own. A removable/optional ingredient at the critical band does not block the product (only its add-on becomes unavailable). The dashboard distinguishes a manual pull from a stock-driven OOS.
---
@ -257,7 +271,9 @@ erDiagram
customer_order {
int id PK
varchar order_number
varchar idempotency_key
enum source
int acting_user_id FK
enum service_mode
enum status
int total_ht_cents
@ -358,6 +374,12 @@ the MLD).
`preparing` and `ready` are dropped (decision D4, `revue-alignement-p1.md` §7). KPI timing is
`delivered_at - paid_at`; KDS colour coding is computed from `NOW() - paid_at`.
**Security-by-design columns (2026-06-11)**: `idempotency_key` (client UUID, UNIQUE)
deduplicates a retried `POST /api/orders`. `acting_user_id` (FK -> `user`, ON DELETE SET NULL)
records the counter/drive staff who took the order under PIN; NULL for anonymous kiosk orders.
This adds a `customer_order |o--o| user : "taken_by"` association (cardinality: an order is
taken by (0,1) user; a user takes (0,N) orders). See dictionary note 13.
---
## 7. Sub-domain: RBAC
@ -370,11 +392,15 @@ erDiagram
int id PK
varchar email
varchar password_hash
varchar pin_hash
varchar first_name
varchar last_name
int role_id FK
tinyint is_active
datetime last_login_at
smallint failed_login_attempts
datetime lockout_until
datetime anonymized_at
}
role {
int id PK
@ -399,13 +425,38 @@ erDiagram
int role_id FK
int permission_id FK
}
audit_log {
int id PK
int actor_user_id FK
int actor_role_id FK
varchar action_code
varchar entity_type
int entity_id
varchar summary
json details
datetime created_at
}
login_throttle {
int id PK
varchar ip_address UK
smallint failed_attempts
datetime window_started_at
datetime lockout_until
datetime last_attempt_at
}
user }o--|| role : "holds"
role ||--o{ role_visible_source : "sees_source"
role ||--o{ role_permission : "grants"
permission ||--o{ role_permission : "granted_to"
user |o--o{ audit_log : "performs"
role |o--o{ audit_log : "context_of"
```
> `login_throttle` is a standalone entity with no association: it is keyed by source IP
> (`ip_address UNIQUE`), not by a modelled actor, so it carries no FK and connects to no
> other entity in the diagram.
### 7.2 Association cardinalities
| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification |
@ -414,6 +465,8 @@ erDiagram
| R2 | sees_source | role | (0,N) | role_visible_source | (1,1) | A role may see 0 or more order sources on the preparation dashboard (admin/manager use a global view with no source filter). Each visibility row belongs to exactly one role. |
| R3 | grants | role | (0,N) | role_permission | (1,1) | A role may have no permissions (a newly created role before assignment) or many. Each mapping row belongs to one role. |
| R4 | granted_to | permission | (0,N) | role_permission | (1,1) | A permission may be granted to no roles yet (declared at seed, not yet distributed) or to several. Each mapping row references one permission. |
| R5 | performs | user | (0,1) | audit_log | (0,N) | A sensitive action captured under PIN records its acting user; automated/non-attributable entries carry NULL. A user may have logged any number of actions. ON DELETE SET NULL preserves the trail on user anonymisation/removal. |
| R6 | context_of | role | (0,1) | audit_log | (0,N) | Each audit row may denormalise the actor's role at action time (NULL allowed). A role may be the context of many audit rows. ON DELETE SET NULL preserves the trail. |
### 7.3 Notes on the RBAC sub-domain
@ -430,11 +483,28 @@ erDiagram
**Seed roles** (5 roles, frozen at DDL; extendable without code change):
`admin`, `manager`, `kitchen`, `counter`, `drive`.
**`audit_log` (security-by-design)**: append-only log of sensitive actions, immutable like
`stock_movement`. Both FKs (`actor_user_id`, `actor_role_id`) are nullable with ON DELETE
SET NULL, so the trail survives user anonymisation (RGPD) and role removal. The `actor_role_id`
is denormalised on purpose: even if the user is later anonymised, the role context of the
action is preserved. It carries no PII (the `details` JSON stores changed field names, not
values for user-targeted actions). See dictionary 3.20 and note 13.
**`login_throttle` (security-by-design)**: per-source-IP brute-force throttle, complementing
the per-account counter already on `user` (`failed_login_attempts` / `lockout_until`). One row
per IP (`ip_address VARCHAR(45) UNIQUE`, 45 chars to hold a full IPv6 literal), upserted on each
failed login: `failed_attempts` counts consecutive failures from this IP in the current window,
`window_started_at` marks the start of that window (which resets when expired), `lockout_until`
holds the end of the degressive backoff (NULL = not throttled), `last_attempt_at` the timestamp
of the last failed attempt. It has no FK (an IP is not a modelled entity) and no association. A
daily cron purges rows with no active lockout whose `last_attempt_at` is older than 24h. See
dictionary 3.21 and note 13.
---
## 8. Cross-validation MCD <-> dictionary
Verification that all 19 dictionary entities appear in the MCD and vice versa.
Verification that all 21 dictionary entities appear in the MCD and vice versa.
| # | Dictionary entity (section 3) | Sub-domain in MCD | Present |
|---|---|---|---|
@ -457,17 +527,21 @@ Verification that all 19 dictionary entities appear in the MCD and vice versa.
| 17 | `permission` (3.17) | RBAC | Yes |
| 18 | `role_permission` (3.18) | RBAC | Yes |
| 19 | `stock_movement` (3.19) | Ingredients & Stock | Yes |
| 20 | `audit_log` (3.20) | RBAC & Audit | Yes |
| 21 | `login_throttle` (3.21) | RBAC & Audit | Yes |
**Result**: 19/19 entities traced. No entity from the dictionary is absent from the MCD.
No entity in the MCD falls outside the dictionary.
**Result**: 21/21 entities traced (19 prod-like + `audit_log` and `login_throttle`
security-by-design). No entity from the dictionary is absent from the MCD. No entity in the MCD
falls outside the dictionary.
**Entities appearing in multiple sub-domains** (cross-domain shared entities):
- `product`: Catalogue (sold item, slot eligibility) + Ingredients (recipe) + Order (line reference, slot choice)
- `menu`: Catalogue (definition, slots) + Order (line reference)
- `menu_slot`: Catalogue (slot definition) + Order (slot choices via `order_item_selection`)
- `ingredient`: Ingredients (recipe, stock) + Order (modifiers)
- `customer_order`: Order (order lifecycle) + Ingredients (stock movement trigger)
- `user`: RBAC (authentication) + Ingredients (stock movement author)
- `customer_order`: Order (order lifecycle) + Ingredients (stock movement trigger) + RBAC & Audit (taken_by staff via `acting_user_id`)
- `user`: RBAC (authentication) + Ingredients (stock movement author) + Order (`acting_user_id` on counter/drive orders) + Audit (actor of `audit_log`)
- `role`: RBAC (permissions, visible sources) + Audit (denormalised `actor_role_id` context on `audit_log`)
This is expected in a normalised model. The sub-domain split is for readability; the actual
relational schema is a unified graph.
@ -518,16 +592,36 @@ Pre-validation: each entity participates in at least one treatment.
| `permission` | Admin permission matrix management |
| `role_permission` | Admin permission matrix management |
| `stock_movement` | Automatic at `paid` transition; manual restock and inventory correction |
| `audit_log` | Written by sensitive operations: UPDATE/DELETE product/menu (8.2/8.3/8.6), CANCEL_ORDER (7.1), RESTOCK/INVENTORY_COUNT (9.1/9.2), user ops (10.1-10.3), MANAGE_RBAC (10.4), and failed/successful logins (12.1) |
| `login_throttle` | Read and written by AUTHENTICATE_USER (12.1): per-source-IP throttle upserted on each failed login, read to enforce the backoff window, purged by a daily cron |
Cross-validation MCD <-> MCT (mantra #34) to be completed exhaustively in `mct.md`
once the MCT is updated to the 4-state machine and 19-entity model.
once the MCT incorporates the security-by-design operations (PIN-gated sensitive actions,
audit writes, reset/lockout, anonymisation). The treatment-layer additions are tracked there.
---
## 11. Note on .drawio diagram regeneration
## 11. Diagram sources and regeneration
The `.drawio` XML sources in `docs/merise/_diagrams/` reflect the v0.1 model (11 entities,
French naming). They are scheduled for regeneration from this v0.2 MCD as a separate task.
Until regenerated, this Markdown document is the authoritative conceptual model. The Mermaid
`erDiagram` blocks in sections 4-7 render natively on GitHub and serve as the interim
graphical reference.
The authoritative graphical model is the set of Mermaid `erDiagram` blocks in sections 4-7,
one per sub-domain. They render natively on Forgejo and GitHub. The MCD is decomposed by
sub-domain on purpose: a single 21-entity diagram cannot be laid out without crossing
relationship lines (intrinsic planarity limit, and `erDiagram` offers no manual layout
control). Each sub-domain stays at 5-8 entities, which auto-layout handles cleanly. The
integrated view across sub-domains is the cross-validation table in section 8.
Portable SVG renders live in `docs/merise/_diagrams/` (for PDF export / offline viewing):
| Sub-domain | Source | Render |
|---|---|---|
| Catalogue | `mcd-catalogue.mmd` | `mcd-catalogue.svg` |
| Ingredients & Stock | `mcd-ingredients-stock.mmd` | `mcd-ingredients-stock.svg` |
| Order | `mcd-order.mmd` | `mcd-order.svg` |
| RBAC | `mcd-rbac.mmd` | `mcd-rbac.svg` |
The `.mmd` files are extracted from the `erDiagram` blocks above; the `.svg` are produced by
`make docs-render` (mmdc). If a block here changes, re-extract the matching `.mmd` and re-run
`make docs-render`. The legacy v0.1 `.drawio` sources have been removed: drawio gave manual
layout control but required hand-editing and did not render in the Markdown previews, whereas
the decomposed Mermaid blocks are version-controlled, render everywhere, and stay in sync with
this document.

View file

@ -1,10 +1,10 @@
# Model of Conceptual Treatments (MCT) — Wakdo
**Merise phase** : P1 - Conception, step 3 (after MCD)
**Version** : v0.2 — prod-like, 4-state machine
**Date** : 2026-06-04
**Version** : v0.2 — prod-like, 4-state machine (+ security-by-design layer 2026-06-11)
**Date** : 2026-06-04 (security-by-design additions 2026-06-11)
**Branch** : `feat/p1-conception`
**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7)
**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design ops added (ERASE_USER_PII, RESET_PASSWORD, PIN-gated sensitive set, audit_log writes, auth throttling) — 28 operations
**Author** : BYAN (methodology layer)
---
@ -56,6 +56,17 @@ the ticket and act. The single staff gesture is "deliver". KPI is total time
and `MARK_READY` (`MARQUER_PRETE`) are removed because their intermediate states no longer
exist. `DELIVER_ORDER` becomes the sole status-advancing action for counter/drive staff.
**Security-by-design layer (2026-06-11)**: two operations are added — `RESET_PASSWORD` (12.3)
and `ERASE_USER_PII` (10.5, RGPD anonymisation). A subset of operations is **PIN-gated**:
back-office sessions stay shared per workstation, but a per-staff PIN re-authorises the
sensitive set — `CANCEL_ORDER` (7.1), `UPDATE_PRODUCT`/`DELETE_PRODUCT` (8.2/8.3),
`DELETE_MENU` (8.6), `INVENTORY_COUNT` (9.2), user management (10.1-10.3), `MANAGE_RBAC`
(10.4), `ERASE_USER_PII` (10.5). These non-stock actions append an immutable `audit_log` row
(actor, action, target); stock actions record attribution in `stock_movement`. The treatment
logic (PIN, audit, throttling, idempotency, atomic stock decrement, computed product
availability) is specified in `mlt.md` (rules RG-T13-T21). This adds entities 20 `audit_log`
and 21 `login_throttle` to the model.
---
## 2. Representation conventions
@ -102,7 +113,7 @@ For each operation the document provides:
| **Synchronisation** | None (single event) |
| **Condition** | The kiosk is in service (within business hours 10:00-01:00) |
| **Operation** | LOAD_CATALOGUE |
| **Description** | Retrieval of active categories, available products, and available menus (with their slots and eligible options) for display on the kiosk screen. |
| **Description** | Retrieval of active categories, available products, and available menus (with their slots and eligible options) for display on the kiosk screen. Product availability is COMPUTED: a product is orderable only if its `is_available` flag is set AND each non-removable (`is_removable=0`) ingredient in its `product_ingredient` is above the critical band (`stock_quantity > stock_capacity * critical_stock_pct/100`). See rule RG-T21 in `mlt.md`. |
| **MCD entities** | R: `category` (is_active=1), `product` (is_available=1), `menu` (is_available=1), `menu_slot`, `menu_slot_option`, `ingredient` (is_active=1), `allergen`, `ingredient_allergen` |
| **Result** | Catalogue loaded; kiosk displays the home screen |
@ -337,7 +348,7 @@ For each operation the document provides:
| **Synchronisation** | OR (create ingredient, update ingredient, update composition, update allergen mapping) |
| **Condition** | Actor holds permission `ingredient.manage`. |
| **Operation** | MANAGE_INGREDIENT |
| **Description** | CRUD on `ingredient` (name, unit, pack_size, pack_label, low_stock_threshold, is_active). Manage `product_ingredient` composition (quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) for any product. Manage `ingredient_allergen` mapping (14 EU regulated allergens). Deactivating an ingredient (`is_active=0`) hides it from the configurator without deletion. Physical deletion of `ingredient` is blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). |
| **Description** | CRUD on `ingredient` (name, unit, pack_size, pack_label, stock_capacity, low_stock_pct, critical_stock_pct, is_active). Manage `product_ingredient` composition (quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) for any product. Manage `ingredient_allergen` mapping (14 EU regulated allergens). Deactivating an ingredient (`is_active=0`) hides it from the configurator without deletion. Physical deletion of `ingredient` is blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). |
| **MCD entities** | R: `product` (FK validation), `allergen` (FK validation) — W: `ingredient` (INSERT/UPDATE/DELETE conditional), `product_ingredient` (INSERT/UPDATE/DELETE), `ingredient_allergen` (INSERT/DELETE) |
| **Result** | Ingredient / composition / allergen mapping updated |
@ -384,7 +395,7 @@ For each operation the document provides:
| **Synchronisation** | None |
| **Condition** | Actor holds permission `stock.read`. |
| **Operation** | READ_STOCK |
| **Description** | Read `ingredient` list with current `stock_quantity`, `low_stock_threshold`, `pack_size`, `pack_label`. Low-stock alert computed at display time: `stock_quantity <= low_stock_threshold`. Optional: read `stock_movement` history for a given ingredient, filtered by date range. |
| **Description** | Read `ingredient` list with current `stock_quantity`, `stock_capacity`, computed `stock_pct`, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`. Stock bands computed at display time: `low_stock` when `stock_quantity <= stock_capacity * low_stock_pct/100`, `critical_stock` when `stock_quantity <= stock_capacity * critical_stock_pct/100`. Optional: read `stock_movement` history for a given ingredient, filtered by date range. |
| **MCD entities** | R: `ingredient`, `stock_movement` (optional history) |
| **Result** | Stock list displayed with low-stock indicators |
@ -452,6 +463,21 @@ For each operation the document provides:
---
### 10.5 ERASE_USER_PII (security-by-design)
| Field | Value |
|-------|-------|
| **Triggering event** | A RGPD erasure request is processed for a back-office user |
| **Actor** | ADMIN (PIN-gated) |
| **Synchronisation** | None |
| **Condition** | Actor holds permission `user.update` and has re-authorised via PIN. Target user exists and is not already anonymised. |
| **Operation** | ERASE_USER_PII |
| **Description** | RGPD right-to-erasure honoured by **anonymisation**, not physical deletion: PII (`email`, `first_name`, `last_name`) is cleared/replaced by a non-identifying placeholder, credentials invalidated, `anonymized_at` set. The row persists so referential links (`stock_movement`, `customer_order`, `audit_log`) stay valid and resolve to an anonymised principal. See `mlt.md` 10.5 and dictionary note 13. |
| **MCD entities** | W: `user` (UPDATE — PII cleared, `anonymized_at` set), `audit_log` (INSERT) |
| **Result** | User anonymised; PII removed; accountability links preserved; one `audit_log` row recorded |
---
## 11. Domain 9 — Stats and KPI
### 11.1 READ_STATS
@ -478,11 +504,11 @@ For each operation the document provides:
| **Triggering event** | An actor submits the login form |
| **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN |
| **Synchronisation** | None |
| **Condition** | Email exists in database. Password matches argon2id hash. User `is_active=1`. |
| **Condition** | Account not in a throttling window (`lockout_until`). Email exists in database. Password matches argon2id hash. User `is_active=1`. |
| **Operation** | AUTHENTICATE_USER |
| **Description** | Credential verification. If valid: session ID regeneration (protection against session fixation), storage of `user_id` and `role_id` in session, UPDATE `last_login_at`. Idle timeout: 4h. Absolute timeout: 10h. Redirect to `role.default_route`. |
| **MCD entities** | R: `user` (verification), `role` (load permissions, default_route), `role_permission` — W: `user` (UPDATE last_login_at) |
| **Result** | Session opened, redirect to role-specific default view |
| **Description** | Credential verification. If valid: session ID regeneration (protection against session fixation), storage of `user_id` and `role_id` in session, UPDATE `last_login_at`, reset of the login failure counter. On failure: increment `failed_login_attempts` and apply a degressive backoff (`lockout_until`), enumeration-safe generic error. Idle timeout: 4h. Absolute timeout: 10h. Redirect to `role.default_route`. See `mlt.md` 12.1. |
| **MCD entities** | R: `user` (verification), `role` (load permissions, default_route), `role_permission`, `login_throttle` (the per-IP throttle gate) — W: `user` (UPDATE last_login_at, `failed_login_attempts`, `lockout_until`), `login_throttle` (upsert `failed_attempts`/`lockout_until` on failure, clear on success), `audit_log` (INSERT login success/failure) |
| **Result** | Session opened, redirect to role-specific default view; or throttled failure logged |
---
@ -501,6 +527,21 @@ For each operation the document provides:
---
### 12.3 RESET_PASSWORD (security-by-design)
| Field | Value |
|-------|-------|
| **Triggering event** | A user requests a password reset, then confirms it via the emailed link |
| **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN |
| **Synchronisation** | Sequential two-phase: request, then confirm |
| **Condition** | Request: the submitted email is processed enumeration-safely (same neutral response whether or not it exists). Confirm: a valid, non-expired token is presented. |
| **Operation** | RESET_PASSWORD |
| **Description** | Request phase generates a random token, stores its hash + expiry, and e-mails the raw token once. Confirm phase validates the token hash + expiry, replaces `password_hash` (argon2id), clears the token, and resets the login failure counter. See `mlt.md` 12.3. |
| **MCD entities** | W: `user` (UPDATE `password_reset_token_hash` + `password_reset_expires_at` on request; UPDATE `password_hash`, clear token, reset `failed_login_attempts`/`lockout_until` on confirm), `audit_log` (INSERT) |
| **Result** | Password reset via a one-time, time-bound token; one `audit_log` row recorded |
---
## 13. State machine — customer_order.status
Summary of transitions covered by MCT operations.
@ -573,8 +614,17 @@ single delivery action (DELIVER_ORDER) collapses the v0.1 three-step sequence in
| 24 | READ_STATS | Stats | MANAGER/ADMIN | — | customer_order, order_item |
| 25 | AUTHENTICATE_USER | Auth | ALL BACK | user | user, role, role_permission |
| 26 | LOGOUT_USER | Auth | ALL BACK | — | — |
| 27 | ERASE_USER_PII | RBAC | ADMIN | user, audit_log | user |
| 28 | RESET_PASSWORD | Auth | ALL BACK | user, audit_log | user |
**Total: 26 operations** covering the complete Wakdo business lifecycle.
**Total: 28 operations** (26 prod-like + `ERASE_USER_PII` and `RESET_PASSWORD` from the
security-by-design layer).
**Audit log writes (security-by-design)**: the sensitive operations 7.1 (cancel), 8.2/8.3
(product update/delete), 8.6 (menu delete), 10.1-10.5 (user/RBAC/erasure) and 12.1 (login)
also write an `audit_log` row (W entity not repeated per row above to keep the table legible).
Stock operations 9.1/9.2 record their attribution via `stock_movement.user_id`. PIN-gated set
per `mlt.md` RG-T13.
---
@ -603,9 +653,20 @@ Verification that each MCD entity participates in at least one MCT operation.
| `permission` | 23 | — (static seed) | OK (*) |
| `role_permission` | 25 | 23 | OK |
| `stock_movement` | 19 | 3, 5, 8, 17, 18 | OK |
| `audit_log` | (admin audit view) | 8, 10, 11, 14, 20, 21, 22, 23, 25, 27, 28 | OK |
| `login_throttle` | 25 | 25 | OK |
(*) `allergen` and `permission` are read-only at the MCT level: their values are declared
in seed migrations and are not modifiable via the UI. `allergen` is managed indirectly
via `ingredient_allergen` in MANAGE_INGREDIENT.
**Conclusion**: 19/19 entities covered. MCT <-> MCD consistency validated.
(**) `audit_log` (entity 20, security-by-design) is write-mostly: it is appended by the
sensitive operations above and read through an admin audit view (a dedicated read operation
can be formalised when the audit UI is specified at P3).
(***) `login_throttle` (entity 21, security-by-design) is the per-source-IP brute-force
throttle gate: it is read AND written (upserted) by `AUTHENTICATE_USER` (25). Its daily purge
of stale rows is a cron, documented in `mlt.md`, outside MCT operation scope.
**Conclusion**: 21/21 entities covered (19 prod-like + `audit_log` + `login_throttle`). MCT <-> MCD consistency
validated.

View file

@ -1,10 +1,10 @@
# Logical Data Model (MLD) — Wakdo
**Merise phase** : P1 - Conception, step 5 (after MCD, MCT, MLT)
**Version** : v0.2 — prod-like, 19 tables
**Date** : 2026-06-04
**Version** : v0.2 — prod-like, 21 tables (19 prod-like + security-by-design layer)
**Date** : 2026-06-04 (security-by-design additions 2026-06-11)
**Branch** : `feat/p1-conception`
**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7)
**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer (audit_log + accountability/auth columns) in progress
**Author** : BYAN (methodology layer)
---
@ -93,7 +93,7 @@ in addition to the composite FK PK. Applied to `product_ingredient`.
---
## 4. Relational schema (19 tables)
## 4. Relational schema (21 tables)
Tables are ordered by dependency (no-FK tables first, then tables that depend on them).
@ -245,14 +245,16 @@ No timestamps. Pure join table.
### 4.6 `ingredient`
```
ingredient (id, name, unit, stock_quantity, pack_size, [pack_label],
low_stock_threshold, is_active, created_at, updated_at)
ingredient (id, name, unit, stock_quantity, stock_capacity, pack_size, [pack_label],
low_stock_pct, critical_stock_pct, is_active, created_at, updated_at)
PK : id
UK : name
CHK : stock_quantity >= 0
CHK : stock_capacity > 0
CHK : pack_size > 0
CHK : low_stock_threshold >= 0
CHK : low_stock_pct BETWEEN 0 AND 100
CHK : critical_stock_pct BETWEEN 0 AND 100
CHK : critical_stock_pct < low_stock_pct
```
| Column | Type | NULL | Notes |
@ -260,16 +262,38 @@ ingredient (id, name, unit, stock_quantity, pack_size, [pack_label],
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `name` | VARCHAR(120) | NO | Unique name, e.g. "Sesame Bun" |
| `unit` | VARCHAR(40) | NO | Packaging unit label (free-form, not ENUM) |
| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Current stock. Signed INT to detect negative (alert) |
| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Current stock. Signed INT that may go negative when sales outrun counted stock (oversell magnitude, surfaced to managers); the system does not block an order on stock |
| `stock_capacity` | INT NOT NULL | NO | Reference "full" level in units = the 100% used to compute the stock percentage; CHECK > 0 also guards the percentage division against divide-by-zero |
| `pack_size` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units per restocking pack |
| `pack_label` | VARCHAR(80) | YES | Human label of the pack |
| `low_stock_threshold` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Alert threshold |
| `low_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 10 | NO | Warning band, percent of capacity (CHECK BETWEEN 0 AND 100) |
| `critical_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 5 | NO | Auto-out-of-stock floor, percent of capacity (CHECK BETWEEN 0 AND 100; table CHECK `critical_stock_pct < low_stock_pct`) |
| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivate obsolete ingredients |
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
No FK. Root table for the Ingredients & Stock sub-domain.
**Percentage-based stock model**: the stock state is computed (NOT stored) as
`stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Two bands derive from it:
`LOW` when `stock_quantity <= stock_capacity * low_stock_pct/100`, and
`CRITICAL` when `stock_quantity <= stock_capacity * critical_stock_pct/100`.
Three-band behaviour: above `low` = normal; between `critical` and `low` = orderable
plus manager alert (the manager either pulls the product via `product.is_available=0`, or
restocks to clear the alert); at or below `critical` = auto out-of-stock (computed, rule
RG-T21). `stock_quantity` is signed and may go negative; the system does not block an order
on stock, so a negative value records the oversell magnitude for managers.
**Computed availability (rule RG-T21)**: a product is effectively orderable when
`product.is_available = 1` AND each non-removable (`is_removable=0`) ingredient in its
`product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct/100`. At the
critical band a product auto-goes out-of-stock with no write and no cascade; a manual pull
(`product.is_available=0`) is a hard override; a restock above the critical band makes the
product orderable again on its own; a removable/optional ingredient at the critical band does
not block the product (only its add-on becomes unavailable). The dashboard distinguishes a
manual pull (`is_available=0`) from a stock-driven OOS (`is_available=1` but a required
ingredient is critical).
---
### 4.7 `product_ingredient`
@ -382,8 +406,10 @@ No FK. Root table for RBAC.
### 4.11 `user`
```
user (id, email, password_hash, first_name, last_name, #role_id,
is_active, [last_login_at], created_at, updated_at)
user (id, email, password_hash, [pin_hash], first_name, last_name, #role_id,
is_active, [last_login_at], failed_login_attempts, [last_failed_login_at],
[lockout_until], [password_reset_token_hash], [password_reset_expires_at],
[anonymized_at], created_at, updated_at)
PK : id
UK : email
@ -394,19 +420,32 @@ user (id, email, password_hash, first_name, last_name, #role_id,
| Column | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `email` | VARCHAR(254) | NO | RFC 5321 max length |
| `email` | VARCHAR(254) | NO | RFC 5321 max length. PII (RGPD anonymisation, see below) |
| `password_hash` | VARCHAR(255) | NO | argon2id hash |
| `first_name` | VARCHAR(60) | NO | |
| `last_name` | VARCHAR(60) | NO | |
| `pin_hash` | VARCHAR(255) | YES | argon2id hash of the per-staff PIN (sensitive-action authorisation). Security-by-design |
| `first_name` | VARCHAR(60) | NO | PII |
| `last_name` | VARCHAR(60) | NO | PII |
| `role_id` | INT UNSIGNED | NO | FK -> role |
| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivation without deletion |
| `last_login_at` | DATETIME | YES | Audit, dormant account detection |
| `failed_login_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Brute-force counter (degressive throttling) |
| `last_failed_login_at` | DATETIME | YES | Timestamp of last failed login |
| `lockout_until` | DATETIME | YES | End of current throttling window (backoff, not indefinite lock) |
| `password_reset_token_hash` | VARCHAR(255) | YES | Hash of the reset token (not the raw token) |
| `password_reset_expires_at` | DATETIME | YES | Reset token expiry |
| `anonymized_at` | DATETIME | YES | RGPD tombstone marker; PII nulled/replaced when set |
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
**ON DELETE RESTRICT** on `role_id`: a role cannot be deleted while users hold it.
Deactivate the role first (`is_active = 0`), then reassign users before deleting.
**RGPD anonymisation** (security-by-design, dict. note 13): the right to erasure is honoured by
anonymising, not hard-deleting. `email` becomes a unique non-identifying placeholder
(`anon-<id>@wakdo.invalid`, RFC 2606 reserved domain — preserves the UNIQUE constraint),
`first_name`/`last_name` are cleared, `password_hash`/`pin_hash` are invalidated, `is_active=0`,
`anonymized_at = NOW()`. The row persists so `audit_log` and `stock_movement` FKs stay valid.
---
### 4.12 `role_visible_source`
@ -488,13 +527,16 @@ No timestamps. Pure join table.
### 4.15 `customer_order`
```
customer_order (id, order_number, source, service_mode, status,
customer_order (id, order_number, [idempotency_key], source, [#acting_user_id],
service_mode, status,
total_ht_cents, total_vat_cents, total_ttc_cents,
[paid_at], [delivered_at], [cancelled_at],
created_at, updated_at)
PK : id
UK : order_number
UK : idempotency_key
FK : acting_user_id -> user(id) ON DELETE SET NULL
IDX : (status, created_at)
IDX : (source, created_at)
IDX : created_at
@ -509,7 +551,9 @@ customer_order (id, order_number, source, service_mode, status,
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `order_number` | VARCHAR(20) | NO | Format `K/C/D-YYYY-MM-DD-NNN` by channel |
| `idempotency_key` | VARCHAR(36) | YES | Client UUID, UNIQUE; deduplicates retried POST (security-by-design) |
| `source` | ENUM('kiosk','counter','drive') | NO | Input channel |
| `acting_user_id` | INT UNSIGNED | YES | FK -> user; counter/drive staff under PIN; NULL for kiosk |
| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Consumption mode (stats only, no fiscal role) |
| `status` | ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment' | NO | 4-state machine |
| `total_ht_cents` | INT UNSIGNED | NO | Ex-VAT total snapshot |
@ -521,8 +565,11 @@ customer_order (id, order_number, source, service_mode, status,
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Used as `service_day` base |
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
No FK toward `user`: staff attribution is not stored on the order. Operational accountability
is covered by `stock_movement.user_id` for stock actions.
**Staff attribution (security-by-design)**: `acting_user_id` (FK -> `user`, ON DELETE SET NULL)
records the counter/drive staff who took the order under PIN; NULL for anonymous kiosk orders.
Kiosk orders stay anonymous by design. `stock_movement.user_id` covers attribution of stock
actions. `idempotency_key` (UNIQUE, nullable) deduplicates a retried `POST /api/orders`
(multiple NULLs allowed by the UNIQUE index, so non-idempotent legacy paths are tolerated).
**4-state machine**: `pending_payment -> paid -> delivered` (+ `cancelled`). States `preparing`
and `ready` are dropped (decision D4). KPI: `delivered_at - paid_at` (target SLA ~10 min).
@ -693,6 +740,76 @@ No `updated_at`. Immutable append-only table.
---
### 4.20 `audit_log`
Append-only log of sensitive back-office actions (security-by-design, dict. 3.20).
```
audit_log (id, [#actor_user_id], [#actor_role_id], action_code,
[entity_type], [entity_id], [summary], [details], created_at)
PK : id
FK : actor_user_id -> user(id) ON DELETE SET NULL
FK : actor_role_id -> role(id) ON DELETE SET NULL
IDX : (actor_user_id, created_at)
IDX : (entity_type, entity_id)
IDX : (action_code, created_at)
```
| Column | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `actor_user_id` | INT UNSIGNED | YES | FK -> user; acting staff (PIN-captured) or NULL if not attributable |
| `actor_role_id` | INT UNSIGNED | YES | FK -> role; denormalised role context (survives user anonymisation) |
| `action_code` | VARCHAR(60) | NO | MCT operation / permission code, e.g. `product.update`, `order.cancel` |
| `entity_type` | VARCHAR(40) | YES | Affected table name |
| `entity_id` | INT UNSIGNED | YES | PK of the affected row |
| `summary` | VARCHAR(255) | YES | Short non-personal change description |
| `details` | JSON | YES | Optional before/after diff (field names for user-targeted actions, not PII values) |
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Immutable timestamp |
**ON DELETE SET NULL** on both FKs: the trail is preserved when a user is anonymised/removed
or a role deleted; only the link is severed (the denormalised `actor_role_id` keeps the role
context even after user anonymisation).
**Immutability rule**: no UPDATE or DELETE at application layer. **Retention**: a scheduled
cron purge removes rows older than the retention window (~12 months, legitimate-interest /
fiscal traceability), decoupled from the user PII lifecycle (dict. note 13).
No `updated_at`. Immutable append-only table.
---
### 4.21 `login_throttle`
Per-source-IP brute-force throttle (security-by-design). Complements the per-account counter
already on `user` (`failed_login_attempts` / `lockout_until`).
```
login_throttle (id, ip_address, failed_attempts, window_started_at,
[lockout_until], last_attempt_at)
PK : id
UK : ip_address
IDX : lockout_until
```
| Column | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `ip_address` | VARCHAR(45) | NO | Source IP, one row per IP, upserted; 45 chars holds a full IPv6 literal. UNIQUE |
| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Consecutive failed logins from this IP in the current window |
| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Start of the current counting window |
| `lockout_until` | DATETIME | YES | End of the degressive backoff window; NULL = not throttled |
| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Timestamp of the last failed attempt |
No FK: an IP is not a modelled entity. Append/upsert by IP; the window resets when expired. A
daily cron purges rows with no active lockout whose `last_attempt_at` is older than 24h.
No `updated_at`: rows are upserted by IP, not edited through a UI.
---
## 5. Referential integrity summary
| FK column | References | ON DELETE | Rationale |
@ -722,6 +839,9 @@ No `updated_at`. Immutable append-only table.
| `stock_movement.ingredient_id` | `ingredient(id)` | RESTRICT | Ingredient with history cannot be deleted |
| `stock_movement.order_id` | `customer_order(id)` | SET NULL | Audit preserved, order link lost |
| `stock_movement.user_id` | `user(id)` | SET NULL | Audit preserved, user attribution lost |
| `customer_order.acting_user_id` | `user(id)` | SET NULL | Staff attribution preserved as anonymised principal; order kept |
| `audit_log.actor_user_id` | `user(id)` | SET NULL | Audit trail preserved on user anonymisation; only the link is severed |
| `audit_log.actor_role_id` | `role(id)` | SET NULL | Role context kept until role deletion; denormalised so it survives user anonymisation |
**Key used**: CASCADE = child has no meaning without parent; RESTRICT = parent deletion
blocked while children exist; SET NULL = child is preserved, only the link is severed.
@ -736,9 +856,11 @@ blocked while children exist; SET NULL = child is preserved, only the link is se
| `product` | `vat_rate IN (55, 100)` | Only two legal VAT rates for this model |
| `menu` | `price_normal_cents > 0` | Same as product |
| `menu` | `price_maxi_cents > 0` | Same |
| `ingredient` | `stock_quantity >= 0` | Negative stock is an alert, not a valid state |
| `ingredient` | `stock_capacity > 0` | The 100% reference must be positive; also guards the percentage division against divide-by-zero |
| `ingredient` | `pack_size > 0` | Pack size of zero makes restock logic incoherent |
| `ingredient` | `low_stock_threshold >= 0` | Threshold cannot be negative |
| `ingredient` | `low_stock_pct BETWEEN 0 AND 100` | Warning band is a percent of capacity |
| `ingredient` | `critical_stock_pct BETWEEN 0 AND 100` | Auto-out-of-stock floor is a percent of capacity |
| `ingredient` | `critical_stock_pct < low_stock_pct` | Critical floor sits below the warning band |
| `product_ingredient` | `quantity_normal > 0` | Recipe quantity of zero is meaningless |
| `product_ingredient` | `quantity_maxi >= quantity_normal` | Maxi consumes at least as much as Normal (side/drink more, burger/sauce equal) |
| `product_ingredient` | `extra_price_cents >= 0` | No negative surcharge |
@ -776,6 +898,10 @@ MCT / MLT.
| `stock_movement` | `(movement_type, created_at)` | Stats: cancellations per week, restocks per month |
| `role_permission` | `permission_id` | Reverse query: "which roles have this permission?" |
| `user` | `(is_active, role_id)` | Login check + permission resolution |
| `audit_log` | `(actor_user_id, created_at)` | Per-actor audit history |
| `audit_log` | `(entity_type, entity_id)` | "what happened to this product/order/user?" |
| `audit_log` | `(action_code, created_at)` | Audit by action type over a time range |
| `login_throttle` | `lockout_until` | Daily cron purge of rows with no active lockout |
**Indexes not added** (intentional):
- `customer_order.order_number`: UK index is sufficient; no range query expected on this column.
@ -787,7 +913,8 @@ MCT / MLT.
## 8. Cross-validation MLD <-> MCD
Verification that all 19 MCD entities map to a table, and that all tables trace to the MCD.
Verification that all 21 MCD entities (19 prod-like + 2 security-by-design) map to a table,
and that all tables trace to the MCD.
| MCD entity | MLD table | Mapping type | Notes |
|---|---|---|---|
@ -810,8 +937,14 @@ Verification that all 19 MCD entities map to a table, and that all tables trace
| `order_item_selection` (C17) | `order_item_selection` (4.17) | 1:1 entity | New entity (v0.2) |
| `order_item_modifier` (C18) | `order_item_modifier` (4.18) | 1:1 entity | New entity (v0.2) |
| `stock_movement` (C19) | `stock_movement` (4.19) | 1:1 entity | New entity (v0.2) |
| `audit_log` (R5/R6) | `audit_log` (4.20) | 1:1 entity | New entity (security-by-design) |
| `login_throttle` (R7) | `login_throttle` (4.21) | 1:1 entity | New entity (security-by-design) |
**Result**: 19/19 entities mapped. No entity without a table; no table outside the MCD.
**Result**: 21/21 entities mapped (19 prod-like + `audit_log` + `login_throttle`). No entity
without a table; no table outside the MCD. New columns on existing tables: `user`
(auth-lifecycle + `pin_hash` + `anonymized_at`), `customer_order` (`idempotency_key`,
`acting_user_id`), `ingredient` (`stock_capacity`, `low_stock_pct`, `critical_stock_pct`;
`low_stock_threshold` repurposed).
**Dropped from v0.1**: `commande_event` (replaced by `paid_at`, `delivered_at`, `cancelled_at`
phase timestamps on `customer_order` — decision 2.A); `menu_produit` fixed-composition model
@ -842,8 +975,11 @@ phase timestamps on `customer_order` — decision 2.A); `menu_produit` fixed-com
| `order_item_selection` | ~300k | 150 bytes | ~45 MB |
| `order_item_modifier` | ~150k | 80 bytes | ~12 MB |
| `stock_movement` | ~500k | 180 bytes | ~90 MB |
| `audit_log` | ~5k-10k | 200 bytes | ~2 MB |
| `login_throttle` | ~100-1k | 80 bytes | < 1 MB |
**Estimated total**: ~190 MB data + ~60-80 MB for indexes = ~250-270 MB over 6 months.
**Estimated total**: ~190 MB data + ~60-80 MB for indexes = ~250-270 MB over 6 months
(`audit_log` is negligible: sensitive actions are orders of magnitude rarer than orders).
Manageable on the MariaDB container (`wakdo_db_data` named volume in `docker-compose.yml`).
`stock_movement` is the highest-volume table (~5-15 rows per order across all ingredients).
@ -889,6 +1025,11 @@ history; it will carry meaningful write amplification at scale.
- `order_item_selection` (depends on `order_item`, `menu_slot`, `product`)
- `order_item_modifier` (depends on `order_item`, `ingredient`)
- `stock_movement` (depends on `ingredient`, `customer_order`, `user`)
- `audit_log` (depends on `user`, `role`)
- `login_throttle` (no FK, can be created at any point)
Note: `customer_order` now carries `acting_user_id -> user`, so `user` must be created
before `customer_order` (already the case: the RBAC block precedes `customer_order`).
2. **Seed** (`db/seeds/0001_demo_data.sql`):
- 9 categories + 53 products + 13 menus from JSON sources (`docs/merise/_sources/`)

View file

@ -1,10 +1,10 @@
# Model of Logical Treatments (MLT) — Wakdo
**Merise phase** : P1 - Conception, step 4 (derived from MCT)
**Version** : v0.2 — prod-like, 4-state machine
**Date** : 2026-06-04
**Version** : v0.2 — prod-like, 4-state machine (+ security-by-design layer 2026-06-11)
**Date** : 2026-06-04 (security-by-design additions 2026-06-11)
**Branch** : `feat/p1-conception`
**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7)
**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design rules added (RG-T13-T21: PIN, audit, escaping, allowlists, idempotency, atomic decrement, computed product availability (RG-T21); ops RESET_PASSWORD, ERASE_USER_PII, auth throttling; per-IP throttle table `login_throttle`)
**Author** : BYAN (methodology layer)
---
@ -49,6 +49,15 @@ These rules apply to multiple operations and are centralised here to avoid repet
| **RG-T10** | VAT computation is line-by-line: each `order_item` carries its own `vat_rate_snapshot` (per-mille integer snapshotted from `product.vat_rate`). Order totals (`total_ht_cents`, `total_vat_cents`, `total_ttc_cents`) are the sum of line-level amounts. | 3.3, 4.1 |
| **RG-T11** | Stock decrements at the `pending_payment -> paid` transition and re-credits at `paid -> cancelled` are within the same database transaction as the status update (no orphan decrement). | 3.3, 4.1, 7.1 |
| **RG-T12** | Dashboard filter by source: each role's visible sources are read from `role_visible_source`; the query uses `WHERE customer_order.source IN (role_visible_sources)`. | 6.1 |
| **RG-T13** | **Sensitive-action PIN** (security-by-design): the set of sensitive operations requires a per-staff PIN re-authorisation before execution: verify the submitted PIN against `user.pin_hash` (`password_verify`, argon2id). On success the acting `user_id` is captured for the audit log; on failure the operation is rejected. Sensitive set: 7.1 (cancel), 8.2/8.3 (product update/delete), 8.6 (menu delete), 9.2 (inventory correction), 10.1/10.2/10.3 (user mgmt), 10.4 (RBAC), 10.5 (PII erasure). Sessions stay shared per workstation for the routine 95%. | 7.1, 8.2, 8.3, 8.6, 9.2, 10.1-10.5 |
| **RG-T14** | **Audit log write**: non-stock sensitive operations append one immutable `audit_log` row in the same transaction as their effect: `actor_user_id` (from RG-T13 PIN), `actor_role_id`, `action_code` (permission/operation code), `entity_type` + `entity_id` of the affected row, `summary` (non-personal change description), `details` JSON (changed field **names** for user-targeted actions, not PII values). No UPDATE/DELETE on `audit_log`. Stock actions (9.1 restock, 9.2 inventory) record their attribution via `stock_movement.user_id` (PIN-captured), which already provides the append-only stock trail — they are not double-logged. | 7.1, 8.2, 8.3, 8.6, 10.1-10.5, 12.1 |
| **RG-T15** | **Output escaping** (anti-XSS): free-text fields (`product.name`/`description`, `ingredient.name`, `user.first_name`/`last_name`, notes) are context-escaped at render. Server-rendered admin views use `htmlspecialchars($v, ENT_QUOTES, 'UTF-8')`; the vanilla-JS kiosk injects text via `textContent` (or an explicit escaper), not `innerHTML`. | All views rendering stored text |
| **RG-T16** | **Mass-assignment allowlist**: INSERT/UPDATE statements bind only an explicit per-operation column allowlist from the request; extra/unknown fields are dropped. Prevents tampering with `price_cents`, `vat_rate`, `role_id`, `is_active`, `status` via injected form fields. | 8.1, 8.2, 8.4, 8.5, 10.1, 10.2 |
| **RG-T17** | **Dynamic identifier allowlist**: column/direction tokens used in dynamic `ORDER BY` / `GROUP BY` are resolved against a fixed allowlist of column names before query build (RG-T06 covers values via bind parameters; SQL identifiers cannot be bound, so they are allowlisted). | 5.1, 9.3, 11.1 |
| **RG-T18** | **Server-side validation and length bounds**: every input is re-validated server-side regardless of client checks — type, range, max length (matching the dictionary VARCHAR sizes), enum membership, FK existence. Client-side validation is a UX aid, not a trust boundary. | All write operations |
| **RG-T19** | **Idempotency**: `POST /api/orders` carries a client-generated `idempotency_key` (UUID). Before creating, look it up on `customer_order.idempotency_key` (UNIQUE); if a row exists, return that order instead of creating a duplicate (replayed network retry). | 3.3, 4.1 |
| **RG-T20** | **Atomic stock decrement**: during the `paid` transition, each affected `ingredient` is decremented with a single self-locking statement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` — no preceding read-gate, no `SELECT ... FOR UPDATE`. Concurrent orders on the same ingredient apply their deltas without a lost update and without a deadlock-ordering concern. `stock_quantity` is signed and may go negative when sales outrun counted stock (oversell magnitude surfaced to managers); the decrement does not block on a floor. | 3.3, 4.1 |
| **RG-T21** | **Computed product availability**: a product's effective orderability is computed, not stored. It is orderable when `product.is_available = 1` AND each non-removable (`is_removable = 0`) ingredient in its `product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct / 100`. At the critical band a required ingredient takes the product out-of-stock with no write and no cascade; restock above the critical band makes it orderable again on its own; a manual pull (`product.is_available = 0`) is a hard override; a removable/optional ingredient at the critical band does not block the product (only its add-on becomes unavailable). | 3.1, 3.3, 4.1, 5.1 |
---
@ -107,10 +116,13 @@ These rules apply to multiple operations and are centralised here to avoid repet
| **[RG-2 — service_day]** | `service_day` for a given order is computed at query time as: `CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END`. Cutoff is 10:00. This is NOT stored as a column — computed at query time only. The v0.1 formula with `INTERVAL 4 HOUR 30 MINUTE` was incorrect and is dropped. |
| **[RG-3 — order number]** | Order number format: `K-YYYY-MM-DD-NNN` where NNN is the sequential counter for the current service_day for the `kiosk` source (SELECT COUNT + 1 with a table-level lock or serialised insert to avoid duplicate generation under concurrency). Source is `kiosk` (set by the kiosk endpoint, derived from the public entry point). |
| **[RG-4 — VAT by line]** | For each `order_item`: `vat_rate_snapshot` is copied from `product.vat_rate`. Line amounts: `unit_ttc = unit_price_cents_snapshot`; `unit_ht = ROUND(unit_ttc * 1000 / (1000 + vat_rate_snapshot))`; `unit_vat = unit_ttc - unit_ht`. Order totals: `total_ttc_cents = SUM(unit_ttc * quantity)` across all lines; `total_ht_cents = SUM(unit_ht * quantity)`; `total_vat_cents = total_ttc_cents - total_ht_cents`. Invariant: `total_ttc_cents = total_ht_cents + total_vat_cents` (verified before INSERT). |
| **[RG-5 — atomic transaction]** | All writes within one database transaction: (1) INSERT `customer_order` (status `pending_payment`, source `kiosk`, service_mode from cart, computed totals); (2) INSERT `order_item` rows (label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot, quantity, format, item_type, product_id or menu_id); (3) INSERT `order_item_selection` rows for each slot filled in a menu item (order_item_id, menu_slot_id, product_id, label_snapshot); (4) INSERT `order_item_modifier` rows for each ingredient modification (order_item_id, ingredient_id, action, extra_price_cents snapshot); (5) for each ingredient consumed: compute units = `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by modifiers (remove => no decrement for that ingredient; add => extra decrement); UPDATE `ingredient.stock_quantity -= units`; INSERT `stock_movement` (type `sale`, delta = -units, order_id, user_id = NULL for kiosk); (6) UPDATE `customer_order` SET status = `paid`, `paid_at = NOW()`. All six steps commit together or roll back entirely. |
| **[RG-5 — atomic transaction]** | All writes within one database transaction: (1) INSERT `customer_order` (status `pending_payment`, source `kiosk`, service_mode from cart, computed totals); (2) INSERT `order_item` rows (label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot, quantity, format, item_type, product_id or menu_id); (3) INSERT `order_item_selection` rows for each slot filled in a menu item (order_item_id, menu_slot_id, product_id, label_snapshot); (4) INSERT `order_item_modifier` rows for each ingredient modification (order_item_id, ingredient_id, action, extra_price_cents snapshot); (5) for each ingredient consumed: compute units = `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by modifiers (remove => no decrement for that ingredient; add => extra decrement); apply the atomic decrement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (single self-locking statement, no preceding read-gate, RG-T20); `stock_quantity` is signed and may go negative (oversell magnitude, surfaced to managers) — the decrement does not gate on a floor; INSERT `stock_movement` (type `sale`, delta = -units, order_id, user_id = NULL for kiosk); (6) UPDATE `customer_order` SET status = `paid`, `paid_at = NOW()`. All six steps commit together or roll back entirely. |
| **[RG-6 — cross-constraint]** | Source `kiosk` implies no particular service_mode constraint; the customer selects `dine_in` or `takeaway`. The drive cross-constraint (RG-T09) does not apply to kiosk-originated orders. |
| **[RG-7 — immutability]** | After INSERT, `label_snapshot`, `unit_price_cents_snapshot`, and `vat_rate_snapshot` are not modified even if the source product is later renamed or repriced (see RG-T05). |
| **[POST-1]** | One `customer_order` row exists with `status = 'paid'`, `source = 'kiosk'`, all totals computed, `paid_at` set. The `pending_payment` phase is not observable outside the transaction. |
| **[RG-8 — idempotency]** | The body carries a client `idempotency_key` (UUID). Before any write, `SELECT id, order_number, status FROM customer_order WHERE idempotency_key = :key`. If found, skip creation and return that order (deduplicates a replayed retry — RG-T19). The key is stored on the new `customer_order` row. |
| **[RG-9 — server-side modifier re-validation]** | The ingredient modifiers in the body are re-validated server-side against `product_ingredient`: an `action='remove'` requires `is_removable=1`; an `action='add'` requires `is_addable=1` and snapshots the current `extra_price_cents`. Client-side checks (3.2 RG-4) are not trusted; a crafted POST adding a non-addable ingredient is rejected (HTTP 422). |
| **[RG-10 — atomic stock decrement]** | No operation gates on a stock read, so the decrement is a single atomic statement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (RG-T20). The row self-locks for the duration of the update, so concurrent kiosk orders on the same ingredient apply their deltas without a lost update and without a deadlock-ordering concern; `stock_quantity` is signed and may go negative (oversell magnitude surfaced to managers). |
| **[POST-1]** | One `customer_order` row exists with `status = 'paid'`, `source = 'kiosk'`, all totals computed, `paid_at` set, `idempotency_key` stored. The `pending_payment` phase is not observable outside the transaction. |
| **[POST-2]** | N `order_item` rows exist, each referencing either a `product_id` (item_type='product') or a `menu_id` (item_type='menu') — exclusivity constraint verified. |
| **[POST-3]** | `customer_order.order_number` is unique in the database (UNIQUE constraint). |
| **[POST-4]** | `ingredient.stock_quantity` decremented for each consumed ingredient unit; one `stock_movement` row of type `sale` per affected ingredient. |
@ -152,7 +164,8 @@ These rules apply to multiple operations and are centralised here to avoid repet
| **[RG-2 — cross-constraint]** | If `source = 'drive'` then `service_mode` must be `'drive'` (RG-T09); verified before INSERT. HTTP 422 if violated. |
| **[RG-3 — order number]** | Format: `C-YYYY-MM-DD-NNN` for counter source; `D-YYYY-MM-DD-NNN` for drive source. Sequential NNN counter is per source per service_day. |
| **[RG-4 — stock]** | Same stock decrement logic as CREATE_ORDER RG-5; `stock_movement.user_id` is set to the authenticated staff member's id. |
| **[POST-1]** | One `customer_order` row with `status = 'paid'`, `source = 'counter'` or `'drive'`, `paid_at` set. |
| **[RG-5 — staff attribution + decrement]** | `customer_order.acting_user_id` is set to the authenticated staff member's id (targeted accountability on counter/drive orders; kiosk orders stay NULL). Server-side modifier re-validation (3.3 RG-9), idempotency (RG-T19) and the atomic stock decrement (RG-T20) apply identically. No PIN is required to create an order (the `order.create` permission suffices); order creation is not in the sensitive-action set. |
| **[POST-1]** | One `customer_order` row with `status = 'paid'`, `source = 'counter'` or `'drive'`, `paid_at` set, `acting_user_id` set. |
| **[POST-2]** | N `order_item` rows with snapshots. Slot selections and modifiers written identically to kiosk flow. |
| **[POST-3]** | Stock decremented; movements logged with actor `user_id`. |
| **[OUT-1]** | HTTP 201: `{data: {id: int, order_number: string, status: 'paid'}}`. Order number communicated to customer. |
@ -218,7 +231,8 @@ These rules apply to multiple operations and are centralised here to avoid repet
| **[RG-3 — stock re-credit — conditional]** | Re-credit applies only if the order was at status `paid` before cancellation. Orders at `pending_payment` had not yet decremented stock (the decrement occurs at the `paid` transition). For each `order_item` line of a `paid` order, recompute ingredient units consumed: `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by `order_item_modifier` rows (remove modifier -> ingredient was not decremented, so no re-credit; add modifier -> ingredient had extra decrement, so extra re-credit). UPDATE `ingredient.stock_quantity += units`. INSERT `stock_movement` (type `cancellation`, delta = +units, order_id, user_id of actor). |
| **[RG-4 — transaction]** | Status update and stock re-credit (when applicable) execute in the same database transaction (RG-T11). |
| **[RG-5 — history]** | Order is not physically deleted; retained for history and stats. Cancelled orders are excluded from revenue totals but included in volume counts in READ_STATS. `order_item` rows are not deleted (ON DELETE CASCADE is not triggered); they allow reconstruction of what was ordered. |
| **[POST-1]** | `customer_order.status = 'cancelled'`, `cancelled_at` set, terminal state. |
| **[RG-6 — PIN + audit]** | Cancellation is a sensitive money-handling action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row in the same transaction (RG-T14): `action_code='order.cancel'`, `entity_type='customer_order'`, `entity_id=:id`, `summary` with prior status and re-credited amount. |
| **[POST-1]** | `customer_order.status = 'cancelled'`, `cancelled_at` set, terminal state. One `audit_log` row recorded with the acting staff. |
| **[POST-2]** | If prior status was `paid`: `ingredient.stock_quantity` re-credited; one `stock_movement` row of type `cancellation` per affected ingredient. |
| **[OUT-1]** | HTTP 200 with cancellation confirmation |
| **[ERR-1]** | Attempt to cancel a delivered or already cancelled order: HTTP 422, `{error: {code: "CANNOT_CANCEL_IN_STATE", current_status: "..."}}` |
@ -258,7 +272,8 @@ These rules apply to multiple operations and are centralised here to avoid repet
| **[RG-1]** | Same validations as CREATE_PRODUCT on modified fields |
| **[RG-2]** | If a new image is uploaded, the old image file is deleted from the filesystem (volume cleanup) |
| **[RG-3]** | `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot` in historical `order_item` rows are not modified (see RG-T05) |
| **[POST-1]** | `product` updated, `updated_at` refreshed |
| **[RG-4 — PIN + audit + allowlist]** | A price/VAT change is a sensitive action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row (RG-T14) with `action_code='product.update'`, `entity_type='product'`, `entity_id=:id`, and a `summary` recording changed values (e.g. `price_cents 880 -> 920`). Only the allowlisted columns (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`) are bound from the request (RG-T16). |
| **[POST-1]** | `product` updated, `updated_at` refreshed; one `audit_log` row recorded |
| **[OUT-1]** | Redirect to product list with success message |
---
@ -275,7 +290,8 @@ These rules apply to multiple operations and are centralised here to avoid repet
| **[RG-2]** | Pre-check (PHP): is the product the `burger_product_id` of any `menu`? If yes, block with message to delete or reassign the menu first. |
| **[RG-3]** | Pre-check (PHP): is the product referenced in `order_item.product_id` (historical orders)? FK `ON DELETE RESTRICT` blocks at DB level. Recommended response: propose deactivation (`is_available=0`) rather than deletion. |
| **[RG-4]** | FK constraints (`menu_slot_option.product_id ON DELETE RESTRICT`, `order_item.product_id ON DELETE RESTRICT`) enforce the constraint even if the PHP check is bypassed. |
| **[POST-1]** | Product deleted if no FK constraint was blocking |
| **[RG-5 — PIN + audit]** | Deletion is a sensitive action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row (RG-T14) with `action_code='product.delete'`, `entity_type='product'`, `entity_id=:id`, `summary` capturing the product name before deletion (recorded before the row is removed). |
| **[POST-1]** | Product deleted if no FK constraint was blocking; one `audit_log` row recorded |
| **[OUT-1]** | Redirect to product list with success message |
| **[ERR-1]** | Product in menu slot: HTTP 422 or inline message with blocking menu list |
| **[ERR-2]** | Product in historical orders: message proposing deactivation instead |
@ -327,7 +343,8 @@ These rules apply to multiple operations and are centralised here to avoid repet
| **[PRE-2]** | Target `menu.id` exists |
| **[RG-1]** | Pre-check (PHP): is the menu referenced in `order_item.menu_id`? FK `ON DELETE RESTRICT`. If yes, propose deactivation (`is_available=0`) instead of deletion. |
| **[RG-2]** | If no historical reference: DELETE `menu` triggers CASCADE to `menu_slot` (which cascades to `menu_slot_option`) |
| **[POST-1]** | `menu`, its `menu_slot` rows, and its `menu_slot_option` rows deleted |
| **[RG-3 — PIN + audit]** | Deletion is a sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='menu.delete'`, `entity_type='menu'`, `entity_id=:id`, `summary` capturing the menu name before deletion. |
| **[POST-1]** | `menu`, its `menu_slot` rows, and its `menu_slot_option` rows deleted; one `audit_log` row recorded |
| **[OUT-1]** | Redirect with success message |
| **[ERR-1]** | Menu in historical orders: message proposing deactivation instead |
@ -357,8 +374,8 @@ These rules apply to multiple operations and are centralised here to avoid repet
| Tag | Content |
|-----|---------|
| **[PRE-1]** | Actor authenticated, holds permission `ingredient.manage` |
| **[RG-CREATE-ING]** | `name` non-empty and UNIQUE; `unit` non-empty; `pack_size >= 1`; `low_stock_threshold >= 0`; `stock_quantity` defaults to 0 at creation |
| **[RG-UPDATE-ING]** | UPDATE `name`, `unit`, `pack_size`, `pack_label`, `low_stock_threshold`, `is_active` |
| **[RG-CREATE-ING]** | `name` non-empty and UNIQUE; `unit` non-empty; `pack_size >= 1`; `stock_capacity >= 1` (the 100% reference); `low_stock_pct` and `critical_stock_pct` in 0-100 with `critical_stock_pct < low_stock_pct` (defaults 10 / 5); `stock_quantity` defaults to 0 at creation |
| **[RG-UPDATE-ING]** | UPDATE `name`, `unit`, `pack_size`, `pack_label`, `stock_capacity`, `low_stock_pct`, `critical_stock_pct`, `is_active` |
| **[RG-DEACTIVATE-ING]** | `is_active=0` hides ingredient from configurator. Physical deletion blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). |
| **[RG-COMPOSITION]** | UPDATE `product_ingredient`: for each ingredient in a product's recipe, set `quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`. Delete-and-reinsert pattern within transaction. |
| **[RG-ALLERGEN]** | Manage `ingredient_allergen`: INSERT or DELETE `(ingredient_id, allergen_id)` pairs. Allergen list is read-only (14 rows fixed by EU regulation 1169/2011). |
@ -398,7 +415,8 @@ These rules apply to multiple operations and are centralised here to avoid repet
| **[RG-1]** | `delta = actual_quantity - ingredient.stock_quantity` (may be negative if actual < theoretical) |
| **[RG-2]** | Transaction: `UPDATE ingredient SET stock_quantity = :actual_quantity WHERE id = :id`; INSERT `stock_movement` (ingredient_id, movement_type=`inventory_correction`, delta=computed, order_id=NULL, user_id=actor, note=optional) |
| **[RG-3]** | `delta = 0` is a valid correction (physical count matches theoretical); a movement row is still inserted for audit completeness |
| **[POST-1]** | `ingredient.stock_quantity = actual_quantity`. One `stock_movement` row of type `inventory_correction` inserted. |
| **[RG-4 — PIN attribution]** | An inventory correction can mask shrinkage, so it requires the per-staff PIN (RG-T13). The PIN-captured `user_id` is written to `stock_movement.user_id`, making the correction attributable to a person even on a shared workstation. No separate `audit_log` row (the `stock_movement` trail already records it). |
| **[POST-1]** | `ingredient.stock_quantity = actual_quantity`. One `stock_movement` row of type `inventory_correction` inserted with the acting `user_id`. |
| **[OUT-1]** | Confirmation with reconciled stock level and discrepancy displayed |
---
@ -411,10 +429,11 @@ These rules apply to multiple operations and are centralised here to avoid repet
|-----|---------|
| **[PRE-1]** | Actor authenticated, holds permission `stock.read` |
| **[RG-1]** | `SELECT * FROM ingredient WHERE is_active = 1 ORDER BY name ASC` |
| **[RG-2]** | Low-stock alert computed at render time: `stock_quantity <= low_stock_threshold` -> flag `low_stock: true` in response. Not stored as a column. |
| **[RG-2]** | Stock bands computed at render time from the percentage thresholds: `low_stock: true` when `stock_quantity <= stock_capacity * low_stock_pct / 100`, `critical_stock: true` when `stock_quantity <= stock_capacity * critical_stock_pct / 100`; `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` is also returned. Not stored as columns. |
| **[RG-3]** | Optional movement history for a given ingredient: `SELECT * FROM stock_movement WHERE ingredient_id = :id ORDER BY created_at DESC LIMIT :n` |
| **[RG-4 — attribution visibility]** | The `stock_movement.user_id` (who restocked / who corrected) is included for `manager`/`admin` only; line staff (`kitchen`/`counter`/`drive`) see the movement deltas without the actor identity. This limits intra-team exposure while preserving accountability for those who manage. The `details` allowlist is applied at the query/serialisation layer. |
| **[POST-1]** | No database write |
| **[OUT-1]** | Ingredient list with `stock_quantity`, `low_stock_threshold`, `pack_size`, `pack_label`, `low_stock` flag |
| **[OUT-1]** | Ingredient list with `stock_quantity`, `stock_capacity`, computed `stock_pct`, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`, `low_stock` / `critical_stock` flags; movement history with actor visible to manager/admin only |
---
@ -432,7 +451,8 @@ These rules apply to multiple operations and are centralised here to avoid repet
| **[RG-1]** | Validation: `email` conforms to RFC 5321 (PHP `FILTER_VALIDATE_EMAIL`), `first_name` and `last_name` non-empty, `role_id` valid |
| **[RG-2]** | Password hash: `password_hash($password, PASSWORD_ARGON2ID)`. Minimum password length: 8 characters. |
| **[RG-3]** | `is_active = 1` by default; `last_login_at = NULL` at creation |
| **[POST-1]** | One `user` row with argon2id `password_hash`, valid `role_id` |
| **[RG-4 — PIN + audit + allowlist]** | Creating a back-office account is a sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.create'`, `entity_type='user'`, `entity_id=:new_id`, `details` recording the assigned `role_id` (field names/role, not the password). Only the allowlisted columns are bound (RG-T16): `email`, `first_name`, `last_name`, `role_id` (+ the hashed password); `is_active` and any other field are server-set, not request-bound. |
| **[POST-1]** | One `user` row with argon2id `password_hash`, valid `role_id`; one `audit_log` row recorded |
| **[OUT-1]** | Redirect to user list with success message |
| **[ERR-1]** | Duplicate email: message "This email is already in use" |
| **[ERR-2]** | Password too short: inline validation message |
@ -450,7 +470,8 @@ These rules apply to multiple operations and are centralised here to avoid repet
| **[RG-1]** | If a new password is supplied (non-empty field): rehash via `PASSWORD_ARGON2ID` and replace existing hash |
| **[RG-2]** | If password field is empty: existing hash is preserved unchanged |
| **[RG-3]** | Email update subject to UNIQUE constraint (pre-check before UPDATE) |
| **[POST-1]** | `user` updated, `updated_at` refreshed |
| **[RG-4 — PIN + audit + allowlist]** | Editing an account (incl. `role_id`, the privilege-escalation vector) is sensitive: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.update'`, `entity_type='user'`, `entity_id=:id`, `details` listing changed field names (not values, no PII). Only the allowlisted columns are bound (RG-T16): `first_name`, `last_name`, `email`, `role_id`, `is_active` (+ optional password rehash). |
| **[POST-1]** | `user` updated, `updated_at` refreshed; one `audit_log` row recorded |
| **[OUT-1]** | Redirect with success message |
---
@ -465,7 +486,8 @@ These rules apply to multiple operations and are centralised here to avoid repet
| **[PRE-2]** | Actor is not targeting their own account (`$targetUserId !== $currentUserId`) |
| **[RG-1]** | `UPDATE user SET is_active = 0, updated_at = NOW() WHERE id = :id` |
| **[RG-2]** | The user's potentially active session is invalidated on next request: middleware checks `user.is_active = 1` on each authenticated request |
| **[POST-1]** | `user.is_active = 0`; user cannot log in; history remains intact |
| **[RG-3 — PIN + audit]** | Sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.deactivate'`, `entity_type='user'`, `entity_id=:id`. |
| **[POST-1]** | `user.is_active = 0`; user cannot log in; history remains intact; one `audit_log` row recorded |
| **[OUT-1]** | Redirect with success message |
| **[ERR-1]** | Self-deactivation attempt: HTTP 403, `{error: {code: "SELF_DEACTIVATION_FORBIDDEN"}}` |
@ -485,11 +507,31 @@ These rules apply to multiple operations and are centralised here to avoid repet
| **[RG-3]** | Effect is immediate for new requests; sessions of users bearing this role see the change on the next permission check (sessions store `role_id`; permissions are reloaded from DB on each check). |
| **[RG-4 — custom role]** | Creating a custom role: INSERT `role` (code UNIQUE, label, description, default_route nullable, order_source nullable); INSERT `role_visible_source` rows as needed. |
| **[RG-5 — order_source]** | `role.order_source` controls the auto-tagging of `customer_order.source` when this role creates an order. NULL for admin and manager (they can create on behalf of any channel). |
| **[POST-1]** | `role_permission` reflects exactly the selected permissions for this role |
| **[RG-6 — PIN + audit change-log]** | RBAC changes are high-impact (privilege escalation): per-staff PIN (RG-T13) + one `audit_log` row (RG-T14) per change, `action_code='role.manage'`, `entity_type='role'`, `entity_id=:role_id`. Because permissions are rewritten delete-and-reinsert (RG-1), the `details` JSON records the **diff** — permission codes added and removed — computed before the rewrite, so the trail shows exactly which capabilities a role gained or lost and who granted them. |
| **[POST-1]** | `role_permission` reflects exactly the selected permissions for this role; one `audit_log` row recorded with the permission diff |
| **[OUT-1]** | Redirect with success message |
---
### 10.5 ERASE_USER_PII (RGPD anonymisation)
**Security-by-design operation (no v0.1 / v0.2 MCT predecessor). Honours the RGPD right to
erasure (Cr 3.d) without breaking referential integrity or the audit trail (dict. note 13).**
| Tag | Content |
|-----|---------|
| **[PRE-1]** | Actor authenticated, holds permission `user.update` (erasure is an admin operation) |
| **[PRE-2]** | Per-staff PIN verified (RG-T13) — sensitive action |
| **[PRE-3]** | Target `user.id` exists and `anonymized_at IS NULL` (not already anonymised) |
| **[RG-1 — anonymise, not delete]** | In one transaction: `UPDATE user SET email = CONCAT('anon-', id, '@wakdo.invalid'), first_name = '', last_name = '', password_hash = '', pin_hash = NULL, password_reset_token_hash = NULL, is_active = 0, anonymized_at = NOW() WHERE id = :id`. The placeholder domain is RFC 2606 reserved (`.invalid`), keeps `email` UNIQUE and non-identifying. |
| **[RG-2 — preserve links]** | The row persists, so FKs pointing at it (`stock_movement.user_id`, `customer_order.acting_user_id`, `audit_log.actor_user_id`) stay valid and now resolve to an anonymised principal. Accountability for past actions is preserved in form (who-as-id) without retaining PII. |
| **[RG-3 — audit]** | One `audit_log` row (RG-T14): `action_code='user.erase_pii'`, `entity_type='user'`, `entity_id=:id`. The `summary`/`details` record the erasure event and its legal basis, not the erased values. |
| **[POST-1]** | `user` row anonymised: PII fields cleared/placeholdered, credentials invalidated, `anonymized_at` set, `is_active = 0`. Referential links intact. |
| **[OUT-1]** | Confirmation; the user disappears from active lists, remains as an anonymised tombstone in history. |
| **[ERR-1]** | Already anonymised: HTTP 409, `{error: {code: "ALREADY_ANONYMISED"}}` |
---
## 11. Domain 9 — Stats and KPI
### 11.1 READ_STATS
@ -519,17 +561,21 @@ These rules apply to multiple operations and are centralised here to avoid repet
|-----|---------|
| **[PRE-1]** | Login form submitted with email and password |
| **[PRE-2]** | CSRF token of the form is valid (anti-CSRF protection) |
| **[PRE-3 — throttle gate]** | If the account is in a throttling window (`user.lockout_until IS NOT NULL AND lockout_until > NOW()`), reject with the generic error before any password check. Throttling is also keyed per source IP via the `login_throttle` table: if a row exists for the source IP with `lockout_until IS NOT NULL AND lockout_until > NOW()`, reject with the same generic error, so distributed attempts on many accounts are slowed too. |
| **[RG-1]** | Lookup: `SELECT * FROM user WHERE email = :email AND is_active = 1 LIMIT 1` |
| **[RG-2]** | Password verification: `password_verify($password, $user->password_hash)`. On failure: same generic error whether the email does not exist or the password is wrong (protection against email enumeration). |
| **[RG-2]** | Password verification: `password_verify($password, $user->password_hash)`. On failure: same generic error whether the email does not exist or the password is wrong (protection against email enumeration). To keep timing comparable when the email is unknown, a dummy `password_verify` against a fixed decoy hash is run. |
| **[RG-3]** | On success: `session_regenerate(true)` (session ID regeneration, protection against session fixation) |
| **[RG-4]** | Session storage: `$_SESSION['user_id']`, `$_SESSION['role_id']`, `$_SESSION['logged_in_at']` |
| **[RG-5]** | UPDATE: `UPDATE user SET last_login_at = NOW() WHERE id = :id` |
| **[RG-6]** | Session timeouts: idle timeout 4h (detection via last-activity timestamp in session); absolute timeout 10h (detection via `logged_in_at`) |
| **[RG-7]** | Redirect target is `role.default_route` (dynamic; no hardcoded role name in routing logic) |
| **[POST-1]** | PHP session open with `user_id` and `role_id`; `user.last_login_at` updated |
| **[RG-8 — failure handling, degressive backoff]** | On a failed verification, the per-account counter on `user`: `UPDATE user SET failed_login_attempts = failed_login_attempts + 1, last_failed_login_at = NOW()`, and once a threshold is reached (suggestion: 5) set `lockout_until = NOW() + INTERVAL (base * 2^(attempts - threshold)) SECOND`, capped (suggestion: cap a few minutes). In the same step, the per-IP dimension is recorded in the `login_throttle` table: upsert the row keyed on `ip_address` (insert if absent, else increment `failed_attempts`; reset the window when expired via `window_started_at`), update `last_attempt_at = NOW()`, and once the IP threshold is reached set `lockout_until` with the same degressive backoff. This is a degressive backoff, not an indefinite lock — it slows brute force without letting a fat-finger streak deny service to a kitchen mid-shift. Write one `audit_log` row (`action_code='auth.login_failed'`, `actor_user_id` if the email resolved, else NULL). |
| **[RG-9 — success reset]** | On success, reset the per-account counter `failed_login_attempts = 0`, clear `lockout_until = NULL`, and also clear the per-IP `login_throttle` row for the source IP (reset `failed_attempts = 0`, `lockout_until = NULL`, restart `window_started_at`), then write one `audit_log` row (`action_code='auth.login_success'`, `actor_user_id`, `actor_role_id`). |
| **[POST-1]** | PHP session open with `user_id` and `role_id`; `user.last_login_at` updated; `failed_login_attempts` reset |
| **[OUT-1]** | Redirect to `role.default_route` |
| **[ERR-1]** | Incorrect credentials or inactive account: generic message "Email or password incorrect" (no distinction to prevent enumeration) |
| **[ERR-1]** | Incorrect credentials or inactive account: generic message "Email or password incorrect" (no distinction to prevent enumeration); failure counter incremented (RG-8) |
| **[ERR-2]** | Invalid CSRF token: HTTP 403 |
| **[ERR-3]** | Account in throttling window (PRE-3): same generic message; the attempt does not reveal that the account exists or is locked |
---
@ -548,7 +594,21 @@ These rules apply to multiple operations and are centralised here to avoid repet
---
## 13. Automated treatments — Crons (outside user interactions)
### 12.3 RESET_PASSWORD
**Security-by-design operation (no v0.1 predecessor). Two phases: request, then confirm.**
| Tag | Content |
|-----|---------|
| **[PRE-1]** | Request phase: a `user` submits the "forgot password" form with an email; CSRF token valid |
| **[RG-1 — request, enumeration-safe]** | Look up the email. The same neutral response ("if the account exists, an email has been sent") is returned whether or not the email exists, to avoid account enumeration. |
| **[RG-2 — token generation]** | If the email resolves to an active user: generate a cryptographically random token (e.g. 32 bytes from a CSPRNG); store its **hash** in `password_reset_token_hash` and `password_reset_expires_at = NOW() + INTERVAL 1 HOUR`. The **raw** token is sent once in the reset link (not stored in clear). |
| **[PRE-2]** | Confirm phase: the user opens the reset link with the raw token and submits a new password; CSRF token valid |
| **[RG-3 — confirm]** | Hash the submitted token and match it against `password_reset_token_hash` where `password_reset_expires_at > NOW()`. On match: `password_hash = password_hash($new, PASSWORD_ARGON2ID)` (min length 8), then clear `password_reset_token_hash = NULL` and `password_reset_expires_at = NULL`, and reset `failed_login_attempts = 0`, `lockout_until = NULL`. One-time use. |
| **[RG-4 — audit]** | Write one `audit_log` row (RG-T14), `action_code='auth.password_reset'`, `actor_user_id = :id`. |
| **[POST-1]** | Password replaced with a new argon2id hash; reset token consumed and cleared |
| **[OUT-1]** | Confirmation; redirect to login |
| **[ERR-1]** | Invalid or expired token: generic message inviting a new reset request (no detail on which condition failed) |
These treatments are executed by the `wakdo-cron` service container in the maintenance
window 01:30-09:30 (outside active service). They are outside the MCT scope (technical
@ -581,6 +641,24 @@ treatments, no user trigger) but are documented here for consistency with PROJEC
| **[RG-2]** | Retention: keep the last 7 dumps; delete older ones |
| **[POST-1]** | SQL dump available for restoration |
### 13.4 Audit log retention purge (cron daily)
| Tag | Content |
|-----|---------|
| **[TRIGGER]** | Cron: `15 4 * * *` (maintenance window) |
| **[RG-1]** | `DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL :retention_months MONTH` (suggestion: 12 months, legitimate-interest / fiscal traceability — configurable in `.env`). |
| **[RG-2]** | The window is decoupled from user PII lifecycle: anonymisation (10.5) removes PII immediately on request, while the audit trail ages out on its own schedule (dict. note 13). |
| **[POST-1]** | `audit_log` rows older than the retention window removed; recent accountability preserved. |
### 13.5 login_throttle purge (cron daily)
| Tag | Content |
|-----|---------|
| **[TRIGGER]** | Cron: `45 4 * * *` (maintenance window) |
| **[RG-1]** | `DELETE FROM login_throttle WHERE (lockout_until IS NULL OR lockout_until < NOW()) AND last_attempt_at < NOW() - INTERVAL 24 HOUR` — purge rows with no active lockout whose last failed attempt is older than 24h. |
| **[RG-2]** | Rows still serving an active lockout are retained; the per-IP counter (S1) is bounded by this purge so the table does not grow unbounded from one-off attempts. |
| **[POST-1]** | Stale `login_throttle` rows removed; active throttles and recent activity preserved. |
---
## 14. State machine — consistency recap (MLT)

View file

@ -0,0 +1,232 @@
# Diagramme de sequence securite - Annulation de commande avec PIN (CANCEL_ORDER)
**Phase UML** : P1 - Conception, complement UML (passe security-by-design)
**Statut** : v0.2 - flux sensible PIN-gate + audit_log (controle anti-fraude interne)
**Date** : 2026-06-12
**Branche** : `feat/p1-conception`
**Auteur methodologie** : BYAN
---
## 1. Objet du document
Ce document decrit le **flux temporel securise** de l'annulation d'une commande
en back-office (`CANCEL_ORDER`). L'annulation est une action de **manipulation
d'argent** : annuler une commande deja `paid` peut servir a masquer un
detournement d'especes (l'equipier encaisse, annule, garde le cash). Le flux
ci-dessous materialise le controle qui adresse ce risque : une **re-authentification
par PIN par equipier** (`RG-T13`) avant l'execution, et l'ecriture d'une ligne
**`audit_log` immuable** dans la meme transaction que l'effet (`RG-T14`), de sorte
que chaque annulation est rattachee a une personne meme sur un poste partage.
Le diagramme reste au niveau conceptuel / logique. Il nomme les echanges entre
participants sans detailler l'implementation PHP ni le SQL exact. Il complete
l'operation `CANCEL_ORDER` du `docs/merise/mlt.md` (7.1), la transition T5 de
`docs/uml/state-commande.md` (`paid -> cancelled`, re-credit du stock) et le cas
d'utilisation "Annuler une commande" de `docs/uml/use-cases.md` (UC13).
**Sources** :
- `docs/merise/mlt.md` 7.1 `CANCEL_ORDER` (PRE-1..PRE-3, RG-1..RG-6, POST, ERR)
- `docs/merise/mlt.md` section 2 : `RG-T13` (PIN sensible), `RG-T14` (audit_log), `RG-T11` (re-credit dans la meme transaction), `RG-T07` (garde de concurrence), `RG-T08` (transaction atomique)
- `docs/merise/dictionary.md` 3.14 (`user.pin_hash`, argon2id) et 3.20 (`audit_log`)
- `docs/uml/state-commande.md` T5 (`paid -> cancelled`, `stock_movement` type `cancellation`)
---
## 2. Participants
| Participant | Role | Couche |
|---|---|---|
| **Equipier** | Counter / Drive / Admin titulaire de `order.cancel`, sur poste partage | Acteur |
| **Admin UI** | Interface back-office (Bloc 3, formulaire d'annulation + saisie PIN) | Presentation |
| **Controleur** | Back-end REST sous `/api/*` (Bloc 2), orchestre la transaction | Application |
| **Verif PIN** | Service de verification du PIN (`password_verify` argon2id) | Application |
| **BDD** | Base de donnees MariaDB | Persistance |
La session est **partagee par poste de travail** pour le routine 95% ; le PIN
re-introduit une attribution individuelle sur le sous-ensemble sensible
(`mlt.md` `RG-T13`). Le PIN n'est pas une session : il est verifie a chaque action
sensible et sert a capturer le `user_id` qui sera ecrit dans `audit_log`.
---
## 3. Diagramme de sequence
```mermaid
sequenceDiagram
actor Equipier
participant AdminUI as Admin UI
participant Ctrl as Controleur
participant PIN as Verif PIN
participant BDD
Note over Equipier,BDD: Phase 1 - Demande et controle de permission
Equipier->>AdminUI: demander l'annulation d'une commande
AdminUI->>Ctrl: POST /api/orders/{id}/cancel
Ctrl->>Ctrl: verifier session active + is_active = 1 (RG-T02)
Ctrl->>BDD: lire role_permission (permission order.cancel ?) (RG-T03, PRE-1)
BDD-->>Ctrl: permission presente
Ctrl->>BDD: lire customer_order (existe ? statut ?) (PRE-2, PRE-3)
BDD-->>Ctrl: status courant
alt status hors ['pending_payment','paid'] (delivered ou cancelled)
Ctrl-->>AdminUI: 422 CANNOT_CANCEL_IN_STATE {current_status}
AdminUI-->>Equipier: refus, aucun changement d'etat (ERR-1)
else status annulable
Note over Equipier,BDD: Phase 2 - Re-authentification PIN (RG-T13)
Ctrl-->>AdminUI: demander la saisie du PIN
Equipier->>AdminUI: saisir le PIN
AdminUI->>Ctrl: soumettre le PIN (re-auth action sensible)
Ctrl->>PIN: verifier le PIN soumis
PIN->>BDD: lire user.pin_hash de l'equipier
BDD-->>PIN: pin_hash (argon2id)
PIN->>PIN: password_verify(pin, pin_hash)
alt PIN incorrect
PIN-->>Ctrl: echec
Ctrl-->>AdminUI: refus du PIN, action rejetee
AdminUI-->>Equipier: PIN incorrect, aucun changement d'etat
else PIN correct
PIN-->>Ctrl: succes, acting user_id capture (RG-T13)
Note over Equipier,BDD: Phase 3 - Transaction atomique (RG-T08, RG-T11)
Ctrl->>BDD: BEGIN transaction
Ctrl->>BDD: UPDATE customer_order SET status = 'cancelled', cancelled_at = NOW() WHERE id = :id AND status IN ('pending_payment','paid') (RG-1, RG-T07)
BDD-->>Ctrl: lignes affectees
alt 0 ligne affectee (annulation concurrente)
Ctrl->>BDD: ROLLBACK
Ctrl-->>AdminUI: 409 INVALID_TRANSITION (ERR-2)
AdminUI-->>Equipier: deja annulee par un autre poste
else 1 ligne affectee
opt statut anterieur = paid (re-credit du stock)
Ctrl->>BDD: UPDATE ingredient SET stock_quantity = stock_quantity + :units (par ingredient consomme) (RG-3)
Ctrl->>BDD: INSERT stock_movement (type cancellation, delta +units, order_id, user_id de l'equipier) (RG-3)
end
Ctrl->>BDD: INSERT audit_log (action_code order.cancel, actor_user_id, actor_role_id, entity_type customer_order, entity_id, summary [statut anterieur + montant re-credite]) (RG-6, RG-T14)
Ctrl->>BDD: COMMIT
BDD-->>Ctrl: transaction validee
Ctrl-->>AdminUI: 200 annulation confirmee (OUT-1)
AdminUI-->>Equipier: commande annulee, trace enregistree
end
end
end
```
---
## 4. Notes de modelisation : chaque pas et sa regle
Le tableau ci-dessous mappe chaque interaction du diagramme a la regle
`mlt.md` 7.1 ou a la regle transverse correspondante, et a l'entite ecrite.
| # | Interaction | Regle (mlt.md) | Entite ecrite / lue |
|---|---|---|---|
| 1 | Verifier session active + `is_active = 1` | `RG-T02` | `user` (lecture) |
| 2 | Verifier `order.cancel` via `role_permission` | `RG-T03`, 7.1 PRE-1 | `role_permission` (lecture) |
| 3 | Charger la commande et lire son `status` | 7.1 PRE-2, PRE-3 | `customer_order` (lecture) |
| 4 | Bloquer si `status` est `delivered` ou `cancelled` | 7.1 ERR-1 | aucune ecriture (HTTP 422) |
| 5 | Demander + verifier le PIN (`password_verify` argon2id) | `RG-T13`, 7.1 RG-6 | `user.pin_hash` (lecture) |
| 6 | Rejeter si PIN incorrect, sans changement d'etat | `RG-T13` | aucune ecriture |
| 7 | Capturer l'`acting user_id` pour l'audit | `RG-T13` | (en memoire, sert aux pas 11-12) |
| 8 | `BEGIN` transaction | `RG-T08` | transaction |
| 9 | `UPDATE customer_order SET status='cancelled'` avec garde `AND status IN (...)` | 7.1 RG-1, `RG-T07` | `customer_order` (ecriture) |
| 10 | `ROLLBACK` + 409 si 0 ligne affectee (concurrence) | 7.1 ERR-2, `RG-T07` | aucune ecriture nette |
| 11 | Re-credit conditionnel du stock si statut anterieur `paid` | 7.1 RG-3, `RG-T11` | `ingredient`, `stock_movement` (type `cancellation`) |
| 12 | `INSERT audit_log` dans la meme transaction | 7.1 RG-6, `RG-T14` | `audit_log` (ecriture) |
| 13 | `COMMIT` (tout ou rien) | `RG-T08`, `RG-T11` | transaction |
| 14 | Reponse 200 de confirmation | 7.1 OUT-1 | aucune ecriture |
### 4.1 Re-credit conditionnel du stock (`RG-T11`)
Le re-credit ne s'applique que si la commande etait au statut `paid` avant
l'annulation (7.1 RG-3). Une commande `pending_payment` n'avait pas encore
decremente le stock (le decrement a lieu a la transition `paid`), donc il n'y a
rien a re-crediter. Pour chaque `order_item` d'une commande `paid`, les unites
consommees sont recalculees (format `normal`/`maxi`, ajustees par les
`order_item_modifier`), `ingredient.stock_quantity` est re-incremente et un
`stock_movement` de type `cancellation` est insere. `RG-T11` garantit que ce
re-credit et l'`UPDATE` du statut sont dans la **meme transaction** : il n'y a pas
de decrement orphelin si une etape echoue.
### 4.2 Garde de concurrence (`RG-T07`)
L'`UPDATE` porte la clause `AND status IN ('pending_payment','paid')`. Si deux
postes tentent d'annuler la meme commande au meme instant, seul le premier
obtient une ligne affectee ; le second recoit 0 ligne et le controleur repond
409 `INVALID_TRANSITION` apres `ROLLBACK` (7.1 ERR-2). Cette garde optimiste
reduit le risque d'une double annulation et d'un double re-credit du stock.
### 4.3 PIN distinct de la session (`RG-T13`)
La session reste **partagee par poste** pour le flux routine. Le PIN est verifie
a chaque action du sous-ensemble sensible (annulation, prix/VAT, RBAC, gestion
utilisateur, correction d'inventaire), et c'est lui qui fournit l'`actor_user_id`
ecrit dans `audit_log`. Le `pin_hash` est un hash argon2id (`dictionary.md` 3.14),
compare via `password_verify` ; il fait partie des champs RESTRICTED tenus hors
des logs et des reponses API.
---
## 5. Menace adressee : repudiation et detournement d'especes
L'annulation d'une commande `paid` est le geste qui permet le schema de fraude
"encaisser puis annuler pour garder le cash" (insider cash-skim). Sans controle,
sur un poste a session partagee, une annulation ne serait rattachee a personne :
l'auteur pourrait nier l'avoir faite (repudiation). Le flux ci-dessus reduit le
risque de ce schema en combinant deux mecanismes concrets :
- **PIN par equipier (`RG-T13`)** : l'annulation exige une re-authentification
individuelle. Sur un poste partage, cela rattache l'acte a une personne et non
au seul poste. Le PIN tend a dissuader l'usage opportuniste d'une session
laissee ouverte par un collegue.
- **`audit_log` immuable (`RG-T14`)** : chaque annulation ecrit une ligne
`audit_log` (`action_code = order.cancel`, `actor_user_id`, `actor_role_id`,
`entity_type`, `entity_id`, `summary` avec le statut anterieur et le montant
re-credite) dans la **meme transaction** que l'`UPDATE` du statut. La table
n'accepte ni `UPDATE` ni `DELETE` au niveau applicatif (`dictionary.md` 3.20).
Une annulation ne peut donc pas exister sans sa trace, et la trace ne peut pas
etre effacee par l'auteur.
L'effet combine : un pic d'annulations rattachees a un meme `actor_user_id`
devient visible et opposable lors d'une revue. Ceci ne supprime pas le risque,
mais le **reduit** en transformant un acte anonyme et niable en un acte attribue
et trace. La residualite (collusion, partage de PIN) releve de controles
organisationnels hors du modele de donnees.
> Note : `audit_log` enregistre des **noms de champs** et un `summary`
> non-personnel (`details` stocke les noms de champs modifies, pas de PII),
> conformement a `RG-T14` et a la classification de `PROJECT_CONTEXT.md` section 19.
> L'attribution `stock_movement.user_id` du re-credit complete la trace cote stock
> sans double journalisation.
---
## 6. Coherence avec les autres livrables
| Verification | Resultat |
|---|---|
| Statuts annulables coherents avec `state-commande.md` | Oui : `pending_payment` et `paid` (T3, T5) ; `delivered` non annulable (7.1 ERR-1) |
| Transition `paid -> cancelled` avec re-credit | Couverte par T5 et 7.1 RG-3 (`stock_movement` type `cancellation`) |
| Entites ecrites presentes au dictionnaire | `customer_order` (3.10), `ingredient`, `stock_movement`, `audit_log` (3.20) |
| Regle PIN appliquee | `RG-T13` (sous-ensemble sensible inclut 7.1) ; `user.pin_hash` (3.14) |
| Regle audit appliquee | `RG-T14` ; colonnes conformes a `audit_log` (3.20) |
| Atomicite re-credit + statut + audit | `RG-T08` + `RG-T11` (une transaction, `COMMIT` / `ROLLBACK`) |
| Codes d'erreur | 422 `CANNOT_CANCEL_IN_STATE` (ERR-1), 409 `INVALID_TRANSITION` (ERR-2) |
---
## 7. Arbitrage tranche
Le flux retient la re-authentification **par PIN** plutot qu'une re-saisie du mot
de passe complet : le PIN couvre le sous-ensemble sensible sans casser le routine
95% a session partagee (`RG-T13`), tout en fournissant l'attribution
individuelle. L'`audit_log` est ecrit dans la **meme transaction** que l'effet
(`RG-T14` + `RG-T08`) : une annulation sans trace ne peut pas etre committee. Le
re-credit du stock est conditionnel au statut anterieur `paid` (`RG-T11`), ce qui
ecarte un re-credit indu sur une commande `pending_payment` qui n'a pas ete
decrementee. Les codes d'erreur (`CANNOT_CANCEL_IN_STATE`, `INVALID_TRANSITION`)
reprennent ceux de `mlt.md` 7.1, sans en inventer de nouveaux.

View file

@ -1,8 +1,8 @@
# Diagramme de sequence - Passer une commande (borne client)
**Phase UML** : P1 - Conception, complement UML (apres MCD)
**Statut** : v0.1
**Date** : 2026-05-21
**Statut** : v0.2 - prod-like, creation atomique (create + pay)
**Date** : 2026-06-11
**Branche** : `feat/p1-conception`
**Auteur methodologie** : BYAN
@ -12,18 +12,24 @@
Ce document decrit le **flux temporel** du parcours "passer une commande" cote
**Client sur la borne kiosk** : navigation dans les categories, selection d'un
produit ou composition d'un menu, gestion du panier, validation avec saisie du
numero de retrait, paiement, puis confirmation.
produit ou composition d'un menu (slots + format Normal/Maxi + modifiers
d'ingredients), gestion du panier, validation avec saisie du numero de retrait,
et confirmation. Dans le modele v0.2, la **creation et le paiement sont
atomiques** : un seul appel `POST /api/orders` cree la commande, la fait passer a
`paid`, decremente le stock et journalise les mouvements, dans une meme
transaction.
Le diagramme reste au niveau **conceptuel / logique**. Il nomme les echanges
entre participants sans detailler l'implementation PHP (controllers, models)
ni le SQL exact. Il complete le cas d'utilisation "Passer une commande" de
`docs/uml/use-cases.md` et la machine a etats de `docs/uml/state-commande.md`.
Le diagramme reste au niveau conceptuel / logique. Il nomme les echanges entre
participants sans detailler l'implementation PHP ni le SQL exact. Il complete le
cas d'utilisation "Passer une commande" de `docs/uml/use-cases.md` (4.1), la
machine a etats de `docs/uml/state-commande.md` (T1/T2) et l'operation
`CREATE_ORDER` du `docs/merise/mct.md` (3.3).
**Sources** :
- `docs/PROJECT_CONTEXT.md` section 2 (processus metier), section 7 (endpoints API)
- `docs/merise/dictionary.md` (`commande`, `ligne_commande`, `menu`, `produit`)
- `docs/uml/state-commande.md` (transitions `pending_payment -> paid`)
- `docs/merise/dictionary.md` 3.10-3.13 (`customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`)
- `docs/merise/mct.md` 3.3 CREATE_ORDER (transaction, snapshots, decrement stock)
- `docs/uml/state-commande.md` (transitions T1/T2, atomicite `pending_payment -> paid`)
---
@ -71,16 +77,18 @@ sequenceDiagram
alt Produit a la carte
Client->>Borne: selectionner un produit
Client->>Borne: regler taille / options
opt Personnaliser les ingredients
Client->>Borne: retirer / ajouter un ingredient
end
Borne->>Borne: ajouter la ligne au panier local
else Composition d'un menu
Client->>Borne: selectionner un menu
Borne->>API: GET /api/menus (composition du menu)
API->>BDD: lire menu et composition
BDD-->>API: menu + produits par role
Borne->>API: GET /api/menus (slots + options eligibles)
API->>BDD: lire menu, menu_slot, menu_slot_option
BDD-->>API: menu + slots + options
API-->>Borne: composition (JSON)
Borne-->>Client: afficher les choix par slot (burger, accompagnement, boisson, sauce)
Client->>Borne: choisir chaque composant + tailles
Borne-->>Client: afficher les slots (boisson, accompagnement, sauce) + format Normal/Maxi
Client->>Borne: choisir chaque slot + format + modifiers du burger
Borne->>Borne: ajouter la ligne menu au panier local
end
@ -94,36 +102,35 @@ sequenceDiagram
Borne-->>Client: panier mis a jour
end
Note over Client,BDD: Phase 4 - Validation du panier et saisie du numero
Note over Client,BDD: Phase 4 - Validation, saisie du numero, creation atomique (create + pay)
Client->>Borne: valider la commande
Client->>Borne: saisir le numero de retrait
Borne->>Borne: valider le panier (au moins 1 ligne)
Borne->>API: POST /api/orders (lignes + mode_consommation + numero)
Borne->>Borne: valider le panier (au moins 1 ligne, numero non vide)
Borne->>API: POST /api/orders (lignes + selections + modifiers + service_mode + numero)
API->>API: recalculer les totaux cote serveur
API->>BDD: creer la commande (statut pending_payment)
API->>BDD: creer les lignes (snapshot libelle + prix)
BDD-->>API: commande persistee {id, numero, statut: pending_payment}
API-->>Borne: 201 Created {id, numero, statut: pending_payment, total}
Borne-->>Client: afficher le total a regler
API->>API: recalculer les totaux cote serveur (HT / TVA / TTC, taux par produit)
API->>BDD: BEGIN transaction
API->>BDD: INSERT customer_order (status pending_payment, source kiosk)
API->>BDD: INSERT order_item (snapshot libelle + prix + vat_rate)
API->>BDD: INSERT order_item_selection (par slot de menu rempli)
API->>BDD: INSERT order_item_modifier (par modification d'ingredient)
API->>BDD: UPDATE ingredient.stock_quantity (decrement, ajuste par modifiers)
API->>BDD: INSERT stock_movement (type sale, par unite consommee)
API->>BDD: UPDATE customer_order status -> paid, paid_at = NOW()
API->>BDD: COMMIT
BDD-->>API: commande persistee {id, order_number, status: paid}
Note over Client,BDD: Phase 5 - Paiement (pending_payment -> paid)
Note over Client,BDD: Phase 5 - Confirmation
Client->>Borne: payer la commande
Borne->>API: POST /api/orders/{id}/pay
API->>BDD: enregistrer le paiement, passer la commande a paid (paye_a)
BDD-->>API: commande mise a jour {id, numero, statut: paid}
Note over Client,BDD: Phase 6 - Confirmation
API-->>Borne: 200 OK {id, numero, statut: paid}
Borne-->>Client: ecran de confirmation avec le numero
API-->>Borne: 201 Created {id, order_number, status: paid, total_ttc}
Borne-->>Client: ecran de confirmation avec le numero de retrait
Note over Client,BDD: Cas d'erreur
alt Panier vide ou donnees invalides
API-->>Borne: 4xx {error: code, message}
alt Panier vide, produit indisponible ou donnees invalides
API->>BDD: ROLLBACK (si transaction entamee)
API-->>Borne: 4xx {error: {code, message}}
Borne-->>Client: message d'erreur, retour au panier
end
```
@ -132,27 +139,31 @@ sequenceDiagram
## 4. Notes de modelisation
### 4.1 Recalcul des totaux cote serveur
### 4.1 Recalcul des totaux cote serveur (controle de securite)
La Borne affiche un total **provisoire** calcule localement pour l'experience
utilisateur. L'API recalcule les totaux a la reception du `POST /api/orders` a
partir des prix en base, puis fige les snapshots
(`prix_unitaire_ttc_cents_snapshot`, `libelle_snapshot` dans `ligne_commande`,
voir `dictionary.md` 3.6). Le total affiche par le client n'est pas considere
comme la source de verite : ceci limite la falsification du prix cote client.
partir des prix en base (HT, TVA ligne par ligne via `vat_rate`, TTC), puis fige
les snapshots (`unit_price_cents_snapshot`, `vat_rate_snapshot`,
`label_snapshot` sur `order_item`, voir `dictionary.md` 3.11). Le total affiche
par le client n'est pas considere comme la source de verite : ceci limite la
falsification du prix cote client.
### 4.2 Transitions de statut
### 4.2 Creation atomique (create + pay)
Le parcours materialise les transitions T1 et T2 de
`docs/uml/state-commande.md`, en deux phases successives conformes a la regle
metier :
`docs/uml/state-commande.md` dans **un seul appel et une seule transaction** :
- `POST /api/orders` cree la commande composee en `pending_payment` (T1).
- `POST /api/orders/{id}/pay` enregistre le paiement et fait passer la commande
a `paid` (T2), avec l'horodatage `paye_a`.
- `POST /api/orders` cree la commande en `pending_payment` (T1) puis la fait
passer a `paid` (T2) avant le `COMMIT`. `paid_at` est renseigne.
- La saisie du numero de retrait tient lieu de paiement (cadre RNCP) ; il n'y a
pas d'appel `POST /api/orders/{id}/pay` separe (supprime par rapport au v0.1).
- Le decrement du stock (`ingredient.stock_quantity`) et la journalisation
(`stock_movement` type `sale`) sont inclus dans la meme transaction que
l'insert de la commande : soit tout reussit, soit tout est annule (`ROLLBACK`).
La separation des deux appels reflete les deux phases du cycle de vie :
composer la commande, puis la payer.
Le statut `pending_payment` n'est donc pas observable en dehors de la
transaction (coherent avec `mct.md` section 13).
### 4.3 Panier local jusqu'a la validation
@ -166,9 +177,20 @@ navigateur peut etre envisage plus tard.
`PROJECT_CONTEXT.md` section 4 prevoit un mode de repli ou la Borne lit des
fichiers JSON statiques si l'API est indisponible. Ce mode concerne uniquement
les lectures (phases 1 a 2). La validation (phase 4) et le paiement (phase 5)
requierent l'API ; sans elle, la commande n'est ni persistee ni payee. Ce cas
degrade n'est pas detaille dans le diagramme nominal ci-dessus.
les lectures (phases 1 a 2). La validation et la creation (phase 4) requierent
l'API ; sans elle, la commande n'est ni persistee ni payee. Ce cas degrade
n'est pas detaille dans le diagramme nominal ci-dessus.
### 4.5 Garde-fous securite a venir (passe security-by-design)
Le flux ci-dessus est la cible **fonctionnelle** v0.2. La passe security-by-design
ajoutera, en complement (append, sans reecrire ce flux), des garde-fous sur le
`POST /api/orders` anonyme : cle d'idempotence (`idempotency_key` UNIQUE pour
dedupliquer les POST rejoues), limitation de debit / anti-spam, et verrou
pessimiste `SELECT ... FOR UPDATE` sur les ingredients pendant le decrement
(anti-oversell multi-bornes). Ces ajouts dependent de decisions encore a
trancher (oversell/idempotence, throttling) et seront documentes dans un artefact
`docs/uml/security-sequence.md` dedie.
---
@ -176,18 +198,21 @@ degrade n'est pas detaille dans le diagramme nominal ci-dessus.
| Verification | Resultat |
|---|---|
| Endpoints utilises existent dans `PROJECT_CONTEXT.md` section 7 | `GET /api/categories`, `GET /api/products`, `GET /api/menus`, `POST /api/orders` ; `POST /api/orders/{id}/pay` est a confirmer en section 7 du brief |
| Entites manipulees presentes au MCD | Oui : `categorie`, `produit`, `menu`, `menu_produit`, `commande`, `ligne_commande` |
| Statuts utilises coherents avec `state-commande.md` | Oui : `pending_payment` puis `paid` (T1, T2), valeurs ENUM anglaises |
| Format de reponse JSON | Coherent avec `PROJECT_CONTEXT.md` section 7 (`{data, error}`) et la reponse `{id, number, status}` du POST orders |
| Endpoints utilises existent dans `PROJECT_CONTEXT.md` section 7 | `GET /api/categories`, `GET /api/products`, `GET /api/menus`, `POST /api/orders` ; l'appel `POST /api/orders/{id}/pay` du v0.1 est supprime (creation atomique) |
| Entites manipulees presentes au MCD / dictionnaire | Oui : `category`, `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `stock_movement` |
| Statuts utilises coherents avec `state-commande.md` | Oui : `pending_payment` puis `paid` (T1, T2), atomiques |
| Operation MCT correspondante | `mct.md` 3.3 CREATE_ORDER (transaction unique, snapshots, decrement stock, transition atomique) |
| Format de reponse JSON | Coherent avec `PROJECT_CONTEXT.md` section 7 (`{data, error}`) et la reponse `{id, order_number, status, total_ttc}` du POST orders |
---
## 6. Arbitrage tranche
La phase de paiement est integree au flux conformement a la regle metier des
deux phases (composer puis payer). La sequence suit la machine canonique de
`state-commande.md` : creation en `pending_payment` (T1) puis paiement vers
`paid` (T2), avec des valeurs ENUM en anglais. Point a confirmer au MCT :
l'endpoint de paiement (`POST /api/orders/{id}/pay`) doit etre reporte dans la
section 7 du brief s'il n'y figure pas encore.
La phase de paiement separee du v0.1 (`POST /api/orders/{id}/pay`) est supprimee :
la creation et le passage a `paid` sont atomiques dans `POST /api/orders`,
conformement au MCT v0.2 (3.3) et a la regle metier (saisie du numero = substitut
de paiement). Le decrement de stock et la journalisation `stock_movement` sont
inclus dans la meme transaction, garantissant la coherence stock/commande. Les
valeurs ENUM sont en anglais (`pending_payment`, `paid`). Les garde-fous de
securite (idempotence, rate-limit, verrou pessimiste) relevent de la passe
security-by-design et seront ajoutes en complement (section 4.5).

View file

@ -1,8 +1,8 @@
# Diagramme d'etats-transitions - Commande
**Phase UML** : P1 - Conception, complement UML (apres MCD)
**Statut** : v0.1
**Date** : 2026-05-21
**Statut** : v0.2 - prod-like, machine a 4 etats
**Date** : 2026-06-11
**Branche** : `feat/p1-conception`
**Auteur methodologie** : BYAN
@ -10,38 +10,41 @@
## 1. Objet du document
Ce document formalise la **machine a etats** de l'attribut `commande.statut`.
Ce document formalise la **machine a etats** de l'attribut `customer_order.status`.
Il decrit les etats possibles d'une commande, les transitions autorisees entre
ces etats, les **evenements** qui les declenchent et les **gardes** (conditions)
qui les conditionnent.
Il complete le MCD (`docs/merise/mcd.md` section 9, qui esquisse le cycle de
vie) et le dictionnaire (`docs/merise/dictionary.md` 3.5, qui declare l'ENUM).
Il complete le MCD (`docs/merise/mcd.md`, cycle de vie de la commande), le
dictionnaire (`docs/merise/dictionary.md` 3.10, qui declare l'ENUM `status`) et
le MCT (`docs/merise/mct.md` section 13, qui resume les transitions par
operation).
---
## 2. Source de verite et regle metier
La regle metier confirmee fixe deux phases successives dans le cycle de vie
d'une commande : le client **compose** sa commande, **puis** il **paie**. Une
fois payee, la commande entre en preparation. Le paiement fait partie integrante
du cycle. Les valeurs d'etat sont en anglais et alignees sur l'ENUM du
dictionnaire.
Le modele v0.2 (prod-like) reduit la machine a **quatre etats**. La regle metier
distingue la **composition payee** de la **remise** : une commande est creee et
payee en une operation atomique (la saisie du numero de retrait tient lieu de
paiement dans le cadre RNCP), puis elle est remise au client en un geste unique.
| Source | Valeurs de statut |
|---|---|
| `dictionary.md` 3.5 (ENUM SQL) | `pending_payment`, `paid`, `preparing`, `ready`, `delivered`, `cancelled` |
| Regle metier confirmee | composer -> payer -> preparer -> pret -> remettre |
| `dictionary.md` 3.10 (ENUM SQL) | `pending_payment`, `paid`, `delivered`, `cancelled` |
| `mct.md` section 13 (transitions) | creer+payer -> remettre, annulation depuis tout etat non terminal |
**Machine a etats canonique** : la machine ci-dessous est la seule autorisee.
Elle suit l'ENUM du dictionnaire et la regle metier des deux phases :
> Le dictionnaire (`dictionary.md` 3.10) et la machine ci-dessous partagent la
> meme ENUM a 4 valeurs, ce qui maintient la coherence entre le modele de
> donnees et le modele d'etats (cross-validation, mantra #34).
- `pending_payment` : commande composee, en attente de paiement.
- `paid` : paiement effectue ; la commande peut entrer en file de preparation.
> Le dictionnaire (`dictionary.md` 3.5) et la machine ci-dessous partagent la
> meme ENUM, ce qui maintient la coherence entre le modele de donnees et le
> modele d'etats (cross-validation, mantra #34).
**Etats supprimes par rapport au v0.1** : `preparing` et `ready`. En contexte
fast-food, l'affichage cuisine (KDS) est un dispositif visuel : l'equipier lit
le ticket et agit. Ces deux etats intermediaires ajoutaient des transitions sans
valeur metier proportionnelle. La cuisine est en **lecture seule** ; la remise
(`DELIVER_ORDER`) est le geste unique qui fait avancer le statut. Le KPI est le
temps total `delivered_at - paid_at` (SLA ~10 min) ; la couleur du KDS est
calculee a l'affichage depuis `now - paid_at`, sans etat stocke supplementaire.
---
@ -49,12 +52,16 @@ Elle suit l'ENUM du dictionnaire et la regle metier des deux phases :
| Etat | Valeur ENUM | Signification | Acteur qui declenche l'entree |
|---|---|---|---|
| En attente de paiement | `pending_payment` | Commande composee, panier fige, en attente de paiement. | Client (kiosk) ou Accueil (counter/drive) |
| Payee | `paid` | Paiement effectue ; la commande peut entrer en file de preparation. | Client (paiement) ou Accueil |
| En preparation | `preparing` | Prise en charge par la Preparation, en cuisine. | Preparation |
| Prete | `ready` | Preparation terminee, prete au comptoir. | Preparation |
| Livree | `delivered` | Remise effectuee au client. Etat **final**. | Accueil |
| Annulee | `cancelled` | Commande abandonnee ou annulee. Etat **final**. | Client, Accueil ou Administration |
| En attente de paiement | `pending_payment` | Etat initial transitoire : commande composee, en attente de paiement. Non observable hors transaction (voir section 7). | Client (kiosk) ou Counter/Drive (back-office) |
| Payee | `paid` | Paiement effectue ; la commande entre en file de preparation (lecture seule cuisine). | Client (kiosk) ou Counter/Drive |
| Livree | `delivered` | Remise effectuee au client. Etat **final**. | Counter ou Drive |
| Annulee | `cancelled` | Commande abandonnee ou annulee avant remise. Etat **final**. | Counter, Drive ou Admin |
`pending_payment` est l'etat par defaut a l'INSERT (`dictionary.md` 3.10), mais
la transition vers `paid` est realisee dans la **meme transaction** que la
creation (operations `CREATE_ORDER` / `CREATE_COUNTER_ORDER` du MCT). Il est donc
conserve dans l'ENUM pour la lisibilite du cycle et pour laisser la porte
ouverte a un paiement reel ulterieur sans migration destructive.
---
@ -62,19 +69,13 @@ Elle suit l'ENUM du dictionnaire et la regle metier des deux phases :
```mermaid
stateDiagram-v2
[*] --> pending_payment : creer commande (kiosk / counter / drive)
[*] --> pending_payment : creer la commande (kiosk / counter / drive)
pending_payment --> paid : payer\n[panier contient au moins 1 ligne]
pending_payment --> cancelled : abandonner\n[avant paiement]
pending_payment --> paid : payer\n[atomique dans CREATE_ORDER / CREATE_COUNTER_ORDER\nsaisie du numero = substitut de paiement]
pending_payment --> cancelled : annuler\n[avant remise]
paid --> preparing : prendre en charge\n[acteur Preparation, file triee par heure croissante]
paid --> cancelled : annuler\n[Accueil ou Administration]
preparing --> ready : declarer preparee\n[acteur Preparation]
preparing --> cancelled : annuler\n[rupture produit / decision Administration]
ready --> delivered : remettre au client\n[acteur Accueil]
ready --> cancelled : annuler\n[client absent / non recuperee]
paid --> delivered : remettre au client\n[acteur Counter / Drive, geste unique]
paid --> cancelled : annuler\n[Counter / Drive / Admin, re-credit du stock]
delivered --> [*]
cancelled --> [*]
@ -86,29 +87,29 @@ stateDiagram-v2
| # | De | Vers | Evenement declencheur | Garde (condition) | Acteur |
|---|---|---|---|---|---|
| T1 | (initial) | `pending_payment` | Creation de la commande composee | Au moins un item ajoute au panier en cours | Client / Accueil |
| T2 | `pending_payment` | `paid` | Paiement de la commande | La commande contient au moins une `ligne_commande` ; le paiement aboutit | Client / Accueil |
| T3 | `pending_payment` | `cancelled` | Abandon avant paiement | Commande pas encore payee | Client / Accueil |
| T4 | `paid` | `preparing` | Prise en charge en file | La commande est la plus ancienne non traitee (tri par heure de livraison croissante) | Preparation |
| T5 | `paid` | `cancelled` | Annulation avant preparation | Decision operationnelle | Accueil / Administration |
| T6 | `preparing` | `ready` | Declaration "preparee" | Preparation terminee | Preparation |
| T7 | `preparing` | `cancelled` | Annulation pendant preparation | Rupture produit ou decision Administration | Preparation / Administration |
| T8 | `ready` | `delivered` | Remise physique au client | Le client se presente avec le bon numero | Accueil |
| T9 | `ready` | `cancelled` | Annulation apres preparation | Client non present / commande non recuperee | Accueil / Administration |
| T1 | (initial) | `pending_payment` | Creation de la commande composee | Au moins une ligne (`order_item`) ; numero de retrait non vide | Client / Counter / Drive |
| T2 | `pending_payment` | `paid` | Paiement (atomique a la creation) | La commande contient au moins une `order_item` ; decrement du stock et insert dans la meme transaction | Client / Counter / Drive |
| T3 | `pending_payment` | `cancelled` | Abandon avant paiement | Commande pas encore payee | Counter / Drive / Admin |
| T4 | `paid` | `delivered` | Remise physique au client | La commande est `paid` ; l'acteur detient `order.deliver` ; source compatible avec son role (`role_visible_source`) | Counter / Drive |
| T5 | `paid` | `cancelled` | Annulation avant remise | L'acteur detient `order.cancel` ; le stock consomme est re-credite (`stock_movement` type `cancellation`) | Counter / Drive / Admin |
### Invariants de la machine a etats
- `delivered` et `cancelled` sont des etats **finaux** : aucune transition n'en
sort.
- Aucune transition ne revient en arriere (pas de `preparing -> paid`). Une
erreur operationnelle se traite par annulation puis nouvelle commande, pour
preserver l'integrite de l'historique et des snapshots de prix.
- La transition vers `cancelled` est possible depuis tous les etats **sauf**
`delivered` (une commande remise ne s'annule pas dans ce modele). Ceci est
coherent avec `mcd.md` section 9 : "Annuler : transition vers `cancelled`
(depuis tout statut sauf `delivered`)".
- `paye_a` (DATETIME, `dictionary.md` 3.5) est renseigne au moment de la
transition T2 (`pending_payment -> paid`) et reste NULL avant.
- Aucune transition ne revient en arriere. Une erreur operationnelle se traite
par annulation puis nouvelle commande, pour preserver l'integrite de
l'historique et des snapshots (`label_snapshot`, `unit_price_cents_snapshot`,
`vat_rate_snapshot` sur `order_item`).
- La transition vers `cancelled` est possible depuis `pending_payment` et
`paid`, mais pas depuis `delivered` (une commande remise ne s'annule pas dans
ce modele). Coherent avec `mct.md` 7.1 (`CANCEL_ORDER`).
- `paid_at` (DATETIME, `dictionary.md` 3.10) est renseigne a la transition T2.
`delivered_at` est renseigne a T4. `cancelled_at` est renseigne a T3/T5. Les
trois colonnes sont NULL tant que la transition correspondante n'a pas eu lieu.
- La cuisine (`kitchen`) ne declenche aucune transition : son acces a la file
des commandes `paid` est en **lecture seule** (`mct.md` 5.1,
`LIST_ORDERS_DISPLAY`).
---
@ -116,29 +117,36 @@ stateDiagram-v2
| Verification | Resultat |
|---|---|
| Tous les etats du diagramme existent dans l'ENUM `dictionary.md` 3.5 | Oui (6 valeurs, toutes utilisees) |
| La regle "annulation possible sauf depuis delivered" de `mcd.md` 9 | Respectee (T5, T7, T9 ; pas de transition depuis `delivered`) |
| Cycle de vie esquisse dans `mcd.md` 9 | Couvert : `pending_payment` -> `paid` (payer), `paid` -> `preparing` (preparer), `preparing` -> `ready` (marquer pret), `ready` -> `delivered` (remettre) |
| Acteurs de `use-cases.md` | Preparation declenche T4/T6/T7 ; Accueil declenche T8/T9 ; Administration peut annuler |
| Tous les etats du diagramme existent dans l'ENUM `dictionary.md` 3.10 | Oui (4 valeurs, toutes utilisees) |
| La regle "annulation possible sauf depuis delivered" de `mct.md` 7.1 | Respectee (T3, T5 ; pas de transition depuis `delivered`) |
| Transition `paid -> delivered` en geste unique de `mct.md` 6.1 | Couvert par T4 (`DELIVER_ORDER`) |
| Atomicite `pending_payment -> paid` de `mct.md` 3.3 / 4.1 / section 13 | Couvert par T2 (saisie du numero = substitut de paiement) |
| Acteurs de `use-cases.md` | Counter/Drive declenchent T4/T5 ; Admin peut annuler (T3/T5) ; Kitchen en lecture seule |
| Timestamps de phase `paid_at` / `delivered_at` / `cancelled_at` | Renseignes a T2 / T4 / T3-T5 |
---
## 7. Arbitrage tranche
La divergence historique entre l'ENUM du dictionnaire et un parcours sans
paiement est resolue par la regle metier confirmee : le cycle de vie comporte
deux phases successives, la composition de la commande puis son paiement. Le
paiement fait partie integrante du cycle.
La machine canonique retenue est donc :
La machine retenue est reduite a quatre etats (Decision 4,
`docs/notes/revue-alignement-p1.md` section 7) :
```
pending_payment -> paid -> preparing -> ready -> delivered
(cancelled atteignable depuis pending_payment, paid, preparing)
pending_payment -> paid -> delivered
| |
+------------+--------> cancelled (depuis pending_payment ou paid)
```
Cette machine est la source de verite partagee par `dictionary.md` 3.5,
`use-cases.md` (cas "Payer la commande" cote Client) et
`sequence-passer-commande.md` (etape paiement entre validation du panier et
confirmation). La colonne `paye_a` est renseignee a la transition T2. A
revalider lors du MCT.
**Note sur la transition `pending_payment -> paid`** : dans le cadre RNCP, le
paiement est remplace par la saisie du numero de commande (kiosk) ou par la
validation de l'equipier (counter/drive). La transition est **atomique** dans
`CREATE_ORDER` et `CREATE_COUNTER_ORDER` : le statut `pending_payment` n'est pas
observable en dehors de la transaction de creation. Il reste declare dans l'ENUM
pour exprimer le cycle de vie complet et pour autoriser un paiement reel
ulterieur (cout d'une valeur d'ENUM).
Les etats `preparing` et `ready` du v0.1 sont supprimes : la cuisine est un
affichage visuel en lecture seule, et la remise fusionne preparation+remise en
un geste unique (`DELIVER_ORDER`). Effet de bord propage : les operations
`MARK_IN_PREPARATION` et `MARK_READY` disparaissent du MCT (voir `mct.md`
sections 1 et 13).

View file

@ -1,8 +1,8 @@
# Diagramme de cas d'utilisation - Wakdo
**Phase UML** : P1 - Conception, complement UML (apres MCD)
**Statut** : v0.1
**Date** : 2026-05-21
**Statut** : v0.2 - prod-like, 5 roles RBAC + catalogue de 23 permissions
**Date** : 2026-06-11
**Branche** : `feat/p1-conception`
**Auteur methodologie** : BYAN
@ -12,174 +12,247 @@
Ce document recense les **cas d'utilisation** de Wakdo, c'est-a-dire les
fonctionnalites observables du systeme du point de vue de ses acteurs. Il
complete le MCD (`docs/merise/mcd.md`) et le dictionnaire
(`docs/merise/dictionary.md`) en passant de la vue **donnees** a la vue
**usages**.
complete le MCD (`docs/merise/mcd.md`), le dictionnaire
(`docs/merise/dictionary.md`) et le MCT (`docs/merise/mct.md`, 26 operations) en
passant de la vue **donnees / traitements** a la vue **usages**.
Le diagramme reste au niveau conceptuel. Il ne prejuge pas de l'ecran ou de
l'endpoint qui realisera chaque cas, mais identifie qui fait quoi.
Le diagramme reste au niveau conceptuel : il identifie qui fait quoi, sans
prejuger de l'ecran ou de l'endpoint qui realise chaque cas. Chaque cas
back-office est rattache a la **permission** qui le conditionne (catalogue fige
de 23 codes, `dictionary.md` 3.17), conformement a la regle RBAC
permission-driven : le code teste une permission, pas un nom de role.
**Sources** :
- `docs/PROJECT_CONTEXT.md` sections 2 (acteurs, processus), 7 (scope RBAC)
- `docs/merise/dictionary.md` (entites `commande`, `role`, `user`)
- `docs/PROJECT_CONTEXT.md` sections 2 (acteurs, processus), 7 (scope back-office)
- `docs/merise/dictionary.md` 3.14-3.18 (`user`, `role`, `role_visible_source`, `permission`, `role_permission`)
- `docs/merise/mct.md` (operations, acteurs, permissions par operation)
---
## 2. Acteurs - perimetre et challenge de pertinence
Le brief (`PROJECT_CONTEXT.md` section 2 et section 7) definit les acteurs
metier. Avant de les retenir, chaque acteur propose dans la consigne initiale
est confronte au perimetre reel du projet.
Le brief initial (`PROJECT_CONTEXT.md` section 2) decrivait quatre acteurs
metier (Client, Accueil, Preparation, Administration) adosses a 3 roles RBAC. Le
modele v0.2 (prod-like, Decision 4 de `revue-alignement-p1.md` section 7) raffine
le back-office en **5 roles** pour coller a l'organisation reelle d'un fast-food
multi-canal. Chaque acteur candidat est confronte au perimetre reel.
| Acteur candidat | Statut | Justification (perimetre reel) |
| Acteur candidat (brief) | Statut v0.2 | Justification (perimetre reel) |
|---|---|---|
| **Client (borne kiosk)** | Retenu | Acteur central du Bloc 1. Compose et valide une commande sur la borne tactile autonome (canal `kiosk`). Non authentifie. |
| **Manager / Admin** | Retenu, fusionne en **Administration** | Le brief ne distingue pas "manager" et "admin" comme deux roles. Le role RBAC reel est `admin` (section 7). Il porte le CRUD catalogue, la gestion des utilisateurs/roles et les stats. On nomme l'acteur **Administration** pour coller au vocabulaire du brief. |
| **Cuisine** | Retenu, renomme **Preparation** | Correspond au role RBAC `preparation` (section 7). Voit la file des commandes a preparer triees par heure de livraison croissante et fait avancer leur statut. Le terme "Cuisine" est un synonyme metier ; le role technique est `preparation`. |
| **Caisse** | Ecarte comme acteur distinct | Challenge : il n'existe pas de role RBAC `caisse` (les 3 roles sont `admin`, `preparation`, `accueil`). Le paiement existe dans le cycle (cote Client sur la borne et cote Accueil au comptoir/drive), mais aucun acteur "Caisse" dedie n'est modelise. L'equivalent operationnel le plus proche est l'**Accueil** (role `accueil`) qui saisit les commandes au comptoir/drive et remet les commandes livrees. |
| **Accueil** | Retenu (non liste dans la consigne mais present au brief) | Role RBAC `accueil`. Saisit les commandes au comptoir (canal `counter`) ou au drive (canal `drive`), puis remet les commandes au client (passage a `delivered`). C'est l'acteur qui recouvre le besoin que la consigne attribuait a "Caisse". |
| **Client (borne kiosk)** | Retenu (acteur `CUSTOMER`) | Acteur central du Bloc 1. Compose et valide une commande sur la borne tactile autonome (canal `kiosk`). **Non authentifie**. |
| **Accueil** | **Scinde** en `counter` et `drive` | Le besoin "Accueil" recouvre deux canaux operationnels distincts : le comptoir (`counter`) et le drive (`drive`). Le v0.2 les separe car le tag `source` de la commande et le filtre de dashboard (`role_visible_source`) different. Tous deux saisissent des commandes, les remettent et les annulent. |
| **Preparation** | Retenu, renomme `kitchen` | Role RBAC `kitchen`. Voit la file des commandes `paid` triees par `paid_at` croissant. **Lecture seule** : ne declenche aucune transition de statut (le KDS est un dispositif visuel ; la remise revient a `counter`/`drive`). |
| **Administration** | **Scinde** en `admin` et `manager` | Le v0.1 fusionnait "Manager/Admin". Le v0.2 distingue : `admin` (gestion des utilisateurs, des roles et permissions, suppressions catalogue) et `manager` (catalogue create/update, stock/reappro, stats), sans acces aux utilisateurs ni au RBAC. Resout le point ouvert v0.1 "Manager vs Admin". |
| **Caisse** | Ecarte (recouvert par `counter`/`drive`) | Aucun role `caisse` n'existe. L'encaissement est atomique a la creation de commande (saisie du numero = substitut de paiement) ; il est realise par le Client (kiosk) ou par `counter`/`drive` (back-office). Resout le point ouvert v0.1 "Caisse absente du RBAC". |
| **Systeme** | Retenu (acteur `SYS`) | Logique interne (generation du numero, reponse API de confirmation). Apparait dans le MCT (3.4 `DISPLAY_CONFIRMATION`) ; non represente comme acteur humain au diagramme. |
### Decision sur les acteurs retenus
Quatre acteurs sont conserves au diagramme :
Six acteurs sont conserves : un acteur public et cinq roles back-office.
1. **Client** (borne, non authentifie)
2. **Administration** (role `admin`)
3. **Preparation** (role `preparation`, ex-"Cuisine")
4. **Accueil** (role `accueil`, recouvre le besoin "Caisse")
1. **Customer** (borne kiosk, non authentifie)
2. **Admin** (role `admin`)
3. **Manager** (role `manager`)
4. **Kitchen** (role `kitchen`, ex-"Preparation", lecture seule)
5. **Counter** (role `counter`, ex-"Accueil" comptoir)
6. **Drive** (role `drive`, ex-"Accueil" drive)
> Decision actee : il n'y a **pas** de parcours employe dedie modelise a part.
> Les cas d'usage des employes (Administration, Preparation, Accueil) sont
> couverts directement ici. Cette decision suit le mantra du Rasoir d'Ockham
> (#37) : on evite une couche de modelisation redondante tant qu'aucun besoin
> ne la justifie.
> Regle RBAC permission-driven (`dictionary.md` 3.15) : les rattachements
> acteur -> cas ci-dessous refletent la **matrice de permissions par defaut au
> seed**. Le gardien reel est la permission, pas le nom du role : un role
> personnalise (ex. "chef-patissier") dote des bonnes permissions ouvre les
> memes cas, sans changement de code. Les 5 roles seed sont un point de depart,
> pas une liste fermee.
---
## 3. Diagramme de cas d'utilisation
Mermaid ne fournit pas de type `usecase` natif. La representation ci-dessous
utilise un `flowchart` : les acteurs sont des noeuds a gauche, les cas
d'utilisation sont des noeuds arrondis regroupes par sous-systeme, et les
fleches portent les relations (`<<include>>`, `<<extend>>`) la ou elles
ont du sens.
Mermaid ne fournit pas de type `usecase` natif. La representation utilise un
`flowchart` : les acteurs sont a gauche, les cas d'utilisation regroupes par
sous-systeme. La permission qui conditionne chaque cas back-office est precisee
en section 4.
```mermaid
flowchart LR
%% Acteurs
Client(("Client<br/>borne kiosk"))
Admin(("Administration<br/>role admin"))
Prep(("Preparation<br/>role preparation"))
Accueil(("Accueil<br/>role accueil"))
Customer(("Customer<br/>borne kiosk<br/>non authentifie"))
Admin(("Admin<br/>role admin"))
Manager(("Manager<br/>role manager"))
Kitchen(("Kitchen<br/>role kitchen<br/>lecture seule"))
Counter(("Counter<br/>role counter"))
Drive(("Drive<br/>role drive"))
%% Sous-systeme Borne client
subgraph BORNE["Borne client - Bloc 1"]
subgraph BORNE["Borne client - Bloc 1 (public)"]
UC1(["Consulter le catalogue"])
UC2(["Composer un menu"])
UC3(["Passer une commande"])
UC4(["Saisir le numero de retrait"])
UC5(["Recevoir la confirmation"])
UC6(["Payer la commande"])
UC2(["Composer le panier"])
UC3(["Consulter les allergenes"])
UC4(["Passer une commande"])
UC5(["Saisir le numero de retrait"])
UC6(["Recevoir la confirmation"])
end
%% Sous-systeme Back-office
subgraph BACK["Back-office - Bloc 2"]
UC10(["Gerer le catalogue<br/>categories, produits, menus"])
UC11(["Gerer les utilisateurs et roles"])
UC12(["Consulter les statistiques"])
UC20(["Consulter la file de preparation"])
UC21(["Faire avancer une commande"])
UC30(["Saisir une commande<br/>comptoir ou drive"])
UC31(["Remettre la commande au client"])
UC40(["S'authentifier"])
%% Sous-systeme Operations commande
subgraph OPS["Operations commande - back-office"]
UC10(["Saisir une commande<br/>comptoir / drive"])
UC11(["Consulter la file de preparation"])
UC12(["Remettre la commande"])
UC13(["Annuler une commande"])
end
%% Relations Client
Client --> UC1
Client --> UC2
Client --> UC3
Client --> UC6
Client --> UC5
%% Sous-systeme Catalogue
subgraph CAT["Catalogue - back-office"]
UC20(["Gerer produits"])
UC21(["Gerer menus et slots"])
UC22(["Gerer categories"])
UC23(["Gerer ingredients,<br/>compositions, allergenes"])
end
%% include / extend cote borne
UC3 -. include .-> UC4
UC3 -. include .-> UC6
%% Sous-systeme Stock
subgraph STK["Stock - back-office"]
UC30(["Consulter le stock"])
UC31(["Compter l'inventaire"])
UC32(["Reapprovisionner"])
end
%% Sous-systeme Administration
subgraph ADM["Administration - back-office"]
UC40(["Gerer les utilisateurs"])
UC41(["Gerer roles et permissions"])
UC42(["Consulter les statistiques"])
end
%% Transverse
UC50(["S'authentifier"])
UC51(["Se deconnecter"])
%% Relations Customer
Customer --> UC1
Customer --> UC2
Customer --> UC4
Customer --> UC6
UC2 -. include .-> UC1
UC3 -. extend .-> UC2
UC2 -. extend .-> UC3
UC4 -. include .-> UC5
UC4 -. include .-> UC2
%% Relations Administration
%% Relations Counter / Drive (operations commande + stock)
Counter --> UC10
Counter --> UC11
Counter --> UC12
Counter --> UC13
Drive --> UC10
Drive --> UC11
Drive --> UC12
Drive --> UC13
UC10 -. include .-> UC1
%% Kitchen (lecture seule)
Kitchen --> UC11
%% Stock (kitchen / counter / drive / manager / admin)
Kitchen --> UC30
Kitchen --> UC31
Counter --> UC30
Counter --> UC31
Drive --> UC30
Drive --> UC31
Manager --> UC30
Manager --> UC31
Manager --> UC32
%% Catalogue (manager + admin)
Manager --> UC20
Manager --> UC21
Manager --> UC22
Manager --> UC23
Admin --> UC20
Admin --> UC21
Admin --> UC22
Admin --> UC23
%% Administration (admin) + stats (manager + admin)
Admin --> UC40
Admin --> UC10
Admin --> UC41
Admin --> UC42
Admin --> UC30
Admin --> UC31
Admin --> UC32
Admin --> UC11
Admin --> UC12
Admin --> UC13
Manager --> UC42
%% Relations Preparation
Prep --> UC40
Prep --> UC20
Prep --> UC21
%% Relations Accueil
Accueil --> UC40
Accueil --> UC30
Accueil --> UC31
UC30 -. include .-> UC1
%% Authentification mutualisee
UC10 -. include .-> UC40
UC11 -. include .-> UC40
UC20 -. include .-> UC40
UC30 -. include .-> UC40
%% Authentification mutualisee (tout cas back-office)
UC10 -. include .-> UC50
UC11 -. include .-> UC50
UC20 -. include .-> UC50
UC30 -. include .-> UC50
UC40 -. include .-> UC50
UC42 -. include .-> UC50
```
---
## 4. Description des cas d'utilisation
### 4.1 Acteur Client (borne kiosk)
### 4.1 Acteur Customer (borne kiosk, non authentifie)
| Cas | Description | Entites manipulees |
| Cas | Operation MCT | Description | Entites manipulees |
|---|---|---|---|
| Consulter le catalogue | 3.1 LOAD_CATALOGUE | Parcourir categories, produits et menus disponibles, charges via `GET /api/categories`, `/api/products`, `/api/menus` (ou JSON fallback). | `category`, `product`, `menu`, `menu_slot`, `menu_slot_option` |
| Composer le panier | 3.2 COMPOSE_CART | Ajouter produits a la carte ou menus ; remplir les slots d'un menu (`order_item_selection`), choisir le format Normal/Maxi, ajouter/retirer des ingredients (`order_item_modifier`). Panier volatil cote front, aucun ecrit BDD a ce stade. | `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` |
| Consulter les allergenes | (derive de 3.1) | Afficher en modal les allergenes d'un produit, **calcules** par jointure `product_ingredient -> ingredient_allergen -> allergen` (INCO 1169/2011). Etend la composition. | `allergen`, `ingredient_allergen`, `product_ingredient` |
| Passer une commande | 3.3 CREATE_ORDER | Valider le panier et saisir le numero de retrait. Creation atomique : INSERT `customer_order` (`pending_payment` puis `paid` dans la meme transaction), `order_item` + selections + modifiers snapshotes, decrement du stock + `stock_movement`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient`, `stock_movement` |
| Saisir le numero de retrait | (inclus dans 3.3) | Renseigner le numero qui identifie le client. Tient lieu de paiement (cadre RNCP). Cas inclus par "Passer une commande". | `customer_order.order_number` |
| Recevoir la confirmation | 3.4 DISPLAY_CONFIRMATION | Afficher l'ecran de confirmation avec le numero, apres reponse `201` (statut `paid`). La borne se reinitialise. | `customer_order` |
### 4.2 Acteurs Counter et Drive (roles `counter`, `drive`)
| Cas | Operation MCT | Permission | Description | Entites |
|---|---|---|---|---|
| Saisir une commande comptoir/drive | 4.1 CREATE_COUNTER_ORDER | `order.create` | Composer une commande pour un client au comptoir (`counter`) ou au drive (`drive`). Logique identique a CREATE_ORDER ; `source` auto-tague depuis `role.order_source`. Numero `C-`/`D-YYYY-MM-DD-NNN`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient`, `stock_movement` |
| Consulter la file de preparation | 5.1 LIST_ORDERS_DISPLAY | `order.read` | Voir les commandes `paid` triees par `paid_at` croissant, filtrees par `role_visible_source` (counter voit kiosk+counter ; drive voit drive). Couleur KDS = `now - paid_at`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` |
| Remettre la commande | 6.1 DELIVER_ORDER | `order.deliver` | Geste unique `paid -> delivered`, `delivered_at = NOW()`. | `customer_order` |
| Annuler une commande | 7.1 CANCEL_ORDER | `order.cancel` | Transition vers `cancelled` depuis `pending_payment`/`paid`, `cancelled_at = NOW()`. Re-credit du stock si `paid`. | `customer_order`, `ingredient`, `stock_movement` |
### 4.3 Acteur Kitchen (role `kitchen`, lecture seule)
| Cas | Operation MCT | Permission | Description | Entites |
|---|---|---|---|---|
| Consulter la file de preparation | 5.1 LIST_ORDERS_DISPLAY | `order.read` | Voir toutes les sources (kiosk, counter, drive) en lecture seule. **Aucune transition de statut** : le KDS est un affichage visuel. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` |
### 4.4 Stock (Kitchen, Counter, Drive, Manager, Admin)
| Cas | Operation MCT | Permission | Description | Entites |
|---|---|---|---|---|
| Consulter le stock | 9.3 READ_STOCK | `stock.read` | Lister les ingredients avec stock courant ; alerte rupture calculee a l'affichage (`stock_quantity <= low_stock_threshold`). | `ingredient`, `stock_movement` |
| Compter l'inventaire | 9.2 INVENTORY_COUNT | `stock.count` | Saisir un comptage physique ; le systeme enregistre l'ecart (`inventory_correction`). Inclut les equipiers (kitchen/counter/drive). | `ingredient`, `stock_movement` |
| Reapprovisionner | 9.1 RESTOCK | `stock.manage` | Enregistrer une livraison en conditionnements (`+= N * pack_size`). Reserve manager/admin. | `ingredient`, `stock_movement` |
### 4.5 Catalogue (Manager, Admin)
| Cas | Operation MCT | Permissions | Description | Entites |
|---|---|---|---|---|
| Gerer produits | 8.1-8.3 CREATE/UPDATE/DELETE_PRODUCT | `product.create`/`update` (manager+admin), `product.delete` (admin seul) | CRUD produits (nom, prix, `vat_rate`, image, dispo). La suppression physique est reservee a `admin` et bloquee si reference (FK RESTRICT). | `product`, `category` |
| Gerer menus et slots | 8.4-8.6 CREATE/UPDATE/DELETE_MENU | `menu.create`/`update` (manager+admin), `menu.delete` (admin seul) | CRUD menus avec leur configuration de slots (`menu_slot`, `menu_slot_option`) et le burger fixe. | `menu`, `menu_slot`, `menu_slot_option`, `product` |
| Gerer categories | 8.7 MANAGE_CATEGORY | `category.manage` (manager+admin) | CRUD categories ; desactivation `is_active=0`. | `category` |
| Gerer ingredients, compositions, allergenes | 8.8 MANAGE_INGREDIENT | `ingredient.manage` (manager+admin) | CRUD `ingredient` ; composition `product_ingredient` (quantites Normal/Maxi, retirable/ajoutable, supplement) ; mapping `ingredient_allergen` (14 allergenes UE). | `ingredient`, `product_ingredient`, `ingredient_allergen`, `allergen` |
### 4.6 Administration (role `admin`) + Stats (Manager, Admin)
| Cas | Operation MCT | Permissions | Description | Entites |
|---|---|---|---|---|
| Gerer les utilisateurs | 10.1-10.3 CREATE/UPDATE/DEACTIVATE_USER | `user.create`/`update`/`deactivate` (admin) ; `user.read` (admin+manager) | CRUD comptes back-office avec hash argon2id ; desactivation sans suppression (historique preserve). | `user`, `role` |
| Gerer roles et permissions | 10.4 MANAGE_RBAC | `role.manage` (admin) | Editer la matrice `role_permission`, creer/modifier des roles personnalises (`default_route`, `order_source`), regler `role_visible_source`. Permissions statiques (declarees en migration). | `role`, `permission`, `role_permission`, `role_visible_source` |
| Consulter les statistiques | 11.1 READ_STATS | `stats.read` (admin+manager) | Agregats par `service_day` (coupure 10h), top produits, taux d'annulation, temps moyen de remise `delivered_at - paid_at`, repartition par `source`/`service_mode`. | `customer_order`, `order_item` |
### 4.7 Cas transverses - Authentification
| Cas | Operation MCT | Description |
|---|---|---|
| Consulter le catalogue | Parcourir les categories, produits et menus disponibles. Charges via `GET /api/categories`, `/api/products`, `/api/menus` (ou JSON fallback). | `categorie`, `produit`, `menu` |
| Composer un menu | Choisir burger + accompagnement + boisson + sauce, regler les options de taille (normale / grande) et de personnalisation. Etend "Passer une commande" car un menu compose est une variante d'item au panier. | `menu`, `menu_produit`, `produit` |
| Passer une commande | Valider le panier, declencher la creation de la commande composee. Inclut la saisie du numero de retrait et le paiement. | `commande`, `ligne_commande` |
| Saisir le numero de retrait | Renseigner le numero qui identifie le client au comptoir. Cas inclus par "Passer une commande". | `commande.numero` |
| Payer la commande | Regler la commande une fois le panier compose et valide. Materialise la transition `pending_payment -> paid` de `state-commande.md`. Cas inclus par "Passer une commande". | `commande.statut`, `commande.paye_a` |
| Recevoir la confirmation | Afficher l'ecran de confirmation avec le numero, apres paiement. | `commande` |
> Note de coherence : le cycle de vie comporte deux phases successives, la
> composition de la commande puis son paiement (regle metier confirmee). Le cas
> "Payer la commande" est retenu cote Client et materialise la transition
> `pending_payment -> paid` de l'ENUM `statut`
> (`dictionary.md` 3.5, `state-commande.md`).
### 4.2 Acteur Administration (role admin)
| Cas | Description | Entites manipulees |
|---|---|---|
| Gerer le catalogue | CRUD sur categories, produits et menus (libelles, prix, images, disponibilite, composition de menu). | `categorie`, `produit`, `menu`, `menu_produit` |
| Gerer les utilisateurs et roles | CRUD sur les comptes back-office et leurs roles ; consultation de la matrice de permissions. | `user`, `role`, `permission`, `role_permission` |
| Consulter les statistiques | Voir les commandes du jour de service, le chiffre d'affaires, les produits les plus commandes. | `commande`, `ligne_commande` |
### 4.3 Acteur Preparation (role preparation, ex-Cuisine)
| Cas | Description | Entites manipulees |
|---|---|---|
| Consulter la file de preparation | Afficher les commandes a preparer triees par heure de livraison croissante, tous canaux confondus. | `commande`, `ligne_commande` |
| Faire avancer une commande | Declarer une commande "preparee", ce qui declenche une transition de statut (voir `state-commande.md`). | `commande.statut` |
### 4.4 Acteur Accueil (role accueil, recouvre Caisse)
| Cas | Description | Entites manipulees |
|---|---|---|
| Saisir une commande | Creer une commande pour un client au comptoir (`counter`) ou au drive (`drive`), en consultant le catalogue. | `commande`, `ligne_commande`, `produit`, `menu` |
| Remettre la commande au client | Declarer une commande "livree" au moment de la remise physique. | `commande.statut` |
### 4.5 Cas transverse - S'authentifier
Tous les acteurs du back-office (Administration, Preparation, Accueil) passent
par "S'authentifier" avant d'acceder a leurs cas. Modelise comme cas inclus
(`<<include>>`) par chaque cas back-office pour eviter de surcharger le
diagramme. Le Client de la borne n'est pas authentifie (canal `kiosk` public).
| S'authentifier | 12.1 AUTHENTICATE_USER | Tous les roles back-office passent par ce cas avant d'acceder a leurs cas (relation `<<include>>`). Verification argon2id, regeneration de session (anti-fixation), `is_active=1` requis, redirection vers `role.default_route`. Le Customer du kiosk n'est pas authentifie. |
| Se deconnecter | 12.2 LOGOUT_USER | Destruction de session (`session_destroy()`) sur clic ou expiration (idle 4h / absolu 10h). |
---
@ -188,35 +261,32 @@ diagramme. Le Client de la borne n'est pas authentifie (canal `kiosk` public).
| Relation | Type | Justification |
|---|---|---|
| Passer une commande -> Saisir le numero de retrait | include | La saisie du numero fait partie integrante de toute validation de commande. |
| Passer une commande -> Payer la commande | include | Le paiement suit la composition du panier et fait partie integrante du parcours (phase 2 du cycle de vie). |
| Composer un menu -> Consulter le catalogue | include | Composer un menu suppose de parcourir les produits eligibles a chaque slot. |
| Passer une commande -> Composer un menu | extend | Le menu est un cas optionnel : une commande peut ne contenir que des produits a la carte. La composition etend le parcours seulement si le client choisit un menu. |
| Saisir une commande (Accueil) -> Consulter le catalogue | include | L'equipier consulte le catalogue pour saisir au comptoir / drive. |
| Cas back-office -> S'authentifier | include | Acces conditionne a une session authentifiee. |
| Passer une commande -> Composer le panier | include | Une commande resulte d'un panier compose. |
| Composer le panier -> Consulter le catalogue | include | Composer suppose de parcourir les produits eligibles (a la carte ou par slot). |
| Composer le panier -> Consulter les allergenes | extend | La consultation des allergenes est un cas optionnel declenche a la demande du client sur un produit. |
| Saisir une commande (Counter/Drive) -> Consulter le catalogue | include | L'equipier consulte le catalogue pour saisir au comptoir/drive. |
| Cas back-office -> S'authentifier | include | Acces conditionne a une session authentifiee detenant la permission requise. |
---
## 6. Incoherences remontees vers les autres livrables
## 6. Points resolus par rapport au v0.1
Ces ecarts entre les sources sont signales pour arbitrage de l'auteur (la
modelisation finale releve de sa decision, mantra de validation humaine).
Les incoherences que le v0.1 remontait pour arbitrage sont desormais tranchees
par le modele v0.2 (`dictionary.md`, `mct.md`).
1. **ENUM `statut` et phase de paiement (tranche)**
Le dictionnaire (`dictionary.md` 3.5) definit
`statut ENUM('pending_payment','paid','preparing','ready','delivered','cancelled')`
avec un paiement explicite. La regle metier confirmee fixe deux phases
successives, la composition de la commande puis son paiement. Le cas
"Payer la commande" est donc retenu cote Client et materialise la transition
`pending_payment -> paid`. Cet ecart est tranche : la machine canonique de
`state-commande.md` fait foi.
2. **Acteur "Caisse" absent du RBAC**
Aucun role `caisse` n'existe (`PROJECT_CONTEXT.md` section 7 : `admin`,
`preparation`, `accueil`). La fonction d'encaissement de la consigne a ete
rattachee a l'acteur **Accueil**. A confirmer.
3. **"Manager" vs "Admin"**
La consigne parle de "Manager/Admin" ; le brief ne connait que `admin`. Les
deux ont ete fusionnes en un acteur **Administration**. A confirmer si un
role manager intermediaire est souhaite (le dictionnaire 3.8 mentionne un
role `manager` extensible, non present dans le scope section 7).
1. **Acteur "Caisse"** : ecarte. L'encaissement est atomique a la creation
(saisie du numero = substitut de paiement, `mct.md` section 13) et realise
par le Customer (kiosk) ou par `counter`/`drive` (back-office). Aucun role
`caisse` n'est necessaire.
2. **"Manager" vs "Admin"** : scindes en deux roles distincts. `manager` gere le
catalogue (create/update), le stock/reappro et les stats ; `admin` ajoute les
suppressions catalogue, la gestion des utilisateurs et le RBAC.
3. **"Accueil" unique** : scinde en `counter` et `drive`, car le tag `source` et
le filtre `role_visible_source` different selon le canal.
4. **Machine a etats** : alignee sur les 4 etats du `dictionary.md` 3.10
(`pending_payment -> paid -> delivered` + `cancelled`). Plus de `preparing` /
`ready` : la cuisine (`kitchen`) est en lecture seule, la remise est un geste
unique (`counter`/`drive`).
5. **Modele permission-driven** : chaque cas back-office est rattache a sa
permission (catalogue de 23 codes fige, `dictionary.md` 3.17). Le diagramme
reflete la matrice seed ; le gardien effectif reste la permission.

View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Wakdo - applique (idempotent) les regles de protection de branche sur Forgejo.
#
# Pourquoi un script versionne : la regle de gouvernance devient reproductible
# et auditable (Cr 7.b), pas un clic dans une UI. Roter le token = editer .env.
#
# Regle posee :
# - main et dev : push direct interdit (PR obligatoire), force-push bloque
# - required_approvals = 0 (travail solo : on ne peut pas approuver sa propre PR)
# - status check : OPTIONNEL via REQUIRE_CI=1, contextes dans CI_CONTEXTS
# (a activer en lot D, une fois les jobs .forgejo/workflows/ nommes ;
# activer avant que le workflow n'existe bloquerait tout merge)
#
# Usage :
# scripts/forgejo-branch-protection.sh # baseline (PR requise)
# REQUIRE_CI=1 CI_CONTEXTS='ci' scripts/forgejo-branch-protection.sh # + CI verte requise
#
set -euo pipefail
REPO_API="https://git.acadenice.com/api/v1/repos/AcadeNice/corentin_wakdo"
ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env"
TOKEN="$(grep -E '^FORGEJO_TOKEN=' "$ENV_FILE" | cut -d= -f2-)"
if [ -z "${TOKEN:-}" ]; then
echo "ERREUR : FORGEJO_TOKEN absent de $ENV_FILE" >&2
exit 1
fi
REQUIRE_CI="${REQUIRE_CI:-0}"
CI_CONTEXTS="${CI_CONTEXTS:-ci}"
# Construit le tableau JSON des contextes de status check si REQUIRE_CI=1.
status_check_json="false"
contexts_json="[]"
if [ "$REQUIRE_CI" = "1" ]; then
status_check_json="true"
contexts_json="$(printf '%s' "$CI_CONTEXTS" | awk -F, '{printf "["; for(i=1;i<=NF;i++){printf "%s\"%s\"", (i>1?",":""), $i}; printf "]"}')"
fi
for branch in main dev; do
payload=$(cat <<JSON
{
"branch_name": "$branch",
"enable_push": false,
"enable_force_push": false,
"required_approvals": 0,
"enable_status_check": $status_check_json,
"status_check_contexts": $contexts_json
}
JSON
)
# PATCH si la protection existe, sinon POST pour la creer.
if curl -sf -o /dev/null -H "Authorization: token $TOKEN" "$REPO_API/branch_protections/$branch"; then
method=PATCH; url="$REPO_API/branch_protections/$branch"
else
method=POST; url="$REPO_API/branch_protections"
fi
echo "[$branch] $method (status_check=$status_check_json contexts=$contexts_json)"
curl -s -X "$method" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "$payload" "$url" >/dev/null
done
echo "OK - protections appliquees sur main et dev."

59
scripts/forgejo-pr-automerge.sh Executable file
View file

@ -0,0 +1,59 @@
#!/usr/bin/env bash
#
# Wakdo - ouvre une PR Forgejo et planifie son auto-merge quand la CI passe.
#
# Strategie solo dev : la PR reste obligatoire (trace de gouvernance, Cr 4.f)
# mais le merge se declenche tout seul des que les checks requis sont verts.
# Prerequis : status checks requis sur la branche de base
# (voir scripts/forgejo-branch-protection.sh avec REQUIRE_CI=1).
#
# Usage :
# scripts/forgejo-pr-automerge.sh [HEAD] [BASE] ["Titre"]
# Defauts : HEAD = branche courante, BASE = dev, titre = dernier sujet de commit.
#
set -euo pipefail
REPO_API="https://git.acadenice.com/api/v1/repos/AcadeNice/corentin_wakdo"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ENV_FILE="$ROOT/.env"
TOKEN="$(grep -E '^FORGEJO_TOKEN=' "$ENV_FILE" | cut -d= -f2-)"
[ -n "${TOKEN:-}" ] || { echo "ERREUR : FORGEJO_TOKEN absent de $ENV_FILE" >&2; exit 1; }
HEAD="${1:-$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)}"
BASE="${2:-dev}"
TITLE="${3:-$(git -C "$ROOT" log -1 --pretty=%s "$HEAD")}"
if [ "$BASE" = "main" ] && [ "$HEAD" != "dev" ]; then
echo "Garde-fou : seules les PR depuis 'dev' visent 'main'. Abandon." >&2
exit 1
fi
echo "PR : $HEAD -> $BASE"
echo "Titre : $TITLE"
# 1. Creer la PR (ou recuperer l'index si elle existe deja).
create_resp=$(curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "$(printf '{"head":"%s","base":"%s","title":"%s"}' "$HEAD" "$BASE" "$TITLE")" \
"$REPO_API/pulls")
index=$(printf '%s' "$create_resp" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('number',''))" 2>/dev/null || true)
if [ -z "$index" ]; then
# PR deja existante : la retrouver par branche head.
index=$(curl -s -H "Authorization: token $TOKEN" "$REPO_API/pulls?state=open&limit=50" \
| python3 -c "import sys,json;hs='$HEAD';d=json.load(sys.stdin);print(next((p['number'] for p in d if p['head']['ref']==hs),''))" 2>/dev/null || true)
fi
[ -n "$index" ] || { echo "Impossible de creer/trouver la PR. Reponse : $create_resp" >&2; exit 1; }
echo "PR #$index"
# 2. Planifier l'auto-merge (squash) quand les checks requis sont verts.
merge_resp=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d '{"Do":"squash","merge_when_checks_succeed":true,"delete_branch_after_merge":false}' \
"$REPO_API/pulls/$index/merge")
case "$merge_resp" in
200|201|202) echo "Auto-merge planifie sur PR #$index (squash a la CI verte)." ;;
405) echo "PR #$index : merge differe - checks pas encore verts, auto-merge en attente." ;;
*) echo "Reponse merge HTTP $merge_resp sur PR #$index (verifier l'etat des checks / protections)." ;;
esac