# Dictionnaire de Donnees — Wakdo

**Phase Merise** : P1 - Conception, etape 1 (dictionnaire de donnees d'abord, mantra #33)
**Version** : v0.3 — prod-like, 22 entites (19 prod-like + couche security-by-design, incl. les entites `login_throttle` et `pin_throttle`)
**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11)
**Branche** : `feat/p1-conception`
**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design en cours (voir note 13)
**Auteur** : BYAN (couche methodologie)

---

## 1. Objectif

Ce dictionnaire liste **toutes les entites de donnees** identifiees pour Wakdo, avec leurs attributs,
types, contraintes et sources. Il sert de base au MCD (entites + relations),
puis au MLD (mapping relationnel), puis au DDL (SQL CREATE TABLE).

**Methodologie** : derivation bottom-up depuis les sources disponibles :
- **Source ecole** : `docs/merise/_sources/categories.json` + `produits.json`
  (66 produits, 9 categories)
- **Brief metier** : `docs/PROJECT_CONTEXT.md` (composition du menu, flux de commande, RBAC,
  modes de service)
- **Maquette** : `docs/design/maquette-borne.pdf` (UX borne, ecrans visibles)

Tous les ecarts entre la source ecole et le modele final sont documentes dans la
section "Notes de modelisation" en bas de ce document.

Pour le diagramme entite-relation et les justifications de cardinalite, voir [`mcd.md`](mcd.md).
Ce dictionnaire ne duplique pas cette vue afin d'eviter des sources de verite divergentes.

---

## 2. Conventions generales

### Nommage

- **Tables** : `snake_case`, singulier (ex. `category`, `product`, `customer_order`).
  Le singulier reflete la perspective "1 ligne = 1 instance de l'entite" (convention relationnelle
  standard). Le code applicatif (PHP, JS) utilise ces noms tels quels via le mapping ORM.
- **Colonnes** : `snake_case`. Suffixes typiques : `_id` (FK), `_at` (timestamp),
  `_cents` (montant monetaire en centimes entiers), `_path` (chemin de fichier), `_rate` (taux ou
  fraction stocke en entier pour-mille).
- **Cles primaires** : colonne `id` (INT UNSIGNED AUTO_INCREMENT). Pas de PK composite sauf
  sur les tables de jointure pures.
- **Cles etrangeres** : `<referenced_table>_id` (ex. `category_id` dans `product`).
- **Valeurs ENUM** : anglais, snake_case (ex. `pending_payment`, `dine_in`, `kiosk`).
- **Chaines cote code** (ENUM, codes de permission, codes de role) : anglais uniquement, coherentes
  entre la BDD, PHP et l'API JSON.

### Types par defaut

| Categorie | Type MariaDB | Justification |
|---|---|---|
| Identifiants | `INT UNSIGNED AUTO_INCREMENT` | 4 milliards d'ids — suffisant pour ce projet |
| Libelles courts | `VARCHAR(120)` | Couvre la plupart des noms de produits (max observe : 41 caracteres dans la source ecole) |
| Descriptions | `TEXT` | Longueur variable, sans limite stricte |
| Montants monetaires | `INT UNSIGNED` (cents) | Evite les bugs d'arrondi FLOAT (voir note 1) |
| Booleens | `TINYINT(1)` | Convention MariaDB pour `BOOLEAN` (alias) |
| Horodatages | `DATETIME` | Lisible par l'humain, fuseau horaire gere au niveau applicatif |
| Enumerations | `ENUM('a','b','c')` | Contrainte au niveau SGBD, lisible (voir note 2) |
| Chemins de fichiers | `VARCHAR(255)` | Limite standard de longueur de chemin POSIX |

### Charset et collation

- **Charset** : `utf8mb4` (RFC 3629 — vrai UTF-8 sur 4 octets, supporte emoji et caracteres asiatiques).
  MariaDB gere `utf8mb4` nativement.
- **Collation** : `utf8mb4_unicode_ci` (insensible a la casse, comparaison conforme Unicode).

### Champs d'audit (presents sur toutes les tables metier sauf les tables de jointure pures)

| Colonne | Type | Default | Role |
|---|---|---|---|
| `created_at` | `DATETIME` | `CURRENT_TIMESTAMP` | Timestamp de creation, ecrit une fois a l'insertion |
| `updated_at` | `DATETIME` | `CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` | Timestamp de derniere modification, mis a jour automatiquement |

### Soft delete

Pas de soft delete generalise. Les entites qui peuvent etre temporairement desactivees portent une
colonne booleenne `is_active` ou `is_available`. Le `DELETE` dur reste possible mais est
reserve aux operations admin avec sauvegarde prealable.

---

## 3. Entites

### 3.1 `category`

Regroupement metier de produits et de menus pour l'affichage sur la borne.

| Attribut | Type | NULL | Default | Contrainte | Source ecole | Notes |
|---|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-9) | identique a la source |
| `name` | VARCHAR(60) | NO | — | UNIQUE | `title` | renomme depuis `title` |
| `slug` | VARCHAR(60) | NO | — | UNIQUE | derive de `title` (kebab-case minuscule) | utilise pour l'URL `/api/categories/burgers` |
| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | chemin relatif, voir note 8 |
| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (ajoute) | ordre d'affichage sur la borne, ajustable depuis l'admin |
| `is_active` | TINYINT(1) | NO | 1 | — | (ajoute) | desactiver sans supprimer |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | — | audit |
| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | — | audit |

**Exemples** : `menus`, `drinks`, `burgers`, `fries`, `snacks`, `wraps`, `salads`,
`desserts`, `sauces`. Volume : 9 lignes a l'init (seed depuis `categories.json`).

---

### 3.2 `product`

Un article vendable unique, disponible a la carte ou comme composant dans un slot de menu.

| Attribut | Type | NULL | Default | Contrainte | Source ecole | Notes |
|---|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` | identique a la source |
| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | (derive de la cle d'objet JSON) | |
| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | renomme depuis `nom` |
| `description` | TEXT | YES | NULL | — | (ajoute) | renseigne plus tard via l'admin |
| `price_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` (FLOAT) | conversion FLOAT -> INT centimes au seed (voir note 1) |
| `vat_rate` | SMALLINT UNSIGNED | NO | 100 | CHECK IN (55, 100) | (ajoute) | taux de TVA en pour-mille : 100 = 10%, 55 = 5,5%. Defaut 10%. Voir note 9 |
| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | chemin relatif, voir note 8 |
| `is_available` | TINYINT(1) | NO | 1 | — | (ajoute) | bascule de disponibilite manuelle depuis l'admin |
| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (ajoute) | ordre d'affichage au sein de la categorie |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | — | audit |
| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | — | audit |

**Volume** : ~53 lignes a l'init (66 lignes dans `produits.json` moins 13 menus deplaces vers `menu`).

---

### 3.3 `menu`

Combo a prix fixe construit autour d'un burger specifique, avec des slots selectionnables par le client
(boisson, accompagnement, sauce). Deux paliers de prix : Normal et Maxi.

| Attribut | Type | NULL | Default | Contrainte | Source ecole | Notes |
|---|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-13 dans la categorie `menus`) | |
| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | implicite (categorie `menus`) | |
| `burger_product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | (ajoute) | le burger fixe qui ancre ce menu ; pilote la personnalisation des ingredients |
| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | ex. "Menu Le 280" |
| `description` | TEXT | YES | NULL | — | (ajoute) | |
| `price_normal_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` | prix format Normal. Remplace le `prix_ttc_cents` unique. |
| `price_maxi_cents` | INT UNSIGNED | NO | — | CHECK > 0 | (ajoute) | prix format Maxi (~+150 centimes vs normal ; voir note 7) |
| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | reutilise generalement l'image du burger |
| `is_available` | TINYINT(1) | NO | 1 | — | (ajoute) | |
| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (ajoute) | |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | — | audit |
| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | — | audit |

**Volume** : 13 lignes a l'init. Remplace l'ancien modele `menu_produit` a composition fixe.

---

### 3.4 `menu_slot`

Un slot selectionnable au sein d'un menu (ex. "slot boisson", "slot accompagnement", "slot sauce").
Chaque slot contraint les produits parmi lesquels le client peut choisir, exprimes via
la table de jointure `menu_slot_option`.

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `menu_id` | INT UNSIGNED | NO | — | FK -> `menu(id)`, ON DELETE CASCADE | un slot appartient a exactement un menu |
| `name` | VARCHAR(80) | NO | — | — | ex. "Drink", "Side", "Sauce" |
| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | — | — | role semantique de ce slot |
| `is_required` | TINYINT(1) | NO | 1 | — | indique si le client doit remplir ce slot |
| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | ordre d'affichage dans le constructeur de menu |

**Pas de champs d'audit** : un slot fait partie de la definition du menu ; cree et mis a jour avec le menu.
**Index composite** : `(menu_id, display_order)`.

---

### 3.5 `menu_slot_option`

Produits eligibles pour un slot de menu donne. Table de jointure pure.

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `menu_slot_id` | INT UNSIGNED | NO | — | FK -> `menu_slot(id)`, ON DELETE CASCADE | |
| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | RESTRICT : retirer un produit ne doit pas casser silencieusement les menus |

**Cle primaire** : composite `(menu_slot_id, product_id)`.

**Volume** : ~3-5 options par slot, ~3 slots par menu, 13 menus = ~120-200 lignes a l'init.

---

### 3.6 `ingredient`

Ingredient elementaire utilise dans la composition des produits. Porte les donnees de stock.

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `name` | VARCHAR(120) | NO | — | UNIQUE | ex. "Sesame Bun", "Cheddar Slice", "Ketchup Portion" |
| `unit` | VARCHAR(40) | NO | — | — | libelle de l'unite de conditionnement : piece / portion / sachet 1kg / pot / bouteille (libelle libre, pas un ENUM — les unites varient par ingredient) |
| `stock_quantity` | INT (signed) | NO | 0 | — | stock courant en unites. INT signe sans `CHECK >= 0` : il PEUT devenir negatif quand les ventes depassent le stock compte (ampleur de la survente, remontee aux managers). Le systeme ne bloque pas une commande sur le stock. |
| `stock_capacity` | INT | NO | — | CHECK > 0 | niveau "plein" de reference en unites = les 100% servant a calculer le pourcentage de stock. Le `CHECK > 0` protege aussi la division du pourcentage contre la division par zero |
| `pack_size` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | unites par pack de reapprovisionnement (ex. 100 pour un sac de 100 portions) |
| `pack_label` | VARCHAR(80) | YES | NULL | — | libelle humain du pack (ex. "Sac 100 portions") |
| `low_stock_pct` | SMALLINT UNSIGNED | NO | 10 | CHECK BETWEEN 0 AND 100 | bande d’alerte, pourcentage de capacite : `stock_quantity <= stock_capacity * low_stock_pct/100` declenche l'indicateur de stock bas |
| `critical_stock_pct` | SMALLINT UNSIGNED | NO | 5 | CHECK BETWEEN 0 AND 100 | seuil de rupture automatique, pourcentage de capacite : `stock_quantity <= stock_capacity * critical_stock_pct/100` rend le produit calcule en rupture |
| `is_active` | TINYINT(1) | NO | 1 | — | desactiver les ingredients obsoletes sans supprimer |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit |
| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit |

**CHECK au niveau table** : `critical_stock_pct < low_stock_pct` (le seuil critique se situe sous la bande d’alerte).

**Regle de decrement de stock** : a la transition `paid`, chaque ingredient est decremente de
`product_ingredient.quantity_normal` ou `quantity_maxi` (selectionne par `order_item.format`)
multiplie par `order_item.quantity`, puis ajuste par les lignes `order_item_modifier`. Voir note 7.
**Regle de reapprovisionnement** : `stock_quantity += N * pack_size` (reapprovisionne en packs complets).
**Regle d'annulation** : le stock est recredite quand une commande `paid` est annulee.
**Modele de stock (base sur le pourcentage, trois bandes)** : le seuil d'alerte absolu est remplace par un
modele en pourcentage ancre sur `stock_capacity` (la reference 100%). Le pourcentage de stock est
calcule, non stocke : `stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Le
`CHECK > 0` sur `stock_capacity` protege cette division contre la division par zero. Trois bandes :
- **Normal** — au-dessus de la bande d’alerte : rien n'est signale.
- **Low** — `stock_quantity <= stock_capacity * low_stock_pct/100` : commandable + alerte manager.
  Le manager retire le produit via `product.is_available=0`, ou reapprovisionne pour lever l'alerte.
- **Critical** — `stock_quantity <= stock_capacity * critical_stock_pct/100` : le produit
  passe automatiquement en rupture (disponibilite calculee, voir regle RG-T21 dans `mlt.md`) ; aucune colonne stockee supplementaire.

---

### 3.7 `product_ingredient`

Composition par defaut d'un produit (burger, wrap, etc.) en termes d'ingredients.
Porte les metadonnees de personnalisation pour le configurateur d'ingredients.

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE CASCADE | |
| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | RESTRICT : impossible de retirer un ingredient encore reference dans une recette de produit |
| `quantity_normal` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | unites consommees en format Normal (ex. 2 pour double cheese) |
| `quantity_maxi` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | unites consommees en format Maxi. Egale `quantity_normal` pour les ingredients invariants au format (burger, sauce) ; superieure pour les ingredients d'accompagnement et de boisson (le Maxi agrandit uniquement l'accompagnement + la boisson). Voir note 7. |
| `is_removable` | TINYINT(1) | NO | 1 | — | le client peut retirer cet ingredient sans frais |
| `is_addable` | TINYINT(1) | NO | 0 | — | le client peut ajouter une unite supplementaire de cet ingredient |
| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | supplement en centimes quand `is_addable=1` et que le client l'ajoute (0 = extra gratuit) |

**Cle primaire** : composite `(product_id, ingredient_id)`.

**Volume** : ~5-10 ingredients par produit, ~53 produits = ~300-500 lignes au seed.

---

### 3.8 `allergen`

Catalogue des 14 allergenes reglementes (Reglement INCO (UE) 1169/2011).

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `code` | VARCHAR(30) | NO | — | UNIQUE | code lisible par machine, ex. `gluten`, `milk`, `nuts` |
| `name` | VARCHAR(80) | NO | — | — | nom d'affichage, ex. "Gluten", "Lait", "Fruits a coque" |
| `description` | TEXT | YES | NULL | — | guidance optionnelle pour le personnel |

**Volume** : 14 lignes au seed (fixe par le reglement UE 1169/2011, liste confirmee au moment du seed).
Les allergenes d'un produit sont **calcules** en joignant `product_ingredient` ->
`ingredient_allergen` -> `allergen` ; pas de ressaisie manuelle par produit.

---

### 3.9 `ingredient_allergen`

Indique quels allergenes contient chaque ingredient. Table de jointure pure.

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE CASCADE | |
| `allergen_id` | INT UNSIGNED | NO | — | FK -> `allergen(id)`, ON DELETE RESTRICT | |

**Cle primaire** : composite `(ingredient_id, allergen_id)`.

---

### 3.10 `customer_order`

Transaction client : 1 commande = 1 panier valide a un instant donne.
(Rationale du nom de table : voir note de modelisation 3.)

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `order_number` | VARCHAR(20) | NO | — | UNIQUE | format lisible par l'humain : `K`/`C`/`D`-YYYY-MM-DD-NNN. Prefixe par canal : K=kiosk, C=counter, D=drive. Voir note 4. |
| `idempotency_key` | VARCHAR(36) | YES | NULL | UNIQUE | UUID genere par le client pour dedupliquer un `POST /api/orders` reessaye (anti-double-charge). UNIQUE rejette les doublons ; plusieurs NULL autorises. Security-by-design, voir note 13 |
| `source` | ENUM('kiosk','counter','drive') | NO | — | INDEX | canal de saisie (qui a saisi la commande). Valeurs en anglais, voir note 5. |
| `acting_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | personnel back-office (counter/drive) ayant cree la commande, capture sous PIN. NULL pour `kiosk` (anonyme). Imputabilite ciblee sans imposer un login par personne sur la borne. Voir note 13 |
| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | — | — | mode de consommation, conserve pour les stats/KPI uniquement. Aucun role fiscal (voir note 9). La source `drive` implique le service_mode `drive` (contrainte croisee appliquee au niveau applicatif). |
| `status` | ENUM('pending_payment','paid','delivered','cancelled') | NO | 'pending_payment' | INDEX | machine a 4 etats : `pending_payment -> paid -> delivered` (+ `cancelled`). Voir note 6. |
| `total_ht_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | total hors TVA, snapshot a la validation de la commande |
| `total_vat_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | montant de TVA, snapshot |
| `total_ttc_cents` | INT UNSIGNED | NO | — | CHECK > 0 | total TTC ; doit egaler total_ht_cents + total_vat_cents (verifie a la couche MLT) |
| `paid_at` | DATETIME | YES | NULL | — | timestamp de la transition vers `paid` (NULL avant paiement) |
| `delivered_at` | DATETIME | YES | NULL | — | timestamp de la transition vers `delivered` (NULL avant la remise) |
| `cancelled_at` | DATETIME | YES | NULL | — | timestamp d'annulation (NULL si non annulee) |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | utilise pour les agregations de stats en direct ; sert aussi de base a `service_day` |
| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit |

**Retire de v0.1** : `tva_taux_pourmille` (deplace au niveau ligne — `order_item.vat_rate_snapshot`),
`paye_a` (renomme `paid_at`). Etats machine `preparing` et `ready` retires (voir note 6).

**Calcul de `service_day`** (regroupement KPI) :
```
CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END
```
Calcule au moment de la requete, non stocke comme colonne (la formule de colonne generee avec `INTERVAL 4 HOUR
30 MINUTE` dans le MLD v0.1 etait incorrecte et est retiree). Coupure : 10:00.

**Volume** : ~100-300 commandes/jour au pic, ~10k lignes sur une demo de 6 mois.

---

### 3.11 `order_item`

Ligne d'une commande : un seul produit ou un menu, avec prix, libelle et taux de TVA
snapshotes au moment de la transaction.

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `order_id` | INT UNSIGNED | NO | — | FK -> `customer_order(id)`, ON DELETE CASCADE | |
| `item_type` | ENUM('product','menu') | NO | — | — | discriminateur |
| `product_id` | INT UNSIGNED | YES | NULL | FK -> `product(id)`, ON DELETE RESTRICT | non nul si `item_type = 'product'` |
| `menu_id` | INT UNSIGNED | YES | NULL | FK -> `menu(id)`, ON DELETE RESTRICT | non nul si `item_type = 'menu'` |
| `format` | ENUM('normal','maxi') | NO | 'normal' | — | s'applique aux items menu (Normal / Maxi). Pour les produits autonomes, la valeur est `normal` (pas d'agrandissement individuel dans ce modele). Voir note 7. |
| `label_snapshot` | VARCHAR(120) | NO | — | — | libelle au moment de la commande (preserve si le produit est renomme) |
| `unit_price_cents_snapshot` | INT UNSIGNED | NO | — | CHECK > 0 | prix unitaire TTC au moment de la commande |
| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | — | CHECK IN (55, 100) | taux de TVA en pour-mille au moment de la commande (snapshote depuis `product.vat_rate`) |
| `quantity` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | quantite commandee (ex. 3 Cocas = 1 ligne avec quantity=3) |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit |

**Contrainte CHECK** (applicative ou MariaDB CHECK >= 10.2) :
`(item_type='product' AND product_id IS NOT NULL AND menu_id IS NULL)
OR (item_type='menu' AND menu_id IS NOT NULL AND product_id IS NULL)`

**Volume** : ~3-5 lignes par commande -> 30k-50k lignes sur 6 mois.

---

### 3.12 `order_item_selection`

Les choix reels effectues par le client pour chaque slot d'une ligne de menu.
1 ligne = 1 slot rempli pour 1 order_item de type `menu`.

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | doit referencer un order_item avec item_type='menu' |
| `menu_slot_id` | INT UNSIGNED | NO | — | FK -> `menu_slot(id)`, ON DELETE RESTRICT | quel slot a ete rempli |
| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | produit choisi par le client pour ce slot |
| `label_snapshot` | VARCHAR(120) | NO | — | — | libelle du produit au moment de la commande |

**Volume** : ~2-3 selections par ligne de menu.
**Usage KPI** : permet d'analyser quelles combinaisons boisson/accompagnement sont les plus choisies.

---

### 3.13 `order_item_modifier`

Modifications au niveau ingredient appliquees par le client a un produit ou au burger fixe
d'un menu : retrait (gratuit) ou ajout (avec supplement optionnel).

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | la ligne de commande modifiee (produit ou menu) |
| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | l'ingredient modifie |
| `action` | ENUM('remove','add') | NO | — | — | `remove` = retrait gratuit ; `add` = unite supplementaire (peut avoir un supplement) |
| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | snapshot de `product_ingredient.extra_price_cents` au moment de la commande (0 pour les retraits) |

**Regle de rattachement du modificateur** (voir note de modelisation 10) :
- Pour un produit autonome (`item_type='product'`) : le modificateur cible le produit
  directement via `order_item_id`.
- Pour un menu (`item_type='menu'`) : le modificateur cible le burger fixe de la ligne de menu
  via le meme `order_item_id`. Le burger est identifie par `menu.burger_product_id`,
  permettant a l'affichage cuisine de resoudre sans ambiguite quels ingredients sont modifies.
  Aucune FK supplementaire n'est necessaire : etant donne `order_item_id`, le burger est
  `order_item.menu_id -> menu.burger_product_id`.

**Impact stock** : chaque modificateur affecte le stock d'ingredient a la transition `paid`
(`remove` -> pas de decrement pour cet ingredient ; `add` -> decrement supplementaire).

---

### 3.14 `user`

Utilisateur back-office (admin, manager, personnel cuisine, counter, drive). Les clients de la borne
ne sont pas authentifies et n'ont pas de ligne ici.

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `email` | VARCHAR(254) | NO | — | UNIQUE | longueur max selon RFC 5321 |
| `password_hash` | VARCHAR(255) | NO | — | — | hash argon2id (voir `PASSWORD_ALGO` dans `.env`) ; longueur typique 96 caracteres, marge jusqu'a 255 |
| `first_name` | VARCHAR(60) | NO | — | — | |
| `last_name` | VARCHAR(60) | NO | — | — | |
| `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE RESTRICT | un utilisateur ne peut exister sans role |
| `is_active` | TINYINT(1) | NO | 1 | — | desactivation sans suppression |
| `last_login_at` | DATETIME | YES | NULL | — | utile pour l'audit et la detection de comptes dormants |
| `pin_hash` | VARCHAR(255) | YES | NULL | — | hash argon2id du PIN par membre du personnel qui autorise les actions sensibles (prix/RBAC/utilisateur/annulation/inventaire). NULL = aucun PIN defini. Security-by-design, voir note 13 |
| `failed_login_attempts` | SMALLINT UNSIGNED | NO | 0 | — | logins echoues consecutifs ; pilote le throttling degressif (note 13) |
| `last_failed_login_at` | DATETIME | YES | NULL | — | timestamp du dernier login echoue |
| `lockout_until` | DATETIME | YES | NULL | — | fin de la fenetre de throttling courante (backoff degressif, pas un verrouillage dur indefini) |
| `password_reset_token_hash` | VARCHAR(255) | YES | NULL | — | hash du token de reset (pas le token brut) ; NULL quand aucun reset en attente |
| `password_reset_expires_at` | DATETIME | YES | NULL | — | expiration du token de reset |
| `anonymized_at` | DATETIME | YES | NULL | — | marqueur tombstone RGPD : quand renseigne, les colonnes PII sont mises a NULL/remplacees (note 13). La ligne est conservee pour l'integrite referentielle |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit |
| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit |

**Volume** : 5-20 lignes (equipe du restaurant + 1-2 admins).

Longueur d'email RFC 5321 : local-part <= 64, domaine <= 255, total <= 254 (incluant `@`).
VARCHAR(254) est la valeur conforme a la spec.

**Colonnes PII** : `email`, `first_name`, `last_name`. Soumises a l'anonymisation RGPD
(voir note 13). `password_hash` et `pin_hash` sont des credentials, tenus hors des logs et
des reponses d'API.

---

### 3.15 `role`

Roles back-office (RBAC). Creables / modifiables / desactivables depuis l'UI admin.
Le seed fournit 5 roles ; des roles personnalises (ex. "chef-patissier") peuvent etre ajoutes sans deploiement.

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `code` | VARCHAR(40) | NO | — | UNIQUE | code machine, ex. `admin`, `manager`, `kitchen`, `counter`, `drive` |
| `label` | VARCHAR(80) | NO | — | — | nom d'affichage, ex. `Administrator`, `Kitchen Staff` |
| `description` | TEXT | YES | NULL | — | |
| `default_route` | VARCHAR(120) | YES | NULL | — | ecran d'atterrissage pour ce role (ex. `/admin/orders`, `/kitchen/display`). Rend le routage dynamique — pas de noms de role en dur dans le routage front-end. |
| `order_source` | ENUM('kiosk','counter','drive') | YES | NULL | — | `source` auto-taggee quand ce role cree une commande (NULL pour admin/manager qui peuvent creer au nom de n'importe quel canal) |
| `is_active` | TINYINT(1) | NO | 1 | — | la desactivation preserve l'historique des utilisateurs ayant detenu ce role |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit |
| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit |

**Roles du seed** :
| Code | `default_route` | `order_source` |
|---|---|---|
| `admin` | `/admin/dashboard` | NULL |
| `manager` | `/admin/stats` | NULL |
| `kitchen` | `/kitchen/display` | NULL |
| `counter` | `/counter/orders` | `counter` |
| `drive` | `/drive/orders` | `drive` |

**Regle d'architecture RBAC (P2)** : le code applicatif teste les permissions, pas les noms de role.
Ajouter un nouveau role avec les bonnes permissions ne requiert aucun changement de code (pilote par permission,
non par nom de role — selon le modele RBAC Sandhu/NIST).

---

### 3.16 `role_visible_source`

Definit quelles sources de commande sont visibles sur le tableau de bord de preparation pour un role donne.
Table de jointure pure.

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE CASCADE | |
| `source` | ENUM('kiosk','counter','drive') | NO | — | — | source visible pour ce role sur l'affichage kitchen/counter/drive |

**Cle primaire** : composite `(role_id, source)`.

**Donnees du seed** :
| Role | Sources visibles |
|---|---|
| `kitchen` | kiosk, counter, drive (toutes) |
| `counter` | kiosk, counter |
| `drive` | drive |

---

### 3.17 `permission`

Permissions granulaires assignables aux roles. Le catalogue est fixe au seed (pas de creation via UI).

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `code` | VARCHAR(60) | NO | — | UNIQUE | format `<resource>.<action>` |
| `label` | VARCHAR(120) | NO | — | — | nom d'affichage |
| `description` | TEXT | YES | NULL | — | |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit |

**Catalogue de permissions fixe** (23 codes — gele avant le DDL) :

| Code | Accorde a (defaut seed) |
|---|---|
| `product.create` | admin, manager |
| `product.read` | admin, manager, kitchen, counter, drive |
| `product.update` | admin, manager |
| `product.delete` | admin |
| `menu.create` | admin, manager |
| `menu.read` | admin, manager, kitchen, counter, drive |
| `menu.update` | admin, manager |
| `menu.delete` | admin |
| `category.manage` | admin, manager |
| `ingredient.manage` | admin, manager |
| `stock.read` | admin, manager, kitchen, counter, drive |
| `stock.count` | admin, manager, kitchen, counter, drive |
| `stock.manage` | admin, manager |
| `order.read` | admin, manager, kitchen, counter, drive |
| `order.create` | admin, counter, drive |
| `order.deliver` | admin, counter, drive |
| `order.cancel` | admin, counter, drive |
| `user.create` | admin |
| `user.read` | admin, manager |
| `user.update` | admin |
| `user.deactivate` | admin |
| `role.manage` | admin |
| `stats.read` | admin, manager |

**Volume** : 23 lignes au seed.

---

### 3.18 `role_permission`

Mapping N-N entre roles et permissions. Table de jointure pure.

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE CASCADE | |
| `permission_id` | INT UNSIGNED | NO | — | FK -> `permission(id)`, ON DELETE CASCADE | |

**Cle primaire** : composite `(role_id, permission_id)`.

**Volume** : ~50-100 lignes au seed (admin couvre tout ; les autres couvrent un sous-ensemble).

---

### 3.19 `stock_movement`

Journal d'audit append-only de tous les changements de stock par ingredient.
1 ligne par mouvement (vente, annulation, reapprovisionnement, correction d'inventaire).

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | ingredient affecte |
| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | — | INDEX | nature du mouvement |
| `delta` | INT | NO | — | — | changement signe : negatif pour la consommation (vente), positif pour reapprovisionnement/annulation/correction |
| `order_id` | INT UNSIGNED | YES | NULL | FK -> `customer_order(id)`, ON DELETE SET NULL | commande liee pour les mouvements `sale` et `cancellation` ; NULL pour restock/correction |
| `user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | utilisateur ayant declenche le mouvement (NULL pour les decrements de vente automatises) |
| `note` | VARCHAR(255) | YES | NULL | — | note humaine optionnelle (ex. raison de la correction, reference de pack) |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | timestamp immuable |

**Immuabilite** : aucun UPDATE ni DELETE sur cette table. Les corrections sont de nouvelles lignes avec
`movement_type='inventory_correction'` et un delta signe.

**Mouvements automatiques** (declenches aux transitions de statut) :
- transition `paid` : 1 ligne `sale` par unite d'ingredient consommee (en tenant compte des modificateurs).
- `cancelled` (depuis `paid`) : 1 ligne `cancellation` par unite d'ingredient recreditee.

**Mouvements manuels** :
- `restock` : le manager ou l'admin enregistre une livraison (`+= N * pack_size`).
- `inventory_correction` : comptage physique matin/soir ; le systeme enregistre l'ecart
  (delta = reel - theorique).

**Volume** : ~5-15 mouvements par commande sur tous les ingredients ; un index sur
`(ingredient_id, created_at)` est recommande pour les requetes d'historique par ingredient.

---

### 3.20 `audit_log`

Journal append-only des **actions back-office sensibles**, pour l'imputabilite la ou elle importe
(menace interne, manipulation d'argent, changements RBAC). Complete `stock_movement` (specifique au
stock) ; couvre les evenements catalogue/prix, utilisateur, role/permission et annulation de commande.
Ajout security-by-design (voir note 13).

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `actor_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | personnel ayant effectue l'action, capture via PIN pour les operations sensibles. NULL si non attribuable a un individu |
| `actor_role_id` | INT UNSIGNED | YES | NULL | FK -> `role(id)`, ON DELETE SET NULL | contexte de role au moment de l'action (denormalise pour que la trace survive a l'anonymisation de l'utilisateur) |
| `action_code` | VARCHAR(60) | NO | — | INDEX | code d'operation MCT / de permission, ex. `product.update`, `order.cancel`, `role.manage`, `user.deactivate` |
| `entity_type` | VARCHAR(40) | YES | NULL | — | nom de la table affectee, ex. `product`, `customer_order`, `role`, `user` |
| `entity_id` | INT UNSIGNED | YES | NULL | — | PK de la ligne affectee |
| `summary` | VARCHAR(255) | YES | NULL | — | courte description non personnelle, ex. "price_cents 880 -> 920", "added permission stock.manage" |
| `details` | JSON | YES | NULL | — | diff before/after optionnel. Pour les actions ciblant un utilisateur, stocke les **noms de champs** modifies, pas les valeurs PII |
| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | timestamp immuable |

**Immuabilite** : aucun UPDATE ni DELETE au niveau applicatif (meme discipline que `stock_movement`).
**Index** : `(actor_user_id, created_at)`, `(entity_type, entity_id)`, `(action_code, created_at)`.
**Retention** : fenetre propre (~12 mois, interet legitime / tracabilite fiscale), decouplee
du cycle de vie des PII utilisateur (note 13). Une purge planifiee (cron) retire les lignes au-dela de la fenetre.

**Operations journalisees** (ensemble sensible) : `UPDATE_PRODUCT` (8.2, incl. prix), `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** : faible (~10-50 actions sensibles/jour) — des ordres de grandeur sous `stock_movement`.

---

### 3.21 `login_throttle`

Throttle anti-brute-force par IP source. Complete le compteur par compte deja present sur `user`
(`failed_login_attempts` / `lockout_until`), une ligne par IP source. Ajout security-by-design
(voir note 13).

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `ip_address` | VARCHAR(45) | NO | — | UNIQUE | IP source, une ligne par IP, upsertee ; 45 caracteres contiennent un litteral IPv6 complet |
| `failed_attempts` | SMALLINT UNSIGNED | NO | 0 | — | logins echoues consecutifs depuis cette IP dans la fenetre courante |
| `window_started_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | debut de la fenetre de comptage courante |
| `lockout_until` | DATETIME | YES | NULL | — | fin de la fenetre de backoff degressif ; NULL = non throttle |
| `last_attempt_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | timestamp de la derniere tentative echouee |

**Pas de FK** : une IP n'est pas une entite modelisee. Les lignes sont appended/upsertees par IP ; la fenetre se reinitialise
a son expiration. Un cron quotidien purge les lignes sans lockout actif dont le `last_attempt_at` est plus ancien
que 24h.

---

### 3.22 `pin_throttle`

Throttle du PIN d'action sensible (RG-T22), complement de RG-T13. Une ligne par utilisateur AGISSANT
(l'identite de session qui soumet email+PIN), STRICTEMENT SEPAREE des compteurs de connexion
(`user.failed_login_attempts` / `login_throttle`) : un echec de PIN n'incremente aucun compteur de login.
Ajout security-by-design (voir note 13).

| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `actor_user_id` | INT UNSIGNED | NO | — | UNIQUE, FK -> `user(id)` ON DELETE CASCADE | l'utilisateur agissant (session), une ligne par acteur, upsertee |
| `failed_attempts` | SMALLINT UNSIGNED | NO | 0 | — | echecs de PIN consecutifs de cet acteur dans la fenetre courante |
| `window_started_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | debut de la fenetre de comptage courante |
| `lockout_until` | DATETIME | YES | NULL | — | fin de la fenetre de backoff degressif ; NULL = non throttle |
| `last_attempt_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | timestamp de la derniere tentative echouee |

**FK ON DELETE CASCADE** (contrairement a `login_throttle`) : la cle est un utilisateur back-office
authentifie, donc supprimer/anonymiser le compte purge proprement sa ligne de throttle. Memes bornes de
backoff que RG-8 mais PROPRES au PIN (PIN_THROTTLE_*, plus permissives). Meme purge cron quotidienne que
`login_throttle` (lignes sans lockout actif > 24h).

---

## 4. Notes de modelisation

### Note 1 — Pourquoi `INT UNSIGNED` en centimes pour les prix

Stocker un prix en `FLOAT` ou `DECIMAL(10,2)` est techniquement valide mais introduit deux risques :

1. **Arrondi FLOAT** : `0.1 + 0.2 = 0.30000000000000004` en virgule flottante IEEE 754.
   Sommer 100 lignes de commande peut produire des ecarts au niveau du centime vs la realite metier.
2. **Conversion FLOAT-vers-chaine** : differentes versions de driver PHP/MariaDB peuvent serialiser les floats
   avec une precision variable.

Stocker en `INT UNSIGNED` (centimes : 880 pour 8,80 EUR) elimine ces risques. La conversion en EUR
pour l'affichage se fait en PHP a la sortie : `number_format($cents / 100, 2)`.

Reference : David Goldberg, *What Every Computer Scientist Should Know About Floating-Point
Arithmetic*, ACM Computing Surveys, 1991.

### Note 2 — Pourquoi `ENUM` plutot qu'une table de reference

Les ENUMs (`service_mode`, `status`, `item_type`, `action`, `slot_type`) auraient pu etre des tables de
reference. Choix retenu : ENUM.

Avantages dans ce contexte :
- Les valeurs sont stables et limitees (3-7 valeurs max), peu susceptibles d'evoluer frequemment.
- Contrainte au niveau SGBD au lieu d'une FK a l'execution ; requetes plus simples.
- Directement lisible en SQL : `WHERE status = 'paid'`.

Cout d'un changement futur : `ALTER TABLE ... MODIFY COLUMN ... ENUM(...)` pour ajouter une valeur.
Acceptable etant donne que les changements sont attendus comme rares.

Si ces ENUMs requierent plus tard des libelles ou descriptions multilingues, ils seront migres vers des
tables de reference. Hors perimetre pour cette iteration.

### Note 3 — Pourquoi `customer_order` et non `order`

`ORDER` est un mot reserve SQL (utilise dans `ORDER BY`). Trois approches existent :
- Quoter le nom partout : `` `order` `` — requiert un quoting dans chaque instruction SQL,
  source d'erreurs et non portable entre dialectes SGBD.
- Utiliser un alias au niveau ORM : possible mais ajoute une couche de mapping.
- Renommer : `customer_order` (choisi) — sans ambiguite, auto-documente, sans quoting requis.

Alternative consideree et rejetee : `purchase` (moins specifique au domaine),
`transaction` (egalement reserve ou ambigu). `customer_order` correspond au langage du domaine
et evite tous les conflits.

`order_item` est conserve comme nom de table de ligne : `item` n'est pas reserve, et le
prefixe `order_` rend claire la relation parent.

### Note 4 — Prefixe de numero de commande par canal

Format : `K`/`C`/`D`-YYYY-MM-DD-NNN (kiosk / counter / drive).

Rationale : le prefixe encode le canal, ce qui est utile pour une identification visuelle rapide
par le personnel cuisine et comptoir sans interroger la colonne `source`. Le compteur sequentiel NNN
repart chaque jour par canal. Sans collision sur une journee, vu le volume attendu.

Alternative rejetee : prefixe neutre `W-` pour tous les canaux (plus simple, mais perd la lisibilite
du canal pour le personnel).

### Note 5 — `source` vs `service_mode` (canal vs mode de consommation)

Deux dimensions distinctes, gardees separees :

| | `source` | `service_mode` |
|---|---|---|
| Nature | canal de saisie (qui a saisi la commande) | mode de consommation (ou le client mange) |
| Valeurs | kiosk, counter, drive | dine_in, takeaway, drive |
| Sert a | authentification, analytics, filtrage de permission | KPI, capacite (aucun role fiscal) |

Les deux dimensions sont independantes pour `kiosk` et `counter` (un client borne peut choisir
`dine_in` ou `takeaway`). `drive` est le seul cas ou les deux dimensions s'alignent :
`source=drive` implique `service_mode=drive`. Cette contrainte croisee est verifiee au niveau applicatif.

### Note 6 — Machine a 4 etats reduite

v0.1 avait 6 etats (`pending_payment`, `paid`, `preparing`, `ready`, `delivered`, `cancelled`).
v0.2 reduit a 4 etats : `pending_payment -> paid -> delivered` (+ `cancelled`).

Rationale (Decision 4 de `revue-alignement-p1.md` §7) : dans un contexte fast-food, l'affichage
cuisine (KDS) est un systeme visuel — le personnel voit le ticket et agit. `preparing` et `ready` etaient
des etats intermediaires qui ajoutaient de la complexite sans valeur metier proportionnelle. L'unique
action cuisine est `deliver` (le personnel counter/drive remet la commande), fusionnant
`preparing + ready + delivered` en un seul geste. Le KPI est le temps total : `delivered_at - paid_at`
(SLA ~10 min). Le codage couleur du KDS est calcule depuis `now - paid_at`, sans etat stocke supplementaire.

**Etats et timestamps retires** : `preparing_at`, `ready_at` ne sont pas stockes.

### Note 7 — Cascade de format Normal / Maxi

Le format Maxi agrandit uniquement l'accompagnement et la boisson. Le burger est inchange et la portion
de sauce est inchangee (un pot de sauce est identique dans les deux formats). Ce perimetre est explicite afin que le
modele de stock reste fidele.

**Cote prix** — non modelise au niveau du prix de composant individuel :
- `menu` porte deux prix : `price_normal_cents` et `price_maxi_cents`.
- `order_item.format` enregistre le format choisi par le client (`normal` ou `maxi`).
- `order_item.unit_price_cents_snapshot` capture le prix reellement paye (Normal ou Maxi).
- Aucun prix individuel par composant de slot n'est stocke ; le differentiel de prix est un attribut
  au niveau menu, coherent avec la maniere dont les menus fast-food tendent a etre tarifes en pratique.

**Cote stock** — modelise via un multiplicateur de format sur la recette :
- `product_ingredient` porte `quantity_normal` et `quantity_maxi`.
- A la transition `paid`, le decrement utilise `quantity_maxi` quand `order_item.format='maxi'`,
  sinon `quantity_normal`.
- Pour les ingredients burger et sauce, `quantity_maxi = quantity_normal` (invariants au format).
- Pour les ingredients accompagnement et boisson, `quantity_maxi > quantity_normal` (le Maxi consomme plus).
- Le format se propage de la ligne de menu (`order_item.format`) a ses selections de slot ; une
  ligne de produit autonome est par defaut a `normal`.
- Un seul produit par choix (ex. un produit `Fries`), pas de produits medium/large separes.

Calibration : le supplement Maxi est dans la fourchette ~1,50 EUR pour ce modele (derive de
donnees internes ; recoupe avec l'ordre de grandeur du marche pour plausibilite. Wakdo est un pastiche
fictif, donc les prix exacts ne sont pas copies d'une chaine reelle).

Calibration : le supplement Maxi est dans la fourchette ~1,50 EUR pour ce modele (derive de
donnees internes ; recoupe avec l'ordre de grandeur du marche pour plausibilite. Wakdo est un pastiche
fictif, donc les prix exacts ne sont pas copies d'une chaine reelle).

### Note 8 — Stockage des images : chemin en VARCHAR vs BLOB en BDD

Les colonnes `image_path` (`category`, `product`, `menu`) stockent un **chemin relatif** depuis la
racine publique (ex. `/uploads/products/classic-burger.jpg`), pas un chemin serveur absolu.
PHP resout via un prefixe depuis `.env` (`UPLOAD_DIR=public/uploads`).

Le stockage BLOB a ete considere et rejete :

| Critere | `image_path` VARCHAR (choisi) | BLOB en BDD |
|---|---|---|
| Performance borne | Apache sert les fichiers en ms (cache OS) | PHP lit la BDD + streame, latence multipliee |
| Cache HTTP | ETag, Last-Modified, cache navigateur, CDN natifs | doit etre reimplemente en PHP |
| Taille de backup BDD | Megaoctets (chemins seulement) | Gigaoctets (66 produits x ~200 Ko + variantes responsive) |
| Pipeline d'images | `convert`, `webp`, optimisation = outils standard du systeme de fichiers | doit etre reinvente en PHP |

Sources : OWASP File Upload Cheat Sheet ; MariaDB Knowledge Base — LONGBLOB performance ;
documentation Apache HTTP Server — serving static content.

### Note 9 — Regle de TVA dans le fast-food francais (fact-checked)

```
FACT-CHECK
Claim audited : "TVA 10% sur place / 5,5% a emporter" (dictionary v0.1 note 9)
Domain         : compliance (fiscal)
Verdict        : CLAIM INEXACT — superseded
Source         : BOFiP BOI-ANNX-000495 + BOI-TVA-LIQ-30-10-10 (official doctrine impots.gouv.fr)
Actual rule    : 10% for immediate consumption (dine-in OR hot takeaway);
                 5.5% for products in resealable containers (bottle, can) / deferred consumption
Confidence     : 95% (L1, official text)
```

**Consequence sur le modele** : le taux de TVA est un attribut du `product` (`vat_rate` en pour-mille :
100 = 10%, 55 = 5,5%), pas de la commande ni du mode de service. Defaut : 100 (10%).
Le taux de 5,5% s'applique aux produits en contenants refermables (eau en bouteille, bouteilles de jus).
La TVA est calculee ligne par ligne ; le taux est snapshote sur `order_item.vat_rate_snapshot`
au moment de la transaction pour preserver l'integrite historique si la legislation change.

`service_mode` est conserve sur `customer_order` pour les stats et le KPI uniquement (planification de capacite,
repartition du chiffre d'affaires par mode). Il n'a aucun role de calcul fiscal.

### Note 10 — Configurateur d'ingredients et rattachement du modificateur

`order_item_modifier` se rattache a une ligne `order_item` via `order_item_id`, que
la ligne soit un produit autonome ou un menu.

Pour un **produit autonome** (`item_type='product'`) : `order_item_id` identifie directement
le produit modifie.

Pour un **menu** (`item_type='menu'`) : le produit modifiable est le burger fixe, identifie
via `order_item.menu_id -> menu.burger_product_id`. L'affichage cuisine resout :
`modifier.order_item_id -> order_item -> menu -> menu.burger_product_id -> product.name`.
Aucune colonne FK supplementaire n'est necessaire sur `order_item_modifier`. Cela garde la table modificateur
simple et evite une colonne nullable `target_product_id` qui ne serait peuplee que pour les
lignes de menu.

Contrainte appliquee au niveau applicatif : les lignes `order_item_modifier` pour une ligne de menu referencent
uniquement des ingredients appartenant a `menu.burger_product_id` via `product_ingredient`.

### Note 11 — Eligibilite `menu_slot` : filtre par categorie vs liste de produits explicite

Deux options ont ete considerees :
- **Filtre par categorie** : `menu_slot.category_id` pointe vers une categorie ; tous les produits de cette
  categorie sont eligibles. Simple, mais une categorie peut contenir des produits non proposes dans ce slot
  (ex. une boisson premium ajoutee a la categorie "drinks" ne devrait pas apparaitre automatiquement dans
  tous les slots de menu).
- **Liste de produits explicite** `menu_slot_option(menu_slot_id, product_id)` (choisie) : chaque
  produit eligible est liste explicitement par slot. Plus verbeux au moment du seed mais precis —
  pas d'eligibilite accidentelle quand le catalogue grandit. Permet des overrides de tarification par slot
  a l'avenir sans changement structurel.

La liste explicite ajoute une entite (`menu_slot_option`, entite 3.5) mais elimine une classe
de bugs de justesse. Coherent avec l'ambition prod-like de ce modele.

### Note 12 — `commande_event` retire

v0.1 portait une table d'audit append-only `commande_event` (pattern event sourcing).
Retiree en v0.2 (Decision 1, `revue-alignement-p1.md` §7).

Rationale : dans un contexte restaurant, le compte back-office est partage par poste de travail, non
individuel. L'attribution par personne d'une transition d'etat n'a aucune valeur metier. Le besoin reel
(durees de phase, stats par heure de la journee) est couvert par les timestamps de phase sur `customer_order`
(`paid_at`, `delivered_at`, `cancelled_at`) sans la complexite d'un event store.

La machine a 4 etats combinee a 3 timestamps de phase fournit toutes les donnees KPI necessaires :
- Temps de remise : `delivered_at - paid_at`
- Taux et timing d'annulation : `cancelled_at - created_at`
- Volume par heure : calcul `HOUR(created_at)` / `service_day`

Pour l'audit de stock, `stock_movement` (entite 3.19) fournit la trace d'audit append-only
la ou elle est genuinement necessaire (reconciliation d'inventaire).

### Note 13 — Ajouts de donnees security-by-design (2026-06-11)

Ces ajouts etendent le modele prod-like avec une couche security-by-design. Ils ne
remplacent aucune decision v0.2 ; ils ajoutent imputabilite, cycle de vie d'auth et resistance a l'abus.

**Imputabilite — compte partage hybride + PIN.** Les sessions back-office restent partagees par
poste de travail pour le flux de routine (un terminal fast-food est partage, les `equipiers` tournent). Un
PIN par membre du personnel (`user.pin_hash`, argon2id) autorise un ensemble defini d'**actions sensibles**
(editions prix/menu 8.2/8.3/8.6, annulation de commande 7.1, correction d'inventaire 9.2, gestion
des utilisateurs 10.1-10.3, RBAC 10.4). Ces actions ecrivent le `user_id` agissant dans `audit_log`
(3.20). Cela resout la justification circulaire qui avait retire `commande_event` en v0.1
(les events etaient juges inutiles parce que les comptes etaient partages) : l'imputabilite est enregistree
la ou elle importe, a friction quasi nulle pour les 95% de routine. `customer_order.acting_user_id`
capture le personnel pour les commandes counter/drive prises sous PIN ; les commandes borne restent anonymes.

**Cycle de vie d'auth.** `password_reset_token_hash` + `password_reset_expires_at` permettent un parcours
de reset (le token est stocke hashe, le token brut est envoye par e-mail une seule fois). La resistance au brute-force utilise
un throttling degressif plutot qu'un verrouillage dur indefini : `failed_login_attempts` +
`lockout_until` implementent un backoff degressif par (compte + IP source), de sorte qu'une serie
de fautes de frappe ne verrouille pas toute une cuisine en plein service (15 h continues). Les logins echoues sont
ecrits dans `audit_log`.

**Anonymisation RGPD vs retention d'audit.** Les PII de `user` (`email`, `first_name`, `last_name`)
sont soumises au droit a l'effacement (Cr 3.d). L'effacement **anonymise** plutot qu'il ne supprime durement :
la ligne est conservee, `email` devient un placeholder unique non identifiant (`anon-<id>@wakdo.invalid`,
domaine reserve RFC 2606), les noms sont effaces, `password_hash`/`pin_hash` sont invalides, et
`anonymized_at` est renseigne. `audit_log` conserve sa propre fenetre de retention (~12 mois,
interet legitime / tracabilite fiscale) et continue de pointer vers le principal anonymise, de sorte que
effacement et imputabilite coexistent sans casser l'integrite referentielle.

**Resistance a l'abus sur la borne anonyme.** `customer_order.idempotency_key` (UUID client,
UNIQUE) deduplique un `POST /api/orders` reessaye de sorte qu'un retry reseau ne cree pas de
commande payee dupliquee. Le stock est decremente avec une seule instruction atomique
(`UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id`) : aucune operation
ne depend d'une lecture de stock, donc la ligne s'auto-verrouille pour la duree de l'ecriture — pas de lost update et
pas de souci d'ordre des deadlocks. Cela remplace l'approche pessimiste anterieure `SELECT ... FOR UPDATE`
(regle de la couche traitement, voir `mlt.md`) ; elle n'ajoute aucune colonne ici.

**Modele de stock en pourcentage + disponibilite calculee.** `ingredient` porte `stock_capacity` (la
reference 100%), `low_stock_pct` (bande d’alerte) et `critical_stock_pct` (seuil de rupture
automatique) — voir 3.6. `stock_quantity` est signe et peut devenir negatif (ampleur de survente remontee aux
managers) ; le systeme ne bloque pas une commande sur le stock. La commandabilite effective du produit est
calculee (regle RG-T21 dans `mlt.md`) : `product.is_available = 1` ET chaque ingredient non retirable
(`is_removable=0`) de son `product_ingredient` a
`stock_quantity > stock_capacity * critical_stock_pct/100`. A la bande critique, un produit
passe automatiquement en rupture sans ecriture ni cascade ; un retrait manuel (`product.is_available=0`) est
une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend le produit a nouveau commandable de lui-meme.

**Throttle anti-brute-force par IP.** `login_throttle` (3.21) suit `failed_attempts` et
`lockout_until` par IP source (une ligne upsertee par IP), completant le compteur par compte
sur `user`. Cela ajoute une seconde dimension de throttling, de sorte qu'une seule IP martelant de nombreux comptes soit
ralentie independamment du compteur de n'importe quel compte. Un cron quotidien purge les lignes inactives et non verrouillees.

**Throttle du PIN d'action sensible (par acteur).** `pin_throttle` (3.22) suit `failed_attempts` et
`lockout_until` par utilisateur AGISSANT (l'identite de session qui valide une action sensible),
dans une table separee des compteurs de connexion. La dimension est l'acteur (et non l'email cible,
contournable par rotation, ni l'IP, qui penaliserait tous les equipiers d'un poste partage) ; le verrou
est un backoff degressif aux bornes propres (PIN_THROTTLE_*). Meme purge cron que `login_throttle`. RG-T22.

References : `docs/notes/revue-alignement-p1.md` §7 (decisions D), carte d'impact security-by-design
(2026-06-11). Modele de menace et matrice de classification des donnees : `PROJECT_CONTEXT.md` §19 (a venir).

---

## 5. Synthese du decompte des entites

| # | Entite | Type | Remplace / nouveau |
|---|---|---|---|
| 1 | `category` | business | v0.1 `categorie` (renommee + traduite) |
| 2 | `product` | business | v0.1 `produit` (+ `vat_rate`) |
| 3 | `menu` | business | v0.1 `menu` (+ FK burger, 2 prix) |
| 4 | `menu_slot` | business | nouveau — remplace la composition fixe `menu_produit` |
| 5 | `menu_slot_option` | join | nouveau — liste d'eligibilite par slot |
| 6 | `ingredient` | business | nouveau — configurateur d'ingredients + stock |
| 7 | `product_ingredient` | join | nouveau — recette + metadonnees de personnalisation |
| 8 | `allergen` | reference | nouveau — INCO 1169/2011 |
| 9 | `ingredient_allergen` | join | nouveau — mappe les allergenes aux ingredients |
| 10 | `customer_order` | business | v0.1 `commande` (renommee, machine a 4 etats, timestamps de phase) |
| 11 | `order_item` | business | v0.1 `ligne_commande` (+ format, vat_rate_snapshot) |
| 12 | `order_item_selection` | business | nouveau — choix de slot de menu du client |
| 13 | `order_item_modifier` | business | nouveau — modifications au niveau ingredient |
| 14 | `user` | business | v0.1 `user` (noms de champs traduits) |
| 15 | `role` | business | v0.1 `role` (+ default_route, order_source) |
| 16 | `role_visible_source` | join | nouveau — filtre de tableau de bord par role |
| 17 | `permission` | reference | v0.1 `permission` (traduite, catalogue gele) |
| 18 | `role_permission` | join | v0.1 `role_permission` (inchangee) |
| 19 | `stock_movement` | audit | nouveau — journal d'audit de stock append-only |
| 20 | `audit_log` | audit | nouveau (security-by-design) — journal append-only d'actions sensibles |
| 21 | `login_throttle` | security | nouveau (security-by-design) - throttle anti-brute-force par IP |
| 22 | `pin_throttle` | security | nouveau (security-by-design) - throttle du PIN d'action sensible par acteur (RG-T22) |

**Retire de v0.1** : `commande_event` (remplace par les timestamps de phase sur `customer_order`),
`menu_produit` (remplace par le modele `menu_slot` + `menu_slot_option`).

**Total : 22 entites** (19 prod-like v0.2 + `audit_log`, `login_throttle` et `pin_throttle`
de la couche security-by-design).

Le security-by-design ajoute aussi des colonnes (au-dela des deux nouvelles entites) : cycle de vie d'auth de `user` +
`pin_hash` + `anonymized_at` (3.14), `customer_order.acting_user_id` + `idempotency_key` (3.10),
et le modele de stock en pourcentage sur `ingredient` (3.6) — `stock_capacity`, `critical_stock_pct`,
plus le renommage de `low_stock_threshold` en `low_stock_pct`. `login_throttle` (3.21) est la 21e
entite et `pin_throttle` (3.22) la 22e. Voir note 13.

---

*Pour le diagramme ER et les justifications de cardinalite, voir [`mcd.md`](mcd.md) — le diagramme est
la source de verite unique pour la representation graphique.*
