P1 conception: security-by-design layer (Merise 21 entities, Forgejo CI/CD, hardening) #3
44
.env.example
|
|
@ -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
|
||||
# ===================================================================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.*
|
||||
|
|
|
|||
102
docs/architecture/forgejo-actions-runner.md
Normal 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).
|
||||
|
|
@ -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="<b>CATEGORIE</b><hr>id : INT (PK)<br>libelle : VARCHAR (UNIQUE)<br>slug : VARCHAR (UNIQUE)<br>image_path : VARCHAR<br>ordre : SMALLINT<br>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="<b>PRODUIT</b><hr>id : INT (PK)<br>categorie_id : INT (FK)<br>libelle : VARCHAR<br>description : TEXT<br>prix_ttc_cents : INT<br>image_path : VARCHAR<br>est_disponible : BOOLEAN<br>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="<b>MENU</b><hr>id : INT (PK)<br>categorie_id : INT (FK)<br>libelle : VARCHAR<br>description : TEXT<br>prix_ttc_cents : INT<br>image_path : VARCHAR<br>est_disponible : BOOLEAN<br>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="<b>MENU_PRODUIT</b> <i>(associative)</i><hr>menu_id : INT (PK, FK)<br>produit_id : INT (PK, FK)<br>role : ENUM<br>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>
|
||||
51
docs/merise/_diagrams/mcd-catalogue.mmd
Normal 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"
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 119 KiB |
|
|
@ -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="<b>COMMANDE</b><hr>id : INT (PK)<br>numero : VARCHAR (UNIQUE)<br>source : ENUM (kiosk|comptoir|drive)<br>mode_consommation : ENUM (sur_place|a_emporter|drive)<br>statut : ENUM<br>total_ht_cents : INT<br>total_tva_cents : INT<br>total_ttc_cents : INT<br>tva_taux_pourmille : SMALLINT<br>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="<b>USER</b><hr>id : INT (PK)<br><i>(detail dans RBAC)</i>" 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="<b>COMMANDE_EVENT</b><hr>id : INT (PK)<br>commande_id : INT (FK)<br>event_type : ENUM<br>from_statut : ENUM (NULL)<br>to_statut : ENUM<br>user_id : INT (FK, NULL)<br>payload : JSON (NULL)<br>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="<b>LIGNE_COMMANDE</b><hr>id : INT (PK)<br>commande_id : INT (FK)<br>type_item : ENUM (produit|menu)<br>produit_id : INT (FK, NULL)<br>menu_id : INT (FK, NULL)<br>libelle_snapshot : VARCHAR<br>prix_unitaire_ttc_cents_snapshot : INT<br>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="<b>PRODUIT</b><hr>id : INT (PK)<br><i>(detail dans Catalogue)</i>" 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="<b>MENU</b><hr>id : INT (PK)<br><i>(detail dans Catalogue)</i>" 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="<b>Polymorphisme</b><br>Exactement UNE des deux references est non-nulle.<br>Discriminateur : type_item &isin; {produit, menu}.<br>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="<b>Journal d'audit (event sourcing)</b><br>Append-only : aucun UPDATE / DELETE applicatif.<br>user_id NULL si auto-validation kiosk.<br>ON DELETE CASCADE cote commande_id.<br>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>
|
||||
|
Before Width: | Height: | Size: 704 KiB |
|
|
@ -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="<b>CATEGORIE</b>" 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="<b>PRODUIT</b>" 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="<b>MENU_PRODUIT</b>" 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="<b>MENU</b>" 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="<b>LIGNE_COMMANDE</b>" 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="<b>COMMANDE</b>" 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="<b>COMMANDE_EVENT</b>" 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="<b>USER</b>" 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="<b>ROLE</b>" 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="<b>ROLE_PERMISSION</b>" 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="<b>PERMISSION</b>" 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>
|
||||
|
Before Width: | Height: | Size: 363 KiB |
61
docs/merise/_diagrams/mcd-ingredients-stock.mmd
Normal 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"
|
||||
1
docs/merise/_diagrams/mcd-ingredients-stock.svg
Normal file
|
After Width: | Height: | Size: 141 KiB |
67
docs/merise/_diagrams/mcd-order.mmd
Normal 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"
|
||||
1
docs/merise/_diagrams/mcd-order.svg
Normal file
|
After Width: | Height: | Size: 156 KiB |
|
|
@ -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="<b>USER</b><hr>id : INT (PK)<br>email : VARCHAR (UNIQUE, RFC 5321)<br>password_hash : VARCHAR (argon2id)<br>nom : VARCHAR<br>prenom : VARCHAR<br>role_id : INT (FK)<br>est_actif : BOOLEAN<br>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="<b>ROLE</b><hr>id : INT (PK)<br>code : VARCHAR (UNIQUE)<br>libelle : VARCHAR<br>description : TEXT<br>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="<b>PERMISSION</b><hr>id : INT (PK)<br>code : VARCHAR (UNIQUE, resource.action)<br>libelle : VARCHAR<br>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="<b>ROLE_PERMISSION</b> <i>(associative)</i><hr>role_id : INT (PK, FK)<br>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>
|
||||
64
docs/merise/_diagrams/mcd-rbac.mmd
Normal 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"
|
||||
|
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 151 KiB |
|
|
@ -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="<b>categorie</b><hr><u>PK id : INT UNSIGNED AUTO_INCREMENT</u><br>UK libelle : VARCHAR(80)<br>UK slug : VARCHAR(60)<br>image_path : VARCHAR(255) NULL<br>ordre : SMALLINT UNSIGNED DEFAULT 0<br>est_actif : TINYINT(1) DEFAULT 1<br>created_at : DATETIME<br>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="<b>produit</b><hr><u>PK id : INT UNSIGNED AUTO_INCREMENT</u><br>FK categorie_id : INT UNSIGNED<br>libelle : VARCHAR(120)<br>description : TEXT NULL<br>prix_ttc_cents : INT UNSIGNED (CHECK > 0)<br>image_path : VARCHAR(255) NULL<br>est_disponible : TINYINT(1) DEFAULT 1<br>ordre : SMALLINT UNSIGNED DEFAULT 0<br>created_at : DATETIME<br>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="<b>menu</b><hr><u>PK id : INT UNSIGNED AUTO_INCREMENT</u><br>FK categorie_id : INT UNSIGNED<br>libelle : VARCHAR(120)<br>description : TEXT NULL<br>prix_ttc_cents : INT UNSIGNED (CHECK > 0)<br>image_path : VARCHAR(255) NULL<br>est_disponible : TINYINT(1) DEFAULT 1<br>ordre : SMALLINT UNSIGNED DEFAULT 0<br>created_at : DATETIME<br>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="<b>menu_produit</b> (jointure)<hr><u>PK FK menu_id : INT UNSIGNED</u><br><u>PK FK produit_id : INT UNSIGNED</u><br>role : ENUM(burger,accompagnement,boisson,sauce,dessert)<br>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="<b>Legende</b><br><u>PK</u> : cle primaire<br>FK : cle etrangere (fleche -> table referencee)<br>UK : contrainte unique<br>Bleu = table principale<br>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>
|
||||
|
|
@ -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="<b>commande</b><hr><u>PK id : INT UNSIGNED AUTO_INCREMENT</u><br>UK numero : VARCHAR(20)<br>source : ENUM(kiosk,comptoir,drive)<br>mode_consommation : ENUM(sur_place,a_emporter,drive)<br>statut : ENUM DEFAULT pending_payment<br>total_ht_cents : INT UNSIGNED<br>total_tva_cents : INT UNSIGNED<br>total_ttc_cents : INT UNSIGNED<br>tva_taux_pourmille : SMALLINT UNSIGNED<br>paye_a : DATETIME NULL<br>created_at : DATETIME<br>updated_at : DATETIME<hr>CHECK (source != drive OR mode = drive)<br>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="<b>ligne_commande</b><hr><u>PK id : INT UNSIGNED AUTO_INCREMENT</u><br>FK commande_id : INT UNSIGNED<br>type_item : ENUM(produit,menu)<br>FK produit_id : INT UNSIGNED NULL<br>FK menu_id : INT UNSIGNED NULL<br>libelle_snapshot : VARCHAR(120)<br>prix_unitaire_ttc_cents_snapshot : INT UNSIGNED<br>quantite : SMALLINT UNSIGNED DEFAULT 1<br>created_at : DATETIME<hr>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="<b>commande_event</b> (append-only)<hr><u>PK id : INT UNSIGNED AUTO_INCREMENT</u><br>FK commande_id : INT UNSIGNED<br>event_type : ENUM(CREATED,PAID,...)<br>from_statut : ENUM NULL<br>to_statut : ENUM<br>FK user_id : INT UNSIGNED NULL<br>payload : JSON NULL<br>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="<b>produit</b> <i>(cf. Catalogue)</i><hr><u>PK id</u><br>..." 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="<b>menu</b> <i>(cf. Catalogue)</i><hr><u>PK id</u><br>..." 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="<b>user</b> <i>(cf. RBAC)</i><hr><u>PK id</u><br>..." 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="<b>Journal d'audit (event sourcing)</b><br>Append-only : aucun UPDATE / DELETE applicatif.<br>3 IDX : (commande_id, created_at), (user_id, created_at), (event_type, created_at).<br>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="<b>Legende</b><br><u>PK</u> : cle primaire<br>FK : cle etrangere (fleche -> table referencee)<br>UK : contrainte unique<br>Bleu = table principale<br>Vert = journal d'audit<br>Violet = stub d'un autre sous-domaine<br>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>
|
||||
|
|
@ -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="<b>user</b><hr><u>PK id : INT UNSIGNED AUTO_INCREMENT</u><br>UK email : VARCHAR(254)<br>password_hash : VARCHAR(255)<br>nom : VARCHAR(60)<br>prenom : VARCHAR(60)<br>FK role_id : INT UNSIGNED<br>est_actif : TINYINT(1) DEFAULT 1<br>last_login_at : DATETIME NULL<br>created_at : DATETIME<br>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="<b>role</b><hr><u>PK id : INT UNSIGNED AUTO_INCREMENT</u><br>UK code : VARCHAR(40)<br>libelle : VARCHAR(80)<br>description : TEXT NULL<br>est_actif : TINYINT(1) DEFAULT 1<br>created_at : DATETIME<br>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="<b>permission</b><hr><u>PK id : INT UNSIGNED AUTO_INCREMENT</u><br>UK code : VARCHAR(60) format resource.action<br>libelle : VARCHAR(120)<br>description : TEXT NULL<br>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="<b>role_permission</b> (jointure)<hr><u>PK FK role_id : INT UNSIGNED</u><br><u>PK FK permission_id : INT UNSIGNED</u>" 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="<b>Modele RBAC</b><br>Roles : dynamiques (CRUD admin UI), table principale role.<br>Permissions : statiques (declarees en migration), pas d'updated_at.<br>Mapping role-permission : matrice editable depuis l'UI admin.<br>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="<b>Legende</b><br><u>PK</u> : cle primaire (composite si plusieurs lignes soulignees)<br>FK : cle etrangere (fleche -> table referencee)<br>UK : contrainte unique<br>Bleu = table principale<br>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>
|
||||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/`)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
232
docs/uml/security-sequence.md
Normal 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.
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
64
scripts/forgejo-branch-protection.sh
Executable 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
|
|
@ -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
|
||||