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.
785 lines
40 KiB
Markdown
785 lines
40 KiB
Markdown
# 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**: `<referenced_table>_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 `<resource>.<action>` |
|
|
| `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.*
|