Polymorphic order_item (exclusivity CHECK), composite-PK join tables, service_day as query-time CASE (10h cutoff, generated column dropped), line-by-line VAT, ON DELETE rules, recommended indexes.
906 lines
38 KiB
Markdown
906 lines
38 KiB
Markdown
# Logical Data Model (MLD) — Wakdo
|
|
|
|
**Merise phase** : P1 - Conception, step 5 (after MCD, MCT, MLT)
|
|
**Version** : v0.2 — prod-like, 19 tables
|
|
**Date** : 2026-06-04
|
|
**Branch** : `feat/p1-conception`
|
|
**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7)
|
|
**Author** : BYAN (methodology layer)
|
|
|
|
---
|
|
|
|
## 1. Purpose of this document
|
|
|
|
The MLD transcribes the MCD into a formal relational schema: 1 entity -> 1 table, each
|
|
association translated according to its cardinality, referential constraints materialised,
|
|
indexes sized for frequent access patterns.
|
|
|
|
This is the step that transforms conceptual modelling into an implementable specification.
|
|
The DDL SQL (`db/migrations/0001_init_schema.sql`) will be derived directly from this
|
|
document at P2.
|
|
|
|
**Sources**:
|
|
- `docs/merise/dictionary.md` (v0.2 — types and constraints per attribute, source of truth)
|
|
- `docs/merise/mcd.md` (v0.2 — entities + cardinalities + deferred decisions)
|
|
- `docs/notes/revue-alignement-p1.md` §7 (decision table D1-D8 + stock)
|
|
|
|
**Target platform**:
|
|
- MariaDB 11.4 LTS (cf. `docker-compose.yml` service `wakdo-db`)
|
|
- Engine InnoDB (ACID, FK support, row-level locking, CHECK from 10.2.1)
|
|
- Charset `utf8mb4`, collation `utf8mb4_unicode_ci`
|
|
|
|
---
|
|
|
|
## 2. Notation conventions
|
|
|
|
### Relational notation
|
|
|
|
```
|
|
table_name (col1, col2, #col_fk, [col_nullable])
|
|
|
|
PK : col1
|
|
UK : col2
|
|
FK : col_fk -> other_table(id) ON DELETE <rule>
|
|
IDX : (col_a, col_b)
|
|
CHK : <expression>
|
|
```
|
|
|
|
| Symbol | Meaning |
|
|
|---|---|
|
|
| `col` | NOT NULL column |
|
|
| `[col]` | Nullable column |
|
|
| `#col` | FK column |
|
|
|
|
Notation follows Merise French usage (Nanci/Espinasse convention adapted for ASCII).
|
|
|
|
### Type summary
|
|
|
|
All exact types are defined in `dictionary.md` section 2. Conventions retained:
|
|
- `INT UNSIGNED AUTO_INCREMENT` for all technical PKs
|
|
- `INT UNSIGNED` for all monetary amounts in cents (anti-FLOAT, see dictionary note 1)
|
|
- `SMALLINT UNSIGNED` for `vat_rate` per-mille values (55 or 100)
|
|
- `ENUM(...)` for stable business values (see dictionary note 2)
|
|
- `DATETIME` for timestamps (not TIMESTAMP, which implicitly converts to UTC in MariaDB)
|
|
|
|
---
|
|
|
|
## 3. MCD -> MLD translation rules applied
|
|
|
|
### 3.1 Entity -> Table
|
|
|
|
Each MCD entity becomes one table. The conceptual identifier `id` becomes PK
|
|
`INT UNSIGNED AUTO_INCREMENT`. Attributes retain their names and types.
|
|
|
|
### 3.2 `(1,1) - (1,N)` association -> simple FK
|
|
|
|
The entity on the `(1,1)` side carries the FK toward the `(0,N)` or `(1,N)` entity.
|
|
|
|
### 3.3 `(0,N) - (0,N)` or `(1,N) - (1,N)` association -> join table
|
|
|
|
The association becomes its own table with a composite PK of the two FKs. Applied to:
|
|
`product_ingredient`, `menu_slot_option`, `ingredient_allergen`,
|
|
`role_visible_source`, `role_permission`.
|
|
|
|
### 3.4 Associative entity with own attributes -> join table with columns
|
|
|
|
When an N-N association carries its own attributes, it becomes a table with those attributes
|
|
in addition to the composite FK PK. Applied to `product_ingredient`.
|
|
|
|
### 3.5 Polymorphism -> 2 nullable FKs + discriminator + CHECK
|
|
|
|
`order_item` references either `product` or `menu`. Translated as 2 nullable FK columns +
|
|
1 discriminator ENUM + 1 CHECK constraint enforcing mutual exclusivity.
|
|
|
|
---
|
|
|
|
## 4. Relational schema (19 tables)
|
|
|
|
Tables are ordered by dependency (no-FK tables first, then tables that depend on them).
|
|
|
|
---
|
|
|
|
### 4.1 `category`
|
|
|
|
```
|
|
category (id, name, slug, [image_path], display_order, is_active, created_at, updated_at)
|
|
|
|
PK : id
|
|
UK : name
|
|
UK : slug
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `name` | VARCHAR(60) | NO | Unique display name (see dict 3.1) |
|
|
| `slug` | VARCHAR(60) | NO | URL slug, e.g. `burgers` |
|
|
| `image_path` | VARCHAR(255) | YES | Relative path from public root |
|
|
| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Kiosk display order |
|
|
| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Soft deactivation |
|
|
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
|
|
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
|
|
|
|
No FK. Root table for the Catalogue sub-domain.
|
|
|
|
---
|
|
|
|
### 4.2 `product`
|
|
|
|
```
|
|
product (id, #category_id, name, [description], price_cents, vat_rate,
|
|
[image_path], is_available, display_order, created_at, updated_at)
|
|
|
|
PK : id
|
|
FK : category_id -> category(id) ON DELETE RESTRICT
|
|
IDX : (category_id, is_available, display_order)
|
|
CHK : price_cents > 0
|
|
CHK : vat_rate IN (55, 100)
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `category_id` | INT UNSIGNED | NO | FK -> category |
|
|
| `name` | VARCHAR(120) | NO | Product label |
|
|
| `description` | TEXT | YES | Optional long description |
|
|
| `price_cents` | INT UNSIGNED | NO | A la carte price, incl. VAT, in cents |
|
|
| `vat_rate` | SMALLINT UNSIGNED | NO | Per-mille: 100 = 10%, 55 = 5.5% |
|
|
| `image_path` | VARCHAR(255) | YES | Relative path from public root |
|
|
| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Manual availability toggle |
|
|
| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Within-category display order |
|
|
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
|
|
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
|
|
|
|
**ON DELETE RESTRICT** on `category_id`: a category with products cannot be deleted. Prevents
|
|
orphaned products.
|
|
|
|
---
|
|
|
|
### 4.3 `menu`
|
|
|
|
```
|
|
menu (id, #category_id, #burger_product_id, name, [description],
|
|
price_normal_cents, price_maxi_cents, [image_path],
|
|
is_available, display_order, created_at, updated_at)
|
|
|
|
PK : id
|
|
FK : category_id -> category(id) ON DELETE RESTRICT
|
|
FK : burger_product_id -> product(id) ON DELETE RESTRICT
|
|
IDX : (category_id, is_available, display_order)
|
|
CHK : price_normal_cents > 0
|
|
CHK : price_maxi_cents > 0
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `category_id` | INT UNSIGNED | NO | FK -> category (typically the `menus` category) |
|
|
| `burger_product_id` | INT UNSIGNED | NO | FK -> product — the fixed burger that anchors this menu |
|
|
| `name` | VARCHAR(120) | NO | e.g. "Menu Le 280" |
|
|
| `description` | TEXT | YES | Optional |
|
|
| `price_normal_cents` | INT UNSIGNED | NO | Normal format price in cents |
|
|
| `price_maxi_cents` | INT UNSIGNED | NO | Maxi format price in cents (~+150 cents) |
|
|
| `image_path` | VARCHAR(255) | YES | Typically reuses the burger image |
|
|
| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Availability toggle |
|
|
| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Display order |
|
|
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
|
|
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
|
|
|
|
**ON DELETE RESTRICT** on both FKs: prevents deletion of a category or burger product that
|
|
is still referenced by a menu definition.
|
|
|
|
---
|
|
|
|
### 4.4 `menu_slot`
|
|
|
|
```
|
|
menu_slot (id, #menu_id, name, slot_type, is_required, display_order)
|
|
|
|
PK : id
|
|
FK : menu_id -> menu(id) ON DELETE CASCADE
|
|
IDX : (menu_id, display_order)
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `menu_id` | INT UNSIGNED | NO | FK -> menu |
|
|
| `name` | VARCHAR(80) | NO | e.g. "Drink", "Side", "Sauce" |
|
|
| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | Semantic role |
|
|
| `is_required` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Whether the customer must fill this slot |
|
|
| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Display order within menu builder |
|
|
|
|
**No audit fields**: a slot is part of menu definition; created and updated together with
|
|
the menu.
|
|
|
|
**ON DELETE CASCADE** on `menu_id`: if a menu is deleted, its slots are deleted with it.
|
|
|
|
---
|
|
|
|
### 4.5 `menu_slot_option`
|
|
|
|
Pure join table. Composite PK.
|
|
|
|
```
|
|
menu_slot_option (#menu_slot_id, #product_id)
|
|
|
|
PK : (menu_slot_id, product_id)
|
|
FK : menu_slot_id -> menu_slot(id) ON DELETE CASCADE
|
|
FK : product_id -> product(id) ON DELETE RESTRICT
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot |
|
|
| `product_id` | INT UNSIGNED | NO | FK -> product |
|
|
|
|
**ON DELETE CASCADE** on `menu_slot_id`: if a slot is deleted, its eligibility list goes with it.
|
|
**ON DELETE RESTRICT** on `product_id`: a product listed as eligible in a slot cannot be
|
|
deleted without first removing it from the slot options. Prevents silent breakage of menus.
|
|
|
|
No timestamps. Pure join table.
|
|
|
|
---
|
|
|
|
### 4.6 `ingredient`
|
|
|
|
```
|
|
ingredient (id, name, unit, stock_quantity, pack_size, [pack_label],
|
|
low_stock_threshold, is_active, created_at, updated_at)
|
|
|
|
PK : id
|
|
UK : name
|
|
CHK : stock_quantity >= 0
|
|
CHK : pack_size > 0
|
|
CHK : low_stock_threshold >= 0
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `name` | VARCHAR(120) | NO | Unique name, e.g. "Sesame Bun" |
|
|
| `unit` | VARCHAR(40) | NO | Packaging unit label (free-form, not ENUM) |
|
|
| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Current stock. Signed INT to detect negative (alert) |
|
|
| `pack_size` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units per restocking pack |
|
|
| `pack_label` | VARCHAR(80) | YES | Human label of the pack |
|
|
| `low_stock_threshold` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Alert threshold |
|
|
| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivate obsolete ingredients |
|
|
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
|
|
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
|
|
|
|
No FK. Root table for the Ingredients & Stock sub-domain.
|
|
|
|
---
|
|
|
|
### 4.7 `product_ingredient`
|
|
|
|
Associative table carrying recipe and customisation metadata. Composite PK.
|
|
|
|
```
|
|
product_ingredient (#product_id, #ingredient_id, quantity_normal, quantity_maxi,
|
|
is_removable, is_addable, extra_price_cents)
|
|
|
|
PK : (product_id, ingredient_id)
|
|
FK : product_id -> product(id) ON DELETE CASCADE
|
|
FK : ingredient_id -> ingredient(id) ON DELETE RESTRICT
|
|
CHK : quantity_normal > 0
|
|
CHK : quantity_maxi >= quantity_normal
|
|
CHK : extra_price_cents >= 0
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `product_id` | INT UNSIGNED | NO | FK -> product |
|
|
| `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient |
|
|
| `quantity_normal` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units consumed in Normal format |
|
|
| `quantity_maxi` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units consumed in Maxi format; equals `quantity_normal` for burger/sauce (format-invariant), higher for side/drink |
|
|
| `is_removable` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Customer may remove at no cost |
|
|
| `is_addable` | TINYINT(1) NOT NULL DEFAULT 0 | NO | Customer may add an extra unit |
|
|
| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Surcharge if `is_addable=1` and customer adds it |
|
|
|
|
**ON DELETE CASCADE** on `product_id`: if a product is deleted, its recipe rows are deleted.
|
|
**ON DELETE RESTRICT** on `ingredient_id`: cannot delete an ingredient still referenced in a
|
|
recipe. Admin must remove the product-ingredient link first.
|
|
|
|
No timestamps. Join table with attributes.
|
|
|
|
---
|
|
|
|
### 4.8 `allergen`
|
|
|
|
```
|
|
allergen (id, code, name, [description])
|
|
|
|
PK : id
|
|
UK : code
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `code` | VARCHAR(30) | NO | Machine code, e.g. `gluten`, `milk` |
|
|
| `name` | VARCHAR(80) | NO | Display name |
|
|
| `description` | TEXT | YES | Optional guidance |
|
|
|
|
No FK. Reference table; 14 rows at seed (INCO Regulation (EU) 1169/2011).
|
|
No `updated_at`: allergen catalogue is considered stable (additions require a migration, not a UI action).
|
|
|
|
---
|
|
|
|
### 4.9 `ingredient_allergen`
|
|
|
|
Pure join table. Composite PK.
|
|
|
|
```
|
|
ingredient_allergen (#ingredient_id, #allergen_id)
|
|
|
|
PK : (ingredient_id, allergen_id)
|
|
FK : ingredient_id -> ingredient(id) ON DELETE CASCADE
|
|
FK : allergen_id -> allergen(id) ON DELETE RESTRICT
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient |
|
|
| `allergen_id` | INT UNSIGNED | NO | FK -> allergen |
|
|
|
|
**ON DELETE CASCADE** on `ingredient_id`: if an ingredient is deleted, its allergen links go with it.
|
|
**ON DELETE RESTRICT** on `allergen_id`: an allergen in the regulated catalogue cannot be deleted.
|
|
|
|
No timestamps. Pure join table.
|
|
|
|
---
|
|
|
|
### 4.10 `role`
|
|
|
|
Placed before `user` because `user` depends on `role`.
|
|
|
|
```
|
|
role (id, code, label, [description], [default_route], [order_source],
|
|
is_active, created_at, updated_at)
|
|
|
|
PK : id
|
|
UK : code
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `code` | VARCHAR(40) | NO | Machine code: `admin`, `manager`, `kitchen`, `counter`, `drive` |
|
|
| `label` | VARCHAR(80) | NO | Display name |
|
|
| `description` | TEXT | YES | Optional |
|
|
| `default_route` | VARCHAR(120) | YES | Landing screen, e.g. `/admin/dashboard` |
|
|
| `order_source` | ENUM('kiosk','counter','drive') | YES | Auto-tagged source when this role creates an order; NULL for admin/manager |
|
|
| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivation preserves history |
|
|
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
|
|
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
|
|
|
|
No FK. Root table for RBAC.
|
|
|
|
---
|
|
|
|
### 4.11 `user`
|
|
|
|
```
|
|
user (id, email, password_hash, first_name, last_name, #role_id,
|
|
is_active, [last_login_at], created_at, updated_at)
|
|
|
|
PK : id
|
|
UK : email
|
|
FK : role_id -> role(id) ON DELETE RESTRICT
|
|
IDX : (is_active, role_id)
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `email` | VARCHAR(254) | NO | RFC 5321 max length |
|
|
| `password_hash` | VARCHAR(255) | NO | argon2id hash |
|
|
| `first_name` | VARCHAR(60) | NO | |
|
|
| `last_name` | VARCHAR(60) | NO | |
|
|
| `role_id` | INT UNSIGNED | NO | FK -> role |
|
|
| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivation without deletion |
|
|
| `last_login_at` | DATETIME | YES | Audit, dormant account detection |
|
|
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
|
|
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
|
|
|
|
**ON DELETE RESTRICT** on `role_id`: a role cannot be deleted while users hold it.
|
|
Deactivate the role first (`is_active = 0`), then reassign users before deleting.
|
|
|
|
---
|
|
|
|
### 4.12 `role_visible_source`
|
|
|
|
Pure join table. Composite PK.
|
|
|
|
```
|
|
role_visible_source (#role_id, source)
|
|
|
|
PK : (role_id, source)
|
|
FK : role_id -> role(id) ON DELETE CASCADE
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `role_id` | INT UNSIGNED | NO | FK -> role |
|
|
| `source` | ENUM('kiosk','counter','drive') | NO | Order source visible on dashboard |
|
|
|
|
**ON DELETE CASCADE** on `role_id`: if a role is deleted, its dashboard source filters go with it.
|
|
|
|
No timestamps. Pure join table.
|
|
|
|
Seed data:
|
|
- `kitchen`: kiosk, counter, drive
|
|
- `counter`: kiosk, counter
|
|
- `drive`: drive
|
|
- `admin`, `manager`: no rows (global view, no source filter)
|
|
|
|
---
|
|
|
|
### 4.13 `permission`
|
|
|
|
```
|
|
permission (id, code, label, [description], created_at)
|
|
|
|
PK : id
|
|
UK : code
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `code` | VARCHAR(60) | NO | Format `<resource>.<action>` |
|
|
| `label` | VARCHAR(120) | NO | Display name |
|
|
| `description` | TEXT | YES | Optional |
|
|
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
|
|
|
|
No `updated_at`: permissions are declared in migration and not modified via UI.
|
|
Catalogue is frozen at 23 codes (see dictionary section 3.17).
|
|
|
|
---
|
|
|
|
### 4.14 `role_permission`
|
|
|
|
Pure join table. Composite PK.
|
|
|
|
```
|
|
role_permission (#role_id, #permission_id)
|
|
|
|
PK : (role_id, permission_id)
|
|
FK : role_id -> role(id) ON DELETE CASCADE
|
|
FK : permission_id -> permission(id) ON DELETE CASCADE
|
|
IDX : permission_id
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `role_id` | INT UNSIGNED | NO | FK -> role |
|
|
| `permission_id` | INT UNSIGNED | NO | FK -> permission |
|
|
|
|
**ON DELETE CASCADE** on both FKs: deleting a role or a permission removes its mappings.
|
|
The secondary index on `permission_id` supports the reverse query "which roles have this
|
|
permission?" without scanning the full table.
|
|
|
|
No timestamps. Pure join table.
|
|
|
|
---
|
|
|
|
### 4.15 `customer_order`
|
|
|
|
```
|
|
customer_order (id, order_number, source, service_mode, status,
|
|
total_ht_cents, total_vat_cents, total_ttc_cents,
|
|
[paid_at], [delivered_at], [cancelled_at],
|
|
created_at, updated_at)
|
|
|
|
PK : id
|
|
UK : order_number
|
|
IDX : (status, created_at)
|
|
IDX : (source, created_at)
|
|
IDX : created_at
|
|
CHK : total_ht_cents >= 0
|
|
CHK : total_vat_cents >= 0
|
|
CHK : total_ttc_cents > 0
|
|
CHK : total_ttc_cents = total_ht_cents + total_vat_cents
|
|
CHK : source != 'drive' OR service_mode = 'drive'
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `order_number` | VARCHAR(20) | NO | Format `K/C/D-YYYY-MM-DD-NNN` by channel |
|
|
| `source` | ENUM('kiosk','counter','drive') | NO | Input channel |
|
|
| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Consumption mode (stats only, no fiscal role) |
|
|
| `status` | ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment' | NO | 4-state machine |
|
|
| `total_ht_cents` | INT UNSIGNED | NO | Ex-VAT total snapshot |
|
|
| `total_vat_cents` | INT UNSIGNED | NO | VAT amount snapshot |
|
|
| `total_ttc_cents` | INT UNSIGNED | NO | Incl.-VAT total; must equal HT + VAT |
|
|
| `paid_at` | DATETIME | YES | Timestamp of transition to `paid` |
|
|
| `delivered_at` | DATETIME | YES | Timestamp of transition to `delivered` |
|
|
| `cancelled_at` | DATETIME | YES | Timestamp of cancellation |
|
|
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Used as `service_day` base |
|
|
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
|
|
|
|
No FK toward `user`: staff attribution is not stored on the order. Operational accountability
|
|
is covered by `stock_movement.user_id` for stock actions.
|
|
|
|
**4-state machine**: `pending_payment -> paid -> delivered` (+ `cancelled`). States `preparing`
|
|
and `ready` are dropped (decision D4). KPI: `delivered_at - paid_at` (target SLA ~10 min).
|
|
|
|
**`service_day` computation** (used in stats queries — NOT a stored column):
|
|
```sql
|
|
CASE WHEN HOUR(created_at) < 10
|
|
THEN DATE(created_at) - INTERVAL 1 DAY
|
|
ELSE DATE(created_at)
|
|
END
|
|
```
|
|
Cutoff: 10:00. The generated-column formula with `INTERVAL 4 HOUR 30 MINUTE` from v0.1 was
|
|
incorrect and is dropped (decision D6).
|
|
|
|
**VAT calculation**: totals on `customer_order` are the sum of line-level calculations.
|
|
Line-level VAT: `unit_price_cents_snapshot * quantity` is the TTC amount per line;
|
|
HT = `ROUND(ttc_cents * 100 / (100 + vat_rate_per_cent))` where `vat_rate_per_cent`
|
|
is `vat_rate_snapshot / 10`. Computed at application layer at cart validation.
|
|
|
|
**`source = 'drive' => service_mode = 'drive'`**: the CHECK enforces this at DB level.
|
|
|
|
---
|
|
|
|
### 4.16 `order_item`
|
|
|
|
```
|
|
order_item (id, #order_id, item_type, [#product_id], [#menu_id], format,
|
|
label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot,
|
|
quantity, created_at)
|
|
|
|
PK : id
|
|
FK : order_id -> customer_order(id) ON DELETE CASCADE
|
|
FK : product_id -> product(id) ON DELETE RESTRICT
|
|
FK : menu_id -> menu(id) ON DELETE RESTRICT
|
|
IDX : order_id
|
|
CHK : unit_price_cents_snapshot > 0
|
|
CHK : vat_rate_snapshot IN (55, 100)
|
|
CHK : quantity > 0
|
|
CHK : (item_type = 'product' AND product_id IS NOT NULL AND menu_id IS NULL)
|
|
OR (item_type = 'menu' AND menu_id IS NOT NULL AND product_id IS NULL)
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `order_id` | INT UNSIGNED | NO | FK -> customer_order |
|
|
| `item_type` | ENUM('product','menu') | NO | Discriminator |
|
|
| `product_id` | INT UNSIGNED | YES | Non-null if `item_type = 'product'`, NULL otherwise |
|
|
| `menu_id` | INT UNSIGNED | YES | Non-null if `item_type = 'menu'`, NULL otherwise |
|
|
| `format` | ENUM('normal','maxi') NOT NULL DEFAULT 'normal' | NO | Menu format. For standalone products, value is `normal` |
|
|
| `label_snapshot` | VARCHAR(120) | NO | Label at time of order |
|
|
| `unit_price_cents_snapshot` | INT UNSIGNED | NO | Unit price incl. VAT at time of order |
|
|
| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | VAT rate per-mille at time of order |
|
|
| `quantity` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Quantity (e.g. 3 drinks = 1 line, quantity=3) |
|
|
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
|
|
|
|
**ON DELETE CASCADE** on `order_id`: lines are deleted with the order.
|
|
**ON DELETE RESTRICT** on `product_id` and `menu_id`: a product or menu referenced in an
|
|
historical order line cannot be deleted. The snapshot makes the FK reference non-critical
|
|
for display, but RESTRICT avoids silent orphaning of the relational structure.
|
|
|
|
**Polymorphism exclusivity CHECK**: MariaDB 10.2+ enforces this at INSERT/UPDATE time.
|
|
|
|
---
|
|
|
|
### 4.17 `order_item_selection`
|
|
|
|
Customer's choice for one slot of a menu order line.
|
|
|
|
```
|
|
order_item_selection (id, #order_item_id, #menu_slot_id, #product_id, label_snapshot)
|
|
|
|
PK : id
|
|
FK : order_item_id -> order_item(id) ON DELETE CASCADE
|
|
FK : menu_slot_id -> menu_slot(id) ON DELETE RESTRICT
|
|
FK : product_id -> product(id) ON DELETE RESTRICT
|
|
IDX : order_item_id
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `order_item_id` | INT UNSIGNED | NO | FK -> order_item (must be a menu-type line) |
|
|
| `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot (which slot was filled) |
|
|
| `product_id` | INT UNSIGNED | NO | FK -> product (chosen by customer) |
|
|
| `label_snapshot` | VARCHAR(120) | NO | Product label at time of order |
|
|
|
|
**ON DELETE CASCADE** on `order_item_id`: if the parent order line is deleted, its slot
|
|
selections go with it.
|
|
**ON DELETE RESTRICT** on `menu_slot_id` and `product_id`: historical slot choice records
|
|
must not be silently broken by catalogue changes.
|
|
|
|
Note: the business constraint that `order_item_id` references a line with `item_type='menu'`
|
|
is enforced at application layer (not in MariaDB without a trigger or deferred constraint).
|
|
|
|
---
|
|
|
|
### 4.18 `order_item_modifier`
|
|
|
|
Ingredient-level modification applied by the customer to a product or the fixed burger of a menu.
|
|
|
|
```
|
|
order_item_modifier (id, #order_item_id, #ingredient_id, action, extra_price_cents)
|
|
|
|
PK : id
|
|
FK : order_item_id -> order_item(id) ON DELETE CASCADE
|
|
FK : ingredient_id -> ingredient(id) ON DELETE RESTRICT
|
|
IDX : order_item_id
|
|
CHK : extra_price_cents >= 0
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `order_item_id` | INT UNSIGNED | NO | FK -> order_item |
|
|
| `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient |
|
|
| `action` | ENUM('remove','add') | NO | `remove` = free removal; `add` = extra unit |
|
|
| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Snapshot of surcharge at time of order (0 for removals) |
|
|
|
|
**ON DELETE CASCADE** on `order_item_id`: if the order line is deleted, its modifiers go with it.
|
|
**ON DELETE RESTRICT** on `ingredient_id`: an ingredient referenced in a historical modifier
|
|
cannot be deleted.
|
|
|
|
**Modifier attachment for menu lines**: the modifiable product is the fixed burger, resolved
|
|
via `order_item.menu_id -> menu.burger_product_id`. No additional FK column is needed on
|
|
this table (see dictionary note 10).
|
|
|
|
---
|
|
|
|
### 4.19 `stock_movement`
|
|
|
|
Append-only audit log of all stock changes per ingredient.
|
|
|
|
```
|
|
stock_movement (id, #ingredient_id, movement_type, delta,
|
|
[#order_id], [#user_id], [note], created_at)
|
|
|
|
PK : id
|
|
FK : ingredient_id -> ingredient(id) ON DELETE RESTRICT
|
|
FK : order_id -> customer_order(id) ON DELETE SET NULL
|
|
FK : user_id -> user(id) ON DELETE SET NULL
|
|
IDX : (ingredient_id, created_at)
|
|
IDX : (movement_type, created_at)
|
|
```
|
|
|
|
| Column | Type | NULL | Notes |
|
|
|---|---|---|---|
|
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
|
| `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient |
|
|
| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | Nature of movement |
|
|
| `delta` | INT | NO | Signed change: negative for consumption, positive for restock/cancellation/correction |
|
|
| `order_id` | INT UNSIGNED | YES | FK -> customer_order; non-null for `sale` and `cancellation` |
|
|
| `user_id` | INT UNSIGNED | YES | FK -> user; null for automated sale decrements |
|
|
| `note` | VARCHAR(255) | YES | Optional human note |
|
|
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Immutable timestamp |
|
|
|
|
**ON DELETE RESTRICT** on `ingredient_id`: an ingredient with a movement history cannot be
|
|
deleted. Admin must archive the ingredient (`is_active = 0`) instead.
|
|
**ON DELETE SET NULL** on `order_id`: if an order is purged from the system, its movement
|
|
records remain with `order_id = NULL`. The audit log is preserved; only the order link is lost.
|
|
**ON DELETE SET NULL** on `user_id`: if a user is deleted, movement records remain with
|
|
`user_id = NULL`. Audit is preserved; individual attribution is lost.
|
|
|
|
**Immutability rule**: no UPDATE or DELETE at application layer. Corrections are new rows
|
|
with `movement_type = 'inventory_correction'` and a signed `delta`.
|
|
|
|
No `updated_at`. Immutable append-only table.
|
|
|
|
---
|
|
|
|
## 5. Referential integrity summary
|
|
|
|
| FK column | References | ON DELETE | Rationale |
|
|
|---|---|---|---|
|
|
| `product.category_id` | `category(id)` | RESTRICT | No orphaned product |
|
|
| `menu.category_id` | `category(id)` | RESTRICT | Same |
|
|
| `menu.burger_product_id` | `product(id)` | RESTRICT | Menu definition requires its anchor burger |
|
|
| `menu_slot.menu_id` | `menu(id)` | CASCADE | Slots have no meaning without their menu |
|
|
| `menu_slot_option.menu_slot_id` | `menu_slot(id)` | CASCADE | Eligibility list disappears with the slot |
|
|
| `menu_slot_option.product_id` | `product(id)` | RESTRICT | Removing a product must not silently break menus |
|
|
| `product_ingredient.product_id` | `product(id)` | CASCADE | Recipe disappears with the product |
|
|
| `product_ingredient.ingredient_id` | `ingredient(id)` | RESTRICT | Cannot remove ingredient still in a recipe |
|
|
| `ingredient_allergen.ingredient_id` | `ingredient(id)` | CASCADE | Allergen links disappear with the ingredient |
|
|
| `ingredient_allergen.allergen_id` | `allergen(id)` | RESTRICT | Regulated allergen catalogue is immutable |
|
|
| `user.role_id` | `role(id)` | RESTRICT | A user cannot exist without a role |
|
|
| `role_visible_source.role_id` | `role(id)` | CASCADE | Dashboard filters disappear with the role |
|
|
| `role_permission.role_id` | `role(id)` | CASCADE | Permission mappings disappear with the role |
|
|
| `role_permission.permission_id` | `permission(id)` | CASCADE | Permission mappings disappear with the permission |
|
|
| `order_item.order_id` | `customer_order(id)` | CASCADE | Lines disappear with the order |
|
|
| `order_item.product_id` | `product(id)` | RESTRICT | Historical reference must not be silently orphaned |
|
|
| `order_item.menu_id` | `menu(id)` | RESTRICT | Same |
|
|
| `order_item_selection.order_item_id` | `order_item(id)` | CASCADE | Slot choices disappear with the line |
|
|
| `order_item_selection.menu_slot_id` | `menu_slot(id)` | RESTRICT | Historical slot record preserved |
|
|
| `order_item_selection.product_id` | `product(id)` | RESTRICT | Historical choice record preserved |
|
|
| `order_item_modifier.order_item_id` | `order_item(id)` | CASCADE | Modifiers disappear with the line |
|
|
| `order_item_modifier.ingredient_id` | `ingredient(id)` | RESTRICT | Historical modifier record preserved |
|
|
| `stock_movement.ingredient_id` | `ingredient(id)` | RESTRICT | Ingredient with history cannot be deleted |
|
|
| `stock_movement.order_id` | `customer_order(id)` | SET NULL | Audit preserved, order link lost |
|
|
| `stock_movement.user_id` | `user(id)` | SET NULL | Audit preserved, user attribution lost |
|
|
|
|
**Key used**: CASCADE = child has no meaning without parent; RESTRICT = parent deletion
|
|
blocked while children exist; SET NULL = child is preserved, only the link is severed.
|
|
|
|
---
|
|
|
|
## 6. CHECK constraints summary
|
|
|
|
| Table | CHECK expression | Purpose |
|
|
|---|---|---|
|
|
| `product` | `price_cents > 0` | Zero or negative price is a bug |
|
|
| `product` | `vat_rate IN (55, 100)` | Only two legal VAT rates for this model |
|
|
| `menu` | `price_normal_cents > 0` | Same as product |
|
|
| `menu` | `price_maxi_cents > 0` | Same |
|
|
| `ingredient` | `stock_quantity >= 0` | Negative stock is an alert, not a valid state |
|
|
| `ingredient` | `pack_size > 0` | Pack size of zero makes restock logic incoherent |
|
|
| `ingredient` | `low_stock_threshold >= 0` | Threshold cannot be negative |
|
|
| `product_ingredient` | `quantity_normal > 0` | Recipe quantity of zero is meaningless |
|
|
| `product_ingredient` | `quantity_maxi >= quantity_normal` | Maxi consumes at least as much as Normal (side/drink more, burger/sauce equal) |
|
|
| `product_ingredient` | `extra_price_cents >= 0` | No negative surcharge |
|
|
| `customer_order` | `total_ht_cents >= 0` | Zero is allowed (edge case during cart building) |
|
|
| `customer_order` | `total_vat_cents >= 0` | Same |
|
|
| `customer_order` | `total_ttc_cents > 0` | A validated order must have a positive total |
|
|
| `customer_order` | `total_ttc_cents = total_ht_cents + total_vat_cents` | Arithmetic invariant; defence-in-depth vs application bugs |
|
|
| `customer_order` | `source != 'drive' OR service_mode = 'drive'` | Cross-dimension constraint (dict. note 5) |
|
|
| `order_item` | `unit_price_cents_snapshot > 0` | Non-zero price at transaction time |
|
|
| `order_item` | `vat_rate_snapshot IN (55, 100)` | Snapshot must match allowed rates |
|
|
| `order_item` | `quantity > 0` | Non-zero quantity |
|
|
| `order_item` | `(item_type='product' AND product_id IS NOT NULL AND menu_id IS NULL) OR (item_type='menu' AND menu_id IS NOT NULL AND product_id IS NULL)` | Polymorphism: exactly one FK populated per discriminator value |
|
|
| `order_item_modifier` | `extra_price_cents >= 0` | Snapshot of surcharge; cannot be negative |
|
|
|
|
---
|
|
|
|
## 7. Recommended indexes (beyond PK / UK / FK auto-indexes)
|
|
|
|
MariaDB InnoDB creates an index automatically for each FK declaration (if no usable index
|
|
exists). The following additional indexes target frequent query patterns identified in the
|
|
MCT / MLT.
|
|
|
|
| Table | Index columns | Query pattern |
|
|
|---|---|---|
|
|
| `product` | `(category_id, is_available, display_order)` | Kiosk catalogue load: filter by category + availability, sort by order |
|
|
| `menu` | `(category_id, is_available, display_order)` | Same pattern for menus |
|
|
| `menu_slot` | `(menu_id, display_order)` | Menu builder: load all slots of a menu in order |
|
|
| `customer_order` | `(status, created_at)` | Active orders queue: pending/paid orders sorted by time |
|
|
| `customer_order` | `(source, created_at)` | Per-channel analytics and order filtering |
|
|
| `customer_order` | `created_at` | Time-range aggregations (hourly stats, `service_day`) |
|
|
| `order_item` | `order_id` | Retrieve all lines of an order |
|
|
| `order_item_selection` | `order_item_id` | Retrieve slot choices for a menu line |
|
|
| `order_item_modifier` | `order_item_id` | Retrieve ingredient modifications for a line |
|
|
| `stock_movement` | `(ingredient_id, created_at)` | Per-ingredient stock history (dict. section 3.19) |
|
|
| `stock_movement` | `(movement_type, created_at)` | Stats: cancellations per week, restocks per month |
|
|
| `role_permission` | `permission_id` | Reverse query: "which roles have this permission?" |
|
|
| `user` | `(is_active, role_id)` | Login check + permission resolution |
|
|
|
|
**Indexes not added** (intentional):
|
|
- `customer_order.order_number`: UK index is sufficient; no range query expected on this column.
|
|
- `customer_order.service_mode`: low cardinality (3 values); full scan on the status index
|
|
with a `service_mode` filter is acceptable at expected volume.
|
|
- `customer_order.paid_at`: NULL for most in-flight rows; sparse index provides limited benefit.
|
|
|
|
---
|
|
|
|
## 8. Cross-validation MLD <-> MCD
|
|
|
|
Verification that all 19 MCD entities map to a table, and that all tables trace to the MCD.
|
|
|
|
| MCD entity | MLD table | Mapping type | Notes |
|
|
|---|---|---|---|
|
|
| `category` (C1) | `category` (4.1) | 1:1 entity | |
|
|
| `product` (C2) | `product` (4.2) | 1:1 entity | |
|
|
| `menu` (C3) | `menu` (4.3) | 1:1 entity | New: `burger_product_id`, `price_normal_cents`, `price_maxi_cents` |
|
|
| `menu_slot` (C4) | `menu_slot` (4.4) | 1:1 entity | New entity (v0.2) |
|
|
| `menu_slot_option` (C5) | `menu_slot_option` (4.5) | Join table (composite PK) | New entity (v0.2) |
|
|
| `ingredient` (C6) | `ingredient` (4.6) | 1:1 entity | New entity (v0.2) |
|
|
| `product_ingredient` (C7) | `product_ingredient` (4.7) | Join table with attributes | New entity (v0.2) |
|
|
| `allergen` (C8) | `allergen` (4.8) | 1:1 entity | New entity (v0.2) |
|
|
| `ingredient_allergen` (C9) | `ingredient_allergen` (4.9) | Join table (composite PK) | New entity (v0.2) |
|
|
| `role` (C10) | `role` (4.10) | 1:1 entity | New: `default_route`, `order_source` |
|
|
| `user` (C11) | `user` (4.11) | 1:1 entity | Columns renamed to English |
|
|
| `role_visible_source` (C12) | `role_visible_source` (4.12) | Join table (composite PK) | New entity (v0.2) |
|
|
| `permission` (C13) | `permission` (4.13) | 1:1 entity | |
|
|
| `role_permission` (C14) | `role_permission` (4.14) | Join table (composite PK) | |
|
|
| `customer_order` (C15) | `customer_order` (4.15) | 1:1 entity | Renamed from `commande`; 4-state machine; phase timestamps |
|
|
| `order_item` (C16) | `order_item` (4.16) | 1:1 entity | New: `format`, `vat_rate_snapshot`; polymorphism CHECK |
|
|
| `order_item_selection` (C17) | `order_item_selection` (4.17) | 1:1 entity | New entity (v0.2) |
|
|
| `order_item_modifier` (C18) | `order_item_modifier` (4.18) | 1:1 entity | New entity (v0.2) |
|
|
| `stock_movement` (C19) | `stock_movement` (4.19) | 1:1 entity | New entity (v0.2) |
|
|
|
|
**Result**: 19/19 entities mapped. No entity without a table; no table outside the MCD.
|
|
|
|
**Dropped from v0.1**: `commande_event` (replaced by `paid_at`, `delivered_at`, `cancelled_at`
|
|
phase timestamps on `customer_order` — decision 2.A); `menu_produit` fixed-composition model
|
|
(replaced by `menu_slot` + `menu_slot_option` — decision D1).
|
|
|
|
---
|
|
|
|
## 9. Volume estimation (6 months)
|
|
|
|
| Table | Rows at 6 months | Avg row size | Est. size |
|
|
|---|---|---|---|
|
|
| `category` | ~10 | 200 bytes | < 1 KB |
|
|
| `product` | ~55 | 400 bytes | ~22 KB |
|
|
| `menu` | ~13 | 450 bytes | ~6 KB |
|
|
| `menu_slot` | ~40 | 150 bytes | ~6 KB |
|
|
| `menu_slot_option` | ~150 | 30 bytes | ~5 KB |
|
|
| `ingredient` | ~100 | 300 bytes | ~30 KB |
|
|
| `product_ingredient` | ~400 | 40 bytes | ~16 KB |
|
|
| `allergen` | 14 | 200 bytes | ~3 KB |
|
|
| `ingredient_allergen` | ~200 | 20 bytes | ~4 KB |
|
|
| `role` | ~5 | 200 bytes | ~1 KB |
|
|
| `user` | ~20 | 500 bytes | ~10 KB |
|
|
| `role_visible_source` | ~7 | 15 bytes | < 1 KB |
|
|
| `permission` | 23 | 250 bytes | ~6 KB |
|
|
| `role_permission` | ~80 | 15 bytes | ~2 KB |
|
|
| `customer_order` | ~30k | 300 bytes | ~9 MB |
|
|
| `order_item` | ~150k | 250 bytes | ~37 MB |
|
|
| `order_item_selection` | ~300k | 150 bytes | ~45 MB |
|
|
| `order_item_modifier` | ~150k | 80 bytes | ~12 MB |
|
|
| `stock_movement` | ~500k | 180 bytes | ~90 MB |
|
|
|
|
**Estimated total**: ~190 MB data + ~60-80 MB for indexes = ~250-270 MB over 6 months.
|
|
Manageable on the MariaDB container (`wakdo_db_data` named volume in `docker-compose.yml`).
|
|
|
|
`stock_movement` is the highest-volume table (~5-15 rows per order across all ingredients).
|
|
The `(ingredient_id, created_at)` index is the primary query path for per-ingredient
|
|
history; it will carry meaningful write amplification at scale.
|
|
|
|
---
|
|
|
|
## 10. Decisions deferred to DDL and P2
|
|
|
|
1. **MariaDB generated column** for `service_day`: a `VIRTUAL GENERATED` column is technically
|
|
possible in MariaDB 5.7+ syntax. If stats queries prove burdensome without a materialised
|
|
column, a `STORED GENERATED` column could be added as a migration. For this model, the
|
|
applicative CASE expression is retained (simpler, avoids generated-column edge cases).
|
|
2. **Partitioning**: `stock_movement` could be partitioned by month if volume exceeds
|
|
estimates. Not in scope for the initial DDL.
|
|
3. **Triggers**: stock decrement on `paid` transition and re-credit on `cancelled` (from `paid`)
|
|
could be implemented as MariaDB triggers or as application-layer logic. To be decided at P2.
|
|
4. **Collation**: `utf8mb4_unicode_ci` retained (Unicode-compliant, case-insensitive).
|
|
If strict French alphabetical sort is needed, `utf8mb4_fr_0900_ai_ci` is available in
|
|
MySQL 8 but not MariaDB; `unicode_ci` is the portable choice.
|
|
5. **Migration tooling**: Phinx, Doctrine Migrations, or a plain PHP script. Decision at P2.
|
|
6. **`order_item_id` constraint for selections**: the business rule that
|
|
`order_item_selection.order_item_id` must reference a line with `item_type='menu'`
|
|
is enforced at application layer. A MariaDB trigger could reinforce this at DB level if
|
|
needed.
|
|
|
|
---
|
|
|
|
## 11. Next steps (DDL + Seed)
|
|
|
|
1. **DDL** (`db/migrations/0001_init_schema.sql`): transcribe this MLD into executable
|
|
`CREATE TABLE` statements, in dependency order:
|
|
- `category` -> `product`, `ingredient`, `allergen`, `role`
|
|
- `menu` (depends on `category`, `product`)
|
|
- `menu_slot` (depends on `menu`), `menu_slot_option` (depends on `menu_slot`, `product`)
|
|
- `product_ingredient` (depends on `product`, `ingredient`)
|
|
- `ingredient_allergen` (depends on `ingredient`, `allergen`)
|
|
- `user` (depends on `role`), `role_visible_source` (depends on `role`)
|
|
- `permission`, `role_permission` (depends on `role`, `permission`)
|
|
- `customer_order`
|
|
- `order_item` (depends on `customer_order`, `product`, `menu`)
|
|
- `order_item_selection` (depends on `order_item`, `menu_slot`, `product`)
|
|
- `order_item_modifier` (depends on `order_item`, `ingredient`)
|
|
- `stock_movement` (depends on `ingredient`, `customer_order`, `user`)
|
|
|
|
2. **Seed** (`db/seeds/0001_demo_data.sql`):
|
|
- 9 categories + 53 products + 13 menus from JSON sources (`docs/merise/_sources/`)
|
|
- 13 menus with slots and slot options
|
|
- 14 allergens (INCO EU 1169/2011)
|
|
- Sample ingredient catalogue with recipes
|
|
- 5 roles with `role_permission` matrix and `role_visible_source` data
|
|
- 1 admin user
|
|
- Sample orders for demo
|
|
|
|
3. **Fallback JSON export** (`scripts/export-fallback.{sh|php}`): extract seed data to
|
|
`src/public/borne/data/*.json` for isolated kiosk mode (Bloc 1 without DB).
|
|
|
|
4. **DDL validation tests**: confirm CHECK constraints trigger as expected; confirm
|
|
ON DELETE CASCADE / RESTRICT / SET NULL behaviours match specification.
|