corentin_wakdo/docs/merise/mld.md
Imugiii d06c0b22fb
All checks were successful
CI / secret-scan (pull_request) Successful in 8s
CI / php-lint (pull_request) Successful in 16s
CI / static-tests (pull_request) Successful in 4s
docs(merise): add relational schema diagrams to MLD (4 sub-domains, Mermaid + SVG)
2026-06-15 10:39:12 +00:00

1320 lines
55 KiB
Markdown

# Logical Data Model (MLD) — Wakdo
**Merise phase** : P1 - Conception, step 5 (after MCD, MCT, MLT)
**Version** : v0.2 — prod-like, 21 tables (19 prod-like + security-by-design layer)
**Date** : 2026-06-04 (security-by-design additions 2026-06-11)
**Branch** : `feat/p1-conception`
**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer (audit_log + accountability/auth columns) in progress
**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 (21 tables)
Tables are ordered by dependency (no-FK tables first, then tables that depend on them).
### Relational diagrams (by sub-domain)
The relational schema is shown as four Mermaid `erDiagram` views, one per sub-domain (same
decomposition as the MCD; a single 21-table diagram would not lay out cleanly). These differ
from the MCD: associative entities are resolved into join tables with composite PKs, the
`order_item` polymorphism appears as two nullable FKs (`product_id` / `menu_id`), and every
foreign key is explicit. Audit timestamps (`created_at` / `updated_at`) are present on most
tables (see the per-table sections below) but omitted from the diagrams to keep them readable.
Relationship labels carry the FK column and its `ON DELETE` behaviour. Cross-sub-domain FK
targets are shown as stub tables (id + name). Portable SVG renders live in `_diagrams/`
(`mld-catalogue.svg`, `mld-ingredients-stock.svg`, `mld-order.svg`, `mld-rbac.svg`).
#### Catalogue
```mermaid
erDiagram
category {
int id PK
varchar name UK
varchar slug UK
smallint display_order
tinyint is_active
}
product {
int id PK
int category_id FK
varchar name
int price_cents
smallint vat_rate
tinyint is_available
smallint display_order
}
menu {
int id PK
int category_id FK
int burger_product_id FK
varchar name
int price_normal_cents
int price_maxi_cents
tinyint is_available
smallint display_order
}
menu_slot {
int id PK
int menu_id FK
varchar name
enum slot_type
tinyint is_required
smallint display_order
}
menu_slot_option {
int menu_slot_id PK,FK
int product_id PK,FK
}
category ||--o{ product : "category_id (RESTRICT)"
category ||--o{ menu : "category_id (RESTRICT)"
product ||--o{ menu : "burger_product_id (RESTRICT)"
menu ||--o{ menu_slot : "menu_id (CASCADE)"
menu_slot ||--o{ menu_slot_option : "menu_slot_id (CASCADE)"
product ||--o{ menu_slot_option : "product_id (RESTRICT)"
```
#### Ingredients & Stock
```mermaid
erDiagram
ingredient {
int id PK
varchar name UK
varchar unit
int stock_quantity
int stock_capacity
smallint pack_size
smallint low_stock_pct
smallint critical_stock_pct
tinyint is_active
}
product_ingredient {
int product_id PK,FK
int ingredient_id PK,FK
smallint quantity_normal
smallint quantity_maxi
tinyint is_removable
tinyint is_addable
int extra_price_cents
}
allergen {
int id PK
varchar code UK
varchar name
}
ingredient_allergen {
int ingredient_id PK,FK
int allergen_id PK,FK
}
stock_movement {
int id PK
int ingredient_id FK
enum movement_type
int delta
int order_id FK
int user_id FK
varchar note
}
product {
int id PK
varchar name
}
customer_order {
int id PK
varchar order_number
}
user {
int id PK
varchar email
}
product ||--o{ product_ingredient : "product_id (CASCADE)"
ingredient ||--o{ product_ingredient : "ingredient_id (RESTRICT)"
ingredient ||--o{ ingredient_allergen : "ingredient_id (CASCADE)"
allergen ||--o{ ingredient_allergen : "allergen_id (RESTRICT)"
ingredient ||--o{ stock_movement : "ingredient_id (RESTRICT)"
customer_order ||--o{ stock_movement : "order_id (SET NULL, nullable)"
user ||--o{ stock_movement : "user_id (SET NULL, nullable)"
```
#### Order
```mermaid
erDiagram
customer_order {
int id PK
varchar order_number UK
varchar idempotency_key UK
enum source
int acting_user_id FK
enum service_mode
enum status
int total_ht_cents
int total_vat_cents
int total_ttc_cents
datetime paid_at
datetime delivered_at
datetime cancelled_at
}
order_item {
int id PK
int order_id FK
enum item_type
int product_id FK
int menu_id FK
enum format
varchar label_snapshot
int unit_price_cents_snapshot
smallint vat_rate_snapshot
smallint quantity
}
order_item_selection {
int id PK
int order_item_id FK
int menu_slot_id FK
int product_id FK
varchar label_snapshot
}
order_item_modifier {
int id PK
int order_item_id FK
int ingredient_id FK
enum action
int extra_price_cents
}
user {
int id PK
varchar email
}
product {
int id PK
varchar name
}
menu {
int id PK
varchar name
}
menu_slot {
int id PK
varchar name
}
ingredient {
int id PK
varchar name
}
user ||--o{ customer_order : "acting_user_id (SET NULL, nullable)"
customer_order ||--o{ order_item : "order_id (CASCADE)"
product ||--o{ order_item : "product_id (RESTRICT, polymorphic)"
menu ||--o{ order_item : "menu_id (RESTRICT, polymorphic)"
order_item ||--o{ order_item_selection : "order_item_id (CASCADE)"
menu_slot ||--o{ order_item_selection : "menu_slot_id (RESTRICT)"
product ||--o{ order_item_selection : "product_id (RESTRICT)"
order_item ||--o{ order_item_modifier : "order_item_id (CASCADE)"
ingredient ||--o{ order_item_modifier : "ingredient_id (RESTRICT)"
```
#### RBAC & security
```mermaid
erDiagram
role {
int id PK
varchar code UK
varchar label
varchar default_route
enum order_source
tinyint is_active
}
user {
int id PK
varchar email UK
varchar password_hash
varchar pin_hash
varchar first_name
varchar last_name
int role_id FK
tinyint is_active
smallint failed_login_attempts
datetime lockout_until
datetime anonymized_at
}
role_visible_source {
int role_id PK,FK
enum source PK
}
permission {
int id PK
varchar code UK
varchar label
}
role_permission {
int role_id PK,FK
int permission_id PK,FK
}
audit_log {
int id PK
int actor_user_id FK
int actor_role_id FK
varchar action_code
varchar entity_type
int entity_id
varchar summary
json details
datetime created_at
}
login_throttle {
int id PK
varchar ip_address UK
smallint failed_attempts
datetime window_started_at
datetime lockout_until
datetime last_attempt_at
}
role ||--o{ user : "role_id (RESTRICT)"
role ||--o{ role_visible_source : "role_id (CASCADE)"
role ||--o{ role_permission : "role_id (CASCADE)"
permission ||--o{ role_permission : "permission_id (CASCADE)"
user ||--o{ audit_log : "actor_user_id (SET NULL, nullable)"
role ||--o{ audit_log : "actor_role_id (SET NULL, nullable)"
```
> `login_throttle` has no FK (an IP is not a modelled entity); it stands alone, keyed by
> `ip_address`.
---
### 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, stock_capacity, pack_size, [pack_label],
low_stock_pct, critical_stock_pct, is_active, created_at, updated_at)
PK : id
UK : name
CHK : stock_capacity > 0
CHK : pack_size > 0
CHK : low_stock_pct BETWEEN 0 AND 100
CHK : critical_stock_pct BETWEEN 0 AND 100
CHK : critical_stock_pct < low_stock_pct
```
| 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 that may go negative when sales outrun counted stock (oversell magnitude, surfaced to managers); the system does not block an order on stock |
| `stock_capacity` | INT NOT NULL | NO | Reference "full" level in units = the 100% used to compute the stock percentage; CHECK > 0 also guards the percentage division against divide-by-zero |
| `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_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 10 | NO | Warning band, percent of capacity (CHECK BETWEEN 0 AND 100) |
| `critical_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 5 | NO | Auto-out-of-stock floor, percent of capacity (CHECK BETWEEN 0 AND 100; table CHECK `critical_stock_pct < low_stock_pct`) |
| `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.
**Percentage-based stock model**: the stock state is computed (NOT stored) as
`stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Two bands derive from it:
`LOW` when `stock_quantity <= stock_capacity * low_stock_pct/100`, and
`CRITICAL` when `stock_quantity <= stock_capacity * critical_stock_pct/100`.
Three-band behaviour: above `low` = normal; between `critical` and `low` = orderable
plus manager alert (the manager either pulls the product via `product.is_available=0`, or
restocks to clear the alert); at or below `critical` = auto out-of-stock (computed, rule
RG-T21). `stock_quantity` is signed and may go negative; the system does not block an order
on stock, so a negative value records the oversell magnitude for managers.
**Computed availability (rule RG-T21)**: a product is effectively orderable when
`product.is_available = 1` AND each non-removable (`is_removable=0`) ingredient in its
`product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct/100`. At the
critical band a product auto-goes out-of-stock with no write and no cascade; a manual pull
(`product.is_available=0`) is a hard override; a restock above the critical band makes the
product orderable again on its own; a removable/optional ingredient at the critical band does
not block the product (only its add-on becomes unavailable). The dashboard distinguishes a
manual pull (`is_available=0`) from a stock-driven OOS (`is_available=1` but a required
ingredient is critical).
---
### 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, [pin_hash], first_name, last_name, #role_id,
is_active, [last_login_at], failed_login_attempts, [last_failed_login_at],
[lockout_until], [password_reset_token_hash], [password_reset_expires_at],
[anonymized_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. PII (RGPD anonymisation, see below) |
| `password_hash` | VARCHAR(255) | NO | argon2id hash |
| `pin_hash` | VARCHAR(255) | YES | argon2id hash of the per-staff PIN (sensitive-action authorisation). Security-by-design |
| `first_name` | VARCHAR(60) | NO | PII |
| `last_name` | VARCHAR(60) | NO | PII |
| `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 |
| `failed_login_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Brute-force counter (degressive throttling) |
| `last_failed_login_at` | DATETIME | YES | Timestamp of last failed login |
| `lockout_until` | DATETIME | YES | End of current throttling window (backoff, not indefinite lock) |
| `password_reset_token_hash` | VARCHAR(255) | YES | Hash of the reset token (not the raw token) |
| `password_reset_expires_at` | DATETIME | YES | Reset token expiry |
| `anonymized_at` | DATETIME | YES | RGPD tombstone marker; PII nulled/replaced when set |
| `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.
**RGPD anonymisation** (security-by-design, dict. note 13): the right to erasure is honoured by
anonymising, not hard-deleting. `email` becomes a unique non-identifying placeholder
(`anon-<id>@wakdo.invalid`, RFC 2606 reserved domain — preserves the UNIQUE constraint),
`first_name`/`last_name` are cleared, `password_hash`/`pin_hash` are invalidated, `is_active=0`,
`anonymized_at = NOW()`. The row persists so `audit_log` and `stock_movement` FKs stay valid.
---
### 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, [idempotency_key], source, [#acting_user_id],
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
UK : idempotency_key
FK : acting_user_id -> user(id) ON DELETE SET NULL
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 |
| `idempotency_key` | VARCHAR(36) | YES | Client UUID, UNIQUE; deduplicates retried POST (security-by-design) |
| `source` | ENUM('kiosk','counter','drive') | NO | Input channel |
| `acting_user_id` | INT UNSIGNED | YES | FK -> user; counter/drive staff under PIN; NULL for kiosk |
| `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 |
**Staff attribution (security-by-design)**: `acting_user_id` (FK -> `user`, ON DELETE SET NULL)
records the counter/drive staff who took the order under PIN; NULL for anonymous kiosk orders.
Kiosk orders stay anonymous by design. `stock_movement.user_id` covers attribution of stock
actions. `idempotency_key` (UNIQUE, nullable) deduplicates a retried `POST /api/orders`
(multiple NULLs allowed by the UNIQUE index, so non-idempotent legacy paths are tolerated).
**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.
---
### 4.20 `audit_log`
Append-only log of sensitive back-office actions (security-by-design, dict. 3.20).
```
audit_log (id, [#actor_user_id], [#actor_role_id], action_code,
[entity_type], [entity_id], [summary], [details], created_at)
PK : id
FK : actor_user_id -> user(id) ON DELETE SET NULL
FK : actor_role_id -> role(id) ON DELETE SET NULL
IDX : (actor_user_id, created_at)
IDX : (entity_type, entity_id)
IDX : (action_code, created_at)
```
| Column | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `actor_user_id` | INT UNSIGNED | YES | FK -> user; acting staff (PIN-captured) or NULL if not attributable |
| `actor_role_id` | INT UNSIGNED | YES | FK -> role; denormalised role context (survives user anonymisation) |
| `action_code` | VARCHAR(60) | NO | MCT operation / permission code, e.g. `product.update`, `order.cancel` |
| `entity_type` | VARCHAR(40) | YES | Affected table name |
| `entity_id` | INT UNSIGNED | YES | PK of the affected row |
| `summary` | VARCHAR(255) | YES | Short non-personal change description |
| `details` | JSON | YES | Optional before/after diff (field names for user-targeted actions, not PII values) |
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Immutable timestamp |
**ON DELETE SET NULL** on both FKs: the trail is preserved when a user is anonymised/removed
or a role deleted; only the link is severed (the denormalised `actor_role_id` keeps the role
context even after user anonymisation).
**Immutability rule**: no UPDATE or DELETE at application layer. **Retention**: a scheduled
cron purge removes rows older than the retention window (~12 months, legitimate-interest /
fiscal traceability), decoupled from the user PII lifecycle (dict. note 13).
No `updated_at`. Immutable append-only table.
---
### 4.21 `login_throttle`
Per-source-IP brute-force throttle (security-by-design). Complements the per-account counter
already on `user` (`failed_login_attempts` / `lockout_until`).
```
login_throttle (id, ip_address, failed_attempts, window_started_at,
[lockout_until], last_attempt_at)
PK : id
UK : ip_address
IDX : lockout_until
```
| Column | Type | NULL | Notes |
|---|---|---|---|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
| `ip_address` | VARCHAR(45) | NO | Source IP, one row per IP, upserted; 45 chars holds a full IPv6 literal. UNIQUE |
| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Consecutive failed logins from this IP in the current window |
| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Start of the current counting window |
| `lockout_until` | DATETIME | YES | End of the degressive backoff window; NULL = not throttled |
| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Timestamp of the last failed attempt |
No FK: an IP is not a modelled entity. Append/upsert by IP; the window resets when expired. A
daily cron purges rows with no active lockout whose `last_attempt_at` is older than 24h.
No `updated_at`: rows are upserted by IP, not edited through a UI.
---
## 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 |
| `customer_order.acting_user_id` | `user(id)` | SET NULL | Staff attribution preserved as anonymised principal; order kept |
| `audit_log.actor_user_id` | `user(id)` | SET NULL | Audit trail preserved on user anonymisation; only the link is severed |
| `audit_log.actor_role_id` | `role(id)` | SET NULL | Role context kept until role deletion; denormalised so it survives user anonymisation |
**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_capacity > 0` | The 100% reference must be positive; also guards the percentage division against divide-by-zero |
| `ingredient` | `pack_size > 0` | Pack size of zero makes restock logic incoherent |
| `ingredient` | `low_stock_pct BETWEEN 0 AND 100` | Warning band is a percent of capacity |
| `ingredient` | `critical_stock_pct BETWEEN 0 AND 100` | Auto-out-of-stock floor is a percent of capacity |
| `ingredient` | `critical_stock_pct < low_stock_pct` | Critical floor sits below the warning band |
| `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 |
| `audit_log` | `(actor_user_id, created_at)` | Per-actor audit history |
| `audit_log` | `(entity_type, entity_id)` | "what happened to this product/order/user?" |
| `audit_log` | `(action_code, created_at)` | Audit by action type over a time range |
| `login_throttle` | `lockout_until` | Daily cron purge of rows with no active lockout |
**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 21 MCD entities (19 prod-like + 2 security-by-design) 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) |
| `audit_log` (R5/R6) | `audit_log` (4.20) | 1:1 entity | New entity (security-by-design) |
| `login_throttle` (R7) | `login_throttle` (4.21) | 1:1 entity | New entity (security-by-design) |
**Result**: 21/21 entities mapped (19 prod-like + `audit_log` + `login_throttle`). No entity
without a table; no table outside the MCD. New columns on existing tables: `user`
(auth-lifecycle + `pin_hash` + `anonymized_at`), `customer_order` (`idempotency_key`,
`acting_user_id`), `ingredient` (`stock_capacity`, `low_stock_pct`, `critical_stock_pct`;
`low_stock_threshold` repurposed).
**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 |
| `audit_log` | ~5k-10k | 200 bytes | ~2 MB |
| `login_throttle` | ~100-1k | 80 bytes | < 1 MB |
**Estimated total**: ~190 MB data + ~60-80 MB for indexes = ~250-270 MB over 6 months
(`audit_log` is negligible: sensitive actions are orders of magnitude rarer than orders).
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`)
- `audit_log` (depends on `user`, `role`)
- `login_throttle` (no FK, can be created at any point)
Note: `customer_order` now carries `acting_user_id -> user`, so `user` must be created
before `customer_order` (already the case: the RBAC block precedes `customer_order`).
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.