corentin_wakdo/docs/merise/mld.md
Corentin JOGUET 2e0d535b58
All checks were successful
CI / secret-scan (push) Successful in 20s
CI / php-lint (push) Successful in 45s
CI / static-tests (push) Successful in 1m23s
CI / js-tests (push) Successful in 1m5s
docs: remediation audit vague 1 - sync Merise + journal/ADR + claims faux (#106)
2026-06-25 10:02:13 +02:00

1415 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Modele Logique de Donnees (MLD) — Wakdo
**Phase Merise** : P1 - Conception, etape 5 (apres MCD, MCT, MLT)
**Version** : v0.3 — prod-like, 22 tables (19 prod-like + couche security-by-design)
**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 (audit_log + colonnes imputabilite/auth) en cours
**Auteur** : BYAN (couche methodologique)
---
## 1. Objectif de ce document
Le MLD transcrit le MCD en un schema relationnel formel : 1 entite -> 1 table, chaque
association traduite selon sa cardinalite, contraintes referentielles materialisees,
index dimensionnes pour les patterns d'acces frequents.
C'est l'etape qui transforme la modelisation conceptuelle en une specification implementable.
Le DDL SQL (`db/migrations/0001_init_schema.sql`) sera derive directement de ce
document en P2.
**Sources** :
- `docs/merise/dictionary.md` (v0.2 — types et contraintes par attribut, source de verite)
- `docs/merise/mcd.md` (v0.2 — entites + cardinalites + decisions reportees)
- `docs/notes/revue-alignement-p1.md` §7 (table de decisions D1-D8 + stock)
**Plateforme cible** :
- MariaDB 11.4 LTS (cf. `docker-compose.yml` service `wakdo-db`)
- Moteur InnoDB (ACID, support des FK, verrouillage au niveau ligne, CHECK depuis 10.2.1)
- Charset `utf8mb4`, collation `utf8mb4_unicode_ci`
---
## 2. Conventions de notation
### Notation relationnelle
```
table_name (col1, col2, #col_fk, [col_nullable])
PK : col1
UK : col2
FK : col_fk -> other_table(id) ON DELETE <rule>
IDX : (col_a, col_b)
CHK : <expression>
```
| Symbole | Signification |
|---|---|
| `col` | Colonne NOT NULL |
| `[col]` | Colonne nullable |
| `#col` | Colonne FK |
La notation suit l'usage Merise francais (convention Nanci/Espinasse adaptee a l'ASCII).
### Resume des types
Tous les types exacts sont definis dans `dictionary.md` section 2. Conventions retenues :
- `INT UNSIGNED AUTO_INCREMENT` pour toutes les PK techniques
- `INT UNSIGNED` pour tous les montants monetaires en centimes (anti-FLOAT, voir note 1 du dictionnaire)
- `SMALLINT UNSIGNED` pour les valeurs pour-mille de `vat_rate` (55 ou 100)
- `ENUM(...)` pour les valeurs metier stables (voir note 2 du dictionnaire)
- `DATETIME` pour les horodatages (pas TIMESTAMP, qui se convertit implicitement en UTC dans MariaDB)
---
## 3. Regles de traduction MCD -> MLD appliquees
### 3.1 Entite -> Table
Chaque entite MCD devient une table. L'identifiant conceptuel `id` devient une PK
`INT UNSIGNED AUTO_INCREMENT`. Les attributs conservent leurs noms et types.
### 3.2 Association `(1,1) - (1,N)` -> FK simple
L'entite du cote `(1,1)` porte la FK vers l'entite `(0,N)` ou `(1,N)`.
### 3.3 Association `(0,N) - (0,N)` ou `(1,N) - (1,N)` -> table de jointure
L'association devient sa propre table avec une PK composite des deux FK. Applique a :
`product_ingredient`, `menu_slot_option`, `ingredient_allergen`,
`role_visible_source`, `role_permission`.
### 3.4 Entite associative avec attributs propres -> table de jointure avec colonnes
Quand une association N-N porte ses propres attributs, elle devient une table avec ces attributs
en plus de la PK composite des FK. Applique a `product_ingredient`.
### 3.5 Polymorphisme -> 2 FK nullables + discriminateur + CHECK
`order_item` reference soit `product` soit `menu`. Traduit en 2 colonnes FK nullables +
1 discriminateur ENUM + 1 contrainte CHECK imposant l'exclusivite mutuelle.
---
## 4. Schema relationnel (22 tables)
Les tables sont ordonnees par dependance (tables sans FK d'abord, puis tables qui en dependent).
### Diagrammes relationnels (par sous-domaine)
Le schema relationnel est presente sous forme de quatre vues Mermaid `erDiagram`, une par sous-domaine (meme
decomposition que le MCD ; un unique diagramme de 22 tables ne se disposerait pas proprement). Elles different
du MCD : les entites associatives sont resolues en tables de jointure avec PK composites, le
polymorphisme de `order_item` apparait sous forme de deux FK nullables (`product_id` / `menu_id`), et chaque
cle etrangere est explicite. Les horodatages d'audit (`created_at` / `updated_at`) sont presents sur la plupart des
tables (voir les sections par table ci-dessous) mais omis des diagrammes pour les garder lisibles.
Les libelles de relation portent la colonne FK et son comportement `ON DELETE`. Les cibles de FK
inter-sous-domaines sont representees comme des tables stub (id + name). Les rendus SVG portables sont dans `_diagrams/`
(`mld-catalogue.svg`, `mld-ingredients-stock.svg`, `mld-order.svg`, `mld-rbac.svg`).
#### Catalogue
```mermaid
erDiagram
category {
int id PK
varchar name UK
varchar slug UK
smallint display_order
tinyint is_active
}
product {
int id PK
int category_id FK
varchar name
int price_cents
int maxi_variant_product_id FK
smallint size_cl
int base_product_id FK
smallint vat_rate
tinyint is_available
smallint display_order
}
menu {
int id PK
int category_id FK
int burger_product_id FK
varchar name
int price_normal_cents
int price_maxi_cents
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 PK,FK
int product_id PK,FK
}
category ||--o{ product : "category_id (RESTRICT)"
category ||--o{ menu : "category_id (RESTRICT)"
product ||--o{ menu : "burger_product_id (RESTRICT)"
product ||--o{ product : "maxi_variant_product_id (SET NULL)"
product ||--o{ product : "base_product_id (CASCADE)"
menu ||--o{ menu_slot : "menu_id (CASCADE)"
menu_slot ||--o{ menu_slot_option : "menu_slot_id (CASCADE)"
product ||--o{ menu_slot_option : "product_id (RESTRICT)"
```
#### Ingredients et Stock
```mermaid
erDiagram
ingredient {
int id PK
varchar name UK
varchar unit
int stock_quantity
int stock_capacity
smallint pack_size
smallint energy_kcal_100g
varchar nutrition_source
datetime nutrition_fetched_at
smallint low_stock_pct
smallint critical_stock_pct
tinyint is_active
}
product_ingredient {
int product_id PK,FK
int ingredient_id PK,FK
smallint quantity_normal
smallint quantity_maxi
tinyint is_removable
tinyint is_addable
int extra_price_cents
}
allergen {
int id PK
varchar code UK
varchar name
}
ingredient_allergen {
int ingredient_id PK,FK
int allergen_id PK,FK
}
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 {
int id PK
varchar name
}
customer_order {
int id PK
varchar order_number
}
user {
int id PK
varchar email
}
product ||--o{ product_ingredient : "product_id (CASCADE)"
ingredient ||--o{ product_ingredient : "ingredient_id (RESTRICT)"
ingredient ||--o{ ingredient_allergen : "ingredient_id (CASCADE)"
allergen ||--o{ ingredient_allergen : "allergen_id (RESTRICT)"
ingredient ||--o{ stock_movement : "ingredient_id (RESTRICT)"
customer_order ||--o{ stock_movement : "order_id (SET NULL, nullable)"
user ||--o{ stock_movement : "user_id (SET NULL, nullable)"
```
#### Commande
```mermaid
erDiagram
customer_order {
int id PK
varchar order_number UK
varchar idempotency_key UK
enum source
int acting_user_id FK
enum service_mode
varchar service_tag
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
}
user {
int id PK
varchar email
}
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
}
user ||--o{ customer_order : "acting_user_id (SET NULL, nullable)"
customer_order ||--o{ order_item : "order_id (CASCADE)"
product ||--o{ order_item : "product_id (RESTRICT, polymorphic)"
menu ||--o{ order_item : "menu_id (RESTRICT, polymorphic)"
order_item ||--o{ order_item_selection : "order_item_id (CASCADE)"
menu_slot ||--o{ order_item_selection : "menu_slot_id (RESTRICT)"
product ||--o{ order_item_selection : "product_id (RESTRICT)"
order_item ||--o{ order_item_modifier : "order_item_id (CASCADE)"
ingredient ||--o{ order_item_modifier : "ingredient_id (RESTRICT)"
```
#### RBAC & securite
```mermaid
erDiagram
role {
int id PK
varchar code UK
varchar label
varchar default_route
enum order_source
tinyint is_active
}
user {
int id PK
varchar email UK
varchar password_hash
varchar pin_hash
varchar first_name
varchar last_name
int role_id FK
tinyint is_active
smallint failed_login_attempts
datetime lockout_until
datetime anonymized_at
}
role_visible_source {
int role_id PK,FK
enum source PK
}
permission {
int id PK
varchar code UK
varchar label
}
role_permission {
int role_id PK,FK
int permission_id PK,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
}
pin_throttle {
int id PK
int actor_user_id FK,UK
smallint failed_attempts
datetime window_started_at
datetime lockout_until
datetime last_attempt_at
}
role ||--o{ user : "role_id (RESTRICT)"
role ||--o{ role_visible_source : "role_id (CASCADE)"
role ||--o{ role_permission : "role_id (CASCADE)"
permission ||--o{ role_permission : "permission_id (CASCADE)"
user ||--o{ audit_log : "actor_user_id (SET NULL, nullable)"
role ||--o{ audit_log : "actor_role_id (SET NULL, nullable)"
user ||--o{ pin_throttle : "actor_user_id (CASCADE)"
```
> `login_throttle` n'a pas de FK (une IP n'est pas une entite modelisee) ; elle est autonome, cle par
> `ip_address`. `pin_throttle` (RG-T22) est cle par `actor_user_id` (FK -> `user`, ON DELETE CASCADE) :
> le throttle du PIN porte sur l'utilisateur AGISSANT, dimension distincte du login.
---
### 4.1 `category`
```
category (id, name, slug, [image_path], display_order, is_active, created_at, updated_at)
PK : id
UK : name
UK : slug
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `name` | VARCHAR(60) | NO | Nom d'affichage unique (voir dict 3.1) |
| `slug` | VARCHAR(60) | NO | Slug d'URL, p. ex. `burgers` |
| `image_path` | VARCHAR(255) | YES | Chemin relatif depuis la racine publique |
| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage borne |
| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Desactivation logique |
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
Pas de FK. Table racine du sous-domaine Catalogue.
---
### 4.2 `product`
```
product (id, #category_id, name, [description], price_cents,
[#maxi_variant_product_id], [size_cl], [#base_product_id], vat_rate,
[image_path], is_available, display_order, created_at, updated_at)
PK : id
FK : category_id -> category(id) ON DELETE RESTRICT
FK : maxi_variant_product_id -> product(id) ON DELETE SET NULL
FK : base_product_id -> product(id) ON DELETE CASCADE
IDX : (category_id, is_available, display_order)
CHK : price_cents > 0
CHK : vat_rate IN (55, 100)
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `category_id` | INT UNSIGNED | NO | FK -> category |
| `name` | VARCHAR(120) | NO | Libelle du produit |
| `description` | TEXT | YES | Description longue optionnelle |
| `price_cents` | INT UNSIGNED | NO | Prix a la carte, TVA incluse, en centimes |
| `maxi_variant_product_id` | INT UNSIGNED | YES | FK -> product (auto-reference), ON DELETE SET NULL ; variante servie en menu Maxi (migration 0006, voir note de table) |
| `size_cl` | SMALLINT UNSIGNED | YES | Volume en cl d'une variante de taille de boisson ; NULL si pas de dimension taille (migration 0007) |
| `base_product_id` | INT UNSIGNED | YES | FK -> product (auto-reference), ON DELETE CASCADE ; ligne de base d'une variante de taille, NULL = base/autonome (migration 0007) |
| `vat_rate` | SMALLINT UNSIGNED | NO | Pour-mille : 100 = 10%, 55 = 5.5% |
| `image_path` | VARCHAR(255) | YES | Chemin relatif depuis la racine publique |
| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Bascule de disponibilite manuelle |
| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage au sein de la categorie |
| `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** sur `category_id` : une categorie avec des produits ne peut pas etre supprimee. Empeche les
produits orphelins.
**Auto-references de variante (migrations 0006 / 0007, voir note 14 du dictionnaire)** : deux groupings
distincts, tous deux pointant vers `product(id)`.
- `maxi_variant_product_id` (ON DELETE SET NULL) : variante servie quand un menu est commande au format
Maxi (ex. Moyenne Frite -> Grande Frite). SET NULL = degradation gracieuse, le produit de base reste
vendable si la variante Grande est supprimee.
- `size_cl` + `base_product_id` (ON DELETE CASCADE) : variante de TAILLE a la carte (boisson 30/50 cl)
modelisee en lignes produit. `base_product_id` NULL = produit de base/autonome (visible catalogue) ;
NON NULL = variante de taille (masquee de la grille, atteinte via le picker). CASCADE car une variante
de taille n'a pas de sens sans sa base.
Les deux coexistent sur une boisson sans se confondre : `base_product_id` pilote la selection de taille a
la carte ; `maxi_variant_product_id` pilote la substitution Maxi en menu.
---
### 4.3 `menu`
```
menu (id, #category_id, #burger_product_id, name, [description],
price_normal_cents, price_maxi_cents, [image_path],
is_available, display_order, created_at, updated_at)
PK : id
FK : category_id -> category(id) ON DELETE RESTRICT
FK : burger_product_id -> product(id) ON DELETE RESTRICT
IDX : (category_id, is_available, display_order)
CHK : price_normal_cents > 0
CHK : price_maxi_cents > 0
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `category_id` | INT UNSIGNED | NO | FK -> category (typiquement la categorie `menus`) |
| `burger_product_id` | INT UNSIGNED | NO | FK -> product — le burger fixe qui ancre ce menu |
| `name` | VARCHAR(120) | NO | p. ex. "Menu Le 280" |
| `description` | TEXT | YES | Optionnel |
| `price_normal_cents` | INT UNSIGNED | NO | Prix du format Normal en centimes |
| `price_maxi_cents` | INT UNSIGNED | NO | Prix du format Maxi en centimes (~+150 centimes) |
| `image_path` | VARCHAR(255) | YES | Reutilise typiquement l'image du burger |
| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Bascule de disponibilite |
| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage |
| `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** sur les deux FK : empeche la suppression d'une categorie ou d'un produit burger
encore reference par une definition de menu.
---
### 4.4 `menu_slot`
```
menu_slot (id, #menu_id, name, slot_type, is_required, display_order)
PK : id
FK : menu_id -> menu(id) ON DELETE CASCADE
IDX : (menu_id, display_order)
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `menu_id` | INT UNSIGNED | NO | FK -> menu |
| `name` | VARCHAR(80) | NO | p. ex. "Drink", "Side", "Sauce" |
| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | Role semantique |
| `is_required` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Indique si le client doit remplir ce slot |
| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | 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 en meme temps que
le menu.
**ON DELETE CASCADE** sur `menu_id` : si un menu est supprime, ses slots sont supprimes avec lui.
---
### 4.5 `menu_slot_option`
Table de jointure pure. PK composite.
```
menu_slot_option (#menu_slot_id, #product_id)
PK : (menu_slot_id, product_id)
FK : menu_slot_id -> menu_slot(id) ON DELETE CASCADE
FK : product_id -> product(id) ON DELETE RESTRICT
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot |
| `product_id` | INT UNSIGNED | NO | FK -> product |
**ON DELETE CASCADE** sur `menu_slot_id` : si un slot est supprime, sa liste d'eligibilite disparait avec lui.
**ON DELETE RESTRICT** sur `product_id` : un produit liste comme eligible dans un slot ne peut pas etre
supprime sans le retirer d'abord des options du slot. Empeche la rupture silencieuse des menus.
Pas d'horodatages. Table de jointure pure.
---
### 4.6 `ingredient`
```
ingredient (id, name, unit, stock_quantity, stock_capacity, pack_size, [pack_label],
[energy_kcal_100g], [nutrition_source], [nutrition_fetched_at],
low_stock_pct, critical_stock_pct, is_active, created_at, updated_at)
PK : id
UK : name
CHK : stock_capacity > 0
CHK : pack_size > 0
CHK : low_stock_pct BETWEEN 0 AND 100
CHK : critical_stock_pct BETWEEN 0 AND 100
CHK : critical_stock_pct < low_stock_pct
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `name` | VARCHAR(120) | NO | Nom unique, p. ex. "Sesame Bun" |
| `unit` | VARCHAR(40) | NO | Libelle d'unite de conditionnement (libre, pas ENUM) |
| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Stock courant. INT signe pouvant 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 NOT NULL | NO | Niveau "plein" de reference en unites = le 100% utilise pour calculer le pourcentage de stock ; CHECK > 0 protege aussi la division du pourcentage contre la division par zero |
| `pack_size` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Unites par lot de reapprovisionnement |
| `pack_label` | VARCHAR(80) | YES | Libelle humain du lot |
| `energy_kcal_100g` | SMALLINT UNSIGNED | YES | Apport energetique pour 100 g, importe depuis l'API externe OpenFoodFacts (migration 0005) |
| `nutrition_source` | VARCHAR(120) | YES | Provenance de la donnee nutritionnelle, ex. "OpenFoodFacts" (migration 0005) |
| `nutrition_fetched_at` | DATETIME | YES | Horodatage de l'import nutritionnel, trace la fraicheur (migration 0005) |
| `low_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 10 | NO | Bande dalerte, pourcentage de la capacite (CHECK BETWEEN 0 AND 100) |
| `critical_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 5 | NO | Plancher de rupture automatique, pourcentage de la capacite (CHECK BETWEEN 0 AND 100 ; CHECK de table `critical_stock_pct < low_stock_pct`) |
| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Desactiver les ingredients obsoletes |
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
Pas de FK. Table racine du sous-domaine Ingredients & Stock.
**Modele de stock base sur les pourcentages** : l'etat de stock est calcule (PAS stocke) comme
`stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Deux bandes en derivent :
`LOW` quand `stock_quantity <= stock_capacity * low_stock_pct/100`, et
`CRITICAL` quand `stock_quantity <= stock_capacity * critical_stock_pct/100`.
Comportement a trois bandes : au-dessus de `low` = normal ; entre `critical` et `low` = commandable
plus alerte manager (le manager soit retire le produit via `product.is_available=0`, soit
reapprovisionne pour lever l'alerte) ; au niveau ou en dessous de `critical` = rupture automatique (calculee, regle
RG-T21). `stock_quantity` est signe et peut devenir negatif ; le systeme ne bloque pas une commande
sur le stock, donc une valeur negative enregistre l'ampleur de la survente pour les managers.
**Disponibilite calculee (regle RG-T21)** : un produit est effectivement commandable quand
`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 ; un ingredient retirable/optionnel a la bande critique ne
bloque pas le produit (seul son supplement devient indisponible). Le tableau de bord distingue un
retrait manuel (`is_available=0`) d'une rupture pilotee par le stock (`is_available=1` mais un ingredient
requis est critique).
**Enrichissement nutritionnel (migration 0005, voir note 14 du dictionnaire)** : `energy_kcal_100g`,
`nutrition_source` et `nutrition_fetched_at` (toutes nullables) stockent une donnee importee depuis l'API
externe OpenFoodFacts (Cr 3.a.3). Opt-in : l'import est declenche par `IngredientController::enrich`
(action manager/admin), pas au runtime borne ; un ingredient non enrichi reste valide.
---
### 4.7 `product_ingredient`
Table associative portant les metadonnees de recette et de personnalisation. PK composite.
```
product_ingredient (#product_id, #ingredient_id, quantity_normal, quantity_maxi,
is_removable, is_addable, extra_price_cents)
PK : (product_id, ingredient_id)
FK : product_id -> product(id) ON DELETE CASCADE
FK : ingredient_id -> ingredient(id) ON DELETE RESTRICT
CHK : quantity_normal > 0
CHK : quantity_maxi >= quantity_normal
CHK : extra_price_cents >= 0
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `product_id` | INT UNSIGNED | NO | FK -> product |
| `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient |
| `quantity_normal` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Unites consommees au format Normal |
| `quantity_maxi` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Unites consommees au format Maxi ; egal a `quantity_normal` pour burger/sauce (invariant au format), superieur pour side/drink |
| `is_removable` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Le client peut retirer sans frais |
| `is_addable` | TINYINT(1) NOT NULL DEFAULT 0 | NO | Le client peut ajouter une unite supplementaire |
| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Supplement si `is_addable=1` et que le client l'ajoute |
**ON DELETE CASCADE** sur `product_id` : si un produit est supprime, ses lignes de recette sont supprimees.
**ON DELETE RESTRICT** sur `ingredient_id` : impossible de supprimer un ingredient encore reference dans une
recette. L'administrateur doit d'abord retirer le lien produit-ingredient.
Pas d'horodatages. Table de jointure avec attributs.
---
### 4.8 `allergen`
```
allergen (id, code, name, [description])
PK : id
UK : code
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `code` | VARCHAR(30) | NO | Code machine, p. ex. `gluten`, `milk` |
| `name` | VARCHAR(80) | NO | Nom d'affichage |
| `description` | TEXT | YES | Indication optionnelle |
Pas de FK. Table de reference ; 14 lignes au seed (Reglement INCO (UE) 1169/2011).
Pas de `updated_at` : le catalogue d'allergenes est considere stable (les ajouts requierent une migration, pas une action UI).
---
### 4.9 `ingredient_allergen`
Table de jointure pure. PK composite.
```
ingredient_allergen (#ingredient_id, #allergen_id)
PK : (ingredient_id, allergen_id)
FK : ingredient_id -> ingredient(id) ON DELETE CASCADE
FK : allergen_id -> allergen(id) ON DELETE RESTRICT
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient |
| `allergen_id` | INT UNSIGNED | NO | FK -> allergen |
**ON DELETE CASCADE** sur `ingredient_id` : si un ingredient est supprime, ses liens d'allergenes disparaissent avec lui.
**ON DELETE RESTRICT** sur `allergen_id` : un allergene du catalogue reglemente ne peut pas etre supprime.
Pas d'horodatages. Table de jointure pure.
---
### 4.10 `role`
Placee avant `user` car `user` depend de `role`.
```
role (id, code, label, [description], [default_route], [order_source],
is_active, created_at, updated_at)
PK : id
UK : code
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `code` | VARCHAR(40) | NO | Code machine : `admin`, `manager`, `kitchen`, `counter`, `drive` |
| `label` | VARCHAR(80) | NO | Nom d'affichage |
| `description` | TEXT | YES | Optionnel |
| `default_route` | VARCHAR(120) | YES | Ecran d'arrivee, p. ex. `/admin/dashboard` |
| `order_source` | ENUM('kiosk','counter','drive') | YES | Source auto-etiquetee quand ce role cree une commande ; NULL pour admin/manager |
| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | La desactivation preserve l'historique |
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
Pas de FK. Table racine pour le RBAC.
---
### 4.11 `user`
```
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
FK : role_id -> role(id) ON DELETE RESTRICT
IDX : (is_active, role_id)
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `email` | VARCHAR(254) | NO | Longueur max RFC 5321. PII (anonymisation RGPD, voir ci-dessous) |
| `password_hash` | VARCHAR(255) | NO | hash argon2id |
| `pin_hash` | VARCHAR(255) | YES | hash argon2id du PIN par membre du personnel (autorisation d'action sensible). 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 | Desactivation sans suppression |
| `last_login_at` | DATETIME | YES | Audit, detection de compte dormant |
| `failed_login_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Compteur de force brute (throttling degressif) |
| `last_failed_login_at` | DATETIME | YES | Horodatage de la derniere connexion echouee |
| `lockout_until` | DATETIME | YES | Fin de la fenetre de throttling courante (backoff, pas un verrou indefini) |
| `password_reset_token_hash` | VARCHAR(255) | YES | Hash du token de reinitialisation (pas le token brut) |
| `password_reset_expires_at` | DATETIME | YES | Expiration du token de reinitialisation |
| `anonymized_at` | DATETIME | YES | Marqueur tombstone RGPD ; PII annulees/remplacees quand defini |
| `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** sur `role_id` : un role ne peut pas etre supprime tant que des utilisateurs le detiennent.
Desactivez d'abord le role (`is_active = 0`), puis reaffectez les utilisateurs avant suppression.
**Anonymisation RGPD** (security-by-design, note 13 du dict.) : le droit a l'effacement est honore en
anonymisant, pas en supprimant physiquement. `email` devient un placeholder unique non identifiant
(`anon-<id>@wakdo.invalid`, domaine reserve RFC 2606 — preserve la contrainte UNIQUE),
`first_name`/`last_name` sont effaces, `password_hash`/`pin_hash` sont invalides, `is_active=0`,
`anonymized_at = NOW()`. La ligne persiste pour que les FK `audit_log` et `stock_movement` restent valides.
---
### 4.12 `role_visible_source`
Table de jointure pure. PK composite.
```
role_visible_source (#role_id, source)
PK : (role_id, source)
FK : role_id -> role(id) ON DELETE CASCADE
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `role_id` | INT UNSIGNED | NO | FK -> role |
| `source` | ENUM('kiosk','counter','drive') | NO | Source de commande visible sur le tableau de bord |
**ON DELETE CASCADE** sur `role_id` : si un role est supprime, ses filtres de source du tableau de bord disparaissent avec lui.
Pas d'horodatages. Table de jointure pure.
Donnees de seed :
- `kitchen` : kiosk, counter, drive
- `counter` : kiosk, counter
- `drive` : drive
- `admin`, `manager` : pas de lignes (vue globale, pas de filtre de source)
---
### 4.13 `permission`
```
permission (id, code, label, [description], created_at)
PK : id
UK : code
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `code` | VARCHAR(60) | NO | Format `<resource>.<action>` |
| `label` | VARCHAR(120) | NO | Nom d'affichage |
| `description` | TEXT | YES | Optionnel |
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
Pas de `updated_at` : les permissions sont declarees en migration et non modifiees via l'UI.
Le catalogue est fige a 23 codes (voir dictionnaire section 3.17).
---
### 4.14 `role_permission`
Table de jointure pure. PK composite.
```
role_permission (#role_id, #permission_id)
PK : (role_id, permission_id)
FK : role_id -> role(id) ON DELETE CASCADE
FK : permission_id -> permission(id) ON DELETE CASCADE
IDX : permission_id
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `role_id` | INT UNSIGNED | NO | FK -> role |
| `permission_id` | INT UNSIGNED | NO | FK -> permission |
**ON DELETE CASCADE** sur les deux FK : supprimer un role ou une permission retire ses associations.
L'index secondaire sur `permission_id` supporte la requete inverse "quels roles ont cette
permission ?" sans scanner la table entiere.
Pas d'horodatages. Table de jointure pure.
---
### 4.15 `customer_order`
```
customer_order (id, order_number, [idempotency_key], source, [#acting_user_id],
service_mode, [service_tag], 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
CHK : total_ht_cents >= 0
CHK : total_vat_cents >= 0
CHK : total_ttc_cents > 0
CHK : total_ttc_cents = total_ht_cents + total_vat_cents
CHK : source != 'drive' OR service_mode = 'drive'
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `order_number` | VARCHAR(20) | NO | Prefixe canal + id sequentiel : `K<id>`/`C<id>`/`D<id>` (existant, voir note de table) |
| `idempotency_key` | VARCHAR(36) | YES | UUID client, UNIQUE ; deduplique un POST reessaye (security-by-design) |
| `source` | ENUM('kiosk','counter','drive') | NO | Canal de saisie |
| `acting_user_id` | INT UNSIGNED | YES | FK -> user ; personnel counter/drive sous PIN ; NULL pour kiosk |
| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Mode de consommation (stats uniquement, pas de role fiscal) |
| `service_tag` | VARCHAR(20) | YES | Numero de chevalet du service en salle (`dine_in`), saisi a la borne ; NULL pour takeaway/drive (migration 0003) |
| `status` | ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment' | NO | Machine a 4 etats |
| `total_ht_cents` | INT UNSIGNED | NO | Snapshot du total HT |
| `total_vat_cents` | INT UNSIGNED | NO | Snapshot du montant de TVA |
| `total_ttc_cents` | INT UNSIGNED | NO | Total TTC ; doit egaler HT + TVA |
| `paid_at` | DATETIME | YES | Horodatage de la transition vers `paid` |
| `delivered_at` | DATETIME | YES | Horodatage de la transition vers `delivered` |
| `cancelled_at` | DATETIME | YES | Horodatage de l'annulation |
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Utilise comme base de `service_day` |
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
**Numero de commande (existant, voir note 4 du dictionnaire)** : `order_number` = prefixe canal + id
sequentiel (`K<id>` / `C<id>` / `D<id>`), ecrit en deux temps (INSERT avec numero provisoire vide, puis
UPDATE en `prefix . LAST_INSERT_ID()`) dans `OrderRepository`. La cible initiale `K-AAAA-MM-JJ-NNN`
(compteur journalier) n'a pas ete retenue : la forme `prefixe + id` evite un compteur quotidien. Compromis
connu : numero sequentiel donc devinable, couple a l'endpoint de paiement anonyme cote borne (piste
d'amelioration : numero non sequentiel).
**Service en salle (migration 0003)** : `service_tag` (VARCHAR(20), nullable) porte le numero de chevalet
saisi a la borne pour le mode `dine_in` ; NULL pour `takeaway` / `drive`. Colonne additive, sans contrainte
de BD (la coherence avec `service_mode` est appliquee au niveau applicatif).
**Attribution du personnel (security-by-design)** : `acting_user_id` (FK -> `user`, ON DELETE SET NULL)
enregistre le personnel counter/drive qui a pris la commande sous PIN ; NULL pour les commandes kiosk anonymes.
Les commandes kiosk restent anonymes par conception. `stock_movement.user_id` couvre l'attribution des actions
de stock. `idempotency_key` (UNIQUE, nullable) deduplique un `POST /api/orders` reessaye
(plusieurs NULL autorises par l'index UNIQUE, donc les chemins legacy non idempotents sont toleres).
**Machine a 4 etats** : `pending_payment -> paid -> delivered` (+ `cancelled`). Les etats `preparing`
et `ready` sont abandonnes (decision D4). KPI : `delivered_at - paid_at` (SLA cible ~10 min).
**Calcul de `service_day`** (utilise dans les requetes de stats — PAS une colonne stockee) :
```sql
CASE WHEN HOUR(created_at) < 10
THEN DATE(created_at) - INTERVAL 1 DAY
ELSE DATE(created_at)
END
```
Coupure : 10:00. La formule de colonne generee avec `INTERVAL 4 HOUR 30 MINUTE` de la v0.1 etait
incorrecte et est abandonnee (decision D6).
**Calcul de TVA** : les totaux sur `customer_order` sont la somme des calculs au niveau ligne.
TVA au niveau ligne : `unit_price_cents_snapshot * quantity` est le montant TTC par ligne ;
HT = `ROUND(ttc_cents * 100 / (100 + vat_rate_per_cent))` ou `vat_rate_per_cent`
vaut `vat_rate_snapshot / 10`. Calcule au niveau applicatif a la validation du panier.
**`source = 'drive' => service_mode = 'drive'`** : le CHECK l'impose au niveau de la BD.
---
### 4.16 `order_item`
```
order_item (id, #order_id, item_type, [#product_id], [#menu_id], format,
label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot,
quantity, created_at)
PK : id
FK : order_id -> customer_order(id) ON DELETE CASCADE
FK : product_id -> product(id) ON DELETE RESTRICT
FK : menu_id -> menu(id) ON DELETE RESTRICT
IDX : order_id
CHK : unit_price_cents_snapshot > 0
CHK : vat_rate_snapshot IN (55, 100)
CHK : quantity > 0
CHK : (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)
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `order_id` | INT UNSIGNED | NO | FK -> customer_order |
| `item_type` | ENUM('product','menu') | NO | Discriminateur |
| `product_id` | INT UNSIGNED | YES | Non-null si `item_type = 'product'`, NULL sinon |
| `menu_id` | INT UNSIGNED | YES | Non-null si `item_type = 'menu'`, NULL sinon |
| `format` | ENUM('normal','maxi') NOT NULL DEFAULT 'normal' | NO | Format du menu. Pour les produits autonomes, la valeur est `normal` |
| `label_snapshot` | VARCHAR(120) | NO | Libelle au moment de la commande |
| `unit_price_cents_snapshot` | INT UNSIGNED | NO | Prix unitaire TVA incluse au moment de la commande |
| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | Taux de TVA pour-mille au moment de la commande |
| `quantity` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Quantite (p. ex. 3 boissons = 1 ligne, quantity=3) |
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
**ON DELETE CASCADE** sur `order_id` : les lignes sont supprimees avec la commande.
**ON DELETE RESTRICT** sur `product_id` et `menu_id` : un produit ou menu reference dans une
ligne de commande historique ne peut pas etre supprime. Le snapshot rend la reference FK non critique
pour l'affichage, mais RESTRICT evite l'orphelinage silencieux de la structure relationnelle.
**CHECK d'exclusivite du polymorphisme** : MariaDB 10.2+ l'impose au moment de l'INSERT/UPDATE.
---
### 4.17 `order_item_selection`
Choix du client pour un slot d'une ligne de commande de menu.
```
order_item_selection (id, #order_item_id, #menu_slot_id, #product_id, label_snapshot)
PK : id
FK : order_item_id -> order_item(id) ON DELETE CASCADE
FK : menu_slot_id -> menu_slot(id) ON DELETE RESTRICT
FK : product_id -> product(id) ON DELETE RESTRICT
IDX : order_item_id
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `order_item_id` | INT UNSIGNED | NO | FK -> order_item (doit etre une ligne de type menu) |
| `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot (quel slot a ete rempli) |
| `product_id` | INT UNSIGNED | NO | FK -> product (choisi par le client) |
| `label_snapshot` | VARCHAR(120) | NO | Libelle du produit au moment de la commande |
**ON DELETE CASCADE** sur `order_item_id` : si la ligne de commande parente est supprimee, ses
selections de slot disparaissent avec elle.
**ON DELETE RESTRICT** sur `menu_slot_id` et `product_id` : les enregistrements historiques de choix de slot
ne doivent pas etre silencieusement rompus par des changements de catalogue.
Note : la contrainte metier voulant que `order_item_id` reference une ligne avec `item_type='menu'`
est imposee au niveau applicatif (pas dans MariaDB sans trigger ou contrainte differee).
---
### 4.18 `order_item_modifier`
Modification au niveau ingredient appliquee par le client a un produit ou au burger fixe d'un menu.
```
order_item_modifier (id, #order_item_id, #ingredient_id, action, extra_price_cents)
PK : id
FK : order_item_id -> order_item(id) ON DELETE CASCADE
FK : ingredient_id -> ingredient(id) ON DELETE RESTRICT
IDX : order_item_id
CHK : extra_price_cents >= 0
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `order_item_id` | INT UNSIGNED | NO | FK -> order_item |
| `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient |
| `action` | ENUM('remove','add') | NO | `remove` = retrait gratuit ; `add` = unite supplementaire |
| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Snapshot du supplement au moment de la commande (0 pour les retraits) |
**ON DELETE CASCADE** sur `order_item_id` : si la ligne de commande est supprimee, ses modificateurs disparaissent avec elle.
**ON DELETE RESTRICT** sur `ingredient_id` : un ingredient reference dans un modificateur historique
ne peut pas etre supprime.
**Rattachement du modificateur pour les lignes de menu** : le produit modifiable est le burger fixe, resolu
via `order_item.menu_id -> menu.burger_product_id`. Aucune colonne FK supplementaire n'est necessaire sur
cette table (voir note 10 du dictionnaire).
---
### 4.19 `stock_movement`
Journal d'audit append-only de tous les changements de stock par ingredient.
```
stock_movement (id, #ingredient_id, movement_type, delta,
[#order_id], [#user_id], [note], created_at)
PK : id
FK : ingredient_id -> ingredient(id) ON DELETE RESTRICT
FK : order_id -> customer_order(id) ON DELETE SET NULL
FK : user_id -> user(id) ON DELETE SET NULL
IDX : (ingredient_id, created_at)
IDX : (movement_type, created_at)
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient |
| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | Nature du mouvement |
| `delta` | INT | NO | Changement signe : negatif pour consommation, positif pour reapprovisionnement/annulation/correction |
| `order_id` | INT UNSIGNED | YES | FK -> customer_order ; non-null pour `sale` et `cancellation` |
| `user_id` | INT UNSIGNED | YES | FK -> user ; null pour les decrements de vente automatises |
| `note` | VARCHAR(255) | YES | Note humaine optionnelle |
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage immuable |
**ON DELETE RESTRICT** sur `ingredient_id` : un ingredient avec un historique de mouvements ne peut pas etre
supprime. L'administrateur doit plutot archiver l'ingredient (`is_active = 0`).
**ON DELETE SET NULL** sur `order_id` : si une commande est purgee du systeme, ses enregistrements de
mouvement restent avec `order_id = NULL`. Le journal d'audit est preserve ; seul le lien de commande est perdu.
**ON DELETE SET NULL** sur `user_id` : si un utilisateur est supprime, les enregistrements de mouvement restent avec
`user_id = NULL`. L'audit est preserve ; l'attribution individuelle est perdue.
**Regle d'immuabilite** : aucun UPDATE ni DELETE au niveau applicatif. Les corrections sont de nouvelles lignes
avec `movement_type = 'inventory_correction'` et un `delta` signe.
Pas de `updated_at`. Table immuable append-only.
---
### 4.20 `audit_log`
Journal append-only des actions back-office sensibles (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)
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `actor_user_id` | INT UNSIGNED | YES | FK -> user ; personnel agissant (capture par PIN) ou NULL si non attribuable |
| `actor_role_id` | INT UNSIGNED | YES | FK -> role ; contexte de role denormalise (survit a l'anonymisation de l'utilisateur) |
| `action_code` | VARCHAR(60) | NO | Code d'operation MCT / permission, p. ex. `product.update`, `order.cancel` |
| `entity_type` | VARCHAR(40) | YES | Nom de la table affectee |
| `entity_id` | INT UNSIGNED | YES | PK de la ligne affectee |
| `summary` | VARCHAR(255) | YES | Description courte non personnelle du changement |
| `details` | JSON | YES | Diff avant/apres optionnel (noms de champs pour les actions ciblant un utilisateur, pas les valeurs PII) |
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage immuable |
**ON DELETE SET NULL** sur les deux FK : la piste est preservee quand un utilisateur est anonymise/supprime
ou un role supprime ; seul le lien est rompu (le `actor_role_id` denormalise conserve le contexte de
role meme apres l'anonymisation de l'utilisateur).
**Regle d'immuabilite** : aucun UPDATE ni DELETE au niveau applicatif. **Retention** : une purge cron
planifiee retire les lignes plus anciennes que la fenetre de retention (~12 mois, interet legitime /
tracabilite fiscale), decouplee du cycle de vie des PII de l'utilisateur (note 13 du dict.).
Pas de `updated_at`. Table immuable append-only.
---
### 4.21 `login_throttle`
Throttle de force brute par IP source (security-by-design). Complete le compteur par compte
deja present sur `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
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `ip_address` | VARCHAR(45) | NO | IP source, une ligne par IP, upsertee ; 45 caracteres contiennent un litteral IPv6 complet. UNIQUE |
| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Connexions echouees consecutives depuis cette IP dans la fenetre courante |
| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Debut de la fenetre de comptage courante |
| `lockout_until` | DATETIME | YES | Fin de la fenetre de backoff degressif ; NULL = pas throttle |
| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage de la derniere tentative echouee |
Pas de FK : une IP n'est pas une entite modelisee. Append/upsert par IP ; la fenetre se reinitialise a expiration. Un
cron quotidien purge les lignes sans verrouillage actif dont le `last_attempt_at` est plus ancien que 24h.
Pas de `updated_at` : les lignes sont upsertees par IP, pas editees via une UI.
---
### 4.22 `pin_throttle`
Throttle du PIN d'action sensible par utilisateur AGISSANT (security-by-design, RG-T22). Separe des
compteurs de connexion (`user.failed_login_attempts` / `lockout_until` / `login_throttle`) : un echec de
PIN n'incremente aucun compteur de login.
```
pin_throttle (id, actor_user_id, failed_attempts, window_started_at,
[lockout_until], last_attempt_at)
PK : id
UK : actor_user_id
IDX : lockout_until
FK : actor_user_id -> user(id) ON DELETE CASCADE
```
| Colonne | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `actor_user_id` | INT UNSIGNED | NO | Utilisateur agissant (session), une ligne par acteur, upsertee. UNIQUE. FK -> `user(id)` ON DELETE CASCADE |
| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Echecs de PIN consecutifs de cet acteur dans la fenetre courante |
| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Debut de la fenetre de comptage courante |
| `lockout_until` | DATETIME | YES | Fin de la fenetre de backoff degressif ; NULL = pas throttle |
| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage de la derniere tentative echouee |
FK ON DELETE CASCADE (contrairement a `login_throttle`) : la cle est un user back-office authentifie, donc
supprimer/anonymiser le compte purge sa ligne de throttle. Append/upsert par acteur ; meme purge cron que
`login_throttle`. Pas de `updated_at` (lignes upsertees, pas editees via une UI).
---
## 5. Resume de l'integrite referentielle
| Colonne FK | References | ON DELETE | Justification |
|---|---|---|---|
| `product.category_id` | `category(id)` | RESTRICT | Pas de produit orphelin |
| `menu.category_id` | `category(id)` | RESTRICT | Idem |
| `menu.burger_product_id` | `product(id)` | RESTRICT | La definition du menu requiert son burger d'ancrage |
| `menu_slot.menu_id` | `menu(id)` | CASCADE | Les slots n'ont pas de sens sans leur menu |
| `menu_slot_option.menu_slot_id` | `menu_slot(id)` | CASCADE | La liste d'eligibilite disparait avec le slot |
| `menu_slot_option.product_id` | `product(id)` | RESTRICT | Retirer un produit ne doit pas rompre silencieusement les menus |
| `product_ingredient.product_id` | `product(id)` | CASCADE | La recette disparait avec le produit |
| `product_ingredient.ingredient_id` | `ingredient(id)` | RESTRICT | Impossible de retirer un ingredient encore dans une recette |
| `ingredient_allergen.ingredient_id` | `ingredient(id)` | CASCADE | Les liens d'allergenes disparaissent avec l'ingredient |
| `ingredient_allergen.allergen_id` | `allergen(id)` | RESTRICT | Le catalogue d'allergenes reglemente est immuable |
| `user.role_id` | `role(id)` | RESTRICT | Un utilisateur ne peut pas exister sans role |
| `role_visible_source.role_id` | `role(id)` | CASCADE | Les filtres du tableau de bord disparaissent avec le role |
| `role_permission.role_id` | `role(id)` | CASCADE | Les associations de permission disparaissent avec le role |
| `role_permission.permission_id` | `permission(id)` | CASCADE | Les associations de permission disparaissent avec la permission |
| `order_item.order_id` | `customer_order(id)` | CASCADE | Les lignes disparaissent avec la commande |
| `order_item.product_id` | `product(id)` | RESTRICT | La reference historique ne doit pas etre silencieusement orphelinee |
| `order_item.menu_id` | `menu(id)` | RESTRICT | Idem |
| `order_item_selection.order_item_id` | `order_item(id)` | CASCADE | Les choix de slot disparaissent avec la ligne |
| `order_item_selection.menu_slot_id` | `menu_slot(id)` | RESTRICT | Enregistrement historique de slot preserve |
| `order_item_selection.product_id` | `product(id)` | RESTRICT | Enregistrement historique de choix preserve |
| `order_item_modifier.order_item_id` | `order_item(id)` | CASCADE | Les modificateurs disparaissent avec la ligne |
| `order_item_modifier.ingredient_id` | `ingredient(id)` | RESTRICT | Enregistrement historique de modificateur preserve |
| `stock_movement.ingredient_id` | `ingredient(id)` | RESTRICT | Un ingredient avec historique ne peut pas etre supprime |
| `stock_movement.order_id` | `customer_order(id)` | SET NULL | Audit preserve, lien de commande perdu |
| `stock_movement.user_id` | `user(id)` | SET NULL | Audit preserve, attribution utilisateur perdue |
| `customer_order.acting_user_id` | `user(id)` | SET NULL | Attribution du personnel preservee comme principal anonymise ; commande conservee |
| `audit_log.actor_user_id` | `user(id)` | SET NULL | Piste d'audit preservee a l'anonymisation de l'utilisateur ; seul le lien est rompu |
| `audit_log.actor_role_id` | `role(id)` | SET NULL | Contexte de role conserve jusqu'a la suppression du role ; denormalise donc il survit a l'anonymisation de l'utilisateur |
| `pin_throttle.actor_user_id` | `user(id)` | CASCADE | Etat de throttle ephemere : il part avec le compte agissant supprime/anonymise (contrairement a l'audit, permanent) |
**Cle utilisee** : CASCADE = l'enfant n'a pas de sens sans le parent ; RESTRICT = la suppression du parent
est bloquee tant que des enfants existent ; SET NULL = l'enfant est preserve, seul le lien est rompu.
---
## 6. Resume des contraintes CHECK
| Table | Expression CHECK | Objectif |
|---|---|---|
| `product` | `price_cents > 0` | Un prix nul ou negatif est un bug |
| `product` | `vat_rate IN (55, 100)` | Seuls deux taux de TVA legaux pour ce modele |
| `menu` | `price_normal_cents > 0` | Comme pour product |
| `menu` | `price_maxi_cents > 0` | Idem |
| `ingredient` | `stock_capacity > 0` | La reference 100% doit etre positive ; protege aussi la division du pourcentage contre la division par zero |
| `ingredient` | `pack_size > 0` | Une taille de lot nulle rend la logique de reapprovisionnement incoherente |
| `ingredient` | `low_stock_pct BETWEEN 0 AND 100` | La bande dalerte est un pourcentage de la capacite |
| `ingredient` | `critical_stock_pct BETWEEN 0 AND 100` | Le plancher de rupture automatique est un pourcentage de la capacite |
| `ingredient` | `critical_stock_pct < low_stock_pct` | Le plancher critique se situe sous la bande dalerte |
| `product_ingredient` | `quantity_normal > 0` | Une quantite de recette nulle n'a pas de sens |
| `product_ingredient` | `quantity_maxi >= quantity_normal` | Maxi consomme au moins autant que Normal (side/drink plus, burger/sauce egal) |
| `product_ingredient` | `extra_price_cents >= 0` | Pas de supplement negatif |
| `customer_order` | `total_ht_cents >= 0` | Zero est autorise (cas limite pendant la construction du panier) |
| `customer_order` | `total_vat_cents >= 0` | Idem |
| `customer_order` | `total_ttc_cents > 0` | Une commande validee doit avoir un total positif |
| `customer_order` | `total_ttc_cents = total_ht_cents + total_vat_cents` | Invariant arithmetique ; defense en profondeur contre les bugs applicatifs |
| `customer_order` | `source != 'drive' OR service_mode = 'drive'` | Contrainte inter-dimensions (note 5 du dict.) |
| `order_item` | `unit_price_cents_snapshot > 0` | Prix non nul au moment de la transaction |
| `order_item` | `vat_rate_snapshot IN (55, 100)` | Le snapshot doit correspondre aux taux autorises |
| `order_item` | `quantity > 0` | Quantite non nulle |
| `order_item` | `(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)` | Polymorphisme : exactement une FK renseignee par valeur de discriminateur |
| `order_item_modifier` | `extra_price_cents >= 0` | Snapshot du supplement ; ne peut pas etre negatif |
---
## 7. Index recommandes (au-dela des auto-index PK / UK / FK)
MariaDB InnoDB cree automatiquement un index pour chaque declaration de FK (s'il n'existe pas d'index
utilisable). Les index supplementaires suivants ciblent les patterns de requete frequents identifies dans le
MCT / MLT.
| Table | Colonnes d'index | Pattern de requete |
|---|---|---|
| `product` | `(category_id, is_available, display_order)` | Chargement du catalogue borne : filtre par categorie + disponibilite, tri par ordre |
| `menu` | `(category_id, is_available, display_order)` | Meme pattern pour les menus |
| `menu_slot` | `(menu_id, display_order)` | Constructeur de menu : charger tous les slots d'un menu dans l'ordre |
| `customer_order` | `(status, created_at)` | File des commandes actives : commandes pending/paid triees par temps |
| `customer_order` | `(source, created_at)` | Analytics par canal et filtrage des commandes |
| `customer_order` | `created_at` | Agregations par plage de temps (stats horaires, `service_day`) |
| `order_item` | `order_id` | Recuperer toutes les lignes d'une commande |
| `order_item_selection` | `order_item_id` | Recuperer les choix de slot d'une ligne de menu |
| `order_item_modifier` | `order_item_id` | Recuperer les modifications d'ingredient d'une ligne |
| `stock_movement` | `(ingredient_id, created_at)` | Historique de stock par ingredient (dict. section 3.19) |
| `stock_movement` | `(movement_type, created_at)` | Stats : annulations par semaine, reapprovisionnements par mois |
| `role_permission` | `permission_id` | Requete inverse : "quels roles ont cette permission ?" |
| `user` | `(is_active, role_id)` | Verification de connexion + resolution des permissions |
| `audit_log` | `(actor_user_id, created_at)` | Historique d'audit par acteur |
| `audit_log` | `(entity_type, entity_id)` | "qu'est-il arrive a ce produit/commande/utilisateur ?" |
| `audit_log` | `(action_code, created_at)` | Audit par type d'action sur une plage de temps |
| `login_throttle` | `lockout_until` | Purge cron quotidienne des lignes sans verrouillage actif |
| `pin_throttle` | `lockout_until` | Purge cron quotidienne des lignes sans verrouillage actif (RG-T22) |
**Index non ajoutes** (intentionnel) :
- `customer_order.order_number` : l'index UK suffit ; aucune requete de plage attendue sur cette colonne.
- `customer_order.service_mode` : faible cardinalite (3 valeurs) ; un scan complet sur l'index de status
avec un filtre `service_mode` est acceptable au volume attendu.
- `customer_order.paid_at` : NULL pour la plupart des lignes en cours ; un index clairseme apporte un benefice limite.
---
## 8. Validation croisee MLD <-> MCD
Verification que les 22 entites MCD (19 prod-like + 3 security-by-design) correspondent a une table,
et que toutes les tables se rattachent au MCD.
| Entite MCD | Table MLD | Type de mapping | Notes |
|---|---|---|---|
| `category` (C1) | `category` (4.1) | entite 1:1 | |
| `product` (C2) | `product` (4.2) | entite 1:1 | Additif post-v0.3 : `maxi_variant_product_id` (0006), `size_cl` + `base_product_id` (0007) |
| `menu` (C3) | `menu` (4.3) | entite 1:1 | Nouveau : `burger_product_id`, `price_normal_cents`, `price_maxi_cents` |
| `menu_slot` (C4) | `menu_slot` (4.4) | entite 1:1 | Nouvelle entite (v0.2) |
| `menu_slot_option` (C5) | `menu_slot_option` (4.5) | Table de jointure (PK composite) | Nouvelle entite (v0.2) |
| `ingredient` (C6) | `ingredient` (4.6) | entite 1:1 | Nouvelle entite (v0.2) ; additif post-v0.3 : `energy_kcal_100g`, `nutrition_source`, `nutrition_fetched_at` (0005) |
| `product_ingredient` (C7) | `product_ingredient` (4.7) | Table de jointure avec attributs | Nouvelle entite (v0.2) |
| `allergen` (C8) | `allergen` (4.8) | entite 1:1 | Nouvelle entite (v0.2) |
| `ingredient_allergen` (C9) | `ingredient_allergen` (4.9) | Table de jointure (PK composite) | Nouvelle entite (v0.2) |
| `role` (C10) | `role` (4.10) | entite 1:1 | Nouveau : `default_route`, `order_source` |
| `user` (C11) | `user` (4.11) | entite 1:1 | Colonnes renommees en anglais |
| `role_visible_source` (C12) | `role_visible_source` (4.12) | Table de jointure (PK composite) | Nouvelle entite (v0.2) |
| `permission` (C13) | `permission` (4.13) | entite 1:1 | |
| `role_permission` (C14) | `role_permission` (4.14) | Table de jointure (PK composite) | |
| `customer_order` (C15) | `customer_order` (4.15) | entite 1:1 | Renommee depuis `commande` ; machine a 4 etats ; horodatages de phase ; additif post-v0.3 : `service_tag` (0003) |
| `order_item` (C16) | `order_item` (4.16) | entite 1:1 | Nouveau : `format`, `vat_rate_snapshot` ; CHECK de polymorphisme |
| `order_item_selection` (C17) | `order_item_selection` (4.17) | entite 1:1 | Nouvelle entite (v0.2) |
| `order_item_modifier` (C18) | `order_item_modifier` (4.18) | entite 1:1 | Nouvelle entite (v0.2) |
| `stock_movement` (C19) | `stock_movement` (4.19) | entite 1:1 | Nouvelle entite (v0.2) |
| `audit_log` (R5/R6) | `audit_log` (4.20) | entite 1:1 | Nouvelle entite (security-by-design) |
| `login_throttle` (R7) | `login_throttle` (4.21) | entite 1:1 | Nouvelle entite (security-by-design) |
| `pin_throttle` (R9) | `pin_throttle` (4.22) | entite 1:1 | Nouvelle entite (security-by-design, RG-T22) |
**Resultat** : 22/22 entites mappees (19 prod-like + `audit_log` + `login_throttle` + `pin_throttle`). Aucune entite
sans table ; aucune table hors du MCD. Nouvelles colonnes sur les tables existantes : `user`
(cycle de vie auth + `pin_hash` + `anonymized_at`), `customer_order` (`idempotency_key`,
`acting_user_id`), `ingredient` (`stock_capacity`, `low_stock_pct`, `critical_stock_pct` ;
`low_stock_threshold` reaffecte).
**Abandonne depuis v0.1** : `commande_event` (remplace par les horodatages de phase `paid_at`, `delivered_at`, `cancelled_at`
sur `customer_order` — decision 2.A) ; le modele de composition fixe `menu_produit`
(remplace par `menu_slot` + `menu_slot_option` — decision D1).
---
## 9. Estimation de volume (6 mois)
| Table | Lignes a 6 mois | Taille moyenne de ligne | Taille est. |
|---|---|---|---|
| `category` | ~10 | 200 octets | < 1 KB |
| `product` | ~55 | 400 octets | ~22 KB |
| `menu` | ~13 | 450 octets | ~6 KB |
| `menu_slot` | ~40 | 150 octets | ~6 KB |
| `menu_slot_option` | ~150 | 30 octets | ~5 KB |
| `ingredient` | ~100 | 300 octets | ~30 KB |
| `product_ingredient` | ~400 | 40 octets | ~16 KB |
| `allergen` | 14 | 200 octets | ~3 KB |
| `ingredient_allergen` | ~200 | 20 octets | ~4 KB |
| `role` | ~5 | 200 octets | ~1 KB |
| `user` | ~20 | 500 octets | ~10 KB |
| `role_visible_source` | ~7 | 15 octets | < 1 KB |
| `permission` | 23 | 250 octets | ~6 KB |
| `role_permission` | ~80 | 15 octets | ~2 KB |
| `customer_order` | ~30k | 300 octets | ~9 MB |
| `order_item` | ~150k | 250 octets | ~37 MB |
| `order_item_selection` | ~300k | 150 octets | ~45 MB |
| `order_item_modifier` | ~150k | 80 octets | ~12 MB |
| `stock_movement` | ~500k | 180 octets | ~90 MB |
| `audit_log` | ~5k-10k | 200 octets | ~2 MB |
| `login_throttle` | ~100-1k | 80 octets | < 1 MB |
| `pin_throttle` | ~10-100 | 80 octets | < 1 MB (1 ligne par user back-office) |
**Total estime** : ~190 MB de donnees + ~60-80 MB pour les index = ~250-270 MB sur 6 mois
(`audit_log` est negligeable : les actions sensibles sont d'un ordre de grandeur plus rares que les commandes).
Gerable sur le conteneur MariaDB (volume nomme `wakdo_db_data` dans `docker-compose.yml`).
`stock_movement` est la table au plus fort volume (~5-15 lignes par commande tous ingredients confondus).
L'index `(ingredient_id, created_at)` est le chemin de requete principal pour l'historique par
ingredient ; il portera une amplification d'ecriture significative a l'echelle.
---
## 10. Decisions reportees au DDL et a P2
1. **Colonne generee MariaDB** pour `service_day` : une colonne `VIRTUAL GENERATED` est techniquement
possible avec la syntaxe MariaDB 5.7+. Si les requetes de stats s'averent lourdes sans colonne
materialisee, une colonne `STORED GENERATED` pourrait etre ajoutee en migration. Pour ce modele,
l'expression CASE applicative est retenue (plus simple, evite les cas limites des colonnes generees).
2. **Partitionnement** : `stock_movement` pourrait etre partitionnee par mois si le volume depasse les
estimations. Hors perimetre pour le DDL initial.
3. **Triggers** : decrement de stock a la transition `paid` et re-credit a `cancelled` (depuis `paid`)
pourraient etre implementes en triggers MariaDB ou en logique applicative. A decider en P2.
4. **Collation** : `utf8mb4_unicode_ci` retenue (conforme Unicode, insensible a la casse).
Si un tri alphabetique francais strict est necessaire, `utf8mb4_fr_0900_ai_ci` est disponible dans
MySQL 8 mais pas MariaDB ; `unicode_ci` est le choix portable.
5. **Outillage de migration** : Phinx, Doctrine Migrations, ou un simple script PHP. Decision en P2.
6. **Contrainte `order_item_id` pour les selections** : la regle metier voulant que
`order_item_selection.order_item_id` reference une ligne avec `item_type='menu'`
est imposee au niveau applicatif. Un trigger MariaDB pourrait renforcer cela au niveau de la BD si
necessaire.
---
## 11. Prochaines etapes (DDL + Seed)
1. **DDL** (`db/migrations/0001_init_schema.sql`) : transcrire ce MLD en instructions
`CREATE TABLE` executables, dans l'ordre de dependance :
- `category` -> `product`, `ingredient`, `allergen`, `role`
- `menu` (depend de `category`, `product`)
- `menu_slot` (depend de `menu`), `menu_slot_option` (depend de `menu_slot`, `product`)
- `product_ingredient` (depend de `product`, `ingredient`)
- `ingredient_allergen` (depend de `ingredient`, `allergen`)
- `user` (depend de `role`), `role_visible_source` (depend de `role`)
- `permission`, `role_permission` (depend de `role`, `permission`)
- `customer_order`
- `order_item` (depend de `customer_order`, `product`, `menu`)
- `order_item_selection` (depend de `order_item`, `menu_slot`, `product`)
- `order_item_modifier` (depend de `order_item`, `ingredient`)
- `stock_movement` (depend de `ingredient`, `customer_order`, `user`)
- `audit_log` (depend de `user`, `role`)
- `login_throttle` (pas de FK, peut etre cree a n'importe quel moment)
- `pin_throttle` (FK `actor_user_id -> user`, donc apres le bloc `user`)
Note : `customer_order` porte desormais `acting_user_id -> user`, donc `user` doit etre cree
avant `customer_order` (deja le cas : le bloc RBAC precede `customer_order`).
2. **Seed** (`db/seeds/0001_demo_data.sql`) :
- 9 categories + 53 produits + 13 menus depuis les sources JSON (`docs/merise/_sources/`)
- 13 menus avec slots et options de slot
- 14 allergenes (INCO UE 1169/2011)
- Catalogue d'ingredients exemple avec recettes
- 5 roles avec matrice `role_permission` et donnees `role_visible_source`
- 1 utilisateur admin
- Commandes exemple pour la demo
3. **Export JSON de fallback** (`scripts/export-fallback.{sh|php}`) : extraire les donnees de seed vers
`src/public/borne/data/*.json` pour le mode borne isole (Bloc 1 sans BD).
4. **Tests de validation DDL** : confirmer que les contraintes CHECK se declenchent comme attendu ; confirmer
que les comportements ON DELETE CASCADE / RESTRICT / SET NULL correspondent a la specification.