1320 lines
55 KiB
Markdown
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.
|