From 6ceebf7fb17b02bca71bf61efc3aed7c38b1fa46 Mon Sep 17 00:00:00 2001 From: Imugiii Date: Thu, 4 Jun 2026 15:17:33 +0000 Subject: [PATCH] docs(merise): rewrite data dictionary to prod-like v0.2 (19 entities, English) Drop commande_event and menu_produit. Add ingredient configurator (ingredient, product_ingredient, allergen, ingredient_allergen), numeric stock (stock_movement), customizable menus (menu_slot, menu_slot_option, order_item_selection, order_item_modifier), RBAC role attributes (default_route, order_source) and role_visible_source. VAT carried by product (vat_rate), 4-state order machine, English snake_case naming. Decisions D1-D8 + stock. --- docs/merise/dictionary.md | 1051 +++++++++++++++++++++++-------------- 1 file changed, 664 insertions(+), 387 deletions(-) diff --git a/docs/merise/dictionary.md b/docs/merise/dictionary.md index 326876e..0dcbf80 100644 --- a/docs/merise/dictionary.md +++ b/docs/merise/dictionary.md @@ -1,508 +1,785 @@ -# Dictionnaire de donnees - Wakdo +# Data Dictionary — Wakdo -**Phase Merise** : P1 - Conception, etape 1 (data dictionary first, mantra #33) -**Statut** : v0.1 (squelette MCD a venir, mantra "Incremental Design") -**Date** : 2026-04-30 -**Branche** : `feat/p1-stubs-and-dictionary` +**Merise phase** : P1 - Conception, step 1 (data dictionary first, mantra #33) +**Version** : v0.2 — prod-like, 19 entities +**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 -Ce dictionnaire liste **toutes les entites de donnees** identifiees pour Wakdo, avec -leurs attributs, types, contraintes et sources. Il sert de base au MCD (entites + relations), -puis au MLD (passage relationnel), puis au DDL (SQL CREATE TABLE). +This dictionary lists **all data entities** identified for Wakdo, with their attributes, +types, constraints, and sources. It serves as the basis for the MCD (entities + relations), +then the MLD (relational mapping), then the DDL (SQL CREATE TABLE). -**Methodologie** : derivation bottom-up depuis les sources disponibles : -- **Source ecole** : `docs/merise/_sources/categories.json` + `produits.json` (66 produits, 9 categories) -- **Brief metier** : `docs/PROJECT_CONTEXT.md` (composition de menu, parcours commande, RBAC, - modes de consommation) -- **Maquette** : `docs/design/maquette-borne.pdf` (UX kiosk, ecrans visibles) +**Methodology**: bottom-up derivation from available sources: +- **School source**: `docs/merise/_sources/categories.json` + `produits.json` + (66 products, 9 categories) +- **Business brief**: `docs/PROJECT_CONTEXT.md` (menu composition, order flow, RBAC, + service modes) +- **Mockup**: `docs/design/maquette-borne.pdf` (kiosk UX, visible screens) -Tout ecart entre la source ecole et le modele final est documente dans la section "Notes -de modelisation" en bas de ce document. +All deviations between school source and final model are documented in the +"Modeling notes" section at the bottom of this document. + +For the entity-relationship diagram and cardinality justifications, see [`mcd.md`](mcd.md). +This dictionary does not duplicate that view to avoid diverging sources of truth. --- -## 2. Conventions generales +## 2. General conventions ### Naming -- **Tables** : `snake_case` au singulier (ex : `categorie`, `produit`, `menu_produit`). - Le singulier reflete la perspective "1 ligne = 1 instance de l'entite" (convention courante - dans les ecoles francaises de gestion). Le code applicatif (PHP, JS) utilisera ces noms - tels quels. -- **Colonnes** : `snake_case`. Suffixes typiques : `_id` (FK), `_at` (timestamp), `_cents` - (montant monetaire en centimes), `_path` (chemin de fichier), `_taux` (pourcentage ou - fraction). -- **Cles primaires** : colonne `id` (INT UNSIGNED AUTO_INCREMENT). Pas de cle composite en - PK, sauf sur les tables de jointure pure. -- **Cles etrangeres** : `_id` (ex : `categorie_id` dans `produit`). +- **Tables**: `snake_case`, singular (e.g., `category`, `product`, `customer_order`). + Singular reflects the perspective "1 row = 1 instance of the entity" (standard relational + convention). Application code (PHP, JS) uses these names as-is via ORM mapping. +- **Columns**: `snake_case`. Typical suffixes: `_id` (FK), `_at` (timestamp), + `_cents` (monetary amount in integer cents), `_path` (file path), `_rate` (rate or + fraction stored as per-mille integer). +- **Primary keys**: column `id` (INT UNSIGNED AUTO_INCREMENT). No composite PK except + on pure join tables. +- **Foreign keys**: `_id` (e.g., `category_id` in `product`). +- **ENUM values**: English, snake_case (e.g., `pending_payment`, `dine_in`, `kiosk`). +- **Code-facing strings** (ENUM, permission codes, role codes): English only, consistent + across DB, PHP, and JSON API. -### Types par defaut +### Default types -| Categorie | Type MariaDB | Justification | +| Category | MariaDB type | Justification | |---|---|---| -| Identifiants | `INT UNSIGNED AUTO_INCREMENT` | 4 milliards d'ids = largement suffisant pour ce projet | -| Libelles courts | `VARCHAR(120)` | Couvre la plupart des noms produits (ex : `"Signature Beef BBQ Burger (2 viandes)"` = 41 chars) | -| Descriptions | `TEXT` | Longueur variable, pas de limite stricte | -| Montants monetaires | `INT UNSIGNED` (centimes) | Evite les bugs d'arrondi des FLOAT (cf. note 1 en bas) | -| Booleens | `TINYINT(1)` | Convention MariaDB pour `BOOLEAN` (alias) | -| Timestamps | `DATETIME` | Lisible humainement, gere les timezones via app | -| Enumerations | `ENUM('a','b','c')` | Contrainte SGBD, lisible (cf. note 2) | -| Chemins de fichiers | `VARCHAR(255)` | Limite POSIX courante pour un chemin simple | +| Identifiers | `INT UNSIGNED AUTO_INCREMENT` | 4 billion ids — sufficient for this project | +| Short labels | `VARCHAR(120)` | Covers most product names (max observed: 41 chars in school source) | +| Descriptions | `TEXT` | Variable length, no strict limit | +| Monetary amounts | `INT UNSIGNED` (cents) | Avoids FLOAT rounding bugs (see note 1) | +| Booleans | `TINYINT(1)` | MariaDB convention for `BOOLEAN` (alias) | +| Timestamps | `DATETIME` | Human-readable, timezone handled at app layer | +| Enumerations | `ENUM('a','b','c')` | DBMS-level constraint, readable (see note 2) | +| File paths | `VARCHAR(255)` | Standard POSIX path length limit | -### Charset et collation +### Charset and collation -- **Charset** : `utf8mb4` (RFC 3629 - UTF-8 reel sur 4 octets, supporte les emoji et caracteres - asiatiques). MariaDB gere `utf8mb4` en natif. -- **Collation** : `utf8mb4_unicode_ci` (insensible a la casse, comparaison conforme Unicode). +- **Charset**: `utf8mb4` (RFC 3629 — real 4-byte UTF-8, supports emoji and Asian characters). + MariaDB handles `utf8mb4` natively. +- **Collation**: `utf8mb4_unicode_ci` (case-insensitive, Unicode-compliant comparison). -### Champs d'audit (presents sur toutes les tables metier sauf jointures pures) +### Audit fields (present on all business tables except pure join tables) -| Colonne | Type | Defaut | Role | +| Column | Type | Default | Role | |---|---|---|---| -| `created_at` | `DATETIME` | `CURRENT_TIMESTAMP` | Date de creation, non modifiee par la suite (ecriture unique a l'insertion) | -| `updated_at` | `DATETIME` | `CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` | Date de derniere modification, mise a jour automatique | +| `created_at` | `DATETIME` | `CURRENT_TIMESTAMP` | Creation timestamp, written once at insert | +| `updated_at` | `DATETIME` | `CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` | Last modification timestamp, auto-updated | ### Soft delete -Pas de soft delete generalise pour MVP. Les entites qui peuvent etre desactivees temporairement -ont une colonne `est_actif` ou `est_disponible` (boolean). La suppression dure (`DELETE`) -reste possible mais reservee a des operations admin avec sauvegarde prealable. +No generalized soft delete. Entities that can be temporarily deactivated carry an +`is_active` or `is_available` boolean column. Hard `DELETE` remains possible but is +reserved for admin operations with prior backup. --- -## 3. Entites +## 3. Entities -### 3.1 `categorie` +### 3.1 `category` -Regroupement metier des produits et menus pour l'affichage sur la borne. +Business grouping of products and menus for display on the kiosk. -| Attribut | Type | NULL | Defaut | Contrainte | Source ecole | Notes | +| Attribute | Type | NULL | Default | Constraint | School source | Notes | |---|---|---|---|---|---|---| -| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-9) | identique source | -| `libelle` | VARCHAR(60) | NO | - | UNIQUE | `title` | renomme depuis `title` (semantique francaise) | -| `slug` | VARCHAR(60) | NO | - | UNIQUE | derive de `title` (kebab-case lowercase) | utile pour URL `/api/categories/burgers` | -| `image_path` | VARCHAR(255) | YES | NULL | - | `image` | normalisation post-import (kebab-case lowercase) | -| `ordre` | SMALLINT UNSIGNED | NO | 0 | - | (enrichi) | ordre d'affichage sur la borne, ajustable depuis admin | -| `est_actif` | TINYINT(1) | NO | 1 | - | (enrichi) | permet de desactiver une categorie sans la supprimer | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | - | - | audit | -| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | - | - | audit | +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-9) | same as source | +| `name` | VARCHAR(60) | NO | — | UNIQUE | `title` | renamed from `title` | +| `slug` | VARCHAR(60) | NO | — | UNIQUE | derived from `title` (kebab-case lowercase) | used for URL `/api/categories/burgers` | +| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | relative path, see note 8 | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (added) | display order on kiosk, adjustable from admin | +| `is_active` | TINYINT(1) | NO | 1 | — | (added) | deactivate without deleting | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | — | audit | +| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | — | audit | -**Exemples** : `menus`, `boissons`, `burgers`, `frites`, `encas`, `wraps`, `salades`, -`desserts`, `sauces`. Volume : 9 lignes a l'init (seed depuis `categories.json`). +**Examples**: `menus`, `drinks`, `burgers`, `fries`, `snacks`, `wraps`, `salads`, +`desserts`, `sauces`. Volume: 9 rows at init (seed from `categories.json`). --- -### 3.2 `produit` +### 3.2 `product` -Article unitaire vendable a la carte ou comme composant d'un menu. +A single sellable item, available a la carte or as a component in a menu slot. -| Attribut | Type | NULL | Defaut | Contrainte | Source ecole | Notes | +| Attribute | Type | NULL | Default | Constraint | School source | Notes | |---|---|---|---|---|---|---| -| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (14-66 selon categorie) | identique source | -| `categorie_id` | INT UNSIGNED | NO | - | FK -> `categorie(id)`, ON DELETE RESTRICT | (enrichi : derive de la cle d'objet du JSON) | source absente, deduit de la position dans `produits.json` | -| `libelle` | VARCHAR(120) | NO | - | INDEX | `nom` | renomme depuis `nom` (coherence francaise) | -| `description` | TEXT | YES | NULL | - | (enrichi) | absente de la source ecole, alimente plus tard via admin | -| `prix_ttc_cents` | INT UNSIGNED | NO | - | CHECK > 0 | `prix` (FLOAT) | conversion FLOAT -> INT centimes au seed (cf. note 1) | -| `image_path` | VARCHAR(255) | YES | NULL | - | `image` | normalisation post-import | -| `est_disponible` | TINYINT(1) | NO | 1 | - | (enrichi) | rupture manuelle depuis admin (= booleen, pas de gestion stock numerique en MVP) | -| `ordre` | SMALLINT UNSIGNED | NO | 0 | - | (enrichi) | ordre dans la categorie | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | - | - | audit | -| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | - | - | audit | +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` | same as source | +| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | (derived from JSON object key) | | +| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | renamed from `nom` | +| `description` | TEXT | YES | NULL | — | (added) | populated later via admin | +| `price_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` (FLOAT) | FLOAT -> INT cents conversion at seed (see note 1) | +| `vat_rate` | SMALLINT UNSIGNED | NO | 100 | CHECK IN (55, 100) | (added) | VAT rate in per-mille: 100 = 10%, 55 = 5.5%. Default 10%. See note 9 | +| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | relative path, see note 8 | +| `is_available` | TINYINT(1) | NO | 1 | — | (added) | manual availability toggle from admin | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (added) | display order within category | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | — | audit | +| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | — | audit | -**Volume** : 53 lignes a l'init (66 lignes dans `produits.json` moins les 13 menus qui vont dans `menu`). Cf. note 3 pour la separation produit/menu. +**Volume**: ~53 rows at init (66 rows in `produits.json` minus 13 menus moved to `menu`). --- ### 3.3 `menu` -Combo prix fixe = burger + accompagnement + boisson + sauce (composition modelisee dans -`menu_produit`). +Fixed-price combo built around a specific burger, with customer-selectable slots +(drink, side, sauce). Two price tiers: Normal and Maxi. -| Attribut | Type | NULL | Defaut | Contrainte | Source ecole | Notes | +| Attribute | Type | NULL | Default | Constraint | School source | Notes | |---|---|---|---|---|---|---| -| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-13 dans categorie `menus`) | | -| `categorie_id` | INT UNSIGNED | NO | - | FK -> `categorie(id)`, ON DELETE RESTRICT | implicite (categorie `menus`) | | -| `libelle` | VARCHAR(120) | NO | - | INDEX | `nom` | ex : "Menu Le 280", "Menu Big Mac" | -| `description` | TEXT | YES | NULL | - | (enrichi) | | -| `prix_ttc_cents` | INT UNSIGNED | NO | - | CHECK > 0 | `prix` | | -| `image_path` | VARCHAR(255) | YES | NULL | - | `image` | reutilise typiquement l'image du burger dominant | -| `est_disponible` | TINYINT(1) | NO | 1 | - | (enrichi) | | -| `ordre` | SMALLINT UNSIGNED | NO | 0 | - | (enrichi) | | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | - | - | audit | -| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | - | - | audit | +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-13 in `menus` category) | | +| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | implicit (category `menus`) | | +| `burger_product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | (added) | the fixed burger that anchors this menu; drives ingredient customization | +| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | e.g., "Menu Le 280" | +| `description` | TEXT | YES | NULL | — | (added) | | +| `price_normal_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` | Normal format price. Replaces single `prix_ttc_cents`. | +| `price_maxi_cents` | INT UNSIGNED | NO | — | CHECK > 0 | (added) | Maxi format price (~+150 cents vs normal; see note 7) | +| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | typically reuses the burger image | +| `is_available` | TINYINT(1) | NO | 1 | — | (added) | | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (added) | | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | — | audit | +| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | — | audit | -**Volume** : 13 lignes a l'init. +**Volume**: 13 rows at init. Replaces the old fixed-composition `menu_produit` model. --- -### 3.4 `menu_produit` (jointure) +### 3.4 `menu_slot` -Composition d'un menu : pour chaque menu, la liste des produits avec leur role. +A selectable slot within a menu (e.g., "drink slot", "side slot", "sauce slot"). +Each slot constrains which products the customer can choose from, expressed via +the join table `menu_slot_option`. -| Attribut | Type | NULL | Defaut | Contrainte | Notes | -|---|---|---|---|---|---| -| `menu_id` | INT UNSIGNED | NO | - | FK -> `menu(id)`, ON DELETE CASCADE | | -| `produit_id` | INT UNSIGNED | NO | - | FK -> `produit(id)`, ON DELETE RESTRICT | RESTRICT pour eviter qu'un produit retire ne casse silencieusement les menus existants | -| `role` | ENUM('burger','accompagnement','boisson','sauce','dessert') | NO | - | - | role metier du produit dans le menu | -| `position` | SMALLINT UNSIGNED | NO | 0 | - | ordre d'affichage dans le menu (ex : burger en 1, frites en 2, etc.) | - -**Cle primaire** : composite `(menu_id, produit_id)`. - -**Volume estime** : 13 menus x 3-4 produits chacun = 40-50 lignes a l'init. - -**Decision YAGNI** : pas de colonne `quantite` (cf. discussion Session 5). Si un menu duo -arrivait, il serait modelise comme un nouveau menu distinct, ou la colonne serait ajoutee -via `ALTER TABLE` avec backfill. - ---- - -### 3.5 `commande` - -Transaction client : 1 commande = 1 panier valide a un instant donne. - -| Attribut | Type | NULL | Defaut | Contrainte | Notes | +| Attribute | Type | NULL | Default | Constraint | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `numero` | VARCHAR(20) | NO | - | UNIQUE | format humain ex : `K-2026-04-30-001`, genere a la creation | -| `source` | ENUM('kiosk','comptoir','drive') | NO | - | INDEX | canal de saisie de la commande (cf. note 8) | -| `mode_consommation` | ENUM('sur_place','a_emporter','drive') | NO | - | - | mode de consommation fiscal et operationnel (impacte la TVA, cf. note 9) | -| `statut` | ENUM('pending_payment','paid','preparing','ready','delivered','cancelled') | NO | 'pending_payment' | INDEX | machine a etats (cf. MCT) | -| `total_ht_cents` | INT UNSIGNED | NO | - | CHECK >= 0 | snapshot calcule a la validation | -| `total_tva_cents` | INT UNSIGNED | NO | - | CHECK >= 0 | snapshot | -| `total_ttc_cents` | INT UNSIGNED | NO | - | CHECK > 0 | snapshot, doit valoir total_ht_cents + total_tva_cents (verification au MLT) | -| `tva_taux_pourmille` | SMALLINT UNSIGNED | NO | - | - | TVA en pour mille (ex : 100 pour 10%, 55 pour 5,5%). Stocke en INT pour eviter les arrondis FLOAT | -| `paye_a` | DATETIME | YES | NULL | - | timestamp du passage en `paid` (NULL avant) | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | utilise pour les agregations stats live | -| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | - | audit | +| `menu_id` | INT UNSIGNED | NO | — | FK -> `menu(id)`, ON DELETE CASCADE | a slot belongs to exactly one menu | +| `name` | VARCHAR(80) | NO | — | — | e.g., "Drink", "Side", "Sauce" | +| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | — | — | semantic role of this slot | +| `is_required` | TINYINT(1) | NO | 1 | — | whether the customer must fill this slot | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | order of display in the menu builder | -**Volume estime** : ~100-300 commandes/jour en pic, sur 6 mois de demo = ~10k lignes max. - -**TVA en restauration France** (cf. service-public.fr article F31407, 2024) : -- 10% sur la consommation immediate (sur place ou plats chauds a emporter) -- 5,5% sur les produits a emporter destines a la consommation differee - -Le taux est snapshote au moment de la commande pour preserver l'integrite historique -si la legislation evolue. +**No audit fields**: a slot is part of menu definition; created and updated with the menu. +**Composite index**: `(menu_id, display_order)`. --- -### 3.6 `ligne_commande` +### 3.5 `menu_slot_option` -Detail d'une commande : produits unitaires OU menus, avec snapshot prix et libelle au moment -de la transaction. +Eligible products for a given menu slot. Pure join table. -| Attribut | Type | NULL | Defaut | Contrainte | Notes | +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `menu_slot_id` | INT UNSIGNED | NO | — | FK -> `menu_slot(id)`, ON DELETE CASCADE | | +| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | RESTRICT: removing a product must not silently break menus | + +**Primary key**: composite `(menu_slot_id, product_id)`. + +**Volume**: ~3-5 options per slot, ~3 slots per menu, 13 menus = ~120-200 rows at init. + +--- + +### 3.6 `ingredient` + +Elementary ingredient used in product composition. Carries stock data. + +| Attribute | Type | NULL | Default | Constraint | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `commande_id` | INT UNSIGNED | NO | - | FK -> `commande(id)`, ON DELETE CASCADE | si la commande disparait, ses lignes aussi | -| `type_item` | ENUM('produit','menu') | NO | - | - | discriminateur | -| `produit_id` | INT UNSIGNED | YES | NULL | FK -> `produit(id)`, ON DELETE RESTRICT | non-null SI type_item = 'produit' | -| `menu_id` | INT UNSIGNED | YES | NULL | FK -> `menu(id)`, ON DELETE RESTRICT | non-null SI type_item = 'menu' | -| `libelle_snapshot` | VARCHAR(120) | NO | - | - | copie du libelle au moment de la commande (preserve si on renomme) | -| `prix_unitaire_ttc_cents_snapshot` | INT UNSIGNED | NO | - | CHECK > 0 | copie du prix au moment de la commande | -| `quantite` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | si le client commande 3 cocas, 1 ligne avec `quantite=3` | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | - | - | +| `name` | VARCHAR(120) | NO | — | UNIQUE | e.g., "Sesame Bun", "Cheddar Slice", "Ketchup Portion" | +| `unit` | VARCHAR(40) | NO | — | — | packaging unit label: piece / portion / sachet 1kg / pot / bottle (free-form label, not an ENUM — units vary per ingredient) | +| `stock_quantity` | INT | NO | 0 | CHECK >= 0 | current stock in units. Signed INT to allow negative detection (alert), but business rule enforces >= 0 | +| `pack_size` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | units per restocking pack (e.g., 100 for a bag of 100 portions) | +| `pack_label` | VARCHAR(80) | YES | NULL | — | human label of the pack (e.g., "Sac 100 portions") | +| `low_stock_threshold` | SMALLINT UNSIGNED | NO | 0 | CHECK >= 0 | alert threshold: stock_quantity <= this value triggers low-stock indicator | +| `is_active` | TINYINT(1) | NO | 1 | — | deactivate obsolete ingredients without deleting | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | +| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | -**Contrainte CHECK applicative ou triggers** : -`(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)`. Cette contrainte est verifiable cote MariaDB -via CHECK (depuis 10.2) ou cote PHP au moment de l'insertion. - -**Volume** : ~3-5 lignes par commande -> 30k-50k lignes sur 6 mois. - -**Snapshots** : `libelle_snapshot` et `prix_unitaire_ttc_cents_snapshot` permettent de retrouver -la facturation exacte d'une commande historique meme si le produit a ete renomme/repricaye depuis. -Argumentaire jury : integrite des donnees comptables. +**Stock decrement rule**: at the `paid` transition, each ingredient is decremented by +`product_ingredient.quantity_normal` or `quantity_maxi` (selected by `order_item.format`) +multiplied by `order_item.quantity`, then adjusted by `order_item_modifier` rows. See note 7. +**Restocking rule**: `stock_quantity += N * pack_size` (restocked in full packs). +**Cancellation rule**: stock is re-credited when a `paid` order is cancelled. +**Low-stock alert**: computed at display time (`stock_quantity <= low_stock_threshold`); +no additional stored column. --- -### 3.7 `commande_event` +### 3.7 `product_ingredient` -Journal d'audit append-only : 1 ligne par changement d'etat d'une commande. Pattern -event sourcing simplifie (cf. note 10). Trace **qui** a fait **quoi**, **quand**, sur quelle -commande, avec quel contexte. Aucun update / delete autorise (immuable). +Default composition of a product (burger, wrap, etc.) in terms of ingredients. +Carries customization metadata for the ingredient configurator. -| Attribut | Type | NULL | Defaut | Contrainte | Notes | +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE CASCADE | | +| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | RESTRICT: cannot remove an ingredient still referenced in a product recipe | +| `quantity_normal` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | units consumed in Normal format (e.g., 2 for double cheese) | +| `quantity_maxi` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | units consumed in Maxi format. Equals `quantity_normal` for format-invariant ingredients (burger, sauce); higher for side and drink ingredients (Maxi enlarges side + drink only). See note 7. | +| `is_removable` | TINYINT(1) | NO | 1 | — | customer can remove this ingredient at no cost | +| `is_addable` | TINYINT(1) | NO | 0 | — | customer can add an extra unit of this ingredient | +| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | surcharge in cents when `is_addable=1` and customer adds it (0 = free extra) | + +**Primary key**: composite `(product_id, ingredient_id)`. + +**Volume**: ~5-10 ingredients per product, ~53 products = ~300-500 rows at seed. + +--- + +### 3.8 `allergen` + +Catalogue of the 14 regulated allergens (INCO Regulation (EU) 1169/2011). + +| Attribute | Type | NULL | Default | Constraint | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `commande_id` | INT UNSIGNED | NO | - | FK -> `commande(id)`, ON DELETE CASCADE | si la commande disparait, son journal aussi | -| `event_type` | ENUM('CREATED','PAID','PREPARING_STARTED','READY','DELIVERED','CANCELLED') | NO | - | INDEX | type d'evenement, aligne sur la machine a etats | -| `from_statut` | ENUM('pending_payment','paid','preparing','ready','delivered','cancelled') | YES | NULL | - | statut avant transition (NULL pour CREATED) | -| `to_statut` | ENUM('pending_payment','paid','preparing','ready','delivered','cancelled') | NO | - | - | statut apres transition | -| `user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | NULL si auto-validation kiosk ou system event ; sinon = equipier qui a declenche | -| `payload` | JSON | YES | NULL | - | contexte additionnel : raison annulation, methode paiement, montant rembourse, etc. | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | timestamp immuable de l'evenement | +| `code` | VARCHAR(30) | NO | — | UNIQUE | machine-readable code, e.g., `gluten`, `milk`, `nuts` | +| `name` | VARCHAR(80) | NO | — | — | display name, e.g., "Gluten", "Lait", "Fruits a coque" | +| `description` | TEXT | YES | NULL | — | optional guidance for staff | -**Cle primaire** : `id`. - -**Index supplementaires** : -- `(commande_id, created_at)` pour requete "historique d'une commande" -- `(user_id, created_at)` pour requete "actions d'un equipier sur une periode" - -**Volume** : ~5-8 events par commande (1 CREATED + 1 PAID + 1 PREPARING + 1 READY + 1 DELIVERED, plus eventuels CANCELLED). Sur 6 mois, ~50k-80k lignes. - -**ON DELETE SET NULL sur `user_id`** : si un user est supprime (rare, cf. soft delete), les events restent (audit preserve) mais l'attribution est perdue. Le brief peut imposer `ON DELETE RESTRICT` si l'integrite de l'audit est critique. +**Volume**: 14 rows at seed (fixed by EU regulation 1169/2011, list confirmed at seed time). +Allergens for a product are **computed** by joining `product_ingredient` -> +`ingredient_allergen` -> `allergen`; no manual re-entry per product. --- -### 3.8 `user` +### 3.9 `ingredient_allergen` -Utilisateur du back-office (admin, manager, equipier) - **pas** les clients de la borne, qui -ne sont pas authentifies. +Maps which allergens each ingredient contains. Pure join table. -| Attribut | Type | NULL | Defaut | Contrainte | Notes | +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE CASCADE | | +| `allergen_id` | INT UNSIGNED | NO | — | FK -> `allergen(id)`, ON DELETE RESTRICT | | + +**Primary key**: composite `(ingredient_id, allergen_id)`. + +--- + +### 3.10 `customer_order` + +Customer transaction: 1 order = 1 validated cart at a point in time. +(Table name rationale: see modeling note 3.) + +| Attribute | Type | NULL | Default | Constraint | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `email` | VARCHAR(254) | NO | - | UNIQUE | longueur max RFC 5321 | -| `password_hash` | VARCHAR(255) | NO | - | - | hash argon2id (cf. `PASSWORD_ALGO` dans `.env`), longueur 96 chars typique mais marge 255 | -| `nom` | VARCHAR(60) | NO | - | - | | -| `prenom` | VARCHAR(60) | NO | - | - | | -| `role_id` | INT UNSIGNED | NO | - | FK -> `role(id)`, ON DELETE RESTRICT | un user ne peut pas exister sans role | -| `est_actif` | TINYINT(1) | NO | 1 | - | desactivation sans suppression | -| `last_login_at` | DATETIME | YES | NULL | - | utile pour audit et detection comptes dormants | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | - | - | -| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | - | - | +| `order_number` | VARCHAR(20) | NO | — | UNIQUE | human-readable format: `K`/`C`/`D`-YYYY-MM-DD-NNN. Prefix by channel: K=kiosk, C=counter, D=drive. See note 4. | +| `source` | ENUM('kiosk','counter','drive') | NO | — | INDEX | input channel (who entered the order). Values in English, see note 5. | +| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | — | — | consumption mode, retained for stats/KPI only. No fiscal role (see note 9). `drive` source implies `drive` service_mode (cross-constraint enforced at app layer). | +| `status` | ENUM('pending_payment','paid','delivered','cancelled') | NO | 'pending_payment' | INDEX | 4-state machine: `pending_payment -> paid -> delivered` (+ `cancelled`). See note 6. | +| `total_ht_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | ex-VAT total, snapshot at order validation | +| `total_vat_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | VAT amount, snapshot | +| `total_ttc_cents` | INT UNSIGNED | NO | — | CHECK > 0 | incl.-VAT total; must equal total_ht_cents + total_vat_cents (verified at MLT layer) | +| `paid_at` | DATETIME | YES | NULL | — | timestamp of transition to `paid` (NULL before payment) | +| `delivered_at` | DATETIME | YES | NULL | — | timestamp of transition to `delivered` (NULL before delivery) | +| `cancelled_at` | DATETIME | YES | NULL | — | timestamp of cancellation (NULL if not cancelled) | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | used for live stats aggregations; also serves as `service_day` base | +| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | -**Volume** : 5-20 lignes (equipe restaurant + 1-2 admins). +**Dropped from v0.1**: `tva_taux_pourmille` (moved to line level — `order_item.vat_rate_snapshot`), +`paye_a` (renamed `paid_at`). Machine states `preparing` and `ready` dropped (see note 6). -**Reference RFC 5321 sur la longueur email** : la limite locale-part = 64, domaine = 255, -total = 254 (incluant le `@`). VARCHAR(254) est la valeur conforme spec. +**`service_day` computation** (KPI grouping): +``` +CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END +``` +Computed at query time, not stored as a column (the generated-column formula with `INTERVAL 4 HOUR +30 MINUTE` in v0.1 MLD was incorrect and is dropped). Cutoff: 10:00. + +**Volume**: ~100-300 orders/day at peak, ~10k rows over a 6-month demo. --- -### 3.9 `role` +### 3.11 `order_item` -Roles utilisables dans le back-office (RBAC). Creables / modifiables / desactivables depuis -l'UI admin (les permissions sont statiques, declarees en migration). +Line of an order: a single product or a menu, with price, label, and VAT rate +snapshotted at transaction time. -| Attribut | Type | NULL | Defaut | Contrainte | Notes | +| Attribute | Type | NULL | Default | Constraint | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `code` | VARCHAR(40) | NO | - | UNIQUE | identifiant code (ex : `admin`, `manager`, `equipier`) | -| `libelle` | VARCHAR(80) | NO | - | - | nom affichable (ex : `Administrateur`) | -| `description` | TEXT | YES | NULL | - | | -| `est_actif` | TINYINT(1) | NO | 1 | - | desactivation sans suppression (preserve l'historique des users qui avaient ce role) | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | - | - | -| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | - | audit | +| `order_id` | INT UNSIGNED | NO | — | FK -> `customer_order(id)`, ON DELETE CASCADE | | +| `item_type` | ENUM('product','menu') | NO | — | — | discriminator | +| `product_id` | INT UNSIGNED | YES | NULL | FK -> `product(id)`, ON DELETE RESTRICT | non-null if `item_type = 'product'` | +| `menu_id` | INT UNSIGNED | YES | NULL | FK -> `menu(id)`, ON DELETE RESTRICT | non-null if `item_type = 'menu'` | +| `format` | ENUM('normal','maxi') | NO | 'normal' | — | applies to menu items (Normal / Maxi). For standalone products, value is `normal` (no individual upsizing in this model). See note 7. | +| `label_snapshot` | VARCHAR(120) | NO | — | — | label at time of order (preserved if product is renamed) | +| `unit_price_cents_snapshot` | INT UNSIGNED | NO | — | CHECK > 0 | unit price incl. VAT at time of order | +| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | — | CHECK IN (55, 100) | VAT rate in per-mille at time of order (snapshotted from `product.vat_rate`) | +| `quantity` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | quantity ordered (e.g., 3 Cocas = 1 line with quantity=3) | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | -**Volume** : 3-5 lignes (admin, manager, equipier-comptoir, equipier-drive). Extensible -via UI admin sans deploiement. +**CHECK constraint** (applicative or MariaDB CHECK >= 10.2): +`(item_type='product' AND product_id IS NOT NULL AND menu_id IS NULL) +OR (item_type='menu' AND menu_id IS NOT NULL AND product_id IS NULL)` + +**Volume**: ~3-5 lines per order -> 30k-50k rows over 6 months. --- -### 3.10 `permission` +### 3.12 `order_item_selection` -Permissions granulaires assignables aux roles (ex : `produit.create`, `commande.read`). +The actual choices made by the customer for each slot of a menu line. +1 row = 1 slot filled for 1 order_item of type `menu`. -| Attribut | Type | NULL | Defaut | Contrainte | Notes | +| Attribute | Type | NULL | Default | Constraint | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `code` | VARCHAR(60) | NO | - | UNIQUE | format `.` (ex : `produit.update`) | -| `libelle` | VARCHAR(120) | NO | - | - | nom affichable | -| `description` | TEXT | YES | NULL | - | | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | - | - | +| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | must reference an order_item with item_type='menu' | +| `menu_slot_id` | INT UNSIGNED | NO | — | FK -> `menu_slot(id)`, ON DELETE RESTRICT | which slot was filled | +| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | product chosen by the customer for this slot | +| `label_snapshot` | VARCHAR(120) | NO | — | — | product label at time of order | -**Volume** : ~20-40 lignes selon granularite (CRUD sur produit, menu, categorie, user, role, -commande, stats). +**Volume**: ~2-3 selections per menu line. +**KPI use**: enables analysis of which drink/side combinations are most chosen. --- -### 3.11 `role_permission` (jointure) +### 3.13 `order_item_modifier` -Mapping N-N entre roles et permissions. +Ingredient-level modifications applied by the customer to a product or to the fixed +burger of a menu: removal (free) or addition (with optional surcharge). -| Attribut | Type | NULL | Defaut | Contrainte | -|---|---|---|---|---| -| `role_id` | INT UNSIGNED | NO | - | FK -> `role(id)`, ON DELETE CASCADE | -| `permission_id` | INT UNSIGNED | NO | - | FK -> `permission(id)`, ON DELETE CASCADE | +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | the order line being modified (product or menu) | +| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | the ingredient being modified | +| `action` | ENUM('remove','add') | NO | — | — | `remove` = free removal; `add` = extra unit (may have surcharge) | +| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | snapshot of `product_ingredient.extra_price_cents` at time of order (0 for removals) | -**Cle primaire** : composite `(role_id, permission_id)`. +**Modifier attachment rule** (see modeling note 10): +- For a standalone product (`item_type='product'`): the modifier targets the product + directly via `order_item_id`. +- For a menu (`item_type='menu'`): the modifier targets the menu line's fixed burger + via the same `order_item_id`. The burger is identified by `menu.burger_product_id`, + allowing the kitchen display to resolve which ingredients are modified without ambiguity. + No additional FK is needed: given `order_item_id`, the burger is + `order_item.menu_id -> menu.burger_product_id`. -**Volume** : ~50-100 lignes selon les attributions (admin couvre potentiellement toutes les -permissions, les autres roles un sous-ensemble). +**Stock impact**: each modifier affects ingredient stock at `paid` transition +(`remove` -> no decrement for that ingredient; `add` -> extra decrement). --- -## 4. Notes de modelisation +### 3.14 `user` -> Le diagramme entites-relations et les justifications de cardinalites sont documentes dans [`mcd.md`](mcd.md) (diagrammes drawio des 4 sous-domaines + recapitulatif global). Le dictionnaire ne dedouble pas cette vue pour eviter d'avoir deux sources de verite divergeantes. +Back-office user (admin, manager, kitchen staff, counter, drive). Kiosk customers +are not authenticated and have no row here. -### Note 1 - Pourquoi `INT UNSIGNED` en centimes pour les prix +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `email` | VARCHAR(254) | NO | — | UNIQUE | max length per RFC 5321 | +| `password_hash` | VARCHAR(255) | NO | — | — | argon2id hash (see `PASSWORD_ALGO` in `.env`); typical length 96 chars, margin to 255 | +| `first_name` | VARCHAR(60) | NO | — | — | | +| `last_name` | VARCHAR(60) | NO | — | — | | +| `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE RESTRICT | a user cannot exist without a role | +| `is_active` | TINYINT(1) | NO | 1 | — | deactivation without deletion | +| `last_login_at` | DATETIME | YES | NULL | — | useful for audit and dormant account detection | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | +| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | -Stocker un prix en `FLOAT` ou `DECIMAL(10,2)` est techniquement valide mais introduit deux -risques : +**Volume**: 5-20 rows (restaurant team + 1-2 admins). -1. **Arrondi FLOAT** : `0.1 + 0.2 = 0.30000000000000004` en flottants IEEE 754. Sommer 100 - lignes de commande peut produire des ecarts de centimes vs la realite metier. -2. **Conversion FLOAT -> string** : differents drivers PHP/MariaDB peuvent serialiser les - floats avec une precision variable. +RFC 5321 email length: local-part <= 64, domain <= 255, total <= 254 (including `@`). +VARCHAR(254) is the spec-compliant value. -Stocker en `INT UNSIGNED` (centimes : 880 pour 8,80 EUR) elimine ces risques. La conversion -en EUR pour l'affichage se fait cote PHP a la sortie : `number_format($cents / 100, 2)`. +--- -Reference : David Goldberg, *What Every Computer Scientist Should Know About Floating-Point -Arithmetic*, ACM Computing Surveys, 1991. (Le sujet est devenu un classique de la litterature -informatique.) +### 3.15 `role` -### Note 2 - Pourquoi `ENUM` plutot que table de reference +Back-office roles (RBAC). Creatable / modifiable / deactivatable from admin UI. +Seed provides 5 roles; custom roles (e.g., "chef-patissier") can be added without deployment. -Les ENUM (`mode_consommation`, `statut`, `role` dans `menu_produit`, `type_item`) auraient pu -etre des tables de reference (ex : `mode_consommation_referentiel`). Choix retenu : ENUM. +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `code` | VARCHAR(40) | NO | — | UNIQUE | machine code, e.g., `admin`, `manager`, `kitchen`, `counter`, `drive` | +| `label` | VARCHAR(80) | NO | — | — | display name, e.g., `Administrator`, `Kitchen Staff` | +| `description` | TEXT | YES | NULL | — | | +| `default_route` | VARCHAR(120) | YES | NULL | — | landing screen for this role (e.g., `/admin/orders`, `/kitchen/display`). Makes routing dynamic — no hardcoded role names in front-end routing. | +| `order_source` | ENUM('kiosk','counter','drive') | YES | NULL | — | auto-tagged `source` when this role creates an order (NULL for admin/manager who can create on behalf of any channel) | +| `is_active` | TINYINT(1) | NO | 1 | — | deactivation preserves history of users who held this role | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | +| `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | -Avantages ENUM dans ce contexte : -- Valeurs stables et limitees (3-7 valeurs max), peu probables d'evoluer -- Contrainte SGBD au lieu de FK runtime, requetes plus simples -- Lisibilite directe en SQL : `WHERE mode_consommation = 'sur_place'` - -Cout d'un changement futur : un `ALTER TABLE ... MODIFY COLUMN ... ENUM(...)` pour ajouter une -valeur. Acceptable car les changements sont attendus rarement. - -Si plus tard ces ENUMs prennent des libelles ou descriptions multilingues, on les passera en -tables. Pas pour MVP. - -### Note 3 - Pourquoi `produit` ET `menu` separes (pas une table unique avec STI) - -Option consideree : Single Table Inheritance avec une colonne `type ENUM('produit','menu')` -sur une seule table. Cout : NULLs fantomes sur les colonnes specifiques (un produit n'a pas -de composition). - -Option retenue : 2 tables separees (`produit`, `menu`). Avantages : -- Semantique claire (un menu n'est pas un "produit avec composition", c'est une autre nature) -- Contraintes specifiques possibles (ex : un menu doit avoir au moins 1 entree dans - `menu_produit`, contrainte applicative) -- Pas de NULL sur les colonnes specifiques - -Cout : la table `ligne_commande` doit gerer 2 FKs (produit_id OU menu_id) avec une regle -d'exclusivite. Acceptable et courant en e-commerce. - -### Note 4 - Pas de gestion stock numerique - -Choix MVP : un boolean `est_disponible` suffit. La rupture est geree manuellement par -l'equipier-comptoir depuis le back-office. Si une feature `quantite_stock` est ajoutee -plus tard, ce sera une nouvelle colonne avec sa propre logique de decrement/realimentation. - -### Note 5 - Audit fields uniformes - -Les tables metier portent `created_at` et `updated_at`. Cette uniformite permet : -- Diagnostic ("quand cette donnee a-t-elle ete modifiee ?") -- Tri par recence dans le back-office sans table dediee -- Synchronisation eventuelle avec un cache - -Les tables de jointure pure (`menu_produit`, `role_permission`) n'ont pas de `updated_at` : -les jointures sont supprimees+recreees au lieu d'etre modifiees. - -### Note 6 - Polymorphisme `ligne_commande` -> (`produit` ou `menu`) - -Pattern utilise : 2 colonnes nullables avec un discriminateur `type_item`. Avantages : -- FKs reelles vers les tables ciblees (integrite referentielle) -- Lisible en SQL (`JOIN produit ON l.produit_id = p.id` selon `type_item`) - -Alternative consideree : une colonne `item_id` + `item_type` sans FK reelle (Rails-style -polymorphic association). Inconvenient : pas d'integrite referentielle SGBD. - -Choix retenu : 2 colonnes + 2 FKs + contrainte CHECK. Cout : 1 colonne supplementaire -(`menu_id` souvent NULL, `produit_id` parfois NULL), gain : integrite forte. - -### Note 7 - Limites RFC pour les emails et libelles - -- `email` : VARCHAR(254) (RFC 5321) -- `libelle` produit/menu : VARCHAR(120) - couvre la quasi-totalite des libelles observes dans - la source ecole (max observe : 41 chars). Marge 3x. -- `slug` : VARCHAR(60) - coherent avec les conventions URL kebab-case courantes. - -### Note 8 - `source` vs `mode_consommation` (separation canal / fiscalite) - -Deux dimensions distinctes que la modelisation Wakdo separe explicitement : - -| | `source` | `mode_consommation` | +**Seed roles**: +| Code | `default_route` | `order_source` | |---|---|---| -| Nature | canal de saisie de la commande (input) | mode de consommation (output) | -| Valeurs | kiosk, comptoir, drive | sur_place, a_emporter, drive | -| Decision metier | qui a saisi la commande, authentification, analytics | TVA applicable, gestion capacite salle | +| `admin` | `/admin/dashboard` | NULL | +| `manager` | `/admin/stats` | NULL | +| `kitchen` | `/kitchen/display` | NULL | +| `counter` | `/counter/orders` | `counter` | +| `drive` | `/drive/orders` | `drive` | -Les deux dimensions sont independantes pour `kiosk` et `comptoir` (un client a la borne peut choisir sur_place OU a_emporter ; idem au comptoir). Le `drive` est le seul cas ou les deux dimensions sont identiques : `source=drive` implique `mode_consommation=drive`. - -Cette contrainte croisee est verifiee a l'ecriture (MLT - precondition de l'operation `creer_commande`). En SQL elle pourrait etre exprimee par un CHECK : `CHECK (source != 'drive' OR mode_consommation = 'drive')`. - -### Note 9 - TVA en restauration rapide chez Wakdo - -Wakdo est un fast-food, pas un restaurant a service a table : quel que soit le `mode_consommation`, tout est servi en emballages papier (sur plateau pour `sur_place`, en sac pour `a_emporter` et `drive`). La distinction `sur_place` vs `a_emporter` ne porte donc pas sur le service mais sur : - -- **TVA applicable** : 10% pour la consommation immediate sur place, 5,5% pour les produits a emporter destines a la consommation differee (cf. service-public.fr article F31407, 2024) -- **Occupation salle** : le client `sur_place` consomme une place assise (utile si une feature capacite est ajoutee plus tard) - -Le taux de TVA est snapshote dans `commande.tva_taux_pourmille` au moment de la transaction pour preserver l'integrite historique si la legislation evolue. - -### Note 10 - Pattern event sourcing simplifie via `commande_event` - -Plutot que d'ajouter des colonnes `saisi_par_id`, `valide_par_id`, `prepare_par_id`, `livre_par_id` sur `commande` (denormalisation lourde, 4 FKs), Wakdo retient une table d'audit dediee `commande_event` (cf. entite 3.7). - -**Principe** : `commande` porte uniquement l'**etat courant** (`statut`). Chaque transition d'etat insere une ligne dans `commande_event` (append-only, immuable). Pour reconstituer l'historique d'une commande : `SELECT * FROM commande_event WHERE commande_id = ? ORDER BY created_at`. - -**Avantages** : -- Tracabilite complete sans charger `commande` de colonnes peu remplies -- Extensible : ajouter un nouveau type d'evenement (REFUNDED, RECLAIMED, ...) = ajouter une valeur a l'ENUM `event_type`, sans migration intrusive -- Compatible avec analytics fines : "temps moyen entre PAID et READY par equipier" via JOIN sur `(user_id, event_type)` - -**Couts assumes** : -- Pattern d'ecriture systematique a respecter : chaque service qui modifie `commande.statut` doit aussi inserer dans `commande_event`. A encapsuler dans un repository pour eviter les oublis. -- Volume table x5-x8 par rapport a `commande` -- Requete "qui a saisi cette commande" demande un join (pas de denormalisation `saisi_par_id` directe) - -Si le cout SQL devient penible plus tard, on pourra dupliquer `saisi_par_id` sur `commande` comme colonne denormalisee, sans changer le pattern event. - -**Defendable a l'oral** comme "audit log applicatif" ou "event sourcing simplifie", aligne sur les pratiques de tracabilite des SI en production. - -### Note 11 - Stockage des images : path en VARCHAR vs BLOB en DB - -Les colonnes `image_path` (entites `categorie`, `produit`, `menu`) stockent un **chemin relatif** au public root (ex : `/uploads/produits/burger-classique.jpg`), pas un chemin absolu serveur. Le PHP resout via un prefixe configure dans `.env` (`UPLOAD_DIR=public/uploads`). - -#### Pourquoi pas un BLOB en BDD ? - -L'alternative consistant a stocker les images en LONGBLOB dans MariaDB a ete consideree puis ecartee : - -| Critere | `image_path` VARCHAR (retenu) | BLOB en DB | -|---|---|---| -| Performance kiosk | Apache sert le fichier en ms (cache OS) | PHP lit la DB + streame, latence multipliee | -| Cache HTTP | ETag, Last-Modified, cache browser, CDN natifs | A reimplementer cote PHP | -| Backup BDD | Quelques Mo (paths uniquement) | Croissance Go (66 produits x ~200 Ko + variantes responsive) | -| Replication / dump | Rapide | Lente, ralentit les ACK | -| Pipeline image | `convert`, `webp`, optimisation = outils filesystem standards | A reinventer en PHP | -| Cout cloud (si migration) | Storage S3-like cheap | BDD storage cher | - -Pour un MVP fast-food avec borne tactile reactive, le filesystem est le choix par defaut documente dans la litterature web (cf. references). Le BLOB en DB se justifie pour des cas specifiques (fichiers sensibles avec acces controle par ligne, garantie ACID sur le contenu) qui ne s'appliquent pas a un catalogue produit public. - -#### Le "leak" de path n'en est pas un - -Argument souvent entendu : "stocker un chemin en DB expose la structure du serveur". Analyse : - -- `image_path` contient un chemin **relatif** (`/uploads/produits/...`), pas absolu. -- Cette URL est par definition **publique** : la borne kiosk affiche `` que n'importe quel visiteur voit dans le HTML. -- Pour acceder a la colonne `image_path` en DB, un attaquant doit deja avoir une breche DB (SQLi, credentials voles). A ce stade il a deja toutes les donnees metier (commandes, password_hash, etc.) ; connaitre `/uploads/produits/` est l'info la moins critique de la DB. - -#### Les vrais risques securite filesystem (traites par ailleurs) - -1. **Path traversal a l'upload** : valider que le nom de fichier upload passe par `basename()` + regex `^[a-z0-9_-]+\.(jpg|png|webp)$` cote service admin. -2. **MIME type spoof** : verifier le vrai MIME via `finfo_file()` (extension `.jpg` ne suffit pas). Desactiver l'execution PHP dans `/uploads/` via Apache (`php_flag engine off` + `FilesMatch .(php|phtml|phar)$ deny`). -3. **Stockage hors-webroot pour les fichiers sensibles** : pas applicable au catalogue public, mais regle de principe pour PDF de facturation, exports stats, etc. -4. **Validation taille** : `UPLOAD_MAX_SIZE_MB` dans `.env` + verification PHP cote upload. -5. **Nom non-predictible pour fichiers sensibles** : UUID au lieu du nom metier si l'image contient des donnees sensibles. Pas applicable a un catalogue public. - -#### Sources - -- OWASP File Upload Cheat Sheet (section "Filesystem storage") -- MariaDB Knowledge Base - LONGBLOB performance considerations -- Apache HTTP Server documentation - `mod_xsendfile` et serving static content +**RBAC architecture rule (P2)**: application code tests permissions, not role names. +Adding a new role with the right permissions requires no code change (permission-driven, +not role-name-driven — per Sandhu/NIST RBAC model). --- -## 5. A faire au prochain sprint (MCD) +### 3.16 `role_visible_source` -- Tracer le MCD avec les cardinalites precises (entites + associations + roles + cardinalites - min/max) -- Cross-validation MCD <-> MCT (mantra #34) : verifier que chaque traitement metier identifie - manipule des entites existantes et que chaque entite participe a au moins un traitement -- Decider du nommage final des associations (`compose`, `passe_commande`, `contient`, etc.) -- Eventuellement normaliser plus loin (3NF) si une derive est detectee +Defines which order sources are visible on the preparation dashboard for a given role. +Pure join table. + +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE CASCADE | | +| `source` | ENUM('kiosk','counter','drive') | NO | — | — | source visible to this role on the kitchen/counter/drive display | + +**Primary key**: composite `(role_id, source)`. + +**Seed data**: +| Role | Visible sources | +|---|---| +| `kitchen` | kiosk, counter, drive (all) | +| `counter` | kiosk, counter | +| `drive` | drive | + +--- + +### 3.17 `permission` + +Granular permissions assignable to roles. Catalogue is fixed at seed (no UI creation). + +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `code` | VARCHAR(60) | NO | — | UNIQUE | format `.` | +| `label` | VARCHAR(120) | NO | — | — | display name | +| `description` | TEXT | YES | NULL | — | | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | + +**Fixed permission catalogue** (23 codes — frozen before DDL): + +| Code | Granted to (seed default) | +|---|---| +| `product.create` | admin, manager | +| `product.read` | admin, manager, kitchen, counter, drive | +| `product.update` | admin, manager | +| `product.delete` | admin | +| `menu.create` | admin, manager | +| `menu.read` | admin, manager, kitchen, counter, drive | +| `menu.update` | admin, manager | +| `menu.delete` | admin | +| `category.manage` | admin, manager | +| `ingredient.manage` | admin, manager | +| `stock.read` | admin, manager, kitchen, counter, drive | +| `stock.count` | admin, manager, kitchen, counter, drive | +| `stock.manage` | admin, manager | +| `order.read` | admin, manager, kitchen, counter, drive | +| `order.create` | admin, counter, drive | +| `order.deliver` | admin, counter, drive | +| `order.cancel` | admin, counter, drive | +| `user.create` | admin | +| `user.read` | admin, manager | +| `user.update` | admin | +| `user.deactivate` | admin | +| `role.manage` | admin | +| `stats.read` | admin, manager | + +**Volume**: 23 rows at seed. + +--- + +### 3.18 `role_permission` + +N-N mapping between roles and permissions. Pure join table. + +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE CASCADE | | +| `permission_id` | INT UNSIGNED | NO | — | FK -> `permission(id)`, ON DELETE CASCADE | | + +**Primary key**: composite `(role_id, permission_id)`. + +**Volume**: ~50-100 rows at seed (admin covers all; others cover a subset). + +--- + +### 3.19 `stock_movement` + +Append-only audit log of all stock changes per ingredient. +1 row per movement (sale, cancellation, restock, inventory correction). + +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | ingredient affected | +| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | — | INDEX | nature of the movement | +| `delta` | INT | NO | — | — | signed change: negative for consumption (sale), positive for restock/cancellation/correction | +| `order_id` | INT UNSIGNED | YES | NULL | FK -> `customer_order(id)`, ON DELETE SET NULL | linked order for `sale` and `cancellation` movements; NULL for restock/correction | +| `user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | user who triggered the movement (NULL for automated sale decrements) | +| `note` | VARCHAR(255) | YES | NULL | — | optional human note (e.g., reason for correction, pack reference) | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | immutable timestamp | + +**Immutability**: no UPDATE or DELETE on this table. Corrections are new rows with +`movement_type='inventory_correction'` and a signed delta. + +**Automatic movements** (triggered at status transitions): +- `paid` transition: 1 `sale` row per ingredient unit consumed (accounting for modifiers). +- `cancelled` (from `paid`): 1 `cancellation` row per ingredient unit re-credited. + +**Manual movements**: +- `restock`: manager or admin records a delivery (`+= N * pack_size`). +- `inventory_correction`: morning/evening physical count; system records the discrepancy + (delta = actual - theoretical). + +**Volume**: ~5-15 movements per order across all ingredients; index on +`(ingredient_id, created_at)` is recommended for per-ingredient history queries. + +--- + +## 4. Modeling notes + +### Note 1 — Why `INT UNSIGNED` in cents for prices + +Storing a price as `FLOAT` or `DECIMAL(10,2)` is technically valid but introduces two risks: + +1. **FLOAT rounding**: `0.1 + 0.2 = 0.30000000000000004` in IEEE 754 floating-point. + Summing 100 order lines can produce cent-level discrepancies vs business reality. +2. **FLOAT-to-string conversion**: different PHP/MariaDB driver versions may serialize floats + with variable precision. + +Storing as `INT UNSIGNED` (cents: 880 for EUR 8.80) eliminates these risks. Conversion to EUR +for display is done in PHP at output: `number_format($cents / 100, 2)`. + +Reference: David Goldberg, *What Every Computer Scientist Should Know About Floating-Point +Arithmetic*, ACM Computing Surveys, 1991. + +### Note 2 — Why `ENUM` rather than a reference table + +ENUMs (`service_mode`, `status`, `item_type`, `action`, `slot_type`) could have been reference +tables. Choice retained: ENUM. + +Advantages in this context: +- Values are stable and limited (3-7 values max), unlikely to evolve frequently. +- DBMS-level constraint instead of runtime FK; simpler queries. +- Directly readable in SQL: `WHERE status = 'paid'`. + +Cost of a future change: `ALTER TABLE ... MODIFY COLUMN ... ENUM(...)` to add a value. +Acceptable given changes are expected to be rare. + +If these ENUMs later require multilingual labels or descriptions, they will be migrated to +reference tables. Not in scope for this iteration. + +### Note 3 — Why `customer_order` and not `order` + +`ORDER` is an SQL reserved word (used in `ORDER BY`). Three approaches exist: +- Quote the name everywhere: `` `order` `` — requires quoting in every SQL statement, + error-prone and non-portable across DBMS dialects. +- Use an alias at ORM level: possible but adds a mapping layer. +- Rename: `customer_order` (chosen) — unambiguous, self-documenting, no quoting required. + +Alternative considered and rejected: `purchase` (less domain-specific), +`transaction` (also reserved or ambiguous). `customer_order` matches the domain language +and avoids all conflicts. + +`order_item` is retained as the line table name: `item` is not reserved, and the +`order_` prefix makes the parent relationship clear. + +### Note 4 — Order number prefix by channel + +Format: `K`/`C`/`D`-YYYY-MM-DD-NNN (kiosk / counter / drive). + +Rationale: the prefix encodes the channel, which is useful for rapid visual identification +by kitchen and counter staff without querying the `source` column. The sequential counter NNN +restarts daily per channel. Collision-free within a day given expected volume. + +Alternative rejected: neutral prefix `W-` for all channels (simpler, but loses channel +readability for staff). + +### Note 5 — `source` vs `service_mode` (channel vs consumption mode) + +Two distinct dimensions, kept separate: + +| | `source` | `service_mode` | +|---|---|---| +| Nature | input channel (who entered the order) | consumption mode (where the customer eats) | +| Values | kiosk, counter, drive | dine_in, takeaway, drive | +| Used for | authentication, analytics, permission filtering | KPI, capacity (no fiscal role) | + +The two dimensions are independent for `kiosk` and `counter` (a kiosk customer can choose +`dine_in` or `takeaway`). `drive` is the only case where both dimensions align: +`source=drive` implies `service_mode=drive`. This cross-constraint is verified at app layer. + +### Note 6 — Reduced 4-state machine + +v0.1 had 6 states (`pending_payment`, `paid`, `preparing`, `ready`, `delivered`, `cancelled`). +v0.2 reduces to 4 states: `pending_payment -> paid -> delivered` (+ `cancelled`). + +Rationale (Decision 4 from `revue-alignement-p1.md` §7): in a fast-food context, the kitchen +display (KDS) is a visual system — staff see the ticket and act. `preparing` and `ready` were +intermediate states that added complexity without proportional business value. The single +kitchen action is `deliver` (counter/drive staff hands over the order), collapsing +`preparing + ready + delivered` into one gesture. KPI is total time: `delivered_at - paid_at` +(SLA ~10 min). KDS color coding is computed from `now - paid_at`, no extra stored state. + +**Dropped states and timestamps**: `preparing_at`, `ready_at` are not stored. + +### Note 7 — Normal / Maxi format cascade + +The Maxi format enlarges the side and the drink only. The burger is unchanged and the sauce +portion is unchanged (a sauce pot is the same in both formats). This scope is explicit so the +stock model stays faithful. + +**Price side** — not modeled at individual component price level: +- `menu` carries two prices: `price_normal_cents` and `price_maxi_cents`. +- `order_item.format` records which format the customer chose (`normal` or `maxi`). +- `order_item.unit_price_cents_snapshot` captures the actual price paid (Normal or Maxi). +- No individual price per slot component is stored; the price differential is a menu-level + attribute, consistent with how fast-food menus tend to be priced in practice. + +**Stock side** — modeled via a format multiplier on the recipe: +- `product_ingredient` carries `quantity_normal` and `quantity_maxi`. +- At the `paid` transition, the decrement uses `quantity_maxi` when `order_item.format='maxi'`, + otherwise `quantity_normal`. +- For burger and sauce ingredients, `quantity_maxi = quantity_normal` (format-invariant). +- For side and drink ingredients, `quantity_maxi > quantity_normal` (Maxi consumes more). +- The format cascades from the menu line (`order_item.format`) to its slot selections; a + standalone product line defaults to `normal`. +- Single product per choice (e.g., one `Fries` product), not separate medium/large products. + +Calibration: the Maxi surcharge is in the ~1.50 EUR range for this model (derived from +internal data; cross-checked against market magnitude for plausibility. Wakdo is a fictional +pastiche so exact prices are not copied from a real chain). + +Calibration: the Maxi surcharge is in the ~1.50 EUR range for this model (derived from +internal data; cross-checked against market magnitude for plausibility. Wakdo is a fictional +pastiche so exact prices are not copied from a real chain). + +### Note 8 — Image storage: path in VARCHAR vs BLOB in DB + +`image_path` columns (`category`, `product`, `menu`) store a **relative path** from the +public root (e.g., `/uploads/products/classic-burger.jpg`), not an absolute server path. +PHP resolves via a prefix from `.env` (`UPLOAD_DIR=public/uploads`). + +BLOB storage was considered and rejected: + +| Criterion | `image_path` VARCHAR (chosen) | BLOB in DB | +|---|---|---| +| Kiosk performance | Apache serves files in ms (OS cache) | PHP reads DB + streams, multiplied latency | +| HTTP caching | ETag, Last-Modified, browser cache, CDN native | must be reimplemented in PHP | +| DB backup size | Megabytes (paths only) | Gigabytes (66 products x ~200 KB + responsive variants) | +| Image pipeline | `convert`, `webp`, optimization = standard filesystem tools | must be reinvented in PHP | + +Sources: OWASP File Upload Cheat Sheet; MariaDB Knowledge Base — LONGBLOB performance; +Apache HTTP Server documentation — serving static content. + +### Note 9 — VAT rule in French fast-food (fact-checked) + +``` +FACT-CHECK +Claim audited : "TVA 10% sur place / 5,5% a emporter" (dictionary v0.1 note 9) +Domain : compliance (fiscal) +Verdict : CLAIM INEXACT — superseded +Source : BOFiP BOI-ANNX-000495 + BOI-TVA-LIQ-30-10-10 (official doctrine impots.gouv.fr) +Actual rule : 10% for immediate consumption (dine-in OR hot takeaway); + 5.5% for products in resealable containers (bottle, can) / deferred consumption +Confidence : 95% (L1, official text) +``` + +**Model consequence**: VAT rate is an attribute of the `product` (`vat_rate` in per-mille: +100 = 10%, 55 = 5.5%), not of the order or the service mode. Default: 100 (10%). +The 5.5% rate applies to products in resealable containers (bottled water, juice bottles). +VAT is computed line by line; the rate is snapshotted on `order_item.vat_rate_snapshot` +at transaction time to preserve historical integrity if legislation changes. + +`service_mode` is retained on `customer_order` for stats and KPI only (capacity planning, +per-mode revenue breakdown). It has no fiscal computation role. + +### Note 10 — Ingredient configurator and modifier attachment + +`order_item_modifier` attaches to an `order_item` row via `order_item_id`, regardless of +whether the line is a standalone product or a menu. + +For a **standalone product** (`item_type='product'`): `order_item_id` directly identifies +the product being modified. + +For a **menu** (`item_type='menu'`): the modifiable product is the fixed burger, identified +via `order_item.menu_id -> menu.burger_product_id`. The kitchen display resolves: +`modifier.order_item_id -> order_item -> menu -> menu.burger_product_id -> product.name`. +No additional FK column is needed on `order_item_modifier`. This keeps the modifier table +simple and avoids a nullable `target_product_id` column that would only be populated for +menu lines. + +Constraint enforced at app layer: `order_item_modifier` rows for a menu line reference +only ingredients belonging to `menu.burger_product_id` via `product_ingredient`. + +### Note 11 — `menu_slot` eligibility: category filter vs explicit product list + +Two options were considered: +- **Category filter**: `menu_slot.category_id` points to a category; all products in that + category are eligible. Simple, but a category may contain products not offered in this slot + (e.g., a premium drink added to the "drinks" category should not automatically appear in + all menu slots). +- **Explicit product list** `menu_slot_option(menu_slot_id, product_id)` (chosen): each + eligible product is listed explicitly per slot. More verbose at seed time but precise — + no accidental eligibility when the catalogue grows. Enables per-slot pricing overrides + in the future without structural change. + +The explicit list adds one entity (`menu_slot_option`, entity 3.5) but eliminates a class +of correctness bugs. Consistent with the prod-like ambition of this model. + +### Note 12 — `commande_event` dropped + +v0.1 carried a `commande_event` append-only audit table (event sourcing pattern). +Dropped in v0.2 (Decision 1, `revue-alignement-p1.md` §7). + +Rationale: in a restaurant context, the back-office account is shared per workstation, not +individual. Per-person attribution of a state transition has no business value. The actual +need (phase durations, time-of-day stats) is covered by phase timestamps on `customer_order` +(`paid_at`, `delivered_at`, `cancelled_at`) without the complexity of an event store. + +The 4-state machine combined with 3 phase timestamps provides all KPI data needed: +- Time-to-deliver: `delivered_at - paid_at` +- Cancellation rate and timing: `cancelled_at - created_at` +- Volume by hour: `HOUR(created_at)` / `service_day` computation + +For stock audit, `stock_movement` (entity 3.19) provides the append-only audit trail +where it is genuinely needed (inventory reconciliation). + +--- + +## 5. Entity count summary + +| # | Entity | Type | Replaces / new | +|---|---|---|---| +| 1 | `category` | business | v0.1 `categorie` (renamed + translated) | +| 2 | `product` | business | v0.1 `produit` (+ `vat_rate`) | +| 3 | `menu` | business | v0.1 `menu` (+ burger FK, 2 prices) | +| 4 | `menu_slot` | business | new — replaces `menu_produit` fixed composition | +| 5 | `menu_slot_option` | join | new — eligibility list per slot | +| 6 | `ingredient` | business | new — ingredient configurator + stock | +| 7 | `product_ingredient` | join | new — recipe + customization metadata | +| 8 | `allergen` | reference | new — INCO 1169/2011 | +| 9 | `ingredient_allergen` | join | new — maps allergens to ingredients | +| 10 | `customer_order` | business | v0.1 `commande` (renamed, 4-state machine, phase timestamps) | +| 11 | `order_item` | business | v0.1 `ligne_commande` (+ format, vat_rate_snapshot) | +| 12 | `order_item_selection` | business | new — customer menu slot choices | +| 13 | `order_item_modifier` | business | new — ingredient-level modifications | +| 14 | `user` | business | v0.1 `user` (translated field names) | +| 15 | `role` | business | v0.1 `role` (+ default_route, order_source) | +| 16 | `role_visible_source` | join | new — per-role dashboard filter | +| 17 | `permission` | reference | v0.1 `permission` (translated, catalogue frozen) | +| 18 | `role_permission` | join | v0.1 `role_permission` (unchanged) | +| 19 | `stock_movement` | audit | new — append-only stock audit log | + +**Dropped from v0.1**: `commande_event` (replaced by phase timestamps on `customer_order`), +`menu_produit` (replaced by `menu_slot` + `menu_slot_option` model). + +**Total: 19 entities.** + +--- + +*For the ER diagram and cardinality justifications, see [`mcd.md`](mcd.md) — the diagram is +the single source of truth for graphical representation.*