61 KiB
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.ymlservicewakdo-db) - Moteur InnoDB (ACID, support des FK, verrouillage au niveau ligne, CHECK depuis 10.2.1)
- Charset
utf8mb4, collationutf8mb4_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_INCREMENTpour toutes les PK techniquesINT UNSIGNEDpour tous les montants monetaires en centimes (anti-FLOAT, voir note 1 du dictionnaire)SMALLINT UNSIGNEDpour les valeurs pour-mille devat_rate(55 ou 100)ENUM(...)pour les valeurs metier stables (voir note 2 du dictionnaire)DATETIMEpour 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
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
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)"
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
erDiagram
ingredient {
int id PK
varchar name UK
varchar unit
int stock_quantity
int stock_capacity
smallint pack_size
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
erDiagram
customer_order {
int id PK
varchar order_number UK
varchar idempotency_key UK
enum source
int acting_user_id FK
enum service_mode
enum status
int total_ht_cents
int total_vat_cents
int total_ttc_cents
datetime paid_at
datetime delivered_at
datetime cancelled_at
}
order_item {
int id PK
int order_id FK
enum item_type
int product_id FK
int menu_id FK
enum format
varchar label_snapshot
int unit_price_cents_snapshot
smallint vat_rate_snapshot
smallint quantity
}
order_item_selection {
int id PK
int order_item_id FK
int menu_slot_id FK
int product_id FK
varchar label_snapshot
}
order_item_modifier {
int id PK
int order_item_id FK
int ingredient_id FK
enum action
int extra_price_cents
}
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
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_throttlen'a pas de FK (une IP n'est pas une entite modelisee) ; elle est autonome, cle parip_address.pin_throttle(RG-T22) est cle paractor_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, vat_rate,
[image_path], is_available, display_order, created_at, updated_at)
PK : id
FK : category_id -> category(id) ON DELETE RESTRICT
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 |
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.
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],
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 |
low_stock_pct |
SMALLINT UNSIGNED NOT NULL DEFAULT 10 | NO | Bande d’alerte, 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).
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, drivecounter: kiosk, counterdrive: driveadmin,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, 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 | Format K/C/D-YYYY-MM-DD-NNN par canal |
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) |
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 |
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) :
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 d’alerte 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 d’alerte |
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 filtreservice_modeest 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 | |
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) |
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 |
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
- Colonne generee MariaDB pour
service_day: une colonneVIRTUAL GENERATEDest techniquement possible avec la syntaxe MariaDB 5.7+. Si les requetes de stats s'averent lourdes sans colonne materialisee, une colonneSTORED GENERATEDpourrait etre ajoutee en migration. Pour ce modele, l'expression CASE applicative est retenue (plus simple, evite les cas limites des colonnes generees). - Partitionnement :
stock_movementpourrait etre partitionnee par mois si le volume depasse les estimations. Hors perimetre pour le DDL initial. - Triggers : decrement de stock a la transition
paidet re-credit acancelled(depuispaid) pourraient etre implementes en triggers MariaDB ou en logique applicative. A decider en P2. - Collation :
utf8mb4_unicode_ciretenue (conforme Unicode, insensible a la casse). Si un tri alphabetique francais strict est necessaire,utf8mb4_fr_0900_ai_ciest disponible dans MySQL 8 mais pas MariaDB ;unicode_ciest le choix portable. - Outillage de migration : Phinx, Doctrine Migrations, ou un simple script PHP. Decision en P2.
- Contrainte
order_item_idpour les selections : la regle metier voulant queorder_item_selection.order_item_idreference une ligne avecitem_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)
-
DDL (
db/migrations/0001_init_schema.sql) : transcrire ce MLD en instructionsCREATE TABLEexecutables, dans l'ordre de dependance :category->product,ingredient,allergen,rolemenu(depend decategory,product)menu_slot(depend demenu),menu_slot_option(depend demenu_slot,product)product_ingredient(depend deproduct,ingredient)ingredient_allergen(depend deingredient,allergen)user(depend derole),role_visible_source(depend derole)permission,role_permission(depend derole,permission)customer_orderorder_item(depend decustomer_order,product,menu)order_item_selection(depend deorder_item,menu_slot,product)order_item_modifier(depend deorder_item,ingredient)stock_movement(depend deingredient,customer_order,user)audit_log(depend deuser,role)login_throttle(pas de FK, peut etre cree a n'importe quel moment)pin_throttle(FKactor_user_id -> user, donc apres le blocuser)
Note :
customer_orderporte desormaisacting_user_id -> user, doncuserdoit etre cree avantcustomer_order(deja le cas : le bloc RBAC precedecustomer_order). -
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_permissionet donneesrole_visible_source - 1 utilisateur admin
- Commandes exemple pour la demo
- 9 categories + 53 produits + 13 menus depuis les sources JSON (
-
Export JSON de fallback (
scripts/export-fallback.{sh|php}) : extraire les donnees de seed verssrc/public/borne/data/*.jsonpour le mode borne isole (Bloc 1 sans BD). -
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.