# Data Dictionary — Wakdo **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. Purpose 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). **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) 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. General conventions ### Naming - **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. ### Default types | Category | MariaDB type | Justification | |---|---|---| | 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 and collation - **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). ### Audit fields (present on all business tables except pure join tables) | Column | Type | Default | Role | |---|---|---|---| | `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 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. Entities ### 3.1 `category` Business grouping of products and menus for display on the kiosk. | Attribute | Type | NULL | Default | Constraint | School source | Notes | |---|---|---|---|---|---|---| | `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 | **Examples**: `menus`, `drinks`, `burgers`, `fries`, `snacks`, `wraps`, `salads`, `desserts`, `sauces`. Volume: 9 rows at init (seed from `categories.json`). --- ### 3.2 `product` A single sellable item, available a la carte or as a component in a menu slot. | Attribute | Type | NULL | Default | Constraint | School source | Notes | |---|---|---|---|---|---|---| | `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 rows at init (66 rows in `produits.json` minus 13 menus moved to `menu`). --- ### 3.3 `menu` Fixed-price combo built around a specific burger, with customer-selectable slots (drink, side, sauce). Two price tiers: Normal and Maxi. | Attribute | Type | NULL | Default | Constraint | School source | Notes | |---|---|---|---|---|---|---| | `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 rows at init. Replaces the old fixed-composition `menu_produit` model. --- ### 3.4 `menu_slot` 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`. | Attribute | Type | NULL | Default | Constraint | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | | `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 | **No audit fields**: a slot is part of menu definition; created and updated with the menu. **Composite index**: `(menu_id, display_order)`. --- ### 3.5 `menu_slot_option` Eligible products for a given menu slot. Pure join table. | 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 | | | `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 | **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 `product_ingredient` Default composition of a product (burger, wrap, etc.) in terms of ingredients. Carries customization metadata for the ingredient configurator. | 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 | | | `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 | **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.9 `ingredient_allergen` Maps which allergens each ingredient contains. Pure join table. | 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 | | | `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 | **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). **`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.11 `order_item` Line of an order: a single product or a menu, with price, label, and VAT rate snapshotted at transaction time. | Attribute | Type | NULL | Default | Constraint | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | | `order_id` | INT UNSIGNED | NO | — | FK -> `customer_order(id)`, ON DELETE CASCADE | | | `item_type` | ENUM('product','menu') | NO | — | — | 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 | **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.12 `order_item_selection` 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`. | 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 | 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**: ~2-3 selections per menu line. **KPI use**: enables analysis of which drink/side combinations are most chosen. --- ### 3.13 `order_item_modifier` 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). | 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) | **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`. **Stock impact**: each modifier affects ingredient stock at `paid` transition (`remove` -> no decrement for that ingredient; `add` -> extra decrement). --- ### 3.14 `user` Back-office user (admin, manager, kitchen staff, counter, drive). Kiosk customers are not authenticated and have no row here. | 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 | **Volume**: 5-20 rows (restaurant team + 1-2 admins). RFC 5321 email length: local-part <= 64, domain <= 255, total <= 254 (including `@`). VARCHAR(254) is the spec-compliant value. --- ### 3.15 `role` 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. | 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 | **Seed roles**: | Code | `default_route` | `order_source` | |---|---|---| | `admin` | `/admin/dashboard` | NULL | | `manager` | `/admin/stats` | NULL | | `kitchen` | `/kitchen/display` | NULL | | `counter` | `/counter/orders` | `counter` | | `drive` | `/drive/orders` | `drive` | **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). --- ### 3.16 `role_visible_source` 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.*