Transverse rules RG-T13-T21 (PIN, audit, escaping, allowlists, idempotency, atomic stock decrement, computed availability); RGPD erasure and password reset flows; per-IP login_throttle table with daily purge cron; atomic stock decrement replaces pessimistic FOR UPDATE.
715 lines
59 KiB
Markdown
715 lines
59 KiB
Markdown
# Model of Logical Treatments (MLT) — Wakdo
|
|
|
|
**Merise phase** : P1 - Conception, step 4 (derived from MCT)
|
|
**Version** : v0.2 — prod-like, 4-state machine (+ security-by-design layer 2026-06-11)
|
|
**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 rules added (RG-T13-T21: PIN, audit, escaping, allowlists, idempotency, atomic decrement, computed product availability (RG-T21); ops RESET_PASSWORD, ERASE_USER_PII, auth throttling; per-IP throttle table `login_throttle`)
|
|
**Author** : BYAN (methodology layer)
|
|
|
|
---
|
|
|
|
## 1. Purpose
|
|
|
|
The MLT (Model of Logical Treatments) refines each MCT operation by specifying:
|
|
- **preconditions** — what must be true before execution
|
|
- **business rules** — validation, computation, business logic
|
|
- **postconditions** — the state guaranteed after success
|
|
- **outputs** — produced data or emitted events
|
|
- **error cases** — alternative outputs when a condition fails
|
|
|
|
It bridges the MCT (conceptual level) and the PHP/SQL implementation (physical level).
|
|
All entity/attribute references use the names from `docs/merise/dictionary.md` (English,
|
|
snake_case). All monetary amounts are in integer cents.
|
|
|
|
**Tag conventions**:
|
|
- `[PRE]` — precondition; must be satisfied for the operation to execute
|
|
- `[RG]` — business rule (regle de gestion); logic applied during execution
|
|
- `[POST]` — postcondition; database state guaranteed after success
|
|
- `[OUT]` — output; data or event produced
|
|
- `[ERR]` — error case; alternative output when a condition fails
|
|
|
|
---
|
|
|
|
## 2. Transverse business rules
|
|
|
|
These rules apply to multiple operations and are centralised here to avoid repetition.
|
|
|
|
| Rule code | Label | Operations concerned |
|
|
|-----------|-------|----------------------|
|
|
| **RG-T01** | CSRF token verified on every back-office POST/PUT/DELETE form | AUTH, all admin ops |
|
|
| **RG-T02** | Session active + `user.is_active = 1` verified on each authenticated request | All domains 3-10 |
|
|
| **RG-T03** | Permission verified via `role_permission` before executing operation | All domains 3-10 |
|
|
| **RG-T04** | All monetary amounts are manipulated in integer cents; EUR conversion at output only | 3.3, 4.1, 8.1, 8.4 |
|
|
| **RG-T05** | Snapshots (`label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot`) on `order_item` are not modified after INSERT (historical integrity of placed orders — design guarantee) | 3.3, 4.1, 8.2, 8.5 |
|
|
| **RG-T06** | All SQL queries use PDO with prepared statements; no user data concatenated into SQL | All operations |
|
|
| **RG-T07** | Status transition UPDATE statements include `AND status = <expected_status>` in the WHERE clause (optimistic concurrency protection against double transition) | 6.1, 7.1 |
|
|
| **RG-T08** | Operations touching multiple tables execute in an atomic database transaction; partial failure triggers full rollback | 3.3, 4.1, 7.1, 8.4, 9.1, 9.2 |
|
|
| **RG-T09** | Cross-constraint on `customer_order`: `source = 'drive'` implies `service_mode = 'drive'`; verified at order creation. Materialisable as a MariaDB CHECK: `CHECK (source != 'drive' OR service_mode = 'drive')`. | 3.3, 4.1 |
|
|
| **RG-T10** | VAT computation is line-by-line: each `order_item` carries its own `vat_rate_snapshot` (per-mille integer snapshotted from `product.vat_rate`). Order totals (`total_ht_cents`, `total_vat_cents`, `total_ttc_cents`) are the sum of line-level amounts. | 3.3, 4.1 |
|
|
| **RG-T11** | Stock decrements at the `pending_payment -> paid` transition and re-credits at `paid -> cancelled` are within the same database transaction as the status update (no orphan decrement). | 3.3, 4.1, 7.1 |
|
|
| **RG-T12** | Dashboard filter by source: each role's visible sources are read from `role_visible_source`; the query uses `WHERE customer_order.source IN (role_visible_sources)`. | 6.1 |
|
|
| **RG-T13** | **Sensitive-action PIN** (security-by-design): the set of sensitive operations requires a per-staff PIN re-authorisation before execution: verify the submitted PIN against `user.pin_hash` (`password_verify`, argon2id). On success the acting `user_id` is captured for the audit log; on failure the operation is rejected. Sensitive set: 7.1 (cancel), 8.2/8.3 (product update/delete), 8.6 (menu delete), 9.2 (inventory correction), 10.1/10.2/10.3 (user mgmt), 10.4 (RBAC), 10.5 (PII erasure). Sessions stay shared per workstation for the routine 95%. | 7.1, 8.2, 8.3, 8.6, 9.2, 10.1-10.5 |
|
|
| **RG-T14** | **Audit log write**: non-stock sensitive operations append one immutable `audit_log` row in the same transaction as their effect: `actor_user_id` (from RG-T13 PIN), `actor_role_id`, `action_code` (permission/operation code), `entity_type` + `entity_id` of the affected row, `summary` (non-personal change description), `details` JSON (changed field **names** for user-targeted actions, not PII values). No UPDATE/DELETE on `audit_log`. Stock actions (9.1 restock, 9.2 inventory) record their attribution via `stock_movement.user_id` (PIN-captured), which already provides the append-only stock trail — they are not double-logged. | 7.1, 8.2, 8.3, 8.6, 10.1-10.5, 12.1 |
|
|
| **RG-T15** | **Output escaping** (anti-XSS): free-text fields (`product.name`/`description`, `ingredient.name`, `user.first_name`/`last_name`, notes) are context-escaped at render. Server-rendered admin views use `htmlspecialchars($v, ENT_QUOTES, 'UTF-8')`; the vanilla-JS kiosk injects text via `textContent` (or an explicit escaper), not `innerHTML`. | All views rendering stored text |
|
|
| **RG-T16** | **Mass-assignment allowlist**: INSERT/UPDATE statements bind only an explicit per-operation column allowlist from the request; extra/unknown fields are dropped. Prevents tampering with `price_cents`, `vat_rate`, `role_id`, `is_active`, `status` via injected form fields. | 8.1, 8.2, 8.4, 8.5, 10.1, 10.2 |
|
|
| **RG-T17** | **Dynamic identifier allowlist**: column/direction tokens used in dynamic `ORDER BY` / `GROUP BY` are resolved against a fixed allowlist of column names before query build (RG-T06 covers values via bind parameters; SQL identifiers cannot be bound, so they are allowlisted). | 5.1, 9.3, 11.1 |
|
|
| **RG-T18** | **Server-side validation and length bounds**: every input is re-validated server-side regardless of client checks — type, range, max length (matching the dictionary VARCHAR sizes), enum membership, FK existence. Client-side validation is a UX aid, not a trust boundary. | All write operations |
|
|
| **RG-T19** | **Idempotency**: `POST /api/orders` carries a client-generated `idempotency_key` (UUID). Before creating, look it up on `customer_order.idempotency_key` (UNIQUE); if a row exists, return that order instead of creating a duplicate (replayed network retry). | 3.3, 4.1 |
|
|
| **RG-T20** | **Atomic stock decrement**: during the `paid` transition, each affected `ingredient` is decremented with a single self-locking statement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` — no preceding read-gate, no `SELECT ... FOR UPDATE`. Concurrent orders on the same ingredient apply their deltas without a lost update and without a deadlock-ordering concern. `stock_quantity` is signed and may go negative when sales outrun counted stock (oversell magnitude surfaced to managers); the decrement does not block on a floor. | 3.3, 4.1 |
|
|
| **RG-T21** | **Computed product availability**: a product's effective orderability is computed, not stored. It is 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 required ingredient takes the product out-of-stock with no write and no cascade; restock above the critical band makes it orderable again on its own; a manual pull (`product.is_available = 0`) is a hard override; a removable/optional ingredient at the critical band does not block the product (only its add-on becomes unavailable). | 3.1, 3.3, 4.1, 5.1 |
|
|
|
|
---
|
|
|
|
## 3. Domain 1 — Order lifecycle (kiosk)
|
|
|
|
### 3.1 LOAD_CATALOGUE
|
|
|
|
**Corresponds to MCT section 3.1**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Request originates from the kiosk endpoint (public, no authentication required) |
|
|
| **[PRE-2]** | Current time is within the service window (10:00-01:00); outside the window the kiosk displays a closed message |
|
|
| **[RG-1]** | Read all `category` rows with `is_active = 1`, ordered by `category.display_order ASC` |
|
|
| **[RG-2]** | For each category, read `product` rows with `is_available = 1` and matching `category_id`, ordered by `product.display_order ASC` |
|
|
| **[RG-3]** | Read all `menu` rows with `is_available = 1`; for each menu, load `menu_slot` rows ordered by `menu_slot.display_order ASC`; for each slot, load eligible products via `menu_slot_option JOIN product` (where `product.is_available = 1`) |
|
|
| **[RG-4]** | For each product, compute allergens by joining `product_ingredient -> ingredient_allergen -> allergen` (no manual re-entry per product) |
|
|
| **[RG-5]** | For each product with `product_ingredient` rows, load `ingredient` composition (for the configurator) |
|
|
| **[RG-6]** | Prices are returned in integer cents; EUR conversion is performed client-side |
|
|
| **[POST-1]** | No database write; database state unchanged |
|
|
| **[OUT-1]** | JSON response: `{data: {categories: [...], products: {...}, menus: [{..., slots: [{..., options: [...]}]}]}}` |
|
|
| **[ERR-1]** | DB unreachable: response `{data: null, error: {code: "DB_ERROR"}}` and front-end falls back to static JSON |
|
|
|
|
---
|
|
|
|
### 3.2 COMPOSE_CART
|
|
|
|
**Corresponds to MCT section 3.2**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Catalogue loaded into front-end memory (LOAD_CATALOGUE completed) |
|
|
| **[PRE-2]** | Selected item (product or menu) is present in the loaded catalogue with `is_available = 1` |
|
|
| **[RG-1]** | Cart is a JavaScript in-memory structure (array of items); no database persistence at this stage |
|
|
| **[RG-2]** | Each item contains: `type` (`product` or `menu`), `item_id`, `label`, `unit_price_cents` (snapshot from catalogue), `quantity`, `format` (`normal` or `maxi`, for menus), `slot_selections` (array of `{menu_slot_id, product_id, label}` for menu items), `modifiers` (array of `{ingredient_id, action, extra_price_cents}`) |
|
|
| **[RG-3]** | Format Normal/Maxi (menu items only): `normal` uses `menu.price_normal_cents`; `maxi` uses `menu.price_maxi_cents`. No individual component price change is stored; the price differential is at menu level. |
|
|
| **[RG-4]** | Ingredient modifier rules: `action = 'remove'` requires `is_removable = 1` on `product_ingredient` (free); `action = 'add'` requires `is_addable = 1` (may carry `extra_price_cents`). These constraints are verified at cart composition time against the loaded catalogue. |
|
|
| **[RG-5]** | If an item with the same `(type, item_id, format, slot_selections, modifiers)` already exists in the cart, its quantity is incremented rather than adding a new item |
|
|
| **[RG-6]** | Cart total recomputed after each change: `SUM(unit_price_cents * quantity + modifier_extras)` across all items |
|
|
| **[POST-1]** | No database write; cart in-memory state updated |
|
|
| **[OUT-1]** | Cart summary displayed with TTC total |
|
|
| **[ERR-1]** | If a product becomes `is_available = 0` between catalogue load and order submission, the server-side validation in CREATE_ORDER catches it |
|
|
|
|
---
|
|
|
|
### 3.3 CREATE_ORDER
|
|
|
|
**Corresponds to MCT section 3.3**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Cart contains at least 1 item (`items.length >= 1`) |
|
|
| **[PRE-2]** | Order number entered by customer is non-empty (front-end validation) |
|
|
| **[PRE-3]** | POST JSON body is valid (schema validation at API layer) |
|
|
| **[RG-1]** | Server-side availability check: for each item, verify `product.is_available = 1` or `menu.is_available = 1`. If any item is unavailable, reject with list of unavailable articles. |
|
|
| **[RG-2 — service_day]** | `service_day` for a given order is computed at query time as: `CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END`. Cutoff is 10:00. This is NOT stored as a column — computed at query time only. The v0.1 formula with `INTERVAL 4 HOUR 30 MINUTE` was incorrect and is dropped. |
|
|
| **[RG-3 — order number]** | Order number format: `K-YYYY-MM-DD-NNN` where NNN is the sequential counter for the current service_day for the `kiosk` source (SELECT COUNT + 1 with a table-level lock or serialised insert to avoid duplicate generation under concurrency). Source is `kiosk` (set by the kiosk endpoint, derived from the public entry point). |
|
|
| **[RG-4 — VAT by line]** | For each `order_item`: `vat_rate_snapshot` is copied from `product.vat_rate`. Line amounts: `unit_ttc = unit_price_cents_snapshot`; `unit_ht = ROUND(unit_ttc * 1000 / (1000 + vat_rate_snapshot))`; `unit_vat = unit_ttc - unit_ht`. Order totals: `total_ttc_cents = SUM(unit_ttc * quantity)` across all lines; `total_ht_cents = SUM(unit_ht * quantity)`; `total_vat_cents = total_ttc_cents - total_ht_cents`. Invariant: `total_ttc_cents = total_ht_cents + total_vat_cents` (verified before INSERT). |
|
|
| **[RG-5 — atomic transaction]** | All writes within one database transaction: (1) INSERT `customer_order` (status `pending_payment`, source `kiosk`, service_mode from cart, computed totals); (2) INSERT `order_item` rows (label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot, quantity, format, item_type, product_id or menu_id); (3) INSERT `order_item_selection` rows for each slot filled in a menu item (order_item_id, menu_slot_id, product_id, label_snapshot); (4) INSERT `order_item_modifier` rows for each ingredient modification (order_item_id, ingredient_id, action, extra_price_cents snapshot); (5) for each ingredient consumed: compute units = `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by modifiers (remove => no decrement for that ingredient; add => extra decrement); apply the atomic decrement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (single self-locking statement, no preceding read-gate, RG-T20); `stock_quantity` is signed and may go negative (oversell magnitude, surfaced to managers) — the decrement does not gate on a floor; INSERT `stock_movement` (type `sale`, delta = -units, order_id, user_id = NULL for kiosk); (6) UPDATE `customer_order` SET status = `paid`, `paid_at = NOW()`. All six steps commit together or roll back entirely. |
|
|
| **[RG-6 — cross-constraint]** | Source `kiosk` implies no particular service_mode constraint; the customer selects `dine_in` or `takeaway`. The drive cross-constraint (RG-T09) does not apply to kiosk-originated orders. |
|
|
| **[RG-7 — immutability]** | After INSERT, `label_snapshot`, `unit_price_cents_snapshot`, and `vat_rate_snapshot` are not modified even if the source product is later renamed or repriced (see RG-T05). |
|
|
| **[RG-8 — idempotency]** | The body carries a client `idempotency_key` (UUID). Before any write, `SELECT id, order_number, status FROM customer_order WHERE idempotency_key = :key`. If found, skip creation and return that order (deduplicates a replayed retry — RG-T19). The key is stored on the new `customer_order` row. |
|
|
| **[RG-9 — server-side modifier re-validation]** | The ingredient modifiers in the body are re-validated server-side against `product_ingredient`: an `action='remove'` requires `is_removable=1`; an `action='add'` requires `is_addable=1` and snapshots the current `extra_price_cents`. Client-side checks (3.2 RG-4) are not trusted; a crafted POST adding a non-addable ingredient is rejected (HTTP 422). |
|
|
| **[RG-10 — atomic stock decrement]** | No operation gates on a stock read, so the decrement is a single atomic statement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (RG-T20). The row self-locks for the duration of the update, so concurrent kiosk orders on the same ingredient apply their deltas without a lost update and without a deadlock-ordering concern; `stock_quantity` is signed and may go negative (oversell magnitude surfaced to managers). |
|
|
| **[POST-1]** | One `customer_order` row exists with `status = 'paid'`, `source = 'kiosk'`, all totals computed, `paid_at` set, `idempotency_key` stored. The `pending_payment` phase is not observable outside the transaction. |
|
|
| **[POST-2]** | N `order_item` rows exist, each referencing either a `product_id` (item_type='product') or a `menu_id` (item_type='menu') — exclusivity constraint verified. |
|
|
| **[POST-3]** | `customer_order.order_number` is unique in the database (UNIQUE constraint). |
|
|
| **[POST-4]** | `ingredient.stock_quantity` decremented for each consumed ingredient unit; one `stock_movement` row of type `sale` per affected ingredient. |
|
|
| **[OUT-1]** | HTTP 201: `{data: {id: int, order_number: string, status: 'paid'}}` |
|
|
| **[OUT-2]** | Logical event ORDER_CREATED available for preparation domain (preparation display refreshes via polling or server push depending on implementation) |
|
|
| **[ERR-1]** | Empty cart: HTTP 422, `{error: {code: "EMPTY_CART"}}` |
|
|
| **[ERR-2]** | Unavailable item: HTTP 422, `{error: {code: "ITEM_UNAVAILABLE", items: [...]}}` |
|
|
| **[ERR-3]** | DB error / timeout: HTTP 500 with rollback, `{error: {code: "DB_ERROR"}}` |
|
|
|
|
---
|
|
|
|
### 3.4 DISPLAY_CONFIRMATION
|
|
|
|
**Corresponds to MCT section 3.4**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | CREATE_ORDER returned HTTP 201 with `{id, order_number, status: 'paid'}` |
|
|
| **[RG-1]** | Order number displayed prominently on the confirmation screen |
|
|
| **[RG-2]** | After a configurable delay (suggestion: 15 seconds), the kiosk auto-resets for the next customer |
|
|
| **[POST-1]** | No database write |
|
|
| **[OUT-1]** | Confirmation screen displayed with order number |
|
|
| **[ERR-1]** | If API response is an error: generic error message displayed with option to retry |
|
|
|
|
---
|
|
|
|
## 4. Domain 2 — Order lifecycle (counter and drive)
|
|
|
|
### 4.1 CREATE_COUNTER_ORDER
|
|
|
|
**Corresponds to MCT section 4.1**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor is authenticated (valid session, `user.is_active = 1`) |
|
|
| **[PRE-2]** | Actor holds permission `order.create` (verified via `role_permission`) |
|
|
| **[PRE-3]** | Cart contains at least 1 item |
|
|
| **[RG-1]** | Creation logic identical to CREATE_ORDER (RG-1 through RG-7 apply), with the following differences: `source` is auto-tagged from `role.order_source` (counter role -> `counter`, drive role -> `drive`); `service_mode` is selected by the staff member (`dine_in` / `takeaway` / `drive`); `user_id` is set to the authenticated user's id in `stock_movement` rows (instead of NULL for kiosk). |
|
|
| **[RG-2 — cross-constraint]** | If `source = 'drive'` then `service_mode` must be `'drive'` (RG-T09); verified before INSERT. HTTP 422 if violated. |
|
|
| **[RG-3 — order number]** | Format: `C-YYYY-MM-DD-NNN` for counter source; `D-YYYY-MM-DD-NNN` for drive source. Sequential NNN counter is per source per service_day. |
|
|
| **[RG-4 — stock]** | Same stock decrement logic as CREATE_ORDER RG-5; `stock_movement.user_id` is set to the authenticated staff member's id. |
|
|
| **[RG-5 — staff attribution + decrement]** | `customer_order.acting_user_id` is set to the authenticated staff member's id (targeted accountability on counter/drive orders; kiosk orders stay NULL). Server-side modifier re-validation (3.3 RG-9), idempotency (RG-T19) and the atomic stock decrement (RG-T20) apply identically. No PIN is required to create an order (the `order.create` permission suffices); order creation is not in the sensitive-action set. |
|
|
| **[POST-1]** | One `customer_order` row with `status = 'paid'`, `source = 'counter'` or `'drive'`, `paid_at` set, `acting_user_id` set. |
|
|
| **[POST-2]** | N `order_item` rows with snapshots. Slot selections and modifiers written identically to kiosk flow. |
|
|
| **[POST-3]** | Stock decremented; movements logged with actor `user_id`. |
|
|
| **[OUT-1]** | HTTP 201: `{data: {id: int, order_number: string, status: 'paid'}}`. Order number communicated to customer. |
|
|
| **[ERR-1]** | Same error cases as CREATE_ORDER (ERR-1, ERR-2, ERR-3) |
|
|
| **[ERR-2]** | Cross-constraint violation (`source = drive` but `service_mode != drive`): HTTP 422, `{error: {code: "INVALID_SERVICE_MODE"}}` |
|
|
|
|
---
|
|
|
|
## 5. Domain 3 — Preparation display (kitchen)
|
|
|
|
### 5.1 LIST_ORDERS_DISPLAY
|
|
|
|
**Corresponds to MCT section 5.1**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor is authenticated, `is_active = 1` |
|
|
| **[PRE-2]** | Actor holds permission `order.read` |
|
|
| **[RG-1 — source filter]** | Retrieve visible sources for the actor's role: `SELECT source FROM role_visible_source WHERE role_id = :role_id`. Kitchen sees all three; counter sees `kiosk` and `counter`; drive sees `drive`. |
|
|
| **[RG-2 — query]** | `SELECT customer_order.*, order_item.* FROM customer_order JOIN order_item ON order_item.order_id = customer_order.id WHERE customer_order.status = 'paid' AND customer_order.source IN (:visible_sources) ORDER BY customer_order.paid_at ASC` |
|
|
| **[RG-3 — item detail]** | For each order line of type `menu`, also load `order_item_selection` rows (slot choices). For all lines, load `order_item_modifier` rows (ingredient modifications). Display uses snapshots (`label_snapshot`, `quantity`, `format`); no re-join on `product` or `menu` tables needed. |
|
|
| **[RG-4 — KDS colour]** | Colour indicator computed at render time: `elapsed = NOW() - customer_order.paid_at`; green if elapsed < SLA threshold (configurable, approx. 10 min); amber if approaching; red if exceeded. Not stored; computed client-side or in PHP before response. |
|
|
| **[RG-5 — read only]** | Kitchen staff perform no status transition from this view. No UPDATE is issued by this operation. |
|
|
| **[POST-1]** | No database write |
|
|
| **[OUT-1]** | List of orders with status `paid`, filtered by role, sorted by `paid_at` ascending, with full item detail (selections, modifiers, KDS colour) |
|
|
|
|
---
|
|
|
|
## 6. Domain 4 — Delivery to customer
|
|
|
|
### 6.1 DELIVER_ORDER
|
|
|
|
**Corresponds to MCT section 6.1**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor is authenticated, holds permission `order.deliver` |
|
|
| **[PRE-2]** | Targeted order exists and `status = 'paid'` |
|
|
| **[PRE-3]** | Order source is in the actor's visible sources (verified via `role_visible_source`) |
|
|
| **[RG-1]** | `UPDATE customer_order SET status = 'delivered', delivered_at = NOW(), updated_at = NOW() WHERE id = :id AND status = 'paid'` |
|
|
| **[RG-2 — concurrency]** | The `AND status = 'paid'` clause in the UPDATE protects against concurrent double-delivery: if two staff members click simultaneously, only the first succeeds (second receives 0 rows affected). |
|
|
| **[RG-3]** | `delivered` is a terminal status: no further transition is defined from this status (application constraint, not enforced as a DB trigger). |
|
|
| **[POST-1]** | `customer_order.status = 'delivered'`, `delivered_at` set, lifecycle complete. Order passes to history. |
|
|
| **[OUT-1]** | HTTP 200 with confirmation. Order disappears from the `paid` queue. |
|
|
| **[ERR-1]** | Invalid transition (status was not `paid` when UPDATE executed — concurrency): HTTP 409, `{error: {code: "INVALID_TRANSITION"}}` |
|
|
| **[ERR-2]** | Order source not in actor's visible sources: HTTP 403, `{error: {code: "FORBIDDEN"}}` |
|
|
|
|
---
|
|
|
|
## 7. Domain 5 — Cancellation
|
|
|
|
### 7.1 CANCEL_ORDER
|
|
|
|
**Corresponds to MCT section 7.1**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor is authenticated, holds permission `order.cancel` |
|
|
| **[PRE-2]** | Targeted order exists |
|
|
| **[PRE-3]** | `customer_order.status` is in `['pending_payment', 'paid']`. Terminal statuses `delivered` and `cancelled` cannot transition to `cancelled`. |
|
|
| **[RG-1 — status update]** | `UPDATE customer_order SET status = 'cancelled', cancelled_at = NOW(), updated_at = NOW() WHERE id = :id AND status IN ('pending_payment', 'paid')` |
|
|
| **[RG-2 — concurrency]** | The `AND status IN (...)` clause protects against concurrent cancellation (see RG-T07). |
|
|
| **[RG-3 — stock re-credit — conditional]** | Re-credit applies only if the order was at status `paid` before cancellation. Orders at `pending_payment` had not yet decremented stock (the decrement occurs at the `paid` transition). For each `order_item` line of a `paid` order, recompute ingredient units consumed: `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by `order_item_modifier` rows (remove modifier -> ingredient was not decremented, so no re-credit; add modifier -> ingredient had extra decrement, so extra re-credit). UPDATE `ingredient.stock_quantity += units`. INSERT `stock_movement` (type `cancellation`, delta = +units, order_id, user_id of actor). |
|
|
| **[RG-4 — transaction]** | Status update and stock re-credit (when applicable) execute in the same database transaction (RG-T11). |
|
|
| **[RG-5 — history]** | Order is not physically deleted; retained for history and stats. Cancelled orders are excluded from revenue totals but included in volume counts in READ_STATS. `order_item` rows are not deleted (ON DELETE CASCADE is not triggered); they allow reconstruction of what was ordered. |
|
|
| **[RG-6 — PIN + audit]** | Cancellation is a sensitive money-handling action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row in the same transaction (RG-T14): `action_code='order.cancel'`, `entity_type='customer_order'`, `entity_id=:id`, `summary` with prior status and re-credited amount. |
|
|
| **[POST-1]** | `customer_order.status = 'cancelled'`, `cancelled_at` set, terminal state. One `audit_log` row recorded with the acting staff. |
|
|
| **[POST-2]** | If prior status was `paid`: `ingredient.stock_quantity` re-credited; one `stock_movement` row of type `cancellation` per affected ingredient. |
|
|
| **[OUT-1]** | HTTP 200 with cancellation confirmation |
|
|
| **[ERR-1]** | Attempt to cancel a delivered or already cancelled order: HTTP 422, `{error: {code: "CANNOT_CANCEL_IN_STATE", current_status: "..."}}` |
|
|
| **[ERR-2]** | Concurrent cancellation (0 rows affected by UPDATE): HTTP 409, `{error: {code: "INVALID_TRANSITION"}}` |
|
|
|
|
---
|
|
|
|
## 8. Domain 6 — Catalogue management
|
|
|
|
### 8.1 CREATE_PRODUCT
|
|
|
|
**Corresponds to MCT section 8.1**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `product.create` |
|
|
| **[PRE-2]** | `category_id` references an existing category with `is_active = 1` |
|
|
| **[RG-1]** | Form validation: `name` non-empty, `price_cents > 0`, `category_id` valid, `vat_rate` in `(55, 100)` |
|
|
| **[RG-2]** | Image upload (optional): validate MIME type (JPEG, PNG, WEBP), max size configurable (suggestion: 2 MB), store under `UPLOAD_DIR/products/`, record relative path in `image_path` |
|
|
| **[RG-3]** | `is_available = 1` by default at INSERT |
|
|
| **[RG-4]** | `display_order` set to `MAX(display_order) + 1` for the target category, or 0 if first product |
|
|
| **[POST-1]** | One `product` row in the database with all valid fields |
|
|
| **[OUT-1]** | Redirect to category product list with success message |
|
|
| **[ERR-1]** | Validation failure: inline field errors displayed |
|
|
| **[ERR-2]** | Invalid image (type or size): specific error message |
|
|
|
|
---
|
|
|
|
### 8.2 UPDATE_PRODUCT
|
|
|
|
**Corresponds to MCT section 8.2**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `product.update` |
|
|
| **[PRE-2]** | Target `product.id` exists |
|
|
| **[RG-1]** | Same validations as CREATE_PRODUCT on modified fields |
|
|
| **[RG-2]** | If a new image is uploaded, the old image file is deleted from the filesystem (volume cleanup) |
|
|
| **[RG-3]** | `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot` in historical `order_item` rows are not modified (see RG-T05) |
|
|
| **[RG-4 — PIN + audit + allowlist]** | A price/VAT change is a sensitive action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row (RG-T14) with `action_code='product.update'`, `entity_type='product'`, `entity_id=:id`, and a `summary` recording changed values (e.g. `price_cents 880 -> 920`). Only the allowlisted columns (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`) are bound from the request (RG-T16). |
|
|
| **[POST-1]** | `product` updated, `updated_at` refreshed; one `audit_log` row recorded |
|
|
| **[OUT-1]** | Redirect to product list with success message |
|
|
|
|
---
|
|
|
|
### 8.3 DELETE_PRODUCT
|
|
|
|
**Corresponds to MCT section 8.3**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `product.delete` |
|
|
| **[PRE-2]** | Target `product.id` exists |
|
|
| **[RG-1]** | Pre-check (PHP): is the product referenced in `menu_slot_option.product_id`? If yes, display blocking message listing the menus. |
|
|
| **[RG-2]** | Pre-check (PHP): is the product the `burger_product_id` of any `menu`? If yes, block with message to delete or reassign the menu first. |
|
|
| **[RG-3]** | Pre-check (PHP): is the product referenced in `order_item.product_id` (historical orders)? FK `ON DELETE RESTRICT` blocks at DB level. Recommended response: propose deactivation (`is_available=0`) rather than deletion. |
|
|
| **[RG-4]** | FK constraints (`menu_slot_option.product_id ON DELETE RESTRICT`, `order_item.product_id ON DELETE RESTRICT`) enforce the constraint even if the PHP check is bypassed. |
|
|
| **[RG-5 — PIN + audit]** | Deletion is a sensitive action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row (RG-T14) with `action_code='product.delete'`, `entity_type='product'`, `entity_id=:id`, `summary` capturing the product name before deletion (recorded before the row is removed). |
|
|
| **[POST-1]** | Product deleted if no FK constraint was blocking; one `audit_log` row recorded |
|
|
| **[OUT-1]** | Redirect to product list with success message |
|
|
| **[ERR-1]** | Product in menu slot: HTTP 422 or inline message with blocking menu list |
|
|
| **[ERR-2]** | Product in historical orders: message proposing deactivation instead |
|
|
|
|
---
|
|
|
|
### 8.4 CREATE_MENU
|
|
|
|
**Corresponds to MCT section 8.4**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `menu.create` |
|
|
| **[PRE-2]** | `burger_product_id` references an existing, available product |
|
|
| **[PRE-3]** | At least one `menu_slot` is defined with at least one `menu_slot_option` |
|
|
| **[RG-1]** | Validation: `name` non-empty, `price_normal_cents > 0`, `price_maxi_cents > 0`, `burger_product_id` valid, all `product_id` values in slot options exist |
|
|
| **[RG-2]** | Transaction: INSERT `menu`, then INSERT `menu_slot` rows (name, slot_type, is_required, display_order), then INSERT `menu_slot_option` rows (menu_slot_id, product_id) |
|
|
| **[RG-3]** | Valid `slot_type` values (from dictionary ENUM): `drink`, `side`, `sauce`, `dessert`, `extra` |
|
|
| **[POST-1]** | One `menu` row, N `menu_slot` rows, M `menu_slot_option` rows in the database |
|
|
| **[OUT-1]** | Redirect to menu list with success message |
|
|
| **[ERR-1]** | Invalid configuration (no slot, no option): business error message |
|
|
| **[ERR-2]** | Slot option product unavailable: warning (menu can be created; product availability is checked at order time) |
|
|
|
|
---
|
|
|
|
### 8.5 UPDATE_MENU
|
|
|
|
**Corresponds to MCT section 8.5**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `menu.update` |
|
|
| **[PRE-2]** | Target `menu.id` exists |
|
|
| **[RG-1]** | Same validations as CREATE_MENU on modified fields |
|
|
| **[RG-2]** | If slot configuration is modified: `DELETE FROM menu_slot_option WHERE menu_slot_id IN (SELECT id FROM menu_slot WHERE menu_id = :id)`, then `DELETE FROM menu_slot WHERE menu_id = :id`, then re-INSERT (delete-and-reinsert pattern, atomic in transaction) |
|
|
| **[RG-3]** | `label_snapshot` values in historical `order_item_selection` rows are not affected (see RG-T05) |
|
|
| **[POST-1]** | `menu` updated; `menu_slot` and `menu_slot_option` rebuilt |
|
|
| **[OUT-1]** | Redirect with success message |
|
|
|
|
---
|
|
|
|
### 8.6 DELETE_MENU
|
|
|
|
**Corresponds to MCT section 8.6**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `menu.delete` |
|
|
| **[PRE-2]** | Target `menu.id` exists |
|
|
| **[RG-1]** | Pre-check (PHP): is the menu referenced in `order_item.menu_id`? FK `ON DELETE RESTRICT`. If yes, propose deactivation (`is_available=0`) instead of deletion. |
|
|
| **[RG-2]** | If no historical reference: DELETE `menu` triggers CASCADE to `menu_slot` (which cascades to `menu_slot_option`) |
|
|
| **[RG-3 — PIN + audit]** | Deletion is a sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='menu.delete'`, `entity_type='menu'`, `entity_id=:id`, `summary` capturing the menu name before deletion. |
|
|
| **[POST-1]** | `menu`, its `menu_slot` rows, and its `menu_slot_option` rows deleted; one `audit_log` row recorded |
|
|
| **[OUT-1]** | Redirect with success message |
|
|
| **[ERR-1]** | Menu in historical orders: message proposing deactivation instead |
|
|
|
|
---
|
|
|
|
### 8.7 MANAGE_CATEGORY
|
|
|
|
**Corresponds to MCT section 8.7**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `category.manage` |
|
|
| **[RG-CREATE]** | `name` and `slug` non-empty and unique in the database; `display_order` set to MAX + 1 |
|
|
| **[RG-UPDATE]** | UPDATE `name`, `slug`, `image_path`, `display_order`, `is_active` |
|
|
| **[RG-DEACTIVATE]** | Deactivation (`is_active=0`) does not auto-deactivate child products/menus in the DB (no CASCADE on `is_active`). PHP layer proposes to the admin to also deactivate child products/menus, or the kiosk filter on `category.is_active = 1` implicitly hides them. |
|
|
| **[RG-DELETE]** | Physical deletion blocked if `product.category_id` or `menu.category_id` references this category (FK `ON DELETE RESTRICT`). Propose deactivation. |
|
|
| **[POST-CREATE]** | New `category` row in database |
|
|
| **[POST-UPDATE]** | `category` updated, `updated_at` refreshed |
|
|
| **[OUT-1]** | Confirmation, redirect to category list |
|
|
|
|
---
|
|
|
|
### 8.8 MANAGE_INGREDIENT
|
|
|
|
**Corresponds to MCT section 8.8**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `ingredient.manage` |
|
|
| **[RG-CREATE-ING]** | `name` non-empty and UNIQUE; `unit` non-empty; `pack_size >= 1`; `stock_capacity >= 1` (the 100% reference); `low_stock_pct` and `critical_stock_pct` in 0-100 with `critical_stock_pct < low_stock_pct` (defaults 10 / 5); `stock_quantity` defaults to 0 at creation |
|
|
| **[RG-UPDATE-ING]** | UPDATE `name`, `unit`, `pack_size`, `pack_label`, `stock_capacity`, `low_stock_pct`, `critical_stock_pct`, `is_active` |
|
|
| **[RG-DEACTIVATE-ING]** | `is_active=0` hides ingredient from configurator. Physical deletion blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). |
|
|
| **[RG-COMPOSITION]** | UPDATE `product_ingredient`: for each ingredient in a product's recipe, set `quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`. Delete-and-reinsert pattern within transaction. |
|
|
| **[RG-ALLERGEN]** | Manage `ingredient_allergen`: INSERT or DELETE `(ingredient_id, allergen_id)` pairs. Allergen list is read-only (14 rows fixed by EU regulation 1169/2011). |
|
|
| **[POST-1]** | `ingredient` / `product_ingredient` / `ingredient_allergen` rows updated |
|
|
| **[OUT-1]** | Confirmation, redirect to ingredient list or product composition form |
|
|
|
|
---
|
|
|
|
## 9. Domain 7 — Stock management
|
|
|
|
### 9.1 RESTOCK
|
|
|
|
**Corresponds to MCT section 9.1**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `stock.manage` |
|
|
| **[PRE-2]** | Target ingredient exists and `is_active = 1` |
|
|
| **[PRE-3]** | Number of packs `N >= 1` |
|
|
| **[RG-1]** | `delta = N * ingredient.pack_size` |
|
|
| **[RG-2]** | Transaction: `UPDATE ingredient SET stock_quantity = stock_quantity + :delta WHERE id = :id`; INSERT `stock_movement` (ingredient_id, movement_type=`restock`, delta=+delta, order_id=NULL, user_id=actor, note=optional) |
|
|
| **[RG-3]** | `stock_movement` is append-only: no UPDATE or DELETE on this table (corrections are new rows) |
|
|
| **[POST-1]** | `ingredient.stock_quantity` incremented by `delta`. One `stock_movement` row of type `restock` inserted. |
|
|
| **[OUT-1]** | Confirmation with new stock level displayed |
|
|
|
|
---
|
|
|
|
### 9.2 INVENTORY_COUNT
|
|
|
|
**Corresponds to MCT section 9.2**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `stock.count` |
|
|
| **[PRE-2]** | Target ingredient exists |
|
|
| **[PRE-3]** | `actual_quantity >= 0` (physical count is non-negative) |
|
|
| **[RG-1]** | `delta = actual_quantity - ingredient.stock_quantity` (may be negative if actual < theoretical) |
|
|
| **[RG-2]** | Transaction: `UPDATE ingredient SET stock_quantity = :actual_quantity WHERE id = :id`; INSERT `stock_movement` (ingredient_id, movement_type=`inventory_correction`, delta=computed, order_id=NULL, user_id=actor, note=optional) |
|
|
| **[RG-3]** | `delta = 0` is a valid correction (physical count matches theoretical); a movement row is still inserted for audit completeness |
|
|
| **[RG-4 — PIN attribution]** | An inventory correction can mask shrinkage, so it requires the per-staff PIN (RG-T13). The PIN-captured `user_id` is written to `stock_movement.user_id`, making the correction attributable to a person even on a shared workstation. No separate `audit_log` row (the `stock_movement` trail already records it). |
|
|
| **[POST-1]** | `ingredient.stock_quantity = actual_quantity`. One `stock_movement` row of type `inventory_correction` inserted with the acting `user_id`. |
|
|
| **[OUT-1]** | Confirmation with reconciled stock level and discrepancy displayed |
|
|
|
|
---
|
|
|
|
### 9.3 READ_STOCK
|
|
|
|
**Corresponds to MCT section 9.3**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `stock.read` |
|
|
| **[RG-1]** | `SELECT * FROM ingredient WHERE is_active = 1 ORDER BY name ASC` |
|
|
| **[RG-2]** | Stock bands computed at render time from the percentage thresholds: `low_stock: true` when `stock_quantity <= stock_capacity * low_stock_pct / 100`, `critical_stock: true` when `stock_quantity <= stock_capacity * critical_stock_pct / 100`; `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` is also returned. Not stored as columns. |
|
|
| **[RG-3]** | Optional movement history for a given ingredient: `SELECT * FROM stock_movement WHERE ingredient_id = :id ORDER BY created_at DESC LIMIT :n` |
|
|
| **[RG-4 — attribution visibility]** | The `stock_movement.user_id` (who restocked / who corrected) is included for `manager`/`admin` only; line staff (`kitchen`/`counter`/`drive`) see the movement deltas without the actor identity. This limits intra-team exposure while preserving accountability for those who manage. The `details` allowlist is applied at the query/serialisation layer. |
|
|
| **[POST-1]** | No database write |
|
|
| **[OUT-1]** | Ingredient list with `stock_quantity`, `stock_capacity`, computed `stock_pct`, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`, `low_stock` / `critical_stock` flags; movement history with actor visible to manager/admin only |
|
|
|
|
---
|
|
|
|
## 10. Domain 8 — User and role management
|
|
|
|
### 10.1 CREATE_USER
|
|
|
|
**Corresponds to MCT section 10.1**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `user.create` |
|
|
| **[PRE-2]** | Email does not already exist in `user.email` (UNIQUE constraint) |
|
|
| **[PRE-3]** | `role_id` references an existing, active role |
|
|
| **[RG-1]** | Validation: `email` conforms to RFC 5321 (PHP `FILTER_VALIDATE_EMAIL`), `first_name` and `last_name` non-empty, `role_id` valid |
|
|
| **[RG-2]** | Password hash: `password_hash($password, PASSWORD_ARGON2ID)`. Minimum password length: 8 characters. |
|
|
| **[RG-3]** | `is_active = 1` by default; `last_login_at = NULL` at creation |
|
|
| **[RG-4 — PIN + audit + allowlist]** | Creating a back-office account is a sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.create'`, `entity_type='user'`, `entity_id=:new_id`, `details` recording the assigned `role_id` (field names/role, not the password). Only the allowlisted columns are bound (RG-T16): `email`, `first_name`, `last_name`, `role_id` (+ the hashed password); `is_active` and any other field are server-set, not request-bound. |
|
|
| **[POST-1]** | One `user` row with argon2id `password_hash`, valid `role_id`; one `audit_log` row recorded |
|
|
| **[OUT-1]** | Redirect to user list with success message |
|
|
| **[ERR-1]** | Duplicate email: message "This email is already in use" |
|
|
| **[ERR-2]** | Password too short: inline validation message |
|
|
|
|
---
|
|
|
|
### 10.2 UPDATE_USER
|
|
|
|
**Corresponds to MCT section 10.2**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `user.update` |
|
|
| **[PRE-2]** | Target `user.id` exists |
|
|
| **[RG-1]** | If a new password is supplied (non-empty field): rehash via `PASSWORD_ARGON2ID` and replace existing hash |
|
|
| **[RG-2]** | If password field is empty: existing hash is preserved unchanged |
|
|
| **[RG-3]** | Email update subject to UNIQUE constraint (pre-check before UPDATE) |
|
|
| **[RG-4 — PIN + audit + allowlist]** | Editing an account (incl. `role_id`, the privilege-escalation vector) is sensitive: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.update'`, `entity_type='user'`, `entity_id=:id`, `details` listing changed field names (not values, no PII). Only the allowlisted columns are bound (RG-T16): `first_name`, `last_name`, `email`, `role_id`, `is_active` (+ optional password rehash). |
|
|
| **[POST-1]** | `user` updated, `updated_at` refreshed; one `audit_log` row recorded |
|
|
| **[OUT-1]** | Redirect with success message |
|
|
|
|
---
|
|
|
|
### 10.3 DEACTIVATE_USER
|
|
|
|
**Corresponds to MCT section 10.3**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `user.deactivate` |
|
|
| **[PRE-2]** | Actor is not targeting their own account (`$targetUserId !== $currentUserId`) |
|
|
| **[RG-1]** | `UPDATE user SET is_active = 0, updated_at = NOW() WHERE id = :id` |
|
|
| **[RG-2]** | The user's potentially active session is invalidated on next request: middleware checks `user.is_active = 1` on each authenticated request |
|
|
| **[RG-3 — PIN + audit]** | Sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.deactivate'`, `entity_type='user'`, `entity_id=:id`. |
|
|
| **[POST-1]** | `user.is_active = 0`; user cannot log in; history remains intact; one `audit_log` row recorded |
|
|
| **[OUT-1]** | Redirect with success message |
|
|
| **[ERR-1]** | Self-deactivation attempt: HTTP 403, `{error: {code: "SELF_DEACTIVATION_FORBIDDEN"}}` |
|
|
|
|
---
|
|
|
|
### 10.4 MANAGE_RBAC
|
|
|
|
**Corresponds to MCT section 10.4**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `role.manage` |
|
|
| **[PRE-2]** | Target `role.id` exists (for permission update) or role fields are valid (for role creation) |
|
|
| **[PRE-3]** | All submitted `permission_id` values exist in the `permission` catalogue |
|
|
| **[RG-1 — permissions]** | Transaction: `DELETE FROM role_permission WHERE role_id = :id`; INSERT new `(role_id, permission_id)` pairs for each selected permission |
|
|
| **[RG-2]** | Permissions are not modifiable via this operation: they are read-only to populate the selection form. Permission catalogue is frozen at seed. |
|
|
| **[RG-3]** | Effect is immediate for new requests; sessions of users bearing this role see the change on the next permission check (sessions store `role_id`; permissions are reloaded from DB on each check). |
|
|
| **[RG-4 — custom role]** | Creating a custom role: INSERT `role` (code UNIQUE, label, description, default_route nullable, order_source nullable); INSERT `role_visible_source` rows as needed. |
|
|
| **[RG-5 — order_source]** | `role.order_source` controls the auto-tagging of `customer_order.source` when this role creates an order. NULL for admin and manager (they can create on behalf of any channel). |
|
|
| **[RG-6 — PIN + audit change-log]** | RBAC changes are high-impact (privilege escalation): per-staff PIN (RG-T13) + one `audit_log` row (RG-T14) per change, `action_code='role.manage'`, `entity_type='role'`, `entity_id=:role_id`. Because permissions are rewritten delete-and-reinsert (RG-1), the `details` JSON records the **diff** — permission codes added and removed — computed before the rewrite, so the trail shows exactly which capabilities a role gained or lost and who granted them. |
|
|
| **[POST-1]** | `role_permission` reflects exactly the selected permissions for this role; one `audit_log` row recorded with the permission diff |
|
|
| **[OUT-1]** | Redirect with success message |
|
|
|
|
---
|
|
|
|
### 10.5 ERASE_USER_PII (RGPD anonymisation)
|
|
|
|
**Security-by-design operation (no v0.1 / v0.2 MCT predecessor). Honours the RGPD right to
|
|
erasure (Cr 3.d) without breaking referential integrity or the audit trail (dict. note 13).**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `user.update` (erasure is an admin operation) |
|
|
| **[PRE-2]** | Per-staff PIN verified (RG-T13) — sensitive action |
|
|
| **[PRE-3]** | Target `user.id` exists and `anonymized_at IS NULL` (not already anonymised) |
|
|
| **[RG-1 — anonymise, not delete]** | In one transaction: `UPDATE user SET email = CONCAT('anon-', id, '@wakdo.invalid'), first_name = '', last_name = '', password_hash = '', pin_hash = NULL, password_reset_token_hash = NULL, is_active = 0, anonymized_at = NOW() WHERE id = :id`. The placeholder domain is RFC 2606 reserved (`.invalid`), keeps `email` UNIQUE and non-identifying. |
|
|
| **[RG-2 — preserve links]** | The row persists, so FKs pointing at it (`stock_movement.user_id`, `customer_order.acting_user_id`, `audit_log.actor_user_id`) stay valid and now resolve to an anonymised principal. Accountability for past actions is preserved in form (who-as-id) without retaining PII. |
|
|
| **[RG-3 — audit]** | One `audit_log` row (RG-T14): `action_code='user.erase_pii'`, `entity_type='user'`, `entity_id=:id`. The `summary`/`details` record the erasure event and its legal basis, not the erased values. |
|
|
| **[POST-1]** | `user` row anonymised: PII fields cleared/placeholdered, credentials invalidated, `anonymized_at` set, `is_active = 0`. Referential links intact. |
|
|
| **[OUT-1]** | Confirmation; the user disappears from active lists, remains as an anonymised tombstone in history. |
|
|
| **[ERR-1]** | Already anonymised: HTTP 409, `{error: {code: "ALREADY_ANONYMISED"}}` |
|
|
|
|
---
|
|
|
|
## 11. Domain 9 — Stats and KPI
|
|
|
|
### 11.1 READ_STATS
|
|
|
|
**Corresponds to MCT section 11.1**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Actor authenticated, holds permission `stats.read` |
|
|
| **[RG-1 — service_day]** | `service_day` expression used in all stats aggregations: `CASE WHEN HOUR(customer_order.created_at) < 10 THEN DATE(customer_order.created_at) - INTERVAL 1 DAY ELSE DATE(customer_order.created_at) END`. Cutoff at 10:00. No stored column. The v0.1 formula with `INTERVAL 4 HOUR 30 MINUTE` is dropped. |
|
|
| **[RG-2 — revenue]** | Revenue queries filter `status != 'cancelled'`; they sum `total_ttc_cents` from `customer_order`. Cancelled orders are excluded from revenue but appear in volume counts with `status = 'cancelled'` filter. |
|
|
| **[RG-3 — top products]** | `SELECT label_snapshot, SUM(quantity) AS total_sold FROM order_item JOIN customer_order ON ... WHERE customer_order.status != 'cancelled' GROUP BY label_snapshot ORDER BY total_sold DESC LIMIT 10` |
|
|
| **[RG-4 — delivery time KPI]** | Average delivery time: `AVG(TIMESTAMPDIFF(SECOND, paid_at, delivered_at))` on orders with `status = 'delivered'`. SLA reference approx. 10 min (configurable). |
|
|
| **[RG-5 — breakdown]** | Breakdowns available by `source` (kiosk/counter/drive) and `service_mode` (dine_in/takeaway/drive) for capacity planning. `service_mode` carries no fiscal role (see dictionary note 9). |
|
|
| **[POST-1]** | No database write |
|
|
| **[OUT-1]** | Stats dashboard data: revenue by service_day, order counts, top products, cancellation rate, average delivery time, breakdown by source/service_mode |
|
|
|
|
---
|
|
|
|
## 12. Domain 10 — Back-office authentication
|
|
|
|
### 12.1 AUTHENTICATE_USER
|
|
|
|
**Corresponds to MCT section 12.1**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Login form submitted with email and password |
|
|
| **[PRE-2]** | CSRF token of the form is valid (anti-CSRF protection) |
|
|
| **[PRE-3 — throttle gate]** | If the account is in a throttling window (`user.lockout_until IS NOT NULL AND lockout_until > NOW()`), reject with the generic error before any password check. Throttling is also keyed per source IP via the `login_throttle` table: if a row exists for the source IP with `lockout_until IS NOT NULL AND lockout_until > NOW()`, reject with the same generic error, so distributed attempts on many accounts are slowed too. |
|
|
| **[RG-1]** | Lookup: `SELECT * FROM user WHERE email = :email AND is_active = 1 LIMIT 1` |
|
|
| **[RG-2]** | Password verification: `password_verify($password, $user->password_hash)`. On failure: same generic error whether the email does not exist or the password is wrong (protection against email enumeration). To keep timing comparable when the email is unknown, a dummy `password_verify` against a fixed decoy hash is run. |
|
|
| **[RG-3]** | On success: `session_regenerate(true)` (session ID regeneration, protection against session fixation) |
|
|
| **[RG-4]** | Session storage: `$_SESSION['user_id']`, `$_SESSION['role_id']`, `$_SESSION['logged_in_at']` |
|
|
| **[RG-5]** | UPDATE: `UPDATE user SET last_login_at = NOW() WHERE id = :id` |
|
|
| **[RG-6]** | Session timeouts: idle timeout 4h (detection via last-activity timestamp in session); absolute timeout 10h (detection via `logged_in_at`) |
|
|
| **[RG-7]** | Redirect target is `role.default_route` (dynamic; no hardcoded role name in routing logic) |
|
|
| **[RG-8 — failure handling, degressive backoff]** | On a failed verification, the per-account counter on `user`: `UPDATE user SET failed_login_attempts = failed_login_attempts + 1, last_failed_login_at = NOW()`, and once a threshold is reached (suggestion: 5) set `lockout_until = NOW() + INTERVAL (base * 2^(attempts - threshold)) SECOND`, capped (suggestion: cap a few minutes). In the same step, the per-IP dimension is recorded in the `login_throttle` table: upsert the row keyed on `ip_address` (insert if absent, else increment `failed_attempts`; reset the window when expired via `window_started_at`), update `last_attempt_at = NOW()`, and once the IP threshold is reached set `lockout_until` with the same degressive backoff. This is a degressive backoff, not an indefinite lock — it slows brute force without letting a fat-finger streak deny service to a kitchen mid-shift. Write one `audit_log` row (`action_code='auth.login_failed'`, `actor_user_id` if the email resolved, else NULL). |
|
|
| **[RG-9 — success reset]** | On success, reset the per-account counter `failed_login_attempts = 0`, clear `lockout_until = NULL`, and also clear the per-IP `login_throttle` row for the source IP (reset `failed_attempts = 0`, `lockout_until = NULL`, restart `window_started_at`), then write one `audit_log` row (`action_code='auth.login_success'`, `actor_user_id`, `actor_role_id`). |
|
|
| **[POST-1]** | PHP session open with `user_id` and `role_id`; `user.last_login_at` updated; `failed_login_attempts` reset |
|
|
| **[OUT-1]** | Redirect to `role.default_route` |
|
|
| **[ERR-1]** | Incorrect credentials or inactive account: generic message "Email or password incorrect" (no distinction to prevent enumeration); failure counter incremented (RG-8) |
|
|
| **[ERR-2]** | Invalid CSRF token: HTTP 403 |
|
|
| **[ERR-3]** | Account in throttling window (PRE-3): same generic message; the attempt does not reveal that the account exists or is locked |
|
|
|
|
---
|
|
|
|
### 12.2 LOGOUT_USER
|
|
|
|
**Corresponds to MCT section 12.2**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Valid session open (`session_id()` non-empty, `$_SESSION['user_id']` present) |
|
|
| **[RG-1]** | `$_SESSION = []` (clear session data) |
|
|
| **[RG-2]** | If session cookie exists, expire it: `setcookie(session_name(), '', time() - 3600, '/', '', true, true)` |
|
|
| **[RG-3]** | `session_destroy()` |
|
|
| **[POST-1]** | PHP session destroyed; no authenticated access possible with the old cookie |
|
|
| **[OUT-1]** | Redirect to login page |
|
|
|
|
---
|
|
|
|
### 12.3 RESET_PASSWORD
|
|
|
|
**Security-by-design operation (no v0.1 predecessor). Two phases: request, then confirm.**
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[PRE-1]** | Request phase: a `user` submits the "forgot password" form with an email; CSRF token valid |
|
|
| **[RG-1 — request, enumeration-safe]** | Look up the email. The same neutral response ("if the account exists, an email has been sent") is returned whether or not the email exists, to avoid account enumeration. |
|
|
| **[RG-2 — token generation]** | If the email resolves to an active user: generate a cryptographically random token (e.g. 32 bytes from a CSPRNG); store its **hash** in `password_reset_token_hash` and `password_reset_expires_at = NOW() + INTERVAL 1 HOUR`. The **raw** token is sent once in the reset link (not stored in clear). |
|
|
| **[PRE-2]** | Confirm phase: the user opens the reset link with the raw token and submits a new password; CSRF token valid |
|
|
| **[RG-3 — confirm]** | Hash the submitted token and match it against `password_reset_token_hash` where `password_reset_expires_at > NOW()`. On match: `password_hash = password_hash($new, PASSWORD_ARGON2ID)` (min length 8), then clear `password_reset_token_hash = NULL` and `password_reset_expires_at = NULL`, and reset `failed_login_attempts = 0`, `lockout_until = NULL`. One-time use. |
|
|
| **[RG-4 — audit]** | Write one `audit_log` row (RG-T14), `action_code='auth.password_reset'`, `actor_user_id = :id`. |
|
|
| **[POST-1]** | Password replaced with a new argon2id hash; reset token consumed and cleared |
|
|
| **[OUT-1]** | Confirmation; redirect to login |
|
|
| **[ERR-1]** | Invalid or expired token: generic message inviting a new reset request (no detail on which condition failed) |
|
|
|
|
These treatments are executed by the `wakdo-cron` service container in the maintenance
|
|
window 01:30-09:30 (outside active service). They are outside the MCT scope (technical
|
|
treatments, no user trigger) but are documented here for consistency with PROJECT_CONTEXT.
|
|
|
|
### 13.1 Stats aggregation (cron 04:30)
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[TRIGGER]** | Cron: `30 4 * * *` |
|
|
| **[RG-1]** | `service_day` to aggregate: computed per order (see RG-1 of READ_STATS). At 04:30 the service_day in progress is the previous calendar day. |
|
|
| **[RG-2]** | Aggregations by `service_day`: order count, TTC revenue (sum `total_ttc_cents` where `status != 'cancelled'`), top products (by `label_snapshot`, COUNT in `order_item`) |
|
|
| **[POST-1]** | Stats available for admin dashboard (direct queries on `customer_order` filtered by `service_day`, or an aggregation table if implemented) |
|
|
|
|
### 13.2 Expired sessions purge (cron every 15 min)
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[TRIGGER]** | Cron: `*/15 * * * *` |
|
|
| **[RG-1]** | File-based sessions (default): `find /tmp/sessions -mmin +240 -delete` |
|
|
| **[RG-2]** | DB-based sessions (option): `DELETE FROM php_sessions WHERE updated_at < NOW() - INTERVAL 4 HOUR` |
|
|
| **[POST-1]** | Expired sessions deleted; users inactive for more than 4h are forced to re-login |
|
|
|
|
### 13.3 DB backup (cron 03:00)
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[TRIGGER]** | Cron: `0 3 * * *` |
|
|
| **[RG-1]** | `mysqldump` of the `wakdo` database to a dated file in the backup volume |
|
|
| **[RG-2]** | Retention: keep the last 7 dumps; delete older ones |
|
|
| **[POST-1]** | SQL dump available for restoration |
|
|
|
|
### 13.4 Audit log retention purge (cron daily)
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[TRIGGER]** | Cron: `15 4 * * *` (maintenance window) |
|
|
| **[RG-1]** | `DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL :retention_months MONTH` (suggestion: 12 months, legitimate-interest / fiscal traceability — configurable in `.env`). |
|
|
| **[RG-2]** | The window is decoupled from user PII lifecycle: anonymisation (10.5) removes PII immediately on request, while the audit trail ages out on its own schedule (dict. note 13). |
|
|
| **[POST-1]** | `audit_log` rows older than the retention window removed; recent accountability preserved. |
|
|
|
|
### 13.5 login_throttle purge (cron daily)
|
|
|
|
| Tag | Content |
|
|
|-----|---------|
|
|
| **[TRIGGER]** | Cron: `45 4 * * *` (maintenance window) |
|
|
| **[RG-1]** | `DELETE FROM login_throttle WHERE (lockout_until IS NULL OR lockout_until < NOW()) AND last_attempt_at < NOW() - INTERVAL 24 HOUR` — purge rows with no active lockout whose last failed attempt is older than 24h. |
|
|
| **[RG-2]** | Rows still serving an active lockout are retained; the per-IP counter (S1) is bounded by this purge so the table does not grow unbounded from one-off attempts. |
|
|
| **[POST-1]** | Stale `login_throttle` rows removed; active throttles and recent activity preserved. |
|
|
|
|
---
|
|
|
|
## 14. State machine — consistency recap (MLT)
|
|
|
|
Summary of `customer_order.status` transitions covered in the MLT, with corresponding
|
|
operations, SQL condition, concurrency protection, and phase timestamp set.
|
|
|
|
| Transition | MLT operation | SQL condition | Concurrency protection | Phase timestamp set |
|
|
|------------|--------------|---------------|------------------------|---------------------|
|
|
| `-> pending_payment` (creation) | CREATE_ORDER (3.3), CREATE_COUNTER_ORDER (4.1) | INSERT with status `pending_payment` | Atomic transaction | `created_at` |
|
|
| `pending_payment -> paid` | CREATE_ORDER (3.3), CREATE_COUNTER_ORDER (4.1) | UPDATE in same transaction | Atomic transaction | `paid_at` |
|
|
| `paid -> delivered` | DELIVER_ORDER (6.1) | `WHERE status = 'paid'` | AND status in WHERE | `delivered_at` |
|
|
| `pending_payment/paid -> cancelled` | CANCEL_ORDER (7.1) | `WHERE status IN ('pending_payment', 'paid')` | AND status IN WHERE | `cancelled_at` |
|
|
|
|
Terminal statuses (no further transition defined from these states): `delivered`, `cancelled`.
|
|
|
|
**Dropped from v0.1**:
|
|
- `paid -> preparing` and `preparing -> ready` transitions — intermediate states removed.
|
|
- MARQUER_EN_PREPARATION (v0.1 MLT section 4.2) — dropped.
|
|
- MARQUER_PRETE (v0.1 MLT section 4.3) — dropped.
|
|
- `preparing` and `ready` in the cancellable state set — the cancellable set is now
|
|
`['pending_payment', 'paid']` only.
|
|
- `commande_event` table and v0.1 RG-T10 — replaced by phase timestamps on `customer_order`.
|
|
|
|
---
|
|
|
|
## 15. Residual notes and open points
|
|
|
|
### 15.1 `service_day` — not materialised as a column
|
|
|
|
The `service_day` computation is documented (RG-2 of CREATE_ORDER, RG-1 of READ_STATS):
|
|
`CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END`
|
|
(cutoff 10:00). It is computed at query time, not stored. For high-frequency stats queries,
|
|
a MariaDB generated column `VIRTUAL` or `STORED` could be added at DDL time to avoid
|
|
per-row recomputation, but this is not a blocker for the RNCP scope.
|
|
The v0.1 formula with `INTERVAL 4 HOUR 30 MINUTE` was incorrect and is dropped.
|
|
|
|
### 15.2 `order_item_modifier` for menu items
|
|
|
|
For a menu line (`item_type='menu'`), modifiers target the fixed burger identified via
|
|
`order_item.menu_id -> menu.burger_product_id`. The constraint that modifiers reference
|
|
only ingredients belonging to the burger's `product_ingredient` is enforced at the
|
|
application layer, not at the DB FK layer (see dictionary note 10). This is a known
|
|
trade-off: a multi-column FK or a DB trigger would be needed to enforce it at DB level.
|
|
Documenting it as an application invariant is the retained approach for this project scope.
|
|
|
|
### 15.3 Order number NNN counter — concurrency
|
|
|
|
The sequential NNN counter per `(source, service_day)` could produce duplicates under
|
|
high concurrency if implemented naively as `SELECT COUNT + 1`. The recommended
|
|
implementation at DDL/code time is either: (a) a table-level advisory lock around the
|
|
count-and-insert sequence; or (b) a dedicated sequence table with an atomic increment.
|
|
The UNIQUE constraint on `order_number` provides the last-resort guard (INSERT would fail
|
|
and the application retries). This is not a blocker for the RNCP demo volume.
|