corentin_wakdo/docs/merise/dictionary.md
Imugiii fadf0bd630 docs(merise): add security-by-design layer to data dictionary
Adds audit_log (20) and login_throttle (21); user auth lifecycle (pin_hash,
failed_login_attempts, lockout_until, reset token, anonymized_at); customer_order
acting_user_id + idempotency_key; percentage stock model on ingredient (signed
stock_quantity, stock_capacity, low_stock_pct, critical_stock_pct). 21 entities.
2026-06-12 09:29:51 +00:00

51 KiB

Data Dictionary — Wakdo

Merise phase : P1 - Conception, step 1 (data dictionary first, mantra #33) Version : v0.2 — prod-like, 21 entities (19 prod-like + security-by-design layer, incl. the new login_throttle entity) 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 in progress (see note 13) Author : BYAN (methodology layer)


1. Purpose

This dictionary lists all data entities identified for Wakdo, with their attributes, types, constraints, and sources. It serves as the basis for the MCD (entities + relations), then the MLD (relational mapping), then the DDL (SQL CREATE TABLE).

Methodology: bottom-up derivation from available sources:

  • School source: docs/merise/_sources/categories.json + produits.json (66 products, 9 categories)
  • Business brief: docs/PROJECT_CONTEXT.md (menu composition, order flow, RBAC, service modes)
  • Mockup: docs/design/maquette-borne.pdf (kiosk UX, visible screens)

All deviations between school source and final model are documented in the "Modeling notes" section at the bottom of this document.

For the entity-relationship diagram and cardinality justifications, see mcd.md. This dictionary does not duplicate that view to avoid diverging sources of truth.


2. General conventions

Naming

  • Tables: snake_case, singular (e.g., category, product, customer_order). Singular reflects the perspective "1 row = 1 instance of the entity" (standard relational convention). Application code (PHP, JS) uses these names as-is via ORM mapping.
  • Columns: snake_case. Typical suffixes: _id (FK), _at (timestamp), _cents (monetary amount in integer cents), _path (file path), _rate (rate or fraction stored as per-mille integer).
  • Primary keys: column id (INT UNSIGNED AUTO_INCREMENT). No composite PK except on pure join tables.
  • Foreign keys: <referenced_table>_id (e.g., category_id in product).
  • ENUM values: English, snake_case (e.g., pending_payment, dine_in, kiosk).
  • Code-facing strings (ENUM, permission codes, role codes): English only, consistent across DB, PHP, and JSON API.

Default types

Category MariaDB type Justification
Identifiers INT UNSIGNED AUTO_INCREMENT 4 billion ids — sufficient for this project
Short labels VARCHAR(120) Covers most product names (max observed: 41 chars in school source)
Descriptions TEXT Variable length, no strict limit
Monetary amounts INT UNSIGNED (cents) Avoids FLOAT rounding bugs (see note 1)
Booleans TINYINT(1) MariaDB convention for BOOLEAN (alias)
Timestamps DATETIME Human-readable, timezone handled at app layer
Enumerations ENUM('a','b','c') DBMS-level constraint, readable (see note 2)
File paths VARCHAR(255) Standard POSIX path length limit

Charset and collation

  • Charset: utf8mb4 (RFC 3629 — real 4-byte UTF-8, supports emoji and Asian characters). MariaDB handles utf8mb4 natively.
  • Collation: utf8mb4_unicode_ci (case-insensitive, Unicode-compliant comparison).

Audit fields (present on all business tables except pure join tables)

Column Type Default Role
created_at DATETIME CURRENT_TIMESTAMP Creation timestamp, written once at insert
updated_at DATETIME CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP Last modification timestamp, auto-updated

Soft delete

No generalized soft delete. Entities that can be temporarily deactivated carry an is_active or is_available boolean column. Hard DELETE remains possible but is reserved for admin operations with prior backup.


3. Entities

3.1 category

Business grouping of products and menus for display on the kiosk.

Attribute Type NULL Default Constraint School source Notes
id INT UNSIGNED NO AUTO_INCREMENT PK id (1-9) same as source
name VARCHAR(60) NO UNIQUE title renamed from title
slug VARCHAR(60) NO UNIQUE derived from title (kebab-case lowercase) used for URL /api/categories/burgers
image_path VARCHAR(255) YES NULL image relative path, see note 8
display_order SMALLINT UNSIGNED NO 0 (added) display order on kiosk, adjustable from admin
is_active TINYINT(1) NO 1 (added) deactivate without deleting
created_at DATETIME NO CURRENT_TIMESTAMP audit
updated_at DATETIME NO CURRENT_TIMESTAMP ON UPDATE audit

Examples: menus, drinks, burgers, fries, snacks, wraps, salads, desserts, sauces. Volume: 9 rows at init (seed from categories.json).


3.2 product

A single sellable item, available a la carte or as a component in a menu slot.

Attribute Type NULL Default Constraint School source Notes
id INT UNSIGNED NO AUTO_INCREMENT PK id same as source
category_id INT UNSIGNED NO FK -> category(id), ON DELETE RESTRICT (derived from JSON object key)
name VARCHAR(120) NO INDEX nom renamed from nom
description TEXT YES NULL (added) populated later via admin
price_cents INT UNSIGNED NO CHECK > 0 prix (FLOAT) FLOAT -> INT cents conversion at seed (see note 1)
vat_rate SMALLINT UNSIGNED NO 100 CHECK IN (55, 100) (added) VAT rate in per-mille: 100 = 10%, 55 = 5.5%. Default 10%. See note 9
image_path VARCHAR(255) YES NULL image relative path, see note 8
is_available TINYINT(1) NO 1 (added) manual availability toggle from admin
display_order SMALLINT UNSIGNED NO 0 (added) display order within category
created_at DATETIME NO CURRENT_TIMESTAMP audit
updated_at DATETIME NO CURRENT_TIMESTAMP ON UPDATE audit

Volume: ~53 rows at init (66 rows in produits.json minus 13 menus moved to menu).


3.3 menu

Fixed-price combo built around a specific burger, with customer-selectable slots (drink, side, sauce). Two price tiers: Normal and Maxi.

Attribute Type NULL Default Constraint School source Notes
id INT UNSIGNED NO AUTO_INCREMENT PK id (1-13 in menus category)
category_id INT UNSIGNED NO FK -> category(id), ON DELETE RESTRICT implicit (category menus)
burger_product_id INT UNSIGNED NO FK -> product(id), ON DELETE RESTRICT (added) the fixed burger that anchors this menu; drives ingredient customization
name VARCHAR(120) NO INDEX nom e.g., "Menu Le 280"
description TEXT YES NULL (added)
price_normal_cents INT UNSIGNED NO CHECK > 0 prix Normal format price. Replaces single prix_ttc_cents.
price_maxi_cents INT UNSIGNED NO CHECK > 0 (added) Maxi format price (~+150 cents vs normal; see note 7)
image_path VARCHAR(255) YES NULL image typically reuses the burger image
is_available TINYINT(1) NO 1 (added)
display_order SMALLINT UNSIGNED NO 0 (added)
created_at DATETIME NO CURRENT_TIMESTAMP audit
updated_at DATETIME NO CURRENT_TIMESTAMP ON UPDATE audit

Volume: 13 rows at init. Replaces the old fixed-composition menu_produit model.


3.4 menu_slot

A selectable slot within a menu (e.g., "drink slot", "side slot", "sauce slot"). Each slot constrains which products the customer can choose from, expressed via the join table menu_slot_option.

Attribute Type NULL Default Constraint Notes
id INT UNSIGNED NO AUTO_INCREMENT PK
menu_id INT UNSIGNED NO FK -> menu(id), ON DELETE CASCADE a slot belongs to exactly one menu
name VARCHAR(80) NO e.g., "Drink", "Side", "Sauce"
slot_type ENUM('drink','side','sauce','dessert','extra') NO semantic role of this slot
is_required TINYINT(1) NO 1 whether the customer must fill this slot
display_order SMALLINT UNSIGNED NO 0 order of display in the menu builder

No audit fields: a slot is part of menu definition; created and updated with the menu. Composite index: (menu_id, display_order).


3.5 menu_slot_option

Eligible products for a given menu slot. Pure join table.

Attribute Type NULL Default Constraint Notes
menu_slot_id INT UNSIGNED NO FK -> menu_slot(id), ON DELETE CASCADE
product_id INT UNSIGNED NO FK -> product(id), ON DELETE RESTRICT RESTRICT: removing a product must not silently break menus

Primary key: composite (menu_slot_id, product_id).

Volume: ~3-5 options per slot, ~3 slots per menu, 13 menus = ~120-200 rows at init.


3.6 ingredient

Elementary ingredient used in product composition. Carries stock data.

Attribute Type NULL Default Constraint Notes
id INT UNSIGNED NO AUTO_INCREMENT PK
name VARCHAR(120) NO UNIQUE e.g., "Sesame Bun", "Cheddar Slice", "Ketchup Portion"
unit VARCHAR(40) NO packaging unit label: piece / portion / sachet 1kg / pot / bottle (free-form label, not an ENUM — units vary per ingredient)
stock_quantity INT (signed) NO 0 current stock in units. Signed INT with no CHECK >= 0: it 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 NO CHECK > 0 reference "full" level in units = the 100% used to compute the stock percentage. The CHECK > 0 also guards the percentage division against divide-by-zero
pack_size SMALLINT UNSIGNED NO 1 CHECK > 0 units per restocking pack (e.g., 100 for a bag of 100 portions)
pack_label VARCHAR(80) YES NULL human label of the pack (e.g., "Sac 100 portions")
low_stock_pct SMALLINT UNSIGNED NO 10 CHECK BETWEEN 0 AND 100 warning band, percent of capacity: stock_quantity <= stock_capacity * low_stock_pct/100 triggers the low-stock indicator
critical_stock_pct SMALLINT UNSIGNED NO 5 CHECK BETWEEN 0 AND 100 auto-out-of-stock floor, percent of capacity: stock_quantity <= stock_capacity * critical_stock_pct/100 makes the product computed out-of-stock
is_active TINYINT(1) NO 1 deactivate obsolete ingredients without deleting
created_at DATETIME NO CURRENT_TIMESTAMP audit
updated_at DATETIME NO CURRENT_TIMESTAMP ON UPDATE audit

Table-level CHECK: critical_stock_pct < low_stock_pct (the critical floor sits below the warning band).

Stock decrement rule: at the paid transition, each ingredient is decremented by product_ingredient.quantity_normal or quantity_maxi (selected by order_item.format) multiplied by order_item.quantity, then adjusted by order_item_modifier rows. See note 7. Restocking rule: stock_quantity += N * pack_size (restocked in full packs). Cancellation rule: stock is re-credited when a paid order is cancelled. Stock model (percentage-based, three bands): the absolute alert threshold is replaced by a percentage model anchored on stock_capacity (the 100% reference). The stock percentage is computed, not stored: stock_pct = ROUND(stock_quantity / stock_capacity * 100). The CHECK > 0 on stock_capacity guards this division against divide-by-zero. Three bands:

  • Normal — above the low band: nothing flagged.
  • Lowstock_quantity <= stock_capacity * low_stock_pct/100: orderable + manager alert. The manager either pulls the product via product.is_available=0, or restocks to clear the alert.
  • Criticalstock_quantity <= stock_capacity * critical_stock_pct/100: the product auto-goes out-of-stock (computed availability, see rule RG-T21 in mlt.md); no extra stored column.

3.7 product_ingredient

Default composition of a product (burger, wrap, etc.) in terms of ingredients. Carries customization metadata for the ingredient configurator.

Attribute Type NULL Default Constraint Notes
product_id INT UNSIGNED NO FK -> product(id), ON DELETE CASCADE
ingredient_id INT UNSIGNED NO FK -> ingredient(id), ON DELETE RESTRICT RESTRICT: cannot remove an ingredient still referenced in a product recipe
quantity_normal SMALLINT UNSIGNED NO 1 CHECK > 0 units consumed in Normal format (e.g., 2 for double cheese)
quantity_maxi SMALLINT UNSIGNED NO 1 CHECK > 0 units consumed in Maxi format. Equals quantity_normal for format-invariant ingredients (burger, sauce); higher for side and drink ingredients (Maxi enlarges side + drink only). See note 7.
is_removable TINYINT(1) NO 1 customer can remove this ingredient at no cost
is_addable TINYINT(1) NO 0 customer can add an extra unit of this ingredient
extra_price_cents INT UNSIGNED NO 0 CHECK >= 0 surcharge in cents when is_addable=1 and customer adds it (0 = free extra)

Primary key: composite (product_id, ingredient_id).

Volume: ~5-10 ingredients per product, ~53 products = ~300-500 rows at seed.


3.8 allergen

Catalogue of the 14 regulated allergens (INCO Regulation (EU) 1169/2011).

Attribute Type NULL Default Constraint Notes
id INT UNSIGNED NO AUTO_INCREMENT PK
code VARCHAR(30) NO UNIQUE machine-readable code, e.g., gluten, milk, nuts
name VARCHAR(80) NO display name, e.g., "Gluten", "Lait", "Fruits a coque"
description TEXT YES NULL optional guidance for staff

Volume: 14 rows at seed (fixed by EU regulation 1169/2011, list confirmed at seed time). Allergens for a product are computed by joining product_ingredient -> ingredient_allergen -> allergen; no manual re-entry per product.


3.9 ingredient_allergen

Maps which allergens each ingredient contains. Pure join table.

Attribute Type NULL Default Constraint Notes
ingredient_id INT UNSIGNED NO FK -> ingredient(id), ON DELETE CASCADE
allergen_id INT UNSIGNED NO FK -> allergen(id), ON DELETE RESTRICT

Primary key: composite (ingredient_id, allergen_id).


3.10 customer_order

Customer transaction: 1 order = 1 validated cart at a point in time. (Table name rationale: see modeling note 3.)

Attribute Type NULL Default Constraint Notes
id INT UNSIGNED NO AUTO_INCREMENT PK
order_number VARCHAR(20) NO UNIQUE human-readable format: K/C/D-YYYY-MM-DD-NNN. Prefix by channel: K=kiosk, C=counter, D=drive. See note 4.
idempotency_key VARCHAR(36) YES NULL UNIQUE client-generated UUID to deduplicate a retried POST /api/orders (anti-double-charge). UNIQUE rejects duplicates; multiple NULLs allowed. Security-by-design, see note 13
source ENUM('kiosk','counter','drive') NO INDEX input channel (who entered the order). Values in English, see note 5.
acting_user_id INT UNSIGNED YES NULL FK -> user(id), ON DELETE SET NULL back-office staff (counter/drive) who created the order, captured under PIN. NULL for kiosk (anonymous). Targeted accountability without forcing per-person login on the kiosk. See note 13
service_mode ENUM('dine_in','takeaway','drive') NO consumption mode, retained for stats/KPI only. No fiscal role (see note 9). drive source implies drive service_mode (cross-constraint enforced at app layer).
status ENUM('pending_payment','paid','delivered','cancelled') NO 'pending_payment' INDEX 4-state machine: pending_payment -> paid -> delivered (+ cancelled). See note 6.
total_ht_cents INT UNSIGNED NO CHECK >= 0 ex-VAT total, snapshot at order validation
total_vat_cents INT UNSIGNED NO CHECK >= 0 VAT amount, snapshot
total_ttc_cents INT UNSIGNED NO CHECK > 0 incl.-VAT total; must equal total_ht_cents + total_vat_cents (verified at MLT layer)
paid_at DATETIME YES NULL timestamp of transition to paid (NULL before payment)
delivered_at DATETIME YES NULL timestamp of transition to delivered (NULL before delivery)
cancelled_at DATETIME YES NULL timestamp of cancellation (NULL if not cancelled)
created_at DATETIME NO CURRENT_TIMESTAMP INDEX used for live stats aggregations; also serves as service_day base
updated_at DATETIME NO CURRENT_TIMESTAMP ON UPDATE audit

Dropped from v0.1: tva_taux_pourmille (moved to line level — order_item.vat_rate_snapshot), paye_a (renamed paid_at). Machine states preparing and ready dropped (see note 6).

service_day computation (KPI grouping):

CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END

Computed at query time, not stored as a column (the generated-column formula with INTERVAL 4 HOUR 30 MINUTE in v0.1 MLD was incorrect and is dropped). Cutoff: 10:00.

Volume: ~100-300 orders/day at peak, ~10k rows over a 6-month demo.


3.11 order_item

Line of an order: a single product or a menu, with price, label, and VAT rate snapshotted at transaction time.

Attribute Type NULL Default Constraint Notes
id INT UNSIGNED NO AUTO_INCREMENT PK
order_id INT UNSIGNED NO FK -> customer_order(id), ON DELETE CASCADE
item_type ENUM('product','menu') NO discriminator
product_id INT UNSIGNED YES NULL FK -> product(id), ON DELETE RESTRICT non-null if item_type = 'product'
menu_id INT UNSIGNED YES NULL FK -> menu(id), ON DELETE RESTRICT non-null if item_type = 'menu'
format ENUM('normal','maxi') NO 'normal' applies to menu items (Normal / Maxi). For standalone products, value is normal (no individual upsizing in this model). See note 7.
label_snapshot VARCHAR(120) NO label at time of order (preserved if product is renamed)
unit_price_cents_snapshot INT UNSIGNED NO CHECK > 0 unit price incl. VAT at time of order
vat_rate_snapshot SMALLINT UNSIGNED NO CHECK IN (55, 100) VAT rate in per-mille at time of order (snapshotted from product.vat_rate)
quantity SMALLINT UNSIGNED NO 1 CHECK > 0 quantity ordered (e.g., 3 Cocas = 1 line with quantity=3)
created_at DATETIME NO CURRENT_TIMESTAMP audit

CHECK constraint (applicative or MariaDB CHECK >= 10.2): (item_type='product' AND product_id IS NOT NULL AND menu_id IS NULL) OR (item_type='menu' AND menu_id IS NOT NULL AND product_id IS NULL)

Volume: ~3-5 lines per order -> 30k-50k rows over 6 months.


3.12 order_item_selection

The actual choices made by the customer for each slot of a menu line. 1 row = 1 slot filled for 1 order_item of type menu.

Attribute Type NULL Default Constraint Notes
id INT UNSIGNED NO AUTO_INCREMENT PK
order_item_id INT UNSIGNED NO FK -> order_item(id), ON DELETE CASCADE must reference an order_item with item_type='menu'
menu_slot_id INT UNSIGNED NO FK -> menu_slot(id), ON DELETE RESTRICT which slot was filled
product_id INT UNSIGNED NO FK -> product(id), ON DELETE RESTRICT product chosen by the customer for this slot
label_snapshot VARCHAR(120) NO product label at time of order

Volume: ~2-3 selections per menu line. KPI use: enables analysis of which drink/side combinations are most chosen.


3.13 order_item_modifier

Ingredient-level modifications applied by the customer to a product or to the fixed burger of a menu: removal (free) or addition (with optional surcharge).

Attribute Type NULL Default Constraint Notes
id INT UNSIGNED NO AUTO_INCREMENT PK
order_item_id INT UNSIGNED NO FK -> order_item(id), ON DELETE CASCADE the order line being modified (product or menu)
ingredient_id INT UNSIGNED NO FK -> ingredient(id), ON DELETE RESTRICT the ingredient being modified
action ENUM('remove','add') NO remove = free removal; add = extra unit (may have surcharge)
extra_price_cents INT UNSIGNED NO 0 CHECK >= 0 snapshot of product_ingredient.extra_price_cents at time of order (0 for removals)

Modifier attachment rule (see modeling note 10):

  • For a standalone product (item_type='product'): the modifier targets the product directly via order_item_id.
  • For a menu (item_type='menu'): the modifier targets the menu line's fixed burger via the same order_item_id. The burger is identified by menu.burger_product_id, allowing the kitchen display to resolve which ingredients are modified without ambiguity. No additional FK is needed: given order_item_id, the burger is order_item.menu_id -> menu.burger_product_id.

Stock impact: each modifier affects ingredient stock at paid transition (remove -> no decrement for that ingredient; add -> extra decrement).


3.14 user

Back-office user (admin, manager, kitchen staff, counter, drive). Kiosk customers are not authenticated and have no row here.

Attribute Type NULL Default Constraint Notes
id INT UNSIGNED NO AUTO_INCREMENT PK
email VARCHAR(254) NO UNIQUE max length per RFC 5321
password_hash VARCHAR(255) NO argon2id hash (see PASSWORD_ALGO in .env); typical length 96 chars, margin to 255
first_name VARCHAR(60) NO
last_name VARCHAR(60) NO
role_id INT UNSIGNED NO FK -> role(id), ON DELETE RESTRICT a user cannot exist without a role
is_active TINYINT(1) NO 1 deactivation without deletion
last_login_at DATETIME YES NULL useful for audit and dormant account detection
pin_hash VARCHAR(255) YES NULL argon2id hash of the per-staff PIN that authorises sensitive actions (price/RBAC/user/cancel/inventory). NULL = no PIN set. Security-by-design, see note 13
failed_login_attempts SMALLINT UNSIGNED NO 0 consecutive failed logins; drives degressive throttling (note 13)
last_failed_login_at DATETIME YES NULL timestamp of the last failed login
lockout_until DATETIME YES NULL end of the current throttling window (degressive backoff, not a hard indefinite lock)
password_reset_token_hash VARCHAR(255) YES NULL hash of the reset token (not the raw token); NULL when no reset pending
password_reset_expires_at DATETIME YES NULL expiry of the reset token
anonymized_at DATETIME YES NULL RGPD tombstone marker: when set, PII columns are nulled/replaced (note 13). The row is kept for referential integrity
created_at DATETIME NO CURRENT_TIMESTAMP audit
updated_at DATETIME NO CURRENT_TIMESTAMP ON UPDATE audit

Volume: 5-20 rows (restaurant team + 1-2 admins).

RFC 5321 email length: local-part <= 64, domain <= 255, total <= 254 (including @). VARCHAR(254) is the spec-compliant value.

PII columns: email, first_name, last_name. Subject to RGPD anonymisation (see note 13). password_hash and pin_hash are credentials, kept out of logs and API responses.


3.15 role

Back-office roles (RBAC). Creatable / modifiable / deactivatable from admin UI. Seed provides 5 roles; custom roles (e.g., "chef-patissier") can be added without deployment.

Attribute Type NULL Default Constraint Notes
id INT UNSIGNED NO AUTO_INCREMENT PK
code VARCHAR(40) NO UNIQUE machine code, e.g., admin, manager, kitchen, counter, drive
label VARCHAR(80) NO display name, e.g., Administrator, Kitchen Staff
description TEXT YES NULL
default_route VARCHAR(120) YES NULL landing screen for this role (e.g., /admin/orders, /kitchen/display). Makes routing dynamic — no hardcoded role names in front-end routing.
order_source ENUM('kiosk','counter','drive') YES NULL auto-tagged source when this role creates an order (NULL for admin/manager who can create on behalf of any channel)
is_active TINYINT(1) NO 1 deactivation preserves history of users who held this role
created_at DATETIME NO CURRENT_TIMESTAMP audit
updated_at DATETIME NO CURRENT_TIMESTAMP ON UPDATE audit

Seed roles:

Code default_route order_source
admin /admin/dashboard NULL
manager /admin/stats NULL
kitchen /kitchen/display NULL
counter /counter/orders counter
drive /drive/orders drive

RBAC architecture rule (P2): application code tests permissions, not role names. Adding a new role with the right permissions requires no code change (permission-driven, not role-name-driven — per Sandhu/NIST RBAC model).


3.16 role_visible_source

Defines which order sources are visible on the preparation dashboard for a given role. Pure join table.

Attribute Type NULL Default Constraint Notes
role_id INT UNSIGNED NO FK -> role(id), ON DELETE CASCADE
source ENUM('kiosk','counter','drive') NO source visible to this role on the kitchen/counter/drive display

Primary key: composite (role_id, source).

Seed data:

Role Visible sources
kitchen kiosk, counter, drive (all)
counter kiosk, counter
drive drive

3.17 permission

Granular permissions assignable to roles. Catalogue is fixed at seed (no UI creation).

Attribute Type NULL Default Constraint Notes
id INT UNSIGNED NO AUTO_INCREMENT PK
code VARCHAR(60) NO UNIQUE format <resource>.<action>
label VARCHAR(120) NO display name
description TEXT YES NULL
created_at DATETIME NO CURRENT_TIMESTAMP audit

Fixed permission catalogue (23 codes — frozen before DDL):

Code Granted to (seed default)
product.create admin, manager
product.read admin, manager, kitchen, counter, drive
product.update admin, manager
product.delete admin
menu.create admin, manager
menu.read admin, manager, kitchen, counter, drive
menu.update admin, manager
menu.delete admin
category.manage admin, manager
ingredient.manage admin, manager
stock.read admin, manager, kitchen, counter, drive
stock.count admin, manager, kitchen, counter, drive
stock.manage admin, manager
order.read admin, manager, kitchen, counter, drive
order.create admin, counter, drive
order.deliver admin, counter, drive
order.cancel admin, counter, drive
user.create admin
user.read admin, manager
user.update admin
user.deactivate admin
role.manage admin
stats.read admin, manager

Volume: 23 rows at seed.


3.18 role_permission

N-N mapping between roles and permissions. Pure join table.

Attribute Type NULL Default Constraint Notes
role_id INT UNSIGNED NO FK -> role(id), ON DELETE CASCADE
permission_id INT UNSIGNED NO FK -> permission(id), ON DELETE CASCADE

Primary key: composite (role_id, permission_id).

Volume: ~50-100 rows at seed (admin covers all; others cover a subset).


3.19 stock_movement

Append-only audit log of all stock changes per ingredient. 1 row per movement (sale, cancellation, restock, inventory correction).

Attribute Type NULL Default Constraint Notes
id INT UNSIGNED NO AUTO_INCREMENT PK
ingredient_id INT UNSIGNED NO FK -> ingredient(id), ON DELETE RESTRICT ingredient affected
movement_type ENUM('sale','cancellation','restock','inventory_correction') NO INDEX nature of the movement
delta INT NO signed change: negative for consumption (sale), positive for restock/cancellation/correction
order_id INT UNSIGNED YES NULL FK -> customer_order(id), ON DELETE SET NULL linked order for sale and cancellation movements; NULL for restock/correction
user_id INT UNSIGNED YES NULL FK -> user(id), ON DELETE SET NULL user who triggered the movement (NULL for automated sale decrements)
note VARCHAR(255) YES NULL optional human note (e.g., reason for correction, pack reference)
created_at DATETIME NO CURRENT_TIMESTAMP INDEX immutable timestamp

Immutability: no UPDATE or DELETE on this table. Corrections are new rows with movement_type='inventory_correction' and a signed delta.

Automatic movements (triggered at status transitions):

  • paid transition: 1 sale row per ingredient unit consumed (accounting for modifiers).
  • cancelled (from paid): 1 cancellation row per ingredient unit re-credited.

Manual movements:

  • restock: manager or admin records a delivery (+= N * pack_size).
  • inventory_correction: morning/evening physical count; system records the discrepancy (delta = actual - theoretical).

Volume: ~5-15 movements per order across all ingredients; index on (ingredient_id, created_at) is recommended for per-ingredient history queries.


3.20 audit_log

Append-only log of sensitive back-office actions, for accountability where it matters (insider threat, money handling, RBAC changes). Complements stock_movement (which is stock-specific); covers catalogue/price, user, role/permission, and order cancellation events. Security-by-design addition (see note 13).

Attribute Type NULL Default Constraint Notes
id INT UNSIGNED NO AUTO_INCREMENT PK
actor_user_id INT UNSIGNED YES NULL FK -> user(id), ON DELETE SET NULL staff who performed the action, captured via PIN for sensitive operations. NULL if not attributable to an individual
actor_role_id INT UNSIGNED YES NULL FK -> role(id), ON DELETE SET NULL role context at action time (denormalised so the trail survives user anonymisation)
action_code VARCHAR(60) NO INDEX MCT operation / permission code, e.g. product.update, order.cancel, role.manage, user.deactivate
entity_type VARCHAR(40) YES NULL affected table name, e.g. product, customer_order, role, user
entity_id INT UNSIGNED YES NULL PK of the affected row
summary VARCHAR(255) YES NULL short non-personal description, e.g. "price_cents 880 -> 920", "added permission stock.manage"
details JSON YES NULL optional before/after diff. For user-targeted actions, stores changed field names, not PII values
created_at DATETIME NO CURRENT_TIMESTAMP INDEX immutable timestamp

Immutability: no UPDATE or DELETE at application layer (same discipline as stock_movement). Indexes: (actor_user_id, created_at), (entity_type, entity_id), (action_code, created_at). Retention: own window (~12 months, legitimate-interest / fiscal traceability), decoupled from user PII lifecycle (note 13). A scheduled purge (cron) removes rows past the window.

Logged operations (sensitive set): UPDATE_PRODUCT (8.2, incl. price), DELETE_PRODUCT (8.3), DELETE_MENU (8.6), CANCEL_ORDER (7.1), RESTOCK (9.1), INVENTORY_COUNT (9.2), CREATE_USER / UPDATE_USER / DEACTIVATE_USER (10.1-10.3), MANAGE_RBAC (10.4).

Volume: low (~10-50 sensitive actions/day) — orders of magnitude below stock_movement.


3.21 login_throttle

Per-source-IP brute-force throttle. Complements the per-account counter already on user (failed_login_attempts / lockout_until), one row per source IP. Security-by-design addition (see note 13).

Attribute Type NULL Default Constraint Notes
id INT UNSIGNED NO AUTO_INCREMENT PK
ip_address VARCHAR(45) NO UNIQUE source IP, one row per IP, upserted; 45 chars holds a full IPv6 literal
failed_attempts SMALLINT UNSIGNED NO 0 consecutive failed logins from this IP in the current window
window_started_at DATETIME NO CURRENT_TIMESTAMP start of the current counting window
lockout_until DATETIME YES NULL end of the degressive backoff window; NULL = not throttled
last_attempt_at DATETIME NO CURRENT_TIMESTAMP timestamp of the last failed attempt

No FK: an IP is not a modelled entity. Rows are appended/upserted by IP; the window resets when expired. A daily cron purges rows with no active lockout whose last_attempt_at is older than 24h.


4. Modeling notes

Note 1 — Why INT UNSIGNED in cents for prices

Storing a price as FLOAT or DECIMAL(10,2) is technically valid but introduces two risks:

  1. FLOAT rounding: 0.1 + 0.2 = 0.30000000000000004 in IEEE 754 floating-point. Summing 100 order lines can produce cent-level discrepancies vs business reality.
  2. FLOAT-to-string conversion: different PHP/MariaDB driver versions may serialize floats with variable precision.

Storing as INT UNSIGNED (cents: 880 for EUR 8.80) eliminates these risks. Conversion to EUR for display is done in PHP at output: number_format($cents / 100, 2).

Reference: David Goldberg, What Every Computer Scientist Should Know About Floating-Point Arithmetic, ACM Computing Surveys, 1991.

Note 2 — Why ENUM rather than a reference table

ENUMs (service_mode, status, item_type, action, slot_type) could have been reference tables. Choice retained: ENUM.

Advantages in this context:

  • Values are stable and limited (3-7 values max), unlikely to evolve frequently.
  • DBMS-level constraint instead of runtime FK; simpler queries.
  • Directly readable in SQL: WHERE status = 'paid'.

Cost of a future change: ALTER TABLE ... MODIFY COLUMN ... ENUM(...) to add a value. Acceptable given changes are expected to be rare.

If these ENUMs later require multilingual labels or descriptions, they will be migrated to reference tables. Not in scope for this iteration.

Note 3 — Why customer_order and not order

ORDER is an SQL reserved word (used in ORDER BY). Three approaches exist:

  • Quote the name everywhere: `order` — requires quoting in every SQL statement, error-prone and non-portable across DBMS dialects.
  • Use an alias at ORM level: possible but adds a mapping layer.
  • Rename: customer_order (chosen) — unambiguous, self-documenting, no quoting required.

Alternative considered and rejected: purchase (less domain-specific), transaction (also reserved or ambiguous). customer_order matches the domain language and avoids all conflicts.

order_item is retained as the line table name: item is not reserved, and the order_ prefix makes the parent relationship clear.

Note 4 — Order number prefix by channel

Format: K/C/D-YYYY-MM-DD-NNN (kiosk / counter / drive).

Rationale: the prefix encodes the channel, which is useful for rapid visual identification by kitchen and counter staff without querying the source column. The sequential counter NNN restarts daily per channel. Collision-free within a day given expected volume.

Alternative rejected: neutral prefix W- for all channels (simpler, but loses channel readability for staff).

Note 5 — source vs service_mode (channel vs consumption mode)

Two distinct dimensions, kept separate:

source service_mode
Nature input channel (who entered the order) consumption mode (where the customer eats)
Values kiosk, counter, drive dine_in, takeaway, drive
Used for authentication, analytics, permission filtering KPI, capacity (no fiscal role)

The two dimensions are independent for kiosk and counter (a kiosk customer can choose dine_in or takeaway). drive is the only case where both dimensions align: source=drive implies service_mode=drive. This cross-constraint is verified at app layer.

Note 6 — Reduced 4-state machine

v0.1 had 6 states (pending_payment, paid, preparing, ready, delivered, cancelled). v0.2 reduces to 4 states: pending_payment -> paid -> delivered (+ cancelled).

Rationale (Decision 4 from revue-alignement-p1.md §7): in a fast-food context, the kitchen display (KDS) is a visual system — staff see the ticket and act. preparing and ready were intermediate states that added complexity without proportional business value. The single kitchen action is deliver (counter/drive staff hands over the order), collapsing preparing + ready + delivered into one gesture. KPI is total time: delivered_at - paid_at (SLA ~10 min). KDS color coding is computed from now - paid_at, no extra stored state.

Dropped states and timestamps: preparing_at, ready_at are not stored.

Note 7 — Normal / Maxi format cascade

The Maxi format enlarges the side and the drink only. The burger is unchanged and the sauce portion is unchanged (a sauce pot is the same in both formats). This scope is explicit so the stock model stays faithful.

Price side — not modeled at individual component price level:

  • menu carries two prices: price_normal_cents and price_maxi_cents.
  • order_item.format records which format the customer chose (normal or maxi).
  • order_item.unit_price_cents_snapshot captures the actual price paid (Normal or Maxi).
  • No individual price per slot component is stored; the price differential is a menu-level attribute, consistent with how fast-food menus tend to be priced in practice.

Stock side — modeled via a format multiplier on the recipe:

  • product_ingredient carries quantity_normal and quantity_maxi.
  • At the paid transition, the decrement uses quantity_maxi when order_item.format='maxi', otherwise quantity_normal.
  • For burger and sauce ingredients, quantity_maxi = quantity_normal (format-invariant).
  • For side and drink ingredients, quantity_maxi > quantity_normal (Maxi consumes more).
  • The format cascades from the menu line (order_item.format) to its slot selections; a standalone product line defaults to normal.
  • Single product per choice (e.g., one Fries product), not separate medium/large products.

Calibration: the Maxi surcharge is in the ~1.50 EUR range for this model (derived from internal data; cross-checked against market magnitude for plausibility. Wakdo is a fictional pastiche so exact prices are not copied from a real chain).

Calibration: the Maxi surcharge is in the ~1.50 EUR range for this model (derived from internal data; cross-checked against market magnitude for plausibility. Wakdo is a fictional pastiche so exact prices are not copied from a real chain).

Note 8 — Image storage: path in VARCHAR vs BLOB in DB

image_path columns (category, product, menu) store a relative path from the public root (e.g., /uploads/products/classic-burger.jpg), not an absolute server path. PHP resolves via a prefix from .env (UPLOAD_DIR=public/uploads).

BLOB storage was considered and rejected:

Criterion image_path VARCHAR (chosen) BLOB in DB
Kiosk performance Apache serves files in ms (OS cache) PHP reads DB + streams, multiplied latency
HTTP caching ETag, Last-Modified, browser cache, CDN native must be reimplemented in PHP
DB backup size Megabytes (paths only) Gigabytes (66 products x ~200 KB + responsive variants)
Image pipeline convert, webp, optimization = standard filesystem tools must be reinvented in PHP

Sources: OWASP File Upload Cheat Sheet; MariaDB Knowledge Base — LONGBLOB performance; Apache HTTP Server documentation — serving static content.

Note 9 — VAT rule in French fast-food (fact-checked)

FACT-CHECK
Claim audited : "TVA 10% sur place / 5,5% a emporter" (dictionary v0.1 note 9)
Domain         : compliance (fiscal)
Verdict        : CLAIM INEXACT — superseded
Source         : BOFiP BOI-ANNX-000495 + BOI-TVA-LIQ-30-10-10 (official doctrine impots.gouv.fr)
Actual rule    : 10% for immediate consumption (dine-in OR hot takeaway);
                 5.5% for products in resealable containers (bottle, can) / deferred consumption
Confidence     : 95% (L1, official text)

Model consequence: VAT rate is an attribute of the product (vat_rate in per-mille: 100 = 10%, 55 = 5.5%), not of the order or the service mode. Default: 100 (10%). The 5.5% rate applies to products in resealable containers (bottled water, juice bottles). VAT is computed line by line; the rate is snapshotted on order_item.vat_rate_snapshot at transaction time to preserve historical integrity if legislation changes.

service_mode is retained on customer_order for stats and KPI only (capacity planning, per-mode revenue breakdown). It has no fiscal computation role.

Note 10 — Ingredient configurator and modifier attachment

order_item_modifier attaches to an order_item row via order_item_id, regardless of whether the line is a standalone product or a menu.

For a standalone product (item_type='product'): order_item_id directly identifies the product being modified.

For a menu (item_type='menu'): the modifiable product is the fixed burger, identified via order_item.menu_id -> menu.burger_product_id. The kitchen display resolves: modifier.order_item_id -> order_item -> menu -> menu.burger_product_id -> product.name. No additional FK column is needed on order_item_modifier. This keeps the modifier table simple and avoids a nullable target_product_id column that would only be populated for menu lines.

Constraint enforced at app layer: order_item_modifier rows for a menu line reference only ingredients belonging to menu.burger_product_id via product_ingredient.

Note 11 — menu_slot eligibility: category filter vs explicit product list

Two options were considered:

  • Category filter: menu_slot.category_id points to a category; all products in that category are eligible. Simple, but a category may contain products not offered in this slot (e.g., a premium drink added to the "drinks" category should not automatically appear in all menu slots).
  • Explicit product list menu_slot_option(menu_slot_id, product_id) (chosen): each eligible product is listed explicitly per slot. More verbose at seed time but precise — no accidental eligibility when the catalogue grows. Enables per-slot pricing overrides in the future without structural change.

The explicit list adds one entity (menu_slot_option, entity 3.5) but eliminates a class of correctness bugs. Consistent with the prod-like ambition of this model.

Note 12 — commande_event dropped

v0.1 carried a commande_event append-only audit table (event sourcing pattern). Dropped in v0.2 (Decision 1, revue-alignement-p1.md §7).

Rationale: in a restaurant context, the back-office account is shared per workstation, not individual. Per-person attribution of a state transition has no business value. The actual need (phase durations, time-of-day stats) is covered by phase timestamps on customer_order (paid_at, delivered_at, cancelled_at) without the complexity of an event store.

The 4-state machine combined with 3 phase timestamps provides all KPI data needed:

  • Time-to-deliver: delivered_at - paid_at
  • Cancellation rate and timing: cancelled_at - created_at
  • Volume by hour: HOUR(created_at) / service_day computation

For stock audit, stock_movement (entity 3.19) provides the append-only audit trail where it is genuinely needed (inventory reconciliation).

Note 13 — Security-by-design data additions (2026-06-11)

These additions extend the prod-like model with a security-by-design layer. They do not replace any v0.2 decision; they add accountability, auth lifecycle, and abuse resistance.

Accountability — hybrid shared-account + PIN. Back-office sessions stay shared per workstation for the routine flow (a fast-food terminal is shared, equipiers rotate). A per-staff PIN (user.pin_hash, argon2id) authorises a defined set of sensitive actions (price/menu edits 8.2/8.3/8.6, order cancellation 7.1, inventory correction 9.2, user management 10.1-10.3, RBAC 10.4). Those actions write the acting user_id into audit_log (3.20). This resolves the circular justification that dropped commande_event in v0.1 (events were considered useless because accounts were shared): accountability is recorded where it matters, at near-zero friction for the routine 95%. customer_order.acting_user_id captures the staff for counter/drive orders taken under PIN; kiosk orders stay anonymous.

Auth lifecycle. password_reset_token_hash + password_reset_expires_at enable a reset path (the token is stored hashed, the raw token is e-mailed once). Brute-force resistance uses degressive throttling rather than a hard indefinite lock: failed_login_attempts + lockout_until implement an exponential backoff per (account + source IP), so a fat-finger streak does not lock out a whole kitchen mid-service (15 h continuous). Failed logins are written to audit_log.

RGPD anonymisation vs audit retention. user PII (email, first_name, last_name) is subject to the right to erasure (Cr 3.d). Erasure anonymises rather than hard-deletes: the row is kept, email becomes a non-identifying unique placeholder (anon-<id>@wakdo.invalid, RFC 2606 reserved domain), names are cleared, password_hash/pin_hash are invalidated, and anonymized_at is set. The audit_log retains its own retention window (~12 months, legitimate-interest / fiscal traceability) and keeps pointing at the anonymised principal, so erasure and accountability coexist without breaking referential integrity.

Abuse resistance on the anonymous kiosk. customer_order.idempotency_key (client UUID, UNIQUE) deduplicates a retried POST /api/orders so a network retry does not create a duplicate paid order. Stock is decremented with a single atomic statement (UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id): no operation gates on a stock read, so the row self-locks for the duration of the write — no lost update and no deadlock-ordering concern. This replaces the earlier pessimistic SELECT ... FOR UPDATE approach (treatment-layer rule, see mlt.md); it adds no column here.

Percentage stock model + computed availability. ingredient carries stock_capacity (the 100% reference), low_stock_pct (warning band) and critical_stock_pct (auto-out-of-stock floor) — see 3.6. stock_quantity is signed and may go negative (oversell magnitude surfaced to managers); the system does not block an order on stock. Effective product orderability is computed (rule RG-T21 in mlt.md): product.is_available = 1 AND each non-removable (is_removable=0) ingredient of 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; restock above the critical band makes the product orderable again on its own.

Per-IP brute-force throttle. login_throttle (3.21) tracks failed_attempts and lockout_until per source IP (one upserted row per IP), complementing the per-account counter on user. This adds a second throttling dimension so a single IP hammering many accounts is slowed independently of any one account's counter. A daily cron purges idle, non-locked rows.

References: docs/notes/revue-alignement-p1.md §7 (D-decisions), security-by-design impact map (2026-06-11). Threat model and data-classification matrix: PROJECT_CONTEXT.md §19 (to come).


5. Entity count summary

# Entity Type Replaces / new
1 category business v0.1 categorie (renamed + translated)
2 product business v0.1 produit (+ vat_rate)
3 menu business v0.1 menu (+ burger FK, 2 prices)
4 menu_slot business new — replaces menu_produit fixed composition
5 menu_slot_option join new — eligibility list per slot
6 ingredient business new — ingredient configurator + stock
7 product_ingredient join new — recipe + customization metadata
8 allergen reference new — INCO 1169/2011
9 ingredient_allergen join new — maps allergens to ingredients
10 customer_order business v0.1 commande (renamed, 4-state machine, phase timestamps)
11 order_item business v0.1 ligne_commande (+ format, vat_rate_snapshot)
12 order_item_selection business new — customer menu slot choices
13 order_item_modifier business new — ingredient-level modifications
14 user business v0.1 user (translated field names)
15 role business v0.1 role (+ default_route, order_source)
16 role_visible_source join new — per-role dashboard filter
17 permission reference v0.1 permission (translated, catalogue frozen)
18 role_permission join v0.1 role_permission (unchanged)
19 stock_movement audit new — append-only stock audit log
20 audit_log audit new (security-by-design) — append-only sensitive-action log
21 login_throttle security new (security-by-design) - per-IP brute-force throttle

Dropped from v0.1: commande_event (replaced by phase timestamps on customer_order), menu_produit (replaced by menu_slot + menu_slot_option model).

Total: 21 entities (19 prod-like v0.2 + audit_log and login_throttle from the security-by-design layer).

Security-by-design also adds columns (beyond the two new entities): user auth-lifecycle + pin_hash + anonymized_at (3.14), customer_order.acting_user_id + idempotency_key (3.10), and the percentage stock model on ingredient (3.6) — stock_capacity, critical_stock_pct, plus the rename of low_stock_threshold to low_stock_pct. login_throttle (3.21) is the 21st entity. See note 13.


For the ER diagram and cardinality justifications, see mcd.md — the diagram is the single source of truth for graphical representation.