From 36332b42842f4ffb6f69b910eee93e130a001b31 Mon Sep 17 00:00:00 2001 From: Imugiii Date: Thu, 4 Jun 2026 15:17:33 +0000 Subject: [PATCH] docs(merise): rewrite MLD to prod-like v0.2 (19 tables) Polymorphic order_item (exclusivity CHECK), composite-PK join tables, service_day as query-time CASE (10h cutoff, generated column dropped), line-by-line VAT, ON DELETE rules, recommended indexes. --- docs/merise/mld.md | 1139 +++++++++++++++++++++++++++++--------------- 1 file changed, 760 insertions(+), 379 deletions(-) diff --git a/docs/merise/mld.md b/docs/merise/mld.md index 8e49cd4..a48d6e2 100644 --- a/docs/merise/mld.md +++ b/docs/merise/mld.md @@ -1,525 +1,906 @@ -# Modele Logique des Donnees (MLD) - Wakdo +# Logical Data Model (MLD) — Wakdo -**Phase Merise** : P1 - Conception, etape 5 (apres MCD, MCT, MLT) -**Statut** : v0.1 -**Date** : 2026-05-28 -**Branche** : `feat/p1-conception` -**Auteur methodologie** : BYAN +**Merise phase** : P1 - Conception, step 5 (after MCD, MCT, MLT) +**Version** : v0.2 — prod-like, 19 tables +**Date** : 2026-06-04 +**Branch** : `feat/p1-conception` +**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) +**Author** : BYAN (methodology layer) --- -## 1. Objet du document +## 1. Purpose of this document -Le MLD transcrit le MCD en schema relationnel formel : 1 entite -> 1 table, chaque association traduite selon sa cardinalite, contraintes referentielles materialisees, index dimensionnes pour les acces frequents. +The MLD transcribes the MCD into a formal relational schema: 1 entity -> 1 table, each +association translated according to its cardinality, referential constraints materialised, +indexes sized for frequent access patterns. -C'est l'etape qui transforme la modelisation conceptuelle en specification implementable. Le DDL SQL (`db/migrations/0001_init_schema.sql`) sera derive directement de ce document a P2. +This is the step that transforms conceptual modelling into an implementable specification. +The DDL SQL (`db/migrations/0001_init_schema.sql`) will be derived directly from this +document at P2. -**Source** : `dictionary.md` (types et contraintes par attribut), `mcd.md` (entites + cardinalites + decisions reportees), `mct.md` (operations + entites manipulees), `mlt.md` (regles de gestion + transitions + protection concurrence). +**Sources**: +- `docs/merise/dictionary.md` (v0.2 — types and constraints per attribute, source of truth) +- `docs/merise/mcd.md` (v0.2 — entities + cardinalities + deferred decisions) +- `docs/notes/revue-alignement-p1.md` §7 (decision table D1-D8 + stock) -**Cibles** : +**Target platform**: - MariaDB 11.4 LTS (cf. `docker-compose.yml` service `wakdo-db`) -- Engine InnoDB (ACID, FKs, row-level locking, CHECK depuis 10.2.1) -- Charset `utf8mb4` collation `utf8mb4_unicode_ci` +- Engine InnoDB (ACID, FK support, row-level locking, CHECK from 10.2.1) +- Charset `utf8mb4`, collation `utf8mb4_unicode_ci` --- -## 2. Conventions de notation +## 2. Notation conventions -### Notation relationnelle +### Relational notation ``` -TABLE_NAME (col1, col2, #col_fk, [col_optionnelle]) +table_name (col1, col2, #col_fk, [col_nullable]) - PK : col1 - UK : col2 - FK : col_fk -> AUTRE_TABLE(id) + PK : col1 + UK : col2 + FK : col_fk -> other_table(id) ON DELETE + IDX : (col_a, col_b) + CHK : ``` -| Symbole | Signification | +| Symbol | Meaning | |---|---| -| `col` | Colonne NOT NULL | -| `[col]` | Colonne nullable | -| `#col` | Colonne FK (sans le diese : non-FK) | +| `col` | NOT NULL column | +| `[col]` | Nullable column | +| `#col` | FK column | -Cette notation reste proche de l'usage Merise francais (UNIRIS, ouvrages Nanci/Espinasse) : la cle primaire est soulignee dans les documents classiques, ici on prefixe par `PK` pour la portabilite ASCII. +Notation follows Merise French usage (Nanci/Espinasse convention adapted for ASCII). -### Types +### Type summary -Les types SQL exacts sont definis dans `dictionary.md` section 2 (Conventions generales) et reprecises dans chaque section de cette MLD. Conventions retenues : - -- `INT UNSIGNED AUTO_INCREMENT` pour toutes les PK techniques -- `INT UNSIGNED` pour tous les montants en centimes (anti-FLOAT cf. dictionary note 1) -- `VARCHAR()` avec longueur calibree selon dictionary note 7 -- `ENUM(...)` pour les valeurs metier stables (cf. dictionary note 2) -- `DATETIME` pour les timestamps (pas TIMESTAMP qui ferait du fuseau auto-implicite) +All exact types are defined in `dictionary.md` section 2. Conventions retained: +- `INT UNSIGNED AUTO_INCREMENT` for all technical PKs +- `INT UNSIGNED` for all monetary amounts in cents (anti-FLOAT, see dictionary note 1) +- `SMALLINT UNSIGNED` for `vat_rate` per-mille values (55 or 100) +- `ENUM(...)` for stable business values (see dictionary note 2) +- `DATETIME` for timestamps (not TIMESTAMP, which implicitly converts to UTC in MariaDB) --- -## 3. Regles de traduction MCD -> MLD +## 3. MCD -> MLD translation rules applied -Les regles classiques de passage MCD -> MLD appliquees : +### 3.1 Entity -> Table -### 3.1 Entite -> Table +Each MCD entity becomes one table. The conceptual identifier `id` becomes PK +`INT UNSIGNED AUTO_INCREMENT`. Attributes retain their names and types. -Chaque entite du MCD devient une table. L'identifiant conceptuel `id` devient PK technique `INT UNSIGNED AUTO_INCREMENT`. Les attributs gardent leurs noms et types. +### 3.2 `(1,1) - (1,N)` association -> simple FK -### 3.2 Association `(1,1) - (1,N)` -> FK simple +The entity on the `(1,1)` side carries the FK toward the `(0,N)` or `(1,N)` entity. -L'entite cote `(1,1)` porte la FK vers l'entite cote `(0,N)` ou `(1,N)`. Exemple : +### 3.3 `(0,N) - (0,N)` or `(1,N) - (1,N)` association -> join table -``` -CATEGORIE (1,1) <--regroupe--> (0,N) PRODUIT +The association becomes its own table with a composite PK of the two FKs. Applied to: +`product_ingredient`, `menu_slot_option`, `ingredient_allergen`, +`role_visible_source`, `role_permission`. -devient +### 3.4 Associative entity with own attributes -> join table with columns -CATEGORIE (id, libelle, ...) -- pas de FK -PRODUIT (id, #categorie_id, ...) -- FK vers CATEGORIE -``` +When an N-N association carries its own attributes, it becomes a table with those attributes +in addition to the composite FK PK. Applied to `product_ingredient`. -### 3.3 Association `(0,N) - (0,N)` ou `(1,N) - (1,N)` -> Table de jointure +### 3.5 Polymorphism -> 2 nullable FKs + discriminator + CHECK -L'association devient sa propre table avec PK composite des deux FKs. Exemple : - -``` -MENU (1,N) <--compose--> (0,N) PRODUIT (via MENU_PRODUIT) - -devient - -MENU_PRODUIT (#menu_id, #produit_id, role, position) - PK composite : (menu_id, produit_id) -``` - -### 3.4 Association porteuse d'attributs -> Table associative - -Si une association MCD porte des attributs propres (`role`, `position` sur `compose`), elle devient table meme si elle pourrait theoriquement etre une FK. Cas applique a `MENU_PRODUIT` et `ROLE_PERMISSION`. - -### 3.5 Polymorphisme -> 2 FKs nullables + discriminateur - -`LIGNE_COMMANDE` -> (`PRODUIT` ou `MENU`) traduit en 2 colonnes FK nullable + 1 colonne discriminateur : - -``` -LIGNE_COMMANDE (id, #commande_id, type_item, [#produit_id], [#menu_id], ...) - CHECK ((type_item='produit' AND produit_id IS NOT NULL AND menu_id IS NULL) - OR (type_item='menu' AND menu_id IS NOT NULL AND produit_id IS NULL)) -``` - -Cf. `docs/notes/polymorphic-fk-snapshots.md` pour la justification. - -### 3.6 Audit (event sourcing) -> Table dediee - -`COMMANDE_EVENT` est une table append-only, traduction directe de l'entite MCD 3.7. Aucune denormalisation `user_id` sur `commande` (cf. dictionary note 10). +`order_item` references either `product` or `menu`. Translated as 2 nullable FK columns + +1 discriminator ENUM + 1 CHECK constraint enforcing mutual exclusivity. --- -## 4. Schema relationnel formel +## 4. Relational schema (19 tables) -Les 11 tables qui composent le schema Wakdo, ordonnees par dependance (les tables sans FK d'abord, puis les tables qui dependent d'elles). +Tables are ordered by dependency (no-FK tables first, then tables that depend on them). -### 4.1 `categorie` +--- + +### 4.1 `category` ``` -categorie (id, libelle, slug, image_path, ordre, est_actif, created_at, updated_at) +category (id, name, slug, [image_path], display_order, is_active, created_at, updated_at) - PK : id - UK : libelle - UK : slug + PK : id + UK : name + UK : slug ``` -Types : -- `id INT UNSIGNED AUTO_INCREMENT` -- `libelle VARCHAR(80) NOT NULL` -- `slug VARCHAR(60) NOT NULL` -- `image_path VARCHAR(255) NULL` (cf. dictionary note 11) -- `ordre SMALLINT UNSIGNED NOT NULL DEFAULT 0` -- `est_actif TINYINT(1) NOT NULL DEFAULT 1` -- `created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP` -- `updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `name` | VARCHAR(60) | NO | Unique display name (see dict 3.1) | +| `slug` | VARCHAR(60) | NO | URL slug, e.g. `burgers` | +| `image_path` | VARCHAR(255) | YES | Relative path from public root | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Kiosk display order | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Soft deactivation | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | +| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -Aucune FK. Table racine du sous-domaine Catalogue. +No FK. Root table for the Catalogue sub-domain. -### 4.2 `produit` +--- + +### 4.2 `product` ``` -produit (id, #categorie_id, libelle, [description], prix_ttc_cents, [image_path], - est_disponible, ordre, created_at, updated_at) +product (id, #category_id, name, [description], price_cents, vat_rate, + [image_path], is_available, display_order, created_at, updated_at) - PK : id - FK : categorie_id -> categorie(id) ON DELETE RESTRICT - IDX : (categorie_id, est_disponible, ordre) + 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) ``` -Types : -- `id INT UNSIGNED AUTO_INCREMENT` -- `categorie_id INT UNSIGNED NOT NULL` -- `libelle VARCHAR(120) NOT NULL` -- `description TEXT NULL` -- `prix_ttc_cents INT UNSIGNED NOT NULL CHECK (prix_ttc_cents > 0)` -- `image_path VARCHAR(255) NULL` -- `est_disponible TINYINT(1) NOT NULL DEFAULT 1` -- `ordre SMALLINT UNSIGNED NOT NULL DEFAULT 0` -- `created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP` -- `updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `category_id` | INT UNSIGNED | NO | FK -> category | +| `name` | VARCHAR(120) | NO | Product label | +| `description` | TEXT | YES | Optional long description | +| `price_cents` | INT UNSIGNED | NO | A la carte price, incl. VAT, in cents | +| `vat_rate` | SMALLINT UNSIGNED | NO | Per-mille: 100 = 10%, 55 = 5.5% | +| `image_path` | VARCHAR(255) | YES | Relative path from public root | +| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Manual availability toggle | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Within-category display order | +| `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 `categorie_id` : impossible de supprimer une categorie qui contient des produits (protection metier, evite les orphelins). +**ON DELETE RESTRICT** on `category_id`: a category with products cannot be deleted. Prevents +orphaned products. + +--- ### 4.3 `menu` ``` -menu (id, #categorie_id, libelle, [description], prix_ttc_cents, [image_path], - est_disponible, ordre, created_at, updated_at) +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 : categorie_id -> categorie(id) ON DELETE RESTRICT - IDX : (categorie_id, est_disponible, ordre) + 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 ``` -Types : identiques a `produit` (meme structure, semantique distincte cf. dictionary note 3). +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `category_id` | INT UNSIGNED | NO | FK -> category (typically the `menus` category) | +| `burger_product_id` | INT UNSIGNED | NO | FK -> product — the fixed burger that anchors this menu | +| `name` | VARCHAR(120) | NO | e.g. "Menu Le 280" | +| `description` | TEXT | YES | Optional | +| `price_normal_cents` | INT UNSIGNED | NO | Normal format price in cents | +| `price_maxi_cents` | INT UNSIGNED | NO | Maxi format price in cents (~+150 cents) | +| `image_path` | VARCHAR(255) | YES | Typically reuses the burger image | +| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Availability toggle | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Display order | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | +| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -### 4.4 `menu_produit` (table associative) +**ON DELETE RESTRICT** on both FKs: prevents deletion of a category or burger product that +is still referenced by a menu definition. + +--- + +### 4.4 `menu_slot` ``` -menu_produit (#menu_id, #produit_id, role, position) +menu_slot (id, #menu_id, name, slot_type, is_required, display_order) - PK : (menu_id, produit_id) - FK : menu_id -> menu(id) ON DELETE CASCADE - FK : produit_id -> produit(id) ON DELETE RESTRICT - IDX : (menu_id, position) + PK : id + FK : menu_id -> menu(id) ON DELETE CASCADE + IDX : (menu_id, display_order) ``` -Types : -- `menu_id INT UNSIGNED NOT NULL` -- `produit_id INT UNSIGNED NOT NULL` -- `role ENUM('burger','accompagnement','boisson','sauce','dessert') NOT NULL` -- `position SMALLINT UNSIGNED NOT NULL DEFAULT 0` +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `menu_id` | INT UNSIGNED | NO | FK -> menu | +| `name` | VARCHAR(80) | NO | e.g. "Drink", "Side", "Sauce" | +| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | Semantic role | +| `is_required` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Whether the customer must fill this slot | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Display order within menu builder | -**ON DELETE CASCADE** sur `menu_id` : si un menu est supprime, ses compositions le sont aussi. -**ON DELETE RESTRICT** sur `produit_id` : impossible de supprimer un produit utilise dans un menu (protection integrite menu). +**No audit fields**: a slot is part of menu definition; created and updated together with +the menu. -Pas d'`updated_at` (table de jointure, cf. dictionary note 5 : les jointures sont supprimees+recreees, pas modifiees). +**ON DELETE CASCADE** on `menu_id`: if a menu is deleted, its slots are deleted with it. -### 4.5 `commande` +--- + +### 4.5 `menu_slot_option` + +Pure join table. Composite PK. ``` -commande (id, numero, source, mode_consommation, statut, - total_ht_cents, total_tva_cents, total_ttc_cents, tva_taux_pourmille, - [paye_a], created_at, updated_at) +menu_slot_option (#menu_slot_id, #product_id) - PK : id - UK : numero - IDX : (source, created_at) - IDX : (statut, created_at) - IDX : created_at - CHECK : source != 'drive' OR mode_consommation = 'drive' - CHECK : total_ttc_cents = total_ht_cents + total_tva_cents + PK : (menu_slot_id, product_id) + FK : menu_slot_id -> menu_slot(id) ON DELETE CASCADE + FK : product_id -> product(id) ON DELETE RESTRICT ``` -Types : -- `id INT UNSIGNED AUTO_INCREMENT` -- `numero VARCHAR(20) NOT NULL` -- `source ENUM('kiosk','comptoir','drive') NOT NULL` -- `mode_consommation ENUM('sur_place','a_emporter','drive') NOT NULL` -- `statut ENUM('pending_payment','paid','preparing','ready','delivered','cancelled') NOT NULL DEFAULT 'pending_payment'` -- `total_ht_cents INT UNSIGNED NOT NULL CHECK (total_ht_cents >= 0)` -- `total_tva_cents INT UNSIGNED NOT NULL CHECK (total_tva_cents >= 0)` -- `total_ttc_cents INT UNSIGNED NOT NULL CHECK (total_ttc_cents > 0)` -- `tva_taux_pourmille SMALLINT UNSIGNED NOT NULL` -- `paye_a DATETIME NULL` (NULL avant paiement, timestamp du passage en `paid`) -- `created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP` -- `updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` +| Column | Type | NULL | Notes | +|---|---|---|---| +| `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot | +| `product_id` | INT UNSIGNED | NO | FK -> product | -**CHECK croise** `source/mode_consommation` (cf. dictionary note 8) : empeche les combinaisons invalides au niveau SGBD plutot que de se reposer uniquement sur le code applicatif. +**ON DELETE CASCADE** on `menu_slot_id`: if a slot is deleted, its eligibility list goes with it. +**ON DELETE RESTRICT** on `product_id`: a product listed as eligible in a slot cannot be +deleted without first removing it from the slot options. Prevents silent breakage of menus. -**CHECK montants** : invariant `TTC = HT + TVA` verifie en base (defense-in-depth contre les bugs de calcul applicatif). +No timestamps. Pure join table. -Aucune FK directe vers `user` : la tracabilite passe par `commande_event` (cf. 4.7). +--- -### 4.6 `ligne_commande` +### 4.6 `ingredient` ``` -ligne_commande (id, #commande_id, type_item, [#produit_id], [#menu_id], - libelle_snapshot, prix_unitaire_ttc_cents_snapshot, quantite, created_at) +ingredient (id, name, unit, stock_quantity, pack_size, [pack_label], + low_stock_threshold, is_active, created_at, updated_at) - PK : id - FK : commande_id -> commande(id) ON DELETE CASCADE - FK : produit_id -> produit(id) ON DELETE RESTRICT - FK : menu_id -> menu(id) ON DELETE RESTRICT - IDX : commande_id - CHECK : (type_item='produit' AND produit_id IS NOT NULL AND menu_id IS NULL) - OR (type_item='menu' AND menu_id IS NOT NULL AND produit_id IS NULL) + PK : id + UK : name + CHK : stock_quantity >= 0 + CHK : pack_size > 0 + CHK : low_stock_threshold >= 0 ``` -Types : -- `id INT UNSIGNED AUTO_INCREMENT` -- `commande_id INT UNSIGNED NOT NULL` -- `type_item ENUM('produit','menu') NOT NULL` -- `produit_id INT UNSIGNED NULL` -- `menu_id INT UNSIGNED NULL` -- `libelle_snapshot VARCHAR(120) NOT NULL` -- `prix_unitaire_ttc_cents_snapshot INT UNSIGNED NOT NULL CHECK (prix_unitaire_ttc_cents_snapshot > 0)` -- `quantite SMALLINT UNSIGNED NOT NULL DEFAULT 1 CHECK (quantite > 0)` -- `created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP` +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `name` | VARCHAR(120) | NO | Unique name, e.g. "Sesame Bun" | +| `unit` | VARCHAR(40) | NO | Packaging unit label (free-form, not ENUM) | +| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Current stock. Signed INT to detect negative (alert) | +| `pack_size` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units per restocking pack | +| `pack_label` | VARCHAR(80) | YES | Human label of the pack | +| `low_stock_threshold` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Alert threshold | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivate obsolete ingredients | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | +| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -**ON DELETE CASCADE** sur `commande_id` : si la commande disparait, ses lignes aussi. -**ON DELETE RESTRICT** sur `produit_id` et `menu_id` : impossible de supprimer un produit/menu reference par une commande historique (preserve les references meme si on snapshote). +No FK. Root table for the Ingredients & Stock sub-domain. -**CHECK polymorphisme** : exclusivite mutuelle `produit_id` / `menu_id` selon `type_item` (cf. dictionary note 6). +--- -### 4.7 `commande_event` +### 4.7 `product_ingredient` + +Associative table carrying recipe and customisation metadata. Composite PK. ``` -commande_event (id, #commande_id, event_type, [from_statut], to_statut, - [#user_id], [payload], created_at) +product_ingredient (#product_id, #ingredient_id, quantity_normal, quantity_maxi, + is_removable, is_addable, extra_price_cents) - PK : id - FK : commande_id -> commande(id) ON DELETE CASCADE - FK : user_id -> user(id) ON DELETE SET NULL - IDX : (commande_id, created_at) - IDX : (user_id, created_at) - IDX : (event_type, created_at) + 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 ``` -Types : -- `id INT UNSIGNED AUTO_INCREMENT` -- `commande_id INT UNSIGNED NOT NULL` -- `event_type ENUM('CREATED','PAID','PREPARING_STARTED','READY','DELIVERED','CANCELLED') NOT NULL` -- `from_statut ENUM('pending_payment','paid','preparing','ready','delivered','cancelled') NULL` -- `to_statut ENUM('pending_payment','paid','preparing','ready','delivered','cancelled') NOT NULL` -- `user_id INT UNSIGNED NULL` -- `payload JSON NULL` -- `created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP` +| Column | 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 | Units consumed in Normal format | +| `quantity_maxi` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units consumed in Maxi format; equals `quantity_normal` for burger/sauce (format-invariant), higher for side/drink | +| `is_removable` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Customer may remove at no cost | +| `is_addable` | TINYINT(1) NOT NULL DEFAULT 0 | NO | Customer may add an extra unit | +| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Surcharge if `is_addable=1` and customer adds it | -**ON DELETE CASCADE** sur `commande_id` : si la commande est purgee, son journal disparait avec elle. -**ON DELETE SET NULL** sur `user_id` : si un equipier est supprime, les events restent (l'audit reste consultable, l'attribution individuelle est perdue). +**ON DELETE CASCADE** on `product_id`: if a product is deleted, its recipe rows are deleted. +**ON DELETE RESTRICT** on `ingredient_id`: cannot delete an ingredient still referenced in a +recipe. Admin must remove the product-ingredient link first. -**Pas d'`updated_at`** : table append-only. Aucun UPDATE applicatif autorise (cf. mlt.md RG-T10). +No timestamps. Join table with attributes. -**Pas de CHECK croise from_statut/to_statut** : la verification de la machine a etats est applicative (mlt section 12), un CHECK SQL serait trop rigide (event_type peut prendre des valeurs non encore prevues). +--- -### 4.8 `user` +### 4.8 `allergen` ``` -user (id, email, password_hash, nom, prenom, #role_id, est_actif, [last_login_at], - created_at, updated_at) +allergen (id, code, name, [description]) - PK : id - UK : email - FK : role_id -> role(id) ON DELETE RESTRICT - IDX : (est_actif, role_id) + PK : id + UK : code ``` -Types : -- `id INT UNSIGNED AUTO_INCREMENT` -- `email VARCHAR(254) NOT NULL` (RFC 5321) -- `password_hash VARCHAR(255) NOT NULL` (argon2id, cf. `.env` `PASSWORD_ALGO`) -- `nom VARCHAR(60) NOT NULL` -- `prenom VARCHAR(60) NOT NULL` -- `role_id INT UNSIGNED NOT NULL` -- `est_actif TINYINT(1) NOT NULL DEFAULT 1` -- `last_login_at DATETIME NULL` -- `created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP` -- `updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `code` | VARCHAR(30) | NO | Machine code, e.g. `gluten`, `milk` | +| `name` | VARCHAR(80) | NO | Display name | +| `description` | TEXT | YES | Optional guidance | -**ON DELETE RESTRICT** sur `role_id` : impossible de supprimer un role qui a encore des users (passer par `est_actif = 0` sur le role avant de supprimer). +No FK. Reference table; 14 rows at seed (INCO Regulation (EU) 1169/2011). +No `updated_at`: allergen catalogue is considered stable (additions require a migration, not a UI action). -### 4.9 `role` +--- + +### 4.9 `ingredient_allergen` + +Pure join table. Composite PK. ``` -role (id, code, libelle, [description], est_actif, created_at, updated_at) +ingredient_allergen (#ingredient_id, #allergen_id) - PK : id - UK : code + PK : (ingredient_id, allergen_id) + FK : ingredient_id -> ingredient(id) ON DELETE CASCADE + FK : allergen_id -> allergen(id) ON DELETE RESTRICT ``` -Types : -- `id INT UNSIGNED AUTO_INCREMENT` -- `code VARCHAR(40) NOT NULL` -- `libelle VARCHAR(80) NOT NULL` -- `description TEXT NULL` -- `est_actif TINYINT(1) NOT NULL DEFAULT 1` -- `created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP` -- `updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` +| Column | Type | NULL | Notes | +|---|---|---|---| +| `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient | +| `allergen_id` | INT UNSIGNED | NO | FK -> allergen | -Aucune FK. Table racine du sous-domaine RBAC. +**ON DELETE CASCADE** on `ingredient_id`: if an ingredient is deleted, its allergen links go with it. +**ON DELETE RESTRICT** on `allergen_id`: an allergen in the regulated catalogue cannot be deleted. -### 4.10 `permission` +No timestamps. Pure join table. + +--- + +### 4.10 `role` + +Placed before `user` because `user` depends on `role`. ``` -permission (id, code, libelle, [description], created_at) +role (id, code, label, [description], [default_route], [order_source], + is_active, created_at, updated_at) - PK : id - UK : code + PK : id + UK : code ``` -Types : -- `id INT UNSIGNED AUTO_INCREMENT` -- `code VARCHAR(60) NOT NULL` (format `.`) -- `libelle VARCHAR(120) NOT NULL` -- `description TEXT NULL` -- `created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP` +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `code` | VARCHAR(40) | NO | Machine code: `admin`, `manager`, `kitchen`, `counter`, `drive` | +| `label` | VARCHAR(80) | NO | Display name | +| `description` | TEXT | YES | Optional | +| `default_route` | VARCHAR(120) | YES | Landing screen, e.g. `/admin/dashboard` | +| `order_source` | ENUM('kiosk','counter','drive') | YES | Auto-tagged source when this role creates an order; NULL for admin/manager | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivation preserves history | +| `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 d'`updated_at` : les permissions sont declarees en migration et ne sont pas modifiees via UI (cf. RBAC statique cote permissions, dictionary 3.10 et MCD 4.3). +No FK. Root table for RBAC. -### 4.11 `role_permission` (table associative) +--- + +### 4.11 `user` + +``` +user (id, email, password_hash, first_name, last_name, #role_id, + is_active, [last_login_at], created_at, updated_at) + + PK : id + UK : email + FK : role_id -> role(id) ON DELETE RESTRICT + IDX : (is_active, role_id) +``` + +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `email` | VARCHAR(254) | NO | RFC 5321 max length | +| `password_hash` | VARCHAR(255) | NO | argon2id hash | +| `first_name` | VARCHAR(60) | NO | | +| `last_name` | VARCHAR(60) | NO | | +| `role_id` | INT UNSIGNED | NO | FK -> role | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivation without deletion | +| `last_login_at` | DATETIME | YES | Audit, dormant account detection | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | +| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | + +**ON DELETE RESTRICT** on `role_id`: a role cannot be deleted while users hold it. +Deactivate the role first (`is_active = 0`), then reassign users before deleting. + +--- + +### 4.12 `role_visible_source` + +Pure join table. Composite PK. + +``` +role_visible_source (#role_id, source) + + PK : (role_id, source) + FK : role_id -> role(id) ON DELETE CASCADE +``` + +| Column | Type | NULL | Notes | +|---|---|---|---| +| `role_id` | INT UNSIGNED | NO | FK -> role | +| `source` | ENUM('kiosk','counter','drive') | NO | Order source visible on dashboard | + +**ON DELETE CASCADE** on `role_id`: if a role is deleted, its dashboard source filters go with it. + +No timestamps. Pure join table. + +Seed data: +- `kitchen`: kiosk, counter, drive +- `counter`: kiosk, counter +- `drive`: drive +- `admin`, `manager`: no rows (global view, no source filter) + +--- + +### 4.13 `permission` + +``` +permission (id, code, label, [description], created_at) + + PK : id + UK : code +``` + +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `code` | VARCHAR(60) | NO | Format `.` | +| `label` | VARCHAR(120) | NO | Display name | +| `description` | TEXT | YES | Optional | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | + +No `updated_at`: permissions are declared in migration and not modified via UI. +Catalogue is frozen at 23 codes (see dictionary section 3.17). + +--- + +### 4.14 `role_permission` + +Pure join table. Composite PK. ``` 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 (acces inverse "quels roles ont cette permission ?") + PK : (role_id, permission_id) + FK : role_id -> role(id) ON DELETE CASCADE + FK : permission_id -> permission(id) ON DELETE CASCADE + IDX : permission_id ``` -Types : -- `role_id INT UNSIGNED NOT NULL` -- `permission_id INT UNSIGNED NOT NULL` - -**ON DELETE CASCADE des deux cotes** : suppression d'un role ou d'une permission supprime ses mappings. - -Pas de timestamps (table de jointure pure, cf. dictionary note 5). - ---- - -## 5. Recapitulatif des contraintes referentielles - -| FK | Reference | ON DELETE | Justification | +| Column | Type | NULL | Notes | |---|---|---|---| -| `produit.categorie_id` | `categorie(id)` | RESTRICT | Pas d'orphelin produit | -| `menu.categorie_id` | `categorie(id)` | RESTRICT | Idem | -| `menu_produit.menu_id` | `menu(id)` | CASCADE | Composition disparait avec le menu | -| `menu_produit.produit_id` | `produit(id)` | RESTRICT | Pas de cascade : un produit reference dans un menu ne peut pas etre supprime sans amender la composition | -| `commande.--` | (aucune FK vers user) | - | Tracabilite via commande_event | -| `ligne_commande.commande_id` | `commande(id)` | CASCADE | Lignes disparaissent avec la commande | -| `ligne_commande.produit_id` | `produit(id)` | RESTRICT | Preserve l'integrite historique | -| `ligne_commande.menu_id` | `menu(id)` | RESTRICT | Idem | -| `commande_event.commande_id` | `commande(id)` | CASCADE | Journal disparait avec la commande | -| `commande_event.user_id` | `user(id)` | SET NULL | Audit conserve, attribution individuelle perdue | -| `user.role_id` | `role(id)` | RESTRICT | Pas d'user sans role | -| `role_permission.role_id` | `role(id)` | CASCADE | Mapping disparait avec le role | -| `role_permission.permission_id` | `permission(id)` | CASCADE | Mapping disparait avec la permission | +| `role_id` | INT UNSIGNED | NO | FK -> role | +| `permission_id` | INT UNSIGNED | NO | FK -> permission | -**Cles** : -- **CASCADE** : la donnee dependante n'a pas de sens hors de son parent (lignes / events / mappings) -- **RESTRICT** : suppression du parent bloquee tant que des references existent (catalogue, role) -- **SET NULL** : preserve la donnee enfant en perdant le lien (audit event sans attribution) +**ON DELETE CASCADE** on both FKs: deleting a role or a permission removes its mappings. +The secondary index on `permission_id` supports the reverse query "which roles have this +permission?" without scanning the full table. + +No timestamps. Pure join table. --- -## 6. Index complementaires +### 4.15 `customer_order` -Au-dela des PK / UK / FK qui creent des index automatiquement, indexes ajoutes pour les requetes frequentes identifiees au MCT/MLT : +``` +customer_order (id, order_number, source, service_mode, status, + total_ht_cents, total_vat_cents, total_ttc_cents, + [paid_at], [delivered_at], [cancelled_at], + created_at, updated_at) -| Table | Index | Justification (operation MCT) | -|---|---|---| -| `produit` | `(categorie_id, est_disponible, ordre)` | Chargement catalogue kiosk (op 1) : filtre par categorie + disponible + tri par ordre | -| `menu` | `(categorie_id, est_disponible, ordre)` | Idem produit | -| `menu_produit` | `(menu_id, position)` | Chargement composition d'un menu | -| `commande` | `(source, created_at)` | Stats "par canal" + tri chronologique | -| `commande` | `(statut, created_at)` | Files d'attente preparation/accueil (ops 6, 9) | -| `commande` | `created_at` | Stats agregations live | -| `ligne_commande` | `commande_id` | Recuperation des lignes d'une commande | -| `commande_event` | `(commande_id, created_at)` | Historique d'une commande | -| `commande_event` | `(user_id, created_at)` | Actions d'un equipier sur une periode | -| `commande_event` | `(event_type, created_at)` | Stats "combien de cancellations cette semaine ?" | -| `user` | `(est_actif, role_id)` | Login + permissions check (op 23) | -| `role_permission` | `permission_id` | Acces inverse "quels roles ont cette permission ?" | + PK : id + UK : order_number + 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' +``` -**Index NON ajoutes** (volontaire) : -- `commande.numero` : UK suffit, pas de range query attendue dessus -- `commande.mode_consommation` : faible cardinalite (3 valeurs), un index n'est pas rentable, full scan acceptable -- `commande.paye_a` : NULL pour la majorite des lignes (commande encore en cours), index peu utile - ---- - -## 7. Contraintes CHECK (MariaDB 10.2+) - -Verification au niveau SGBD pour les invariants critiques. Defense-in-depth contre les bugs applicatifs. - -| Table | CHECK | Pourquoi | -|---|---|---| -| `produit` | `prix_ttc_cents > 0` | Prix nul ou negatif = bug | -| `menu` | `prix_ttc_cents > 0` | Idem | -| `commande` | `total_ht_cents >= 0` | Plancher autorise (commande vide transitoire ?) | -| `commande` | `total_tva_cents >= 0` | Idem | -| `commande` | `total_ttc_cents > 0` | TTC nul = bug | -| `commande` | `total_ttc_cents = total_ht_cents + total_tva_cents` | Invariant arithmetique | -| `commande` | `source != 'drive' OR mode_consommation = 'drive'` | Contrainte croisee (dictionary note 8, mlt RG-T09) | -| `ligne_commande` | `prix_unitaire_ttc_cents_snapshot > 0` | Snapshot prix non nul | -| `ligne_commande` | `quantite > 0` | Quantite non nulle | -| `ligne_commande` | `(type_item='produit' AND produit_id IS NOT NULL AND menu_id IS NULL) OR (type_item='menu' AND menu_id IS NOT NULL AND produit_id IS NULL)` | Polymorphisme exclusif (dictionary note 6) | - ---- - -## 8. Cross-validation entites MCD -> tables MLD - -| Entite MCD | Table MLD | Notes | -|---|---|---| -| `categorie` (3.1) | `categorie` (4.1) | 1:1 | -| `produit` (3.2) | `produit` (4.2) | 1:1 | -| `menu` (3.3) | `menu` (4.3) | 1:1 | -| `menu_produit` (3.4) | `menu_produit` (4.4) | Entite associative -> table de jointure avec PK composite | -| `commande` (3.5) | `commande` (4.5) | 1:1, attribut `source` ajoute (decision 2026-05-28) | -| `ligne_commande` (3.6) | `ligne_commande` (4.6) | 1:1, polymorphisme materialise par 2 FKs nullables + CHECK | -| `commande_event` (3.7) | `commande_event` (4.7) | 1:1, table append-only, decision 2026-05-28 | -| `user` (3.8) | `user` (4.8) | 1:1 | -| `role` (3.9) | `role` (4.9) | 1:1 | -| `permission` (3.10) | `permission` (4.10) | 1:1 | -| `role_permission` (3.11) | `role_permission` (4.11) | Entite associative -> table de jointure avec PK composite | - -**Conclusion** : 11/11 entites tracees. Aucune entite MCD ne reste sans table, aucune table MLD ne sort du modele conceptuel. - ---- - -## 9. Estimation volumes et taille - -| Table | Volume 6 mois | Taille moyenne ligne | Taille totale | +| Column | Type | NULL | Notes | |---|---|---|---| -| `categorie` | ~10 | 200 octets | < 1 Ko | -| `produit` | ~70 | 400 octets | ~30 Ko | -| `menu` | ~15 | 400 octets | ~6 Ko | -| `menu_produit` | ~80 | 30 octets | ~2 Ko | -| `commande` | ~30k | 300 octets | ~9 Mo | -| `ligne_commande` | ~150k | 200 octets | ~30 Mo | -| `commande_event` | ~180k | 200 octets | ~36 Mo | -| `user` | ~20 | 500 octets | ~10 Ko | -| `role` | ~5 | 200 octets | ~1 Ko | -| `permission` | ~40 | 300 octets | ~12 Ko | -| `role_permission` | ~80 | 30 octets | ~2 Ko | +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `order_number` | VARCHAR(20) | NO | Format `K/C/D-YYYY-MM-DD-NNN` by channel | +| `source` | ENUM('kiosk','counter','drive') | NO | Input channel | +| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Consumption mode (stats only, no fiscal role) | +| `status` | ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment' | NO | 4-state machine | +| `total_ht_cents` | INT UNSIGNED | NO | Ex-VAT total snapshot | +| `total_vat_cents` | INT UNSIGNED | NO | VAT amount snapshot | +| `total_ttc_cents` | INT UNSIGNED | NO | Incl.-VAT total; must equal HT + VAT | +| `paid_at` | DATETIME | YES | Timestamp of transition to `paid` | +| `delivered_at` | DATETIME | YES | Timestamp of transition to `delivered` | +| `cancelled_at` | DATETIME | YES | Timestamp of cancellation | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Used as `service_day` base | +| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -**Total : ~75 Mo sur 6 mois**. Largement gerable par MariaDB sur le conteneur Wakdo (volume `wakdo_db_data` named volume, cf. `docker-compose.yml`). +No FK toward `user`: staff attribution is not stored on the order. Operational accountability +is covered by `stock_movement.user_id` for stock actions. -Les indexes ajoutent typiquement 30-50% du volume des tables, soit ~30 Mo supplementaires. **Estimation totale : ~100-110 Mo / 6 mois**. +**4-state machine**: `pending_payment -> paid -> delivered` (+ `cancelled`). States `preparing` +and `ready` are dropped (decision D4). KPI: `delivered_at - paid_at` (target SLA ~10 min). + +**`service_day` computation** (used in stats queries — NOT a stored column): +```sql +CASE WHEN HOUR(created_at) < 10 + THEN DATE(created_at) - INTERVAL 1 DAY + ELSE DATE(created_at) +END +``` +Cutoff: 10:00. The generated-column formula with `INTERVAL 4 HOUR 30 MINUTE` from v0.1 was +incorrect and is dropped (decision D6). + +**VAT calculation**: totals on `customer_order` are the sum of line-level calculations. +Line-level VAT: `unit_price_cents_snapshot * quantity` is the TTC amount per line; +HT = `ROUND(ttc_cents * 100 / (100 + vat_rate_per_cent))` where `vat_rate_per_cent` +is `vat_rate_snapshot / 10`. Computed at application layer at cart validation. + +**`source = 'drive' => service_mode = 'drive'`**: the CHECK enforces this at DB level. --- -## 10. Decisions reportees au DDL et a P2 +### 4.16 `order_item` -Les decisions suivantes sont laissees au DDL (`db/migrations/0001_init_schema.sql`) ou aux phases ulterieures, parce qu'elles concernent l'implementation et pas la modelisation logique : +``` +order_item (id, #order_id, item_type, [#product_id], [#menu_id], format, + label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot, + quantity, created_at) -1. **Triggers ou colonnes generees** : `service_day` (cf. PROJECT_CONTEXT section 2) pourrait etre une `GENERATED ALWAYS AS (DATE_SUB(created_at, INTERVAL 4 HOUR + 30 MINUTE))` virtuelle pour eviter le calcul applicatif. A evaluer en P3 si les stats sont penibles. -2. **Partitionnement** : `commande_event` pourrait etre partitionne par mois si le volume depasse les estimations. Pas pour MVP. -3. **Foreign Key index** : MariaDB cree automatiquement un index sur la FK lors de la declaration, sauf si un index utilisable existe deja. A verifier explicitement dans le DDL. -4. **Collation** : `utf8mb4_unicode_ci` retenu (sensible diacritiques et casse pour les recherches). Si besoin de tri locale francais strict, passer en `utf8mb4_fr_0900_ai_ci` (MySQL 8) ou rester en `unicode_ci`. -5. **Engine** : `InnoDB` par defaut (ACID + FKs). Pas de MEMORY ni Archive. -6. **Charset emoji** : `utf8mb4` (4 octets / char max) couvre les emojis au cas ou ils apparaitraient dans `description` produit ou `payload` JSON. + 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) +``` + +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `order_id` | INT UNSIGNED | NO | FK -> customer_order | +| `item_type` | ENUM('product','menu') | NO | Discriminator | +| `product_id` | INT UNSIGNED | YES | Non-null if `item_type = 'product'`, NULL otherwise | +| `menu_id` | INT UNSIGNED | YES | Non-null if `item_type = 'menu'`, NULL otherwise | +| `format` | ENUM('normal','maxi') NOT NULL DEFAULT 'normal' | NO | Menu format. For standalone products, value is `normal` | +| `label_snapshot` | VARCHAR(120) | NO | Label at time of order | +| `unit_price_cents_snapshot` | INT UNSIGNED | NO | Unit price incl. VAT at time of order | +| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | VAT rate per-mille at time of order | +| `quantity` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Quantity (e.g. 3 drinks = 1 line, quantity=3) | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | + +**ON DELETE CASCADE** on `order_id`: lines are deleted with the order. +**ON DELETE RESTRICT** on `product_id` and `menu_id`: a product or menu referenced in an +historical order line cannot be deleted. The snapshot makes the FK reference non-critical +for display, but RESTRICT avoids silent orphaning of the relational structure. + +**Polymorphism exclusivity CHECK**: MariaDB 10.2+ enforces this at INSERT/UPDATE time. --- -## 11. A faire au prochain sprint (DDL + Seed) +### 4.17 `order_item_selection` -1. **DDL** (`db/migrations/0001_init_schema.sql`) : transcrire ce MLD en CREATE TABLE executables, dans l'ordre des dependances (categorie -> produit/menu -> menu_produit -> commande -> ligne_commande/commande_event ; permission -> role -> role_permission ; user en dernier). +Customer's choice for one slot of a menu order line. -2. **Seed** (`db/seeds/0001_demo_data.sql`) : INSERT pour : - - 9 categories + 53 produits + 13 menus a partir des JSON sources (`docs/merise/_sources/`), prix normalises en centimes - - 1 admin par defaut + roles (admin, manager, equipier-comptoir, equipier-drive) - - Permissions declarees (CRUD produit/menu/categorie/user/role + commande operationnelles) - - Quelques commandes d'exemple pour les demos +``` +order_item_selection (id, #order_item_id, #menu_slot_id, #product_id, label_snapshot) -3. **Export fallback JSON** (`scripts/export-fallback.{sh|php}`) : extrait des donnees seed vers `src/public/borne/data/*.json` pour le mode "Bloc 1 isole" (kiosk sans BDD pour les tests). + 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 +``` -4. **Tests de validation DDL** : verifier que : - - Toutes les CHECK contraintes sont declenchees comme attendu (tests d'integration) - - Les ON DELETE CASCADE / RESTRICT se comportent comme specifie - - Les indexes accelerent reellement les requetes ciblees (EXPLAIN sur les requetes types du MCT) +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `order_item_id` | INT UNSIGNED | NO | FK -> order_item (must be a menu-type line) | +| `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot (which slot was filled) | +| `product_id` | INT UNSIGNED | NO | FK -> product (chosen by customer) | +| `label_snapshot` | VARCHAR(120) | NO | Product label at time of order | -5. **Migration tooling** : decider de l'outil (phinx, doctrine migrations, ou homemade PHP script). Cf. PROJECT_CONTEXT pour le choix retenu. +**ON DELETE CASCADE** on `order_item_id`: if the parent order line is deleted, its slot +selections go with it. +**ON DELETE RESTRICT** on `menu_slot_id` and `product_id`: historical slot choice records +must not be silently broken by catalogue changes. + +Note: the business constraint that `order_item_id` references a line with `item_type='menu'` +is enforced at application layer (not in MariaDB without a trigger or deferred constraint). + +--- + +### 4.18 `order_item_modifier` + +Ingredient-level modification applied by the customer to a product or the fixed burger of a 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 +``` + +| Column | 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` = free removal; `add` = extra unit | +| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Snapshot of surcharge at time of order (0 for removals) | + +**ON DELETE CASCADE** on `order_item_id`: if the order line is deleted, its modifiers go with it. +**ON DELETE RESTRICT** on `ingredient_id`: an ingredient referenced in a historical modifier +cannot be deleted. + +**Modifier attachment for menu lines**: the modifiable product is the fixed burger, resolved +via `order_item.menu_id -> menu.burger_product_id`. No additional FK column is needed on +this table (see dictionary note 10). + +--- + +### 4.19 `stock_movement` + +Append-only audit log of all stock changes per 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) +``` + +| Column | 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 of movement | +| `delta` | INT | NO | Signed change: negative for consumption, positive for restock/cancellation/correction | +| `order_id` | INT UNSIGNED | YES | FK -> customer_order; non-null for `sale` and `cancellation` | +| `user_id` | INT UNSIGNED | YES | FK -> user; null for automated sale decrements | +| `note` | VARCHAR(255) | YES | Optional human note | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Immutable timestamp | + +**ON DELETE RESTRICT** on `ingredient_id`: an ingredient with a movement history cannot be +deleted. Admin must archive the ingredient (`is_active = 0`) instead. +**ON DELETE SET NULL** on `order_id`: if an order is purged from the system, its movement +records remain with `order_id = NULL`. The audit log is preserved; only the order link is lost. +**ON DELETE SET NULL** on `user_id`: if a user is deleted, movement records remain with +`user_id = NULL`. Audit is preserved; individual attribution is lost. + +**Immutability rule**: no UPDATE or DELETE at application layer. Corrections are new rows +with `movement_type = 'inventory_correction'` and a signed `delta`. + +No `updated_at`. Immutable append-only table. + +--- + +## 5. Referential integrity summary + +| FK column | References | ON DELETE | Rationale | +|---|---|---|---| +| `product.category_id` | `category(id)` | RESTRICT | No orphaned product | +| `menu.category_id` | `category(id)` | RESTRICT | Same | +| `menu.burger_product_id` | `product(id)` | RESTRICT | Menu definition requires its anchor burger | +| `menu_slot.menu_id` | `menu(id)` | CASCADE | Slots have no meaning without their menu | +| `menu_slot_option.menu_slot_id` | `menu_slot(id)` | CASCADE | Eligibility list disappears with the slot | +| `menu_slot_option.product_id` | `product(id)` | RESTRICT | Removing a product must not silently break menus | +| `product_ingredient.product_id` | `product(id)` | CASCADE | Recipe disappears with the product | +| `product_ingredient.ingredient_id` | `ingredient(id)` | RESTRICT | Cannot remove ingredient still in a recipe | +| `ingredient_allergen.ingredient_id` | `ingredient(id)` | CASCADE | Allergen links disappear with the ingredient | +| `ingredient_allergen.allergen_id` | `allergen(id)` | RESTRICT | Regulated allergen catalogue is immutable | +| `user.role_id` | `role(id)` | RESTRICT | A user cannot exist without a role | +| `role_visible_source.role_id` | `role(id)` | CASCADE | Dashboard filters disappear with the role | +| `role_permission.role_id` | `role(id)` | CASCADE | Permission mappings disappear with the role | +| `role_permission.permission_id` | `permission(id)` | CASCADE | Permission mappings disappear with the permission | +| `order_item.order_id` | `customer_order(id)` | CASCADE | Lines disappear with the order | +| `order_item.product_id` | `product(id)` | RESTRICT | Historical reference must not be silently orphaned | +| `order_item.menu_id` | `menu(id)` | RESTRICT | Same | +| `order_item_selection.order_item_id` | `order_item(id)` | CASCADE | Slot choices disappear with the line | +| `order_item_selection.menu_slot_id` | `menu_slot(id)` | RESTRICT | Historical slot record preserved | +| `order_item_selection.product_id` | `product(id)` | RESTRICT | Historical choice record preserved | +| `order_item_modifier.order_item_id` | `order_item(id)` | CASCADE | Modifiers disappear with the line | +| `order_item_modifier.ingredient_id` | `ingredient(id)` | RESTRICT | Historical modifier record preserved | +| `stock_movement.ingredient_id` | `ingredient(id)` | RESTRICT | Ingredient with history cannot be deleted | +| `stock_movement.order_id` | `customer_order(id)` | SET NULL | Audit preserved, order link lost | +| `stock_movement.user_id` | `user(id)` | SET NULL | Audit preserved, user attribution lost | + +**Key used**: CASCADE = child has no meaning without parent; RESTRICT = parent deletion +blocked while children exist; SET NULL = child is preserved, only the link is severed. + +--- + +## 6. CHECK constraints summary + +| Table | CHECK expression | Purpose | +|---|---|---| +| `product` | `price_cents > 0` | Zero or negative price is a bug | +| `product` | `vat_rate IN (55, 100)` | Only two legal VAT rates for this model | +| `menu` | `price_normal_cents > 0` | Same as product | +| `menu` | `price_maxi_cents > 0` | Same | +| `ingredient` | `stock_quantity >= 0` | Negative stock is an alert, not a valid state | +| `ingredient` | `pack_size > 0` | Pack size of zero makes restock logic incoherent | +| `ingredient` | `low_stock_threshold >= 0` | Threshold cannot be negative | +| `product_ingredient` | `quantity_normal > 0` | Recipe quantity of zero is meaningless | +| `product_ingredient` | `quantity_maxi >= quantity_normal` | Maxi consumes at least as much as Normal (side/drink more, burger/sauce equal) | +| `product_ingredient` | `extra_price_cents >= 0` | No negative surcharge | +| `customer_order` | `total_ht_cents >= 0` | Zero is allowed (edge case during cart building) | +| `customer_order` | `total_vat_cents >= 0` | Same | +| `customer_order` | `total_ttc_cents > 0` | A validated order must have a positive total | +| `customer_order` | `total_ttc_cents = total_ht_cents + total_vat_cents` | Arithmetic invariant; defence-in-depth vs application bugs | +| `customer_order` | `source != 'drive' OR service_mode = 'drive'` | Cross-dimension constraint (dict. note 5) | +| `order_item` | `unit_price_cents_snapshot > 0` | Non-zero price at transaction time | +| `order_item` | `vat_rate_snapshot IN (55, 100)` | Snapshot must match allowed rates | +| `order_item` | `quantity > 0` | Non-zero quantity | +| `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)` | Polymorphism: exactly one FK populated per discriminator value | +| `order_item_modifier` | `extra_price_cents >= 0` | Snapshot of surcharge; cannot be negative | + +--- + +## 7. Recommended indexes (beyond PK / UK / FK auto-indexes) + +MariaDB InnoDB creates an index automatically for each FK declaration (if no usable index +exists). The following additional indexes target frequent query patterns identified in the +MCT / MLT. + +| Table | Index columns | Query pattern | +|---|---|---| +| `product` | `(category_id, is_available, display_order)` | Kiosk catalogue load: filter by category + availability, sort by order | +| `menu` | `(category_id, is_available, display_order)` | Same pattern for menus | +| `menu_slot` | `(menu_id, display_order)` | Menu builder: load all slots of a menu in order | +| `customer_order` | `(status, created_at)` | Active orders queue: pending/paid orders sorted by time | +| `customer_order` | `(source, created_at)` | Per-channel analytics and order filtering | +| `customer_order` | `created_at` | Time-range aggregations (hourly stats, `service_day`) | +| `order_item` | `order_id` | Retrieve all lines of an order | +| `order_item_selection` | `order_item_id` | Retrieve slot choices for a menu line | +| `order_item_modifier` | `order_item_id` | Retrieve ingredient modifications for a line | +| `stock_movement` | `(ingredient_id, created_at)` | Per-ingredient stock history (dict. section 3.19) | +| `stock_movement` | `(movement_type, created_at)` | Stats: cancellations per week, restocks per month | +| `role_permission` | `permission_id` | Reverse query: "which roles have this permission?" | +| `user` | `(is_active, role_id)` | Login check + permission resolution | + +**Indexes not added** (intentional): +- `customer_order.order_number`: UK index is sufficient; no range query expected on this column. +- `customer_order.service_mode`: low cardinality (3 values); full scan on the status index + with a `service_mode` filter is acceptable at expected volume. +- `customer_order.paid_at`: NULL for most in-flight rows; sparse index provides limited benefit. + +--- + +## 8. Cross-validation MLD <-> MCD + +Verification that all 19 MCD entities map to a table, and that all tables trace to the MCD. + +| MCD entity | MLD table | Mapping type | Notes | +|---|---|---|---| +| `category` (C1) | `category` (4.1) | 1:1 entity | | +| `product` (C2) | `product` (4.2) | 1:1 entity | | +| `menu` (C3) | `menu` (4.3) | 1:1 entity | New: `burger_product_id`, `price_normal_cents`, `price_maxi_cents` | +| `menu_slot` (C4) | `menu_slot` (4.4) | 1:1 entity | New entity (v0.2) | +| `menu_slot_option` (C5) | `menu_slot_option` (4.5) | Join table (composite PK) | New entity (v0.2) | +| `ingredient` (C6) | `ingredient` (4.6) | 1:1 entity | New entity (v0.2) | +| `product_ingredient` (C7) | `product_ingredient` (4.7) | Join table with attributes | New entity (v0.2) | +| `allergen` (C8) | `allergen` (4.8) | 1:1 entity | New entity (v0.2) | +| `ingredient_allergen` (C9) | `ingredient_allergen` (4.9) | Join table (composite PK) | New entity (v0.2) | +| `role` (C10) | `role` (4.10) | 1:1 entity | New: `default_route`, `order_source` | +| `user` (C11) | `user` (4.11) | 1:1 entity | Columns renamed to English | +| `role_visible_source` (C12) | `role_visible_source` (4.12) | Join table (composite PK) | New entity (v0.2) | +| `permission` (C13) | `permission` (4.13) | 1:1 entity | | +| `role_permission` (C14) | `role_permission` (4.14) | Join table (composite PK) | | +| `customer_order` (C15) | `customer_order` (4.15) | 1:1 entity | Renamed from `commande`; 4-state machine; phase timestamps | +| `order_item` (C16) | `order_item` (4.16) | 1:1 entity | New: `format`, `vat_rate_snapshot`; polymorphism CHECK | +| `order_item_selection` (C17) | `order_item_selection` (4.17) | 1:1 entity | New entity (v0.2) | +| `order_item_modifier` (C18) | `order_item_modifier` (4.18) | 1:1 entity | New entity (v0.2) | +| `stock_movement` (C19) | `stock_movement` (4.19) | 1:1 entity | New entity (v0.2) | + +**Result**: 19/19 entities mapped. No entity without a table; no table outside the MCD. + +**Dropped from v0.1**: `commande_event` (replaced by `paid_at`, `delivered_at`, `cancelled_at` +phase timestamps on `customer_order` — decision 2.A); `menu_produit` fixed-composition model +(replaced by `menu_slot` + `menu_slot_option` — decision D1). + +--- + +## 9. Volume estimation (6 months) + +| Table | Rows at 6 months | Avg row size | Est. size | +|---|---|---|---| +| `category` | ~10 | 200 bytes | < 1 KB | +| `product` | ~55 | 400 bytes | ~22 KB | +| `menu` | ~13 | 450 bytes | ~6 KB | +| `menu_slot` | ~40 | 150 bytes | ~6 KB | +| `menu_slot_option` | ~150 | 30 bytes | ~5 KB | +| `ingredient` | ~100 | 300 bytes | ~30 KB | +| `product_ingredient` | ~400 | 40 bytes | ~16 KB | +| `allergen` | 14 | 200 bytes | ~3 KB | +| `ingredient_allergen` | ~200 | 20 bytes | ~4 KB | +| `role` | ~5 | 200 bytes | ~1 KB | +| `user` | ~20 | 500 bytes | ~10 KB | +| `role_visible_source` | ~7 | 15 bytes | < 1 KB | +| `permission` | 23 | 250 bytes | ~6 KB | +| `role_permission` | ~80 | 15 bytes | ~2 KB | +| `customer_order` | ~30k | 300 bytes | ~9 MB | +| `order_item` | ~150k | 250 bytes | ~37 MB | +| `order_item_selection` | ~300k | 150 bytes | ~45 MB | +| `order_item_modifier` | ~150k | 80 bytes | ~12 MB | +| `stock_movement` | ~500k | 180 bytes | ~90 MB | + +**Estimated total**: ~190 MB data + ~60-80 MB for indexes = ~250-270 MB over 6 months. +Manageable on the MariaDB container (`wakdo_db_data` named volume in `docker-compose.yml`). + +`stock_movement` is the highest-volume table (~5-15 rows per order across all ingredients). +The `(ingredient_id, created_at)` index is the primary query path for per-ingredient +history; it will carry meaningful write amplification at scale. + +--- + +## 10. Decisions deferred to DDL and P2 + +1. **MariaDB generated column** for `service_day`: a `VIRTUAL GENERATED` column is technically + possible in MariaDB 5.7+ syntax. If stats queries prove burdensome without a materialised + column, a `STORED GENERATED` column could be added as a migration. For this model, the + applicative CASE expression is retained (simpler, avoids generated-column edge cases). +2. **Partitioning**: `stock_movement` could be partitioned by month if volume exceeds + estimates. Not in scope for the initial DDL. +3. **Triggers**: stock decrement on `paid` transition and re-credit on `cancelled` (from `paid`) + could be implemented as MariaDB triggers or as application-layer logic. To be decided at P2. +4. **Collation**: `utf8mb4_unicode_ci` retained (Unicode-compliant, case-insensitive). + If strict French alphabetical sort is needed, `utf8mb4_fr_0900_ai_ci` is available in + MySQL 8 but not MariaDB; `unicode_ci` is the portable choice. +5. **Migration tooling**: Phinx, Doctrine Migrations, or a plain PHP script. Decision at P2. +6. **`order_item_id` constraint for selections**: the business rule that + `order_item_selection.order_item_id` must reference a line with `item_type='menu'` + is enforced at application layer. A MariaDB trigger could reinforce this at DB level if + needed. + +--- + +## 11. Next steps (DDL + Seed) + +1. **DDL** (`db/migrations/0001_init_schema.sql`): transcribe this MLD into executable + `CREATE TABLE` statements, in dependency order: + - `category` -> `product`, `ingredient`, `allergen`, `role` + - `menu` (depends on `category`, `product`) + - `menu_slot` (depends on `menu`), `menu_slot_option` (depends on `menu_slot`, `product`) + - `product_ingredient` (depends on `product`, `ingredient`) + - `ingredient_allergen` (depends on `ingredient`, `allergen`) + - `user` (depends on `role`), `role_visible_source` (depends on `role`) + - `permission`, `role_permission` (depends on `role`, `permission`) + - `customer_order` + - `order_item` (depends on `customer_order`, `product`, `menu`) + - `order_item_selection` (depends on `order_item`, `menu_slot`, `product`) + - `order_item_modifier` (depends on `order_item`, `ingredient`) + - `stock_movement` (depends on `ingredient`, `customer_order`, `user`) + +2. **Seed** (`db/seeds/0001_demo_data.sql`): + - 9 categories + 53 products + 13 menus from JSON sources (`docs/merise/_sources/`) + - 13 menus with slots and slot options + - 14 allergens (INCO EU 1169/2011) + - Sample ingredient catalogue with recipes + - 5 roles with `role_permission` matrix and `role_visible_source` data + - 1 admin user + - Sample orders for demo + +3. **Fallback JSON export** (`scripts/export-fallback.{sh|php}`): extract seed data to + `src/public/borne/data/*.json` for isolated kiosk mode (Bloc 1 without DB). + +4. **DDL validation tests**: confirm CHECK constraints trigger as expected; confirm + ON DELETE CASCADE / RESTRICT / SET NULL behaviours match specification.