diff --git a/docs/merise/dictionary.md b/docs/merise/dictionary.md index cb72788..b818d77 100644 --- a/docs/merise/dictionary.md +++ b/docs/merise/dictionary.md @@ -1,441 +1,441 @@ -# Data Dictionary — Wakdo +# Dictionnaire de Donnees — 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) +**Phase Merise** : P1 - Conception, etape 1 (dictionnaire de donnees d'abord, mantra #33) +**Version** : v0.2 — prod-like, 21 entites (19 prod-like + couche security-by-design, incl. la nouvelle entite `login_throttle`) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design en cours (voir note 13) +**Auteur** : BYAN (couche methodologie) --- -## 1. Purpose +## 1. Objectif -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). +Ce dictionnaire liste **toutes les entites de donnees** identifiees pour Wakdo, avec leurs attributs, +types, contraintes et sources. Il sert de base au MCD (entites + relations), +puis au MLD (mapping relationnel), puis au 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) +**Methodologie** : derivation bottom-up depuis les sources disponibles : +- **Source ecole** : `docs/merise/_sources/categories.json` + `produits.json` + (66 produits, 9 categories) +- **Brief metier** : `docs/PROJECT_CONTEXT.md` (composition du menu, flux de commande, RBAC, + modes de service) +- **Maquette** : `docs/design/maquette-borne.pdf` (UX borne, ecrans visibles) -All deviations between school source and final model are documented in the -"Modeling notes" section at the bottom of this document. +Tous les ecarts entre la source ecole et le modele final sont documentes dans la +section "Notes de modelisation" en bas de ce document. -For the entity-relationship diagram and cardinality justifications, see [`mcd.md`](mcd.md). -This dictionary does not duplicate that view to avoid diverging sources of truth. +Pour le diagramme entite-relation et les justifications de cardinalite, voir [`mcd.md`](mcd.md). +Ce dictionnaire ne duplique pas cette vue afin d'eviter des sources de verite divergentes. --- -## 2. General conventions +## 2. Conventions generales -### Naming +### Nommage -- **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**: `_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. +- **Tables** : `snake_case`, singulier (ex. `category`, `product`, `customer_order`). + Le singulier reflete la perspective "1 ligne = 1 instance de l'entite" (convention relationnelle + standard). Le code applicatif (PHP, JS) utilise ces noms tels quels via le mapping ORM. +- **Colonnes** : `snake_case`. Suffixes typiques : `_id` (FK), `_at` (timestamp), + `_cents` (montant monetaire en centimes entiers), `_path` (chemin de fichier), `_rate` (taux ou + fraction stocke en entier pour-mille). +- **Cles primaires** : colonne `id` (INT UNSIGNED AUTO_INCREMENT). Pas de PK composite sauf + sur les tables de jointure pures. +- **Cles etrangeres** : `_id` (ex. `category_id` dans `product`). +- **Valeurs ENUM** : anglais, snake_case (ex. `pending_payment`, `dine_in`, `kiosk`). +- **Chaines cote code** (ENUM, codes de permission, codes de role) : anglais uniquement, coherentes + entre la BDD, PHP et l'API JSON. -### Default types +### Types par defaut -| Category | MariaDB type | Justification | +| Categorie | Type MariaDB | 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 | +| Identifiants | `INT UNSIGNED AUTO_INCREMENT` | 4 milliards d'ids — suffisant pour ce projet | +| Libelles courts | `VARCHAR(120)` | Couvre la plupart des noms de produits (max observe : 41 caracteres dans la source ecole) | +| Descriptions | `TEXT` | Longueur variable, sans limite stricte | +| Montants monetaires | `INT UNSIGNED` (cents) | Evite les bugs d'arrondi FLOAT (voir note 1) | +| Booleens | `TINYINT(1)` | Convention MariaDB pour `BOOLEAN` (alias) | +| Horodatages | `DATETIME` | Lisible par l'humain, fuseau horaire gere au niveau applicatif | +| Enumerations | `ENUM('a','b','c')` | Contrainte au niveau SGBD, lisible (voir note 2) | +| Chemins de fichiers | `VARCHAR(255)` | Limite standard de longueur de chemin POSIX | -### Charset and collation +### Charset et 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). +- **Charset** : `utf8mb4` (RFC 3629 — vrai UTF-8 sur 4 octets, supporte emoji et caracteres asiatiques). + MariaDB gere `utf8mb4` nativement. +- **Collation** : `utf8mb4_unicode_ci` (insensible a la casse, comparaison conforme Unicode). -### Audit fields (present on all business tables except pure join tables) +### Champs d'audit (presents sur toutes les tables metier sauf les tables de jointure pures) -| Column | Type | Default | Role | +| Colonne | 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 | +| `created_at` | `DATETIME` | `CURRENT_TIMESTAMP` | Timestamp de creation, ecrit une fois a l'insertion | +| `updated_at` | `DATETIME` | `CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` | Timestamp de derniere modification, mis a jour automatiquement | ### 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. +Pas de soft delete generalise. Les entites qui peuvent etre temporairement desactivees portent une +colonne booleenne `is_active` ou `is_available`. Le `DELETE` dur reste possible mais est +reserve aux operations admin avec sauvegarde prealable. --- -## 3. Entities +## 3. Entites ### 3.1 `category` -Business grouping of products and menus for display on the kiosk. +Regroupement metier de produits et de menus pour l'affichage sur la borne. -| Attribute | Type | NULL | Default | Constraint | School source | Notes | +| Attribut | Type | NULL | Default | Contrainte | Source ecole | 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 | +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-9) | identique a la source | +| `name` | VARCHAR(60) | NO | — | UNIQUE | `title` | renomme depuis `title` | +| `slug` | VARCHAR(60) | NO | — | UNIQUE | derive de `title` (kebab-case minuscule) | utilise pour l'URL `/api/categories/burgers` | +| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | chemin relatif, voir note 8 | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (ajoute) | ordre d'affichage sur la borne, ajustable depuis l'admin | +| `is_active` | TINYINT(1) | NO | 1 | — | (ajoute) | desactiver sans supprimer | | `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`). +**Exemples** : `menus`, `drinks`, `burgers`, `fries`, `snacks`, `wraps`, `salads`, +`desserts`, `sauces`. Volume : 9 lignes a l'init (seed depuis `categories.json`). --- ### 3.2 `product` -A single sellable item, available a la carte or as a component in a menu slot. +Un article vendable unique, disponible a la carte ou comme composant dans un slot de menu. -| Attribute | Type | NULL | Default | Constraint | School source | Notes | +| Attribut | Type | NULL | Default | Contrainte | Source ecole | 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 | +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` | identique a la source | +| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | (derive de la cle d'objet JSON) | | +| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | renomme depuis `nom` | +| `description` | TEXT | YES | NULL | — | (ajoute) | renseigne plus tard via l'admin | +| `price_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` (FLOAT) | conversion FLOAT -> INT centimes au seed (voir note 1) | +| `vat_rate` | SMALLINT UNSIGNED | NO | 100 | CHECK IN (55, 100) | (ajoute) | taux de TVA en pour-mille : 100 = 10%, 55 = 5,5%. Defaut 10%. Voir note 9 | +| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | chemin relatif, voir note 8 | +| `is_available` | TINYINT(1) | NO | 1 | — | (ajoute) | bascule de disponibilite manuelle depuis l'admin | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (ajoute) | ordre d'affichage au sein de la categorie | | `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`). +**Volume** : ~53 lignes a l'init (66 lignes dans `produits.json` moins 13 menus deplaces vers `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. +Combo a prix fixe construit autour d'un burger specifique, avec des slots selectionnables par le client +(boisson, accompagnement, sauce). Deux paliers de prix : Normal et Maxi. -| Attribute | Type | NULL | Default | Constraint | School source | Notes | +| Attribut | Type | NULL | Default | Contrainte | Source ecole | 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) | | +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-13 dans la categorie `menus`) | | +| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | implicite (categorie `menus`) | | +| `burger_product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | (ajoute) | le burger fixe qui ancre ce menu ; pilote la personnalisation des ingredients | +| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | ex. "Menu Le 280" | +| `description` | TEXT | YES | NULL | — | (ajoute) | | +| `price_normal_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` | prix format Normal. Remplace le `prix_ttc_cents` unique. | +| `price_maxi_cents` | INT UNSIGNED | NO | — | CHECK > 0 | (ajoute) | prix format Maxi (~+150 centimes vs normal ; voir note 7) | +| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | reutilise generalement l'image du burger | +| `is_available` | TINYINT(1) | NO | 1 | — | (ajoute) | | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (ajoute) | | | `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. +**Volume** : 13 lignes a l'init. Remplace l'ancien modele `menu_produit` a composition fixe. --- ### 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`. +Un slot selectionnable au sein d'un menu (ex. "slot boisson", "slot accompagnement", "slot sauce"). +Chaque slot contraint les produits parmi lesquels le client peut choisir, exprimes via +la table de jointure `menu_slot_option`. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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 | +| `menu_id` | INT UNSIGNED | NO | — | FK -> `menu(id)`, ON DELETE CASCADE | un slot appartient a exactement un menu | +| `name` | VARCHAR(80) | NO | — | — | ex. "Drink", "Side", "Sauce" | +| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | — | — | role semantique de ce slot | +| `is_required` | TINYINT(1) | NO | 1 | — | indique si le client doit remplir ce slot | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | ordre d'affichage dans le constructeur de menu | -**No audit fields**: a slot is part of menu definition; created and updated with the menu. -**Composite index**: `(menu_id, display_order)`. +**Pas de champs d'audit** : un slot fait partie de la definition du menu ; cree et mis a jour avec le menu. +**Index composite** : `(menu_id, display_order)`. --- ### 3.5 `menu_slot_option` -Eligible products for a given menu slot. Pure join table. +Produits eligibles pour un slot de menu donne. Table de jointure pure. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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 | +| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | RESTRICT : retirer un produit ne doit pas casser silencieusement les menus | -**Primary key**: composite `(menu_slot_id, product_id)`. +**Cle primaire** : composite `(menu_slot_id, product_id)`. -**Volume**: ~3-5 options per slot, ~3 slots per menu, 13 menus = ~120-200 rows at init. +**Volume** : ~3-5 options par slot, ~3 slots par menu, 13 menus = ~120-200 lignes a l'init. --- ### 3.6 `ingredient` -Elementary ingredient used in product composition. Carries stock data. +Ingredient elementaire utilise dans la composition des produits. Porte les donnees de stock. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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 | +| `name` | VARCHAR(120) | NO | — | UNIQUE | ex. "Sesame Bun", "Cheddar Slice", "Ketchup Portion" | +| `unit` | VARCHAR(40) | NO | — | — | libelle de l'unite de conditionnement : piece / portion / sachet 1kg / pot / bouteille (libelle libre, pas un ENUM — les unites varient par ingredient) | +| `stock_quantity` | INT (signed) | NO | 0 | — | stock courant en unites. INT signe sans `CHECK >= 0` : il PEUT devenir negatif quand les ventes depassent le stock compte (ampleur de la survente, remontee aux managers). Le systeme ne bloque pas une commande sur le stock. | +| `stock_capacity` | INT | NO | — | CHECK > 0 | niveau "plein" de reference en unites = les 100% servant a calculer le pourcentage de stock. Le `CHECK > 0` protege aussi la division du pourcentage contre la division par zero | +| `pack_size` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | unites par pack de reapprovisionnement (ex. 100 pour un sac de 100 portions) | +| `pack_label` | VARCHAR(80) | YES | NULL | — | libelle humain du pack (ex. "Sac 100 portions") | +| `low_stock_pct` | SMALLINT UNSIGNED | NO | 10 | CHECK BETWEEN 0 AND 100 | bande d’alerte, pourcentage de capacite : `stock_quantity <= stock_capacity * low_stock_pct/100` declenche l'indicateur de stock bas | +| `critical_stock_pct` | SMALLINT UNSIGNED | NO | 5 | CHECK BETWEEN 0 AND 100 | seuil de rupture automatique, pourcentage de capacite : `stock_quantity <= stock_capacity * critical_stock_pct/100` rend le produit calcule en rupture | +| `is_active` | TINYINT(1) | NO | 1 | — | desactiver les ingredients obsoletes sans supprimer | | `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). +**CHECK au niveau table** : `critical_stock_pct < low_stock_pct` (le seuil critique se situe sous la bande d’alerte). -**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. -- **Low** — `stock_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. -- **Critical** — `stock_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. +**Regle de decrement de stock** : a la transition `paid`, chaque ingredient est decremente de +`product_ingredient.quantity_normal` ou `quantity_maxi` (selectionne par `order_item.format`) +multiplie par `order_item.quantity`, puis ajuste par les lignes `order_item_modifier`. Voir note 7. +**Regle de reapprovisionnement** : `stock_quantity += N * pack_size` (reapprovisionne en packs complets). +**Regle d'annulation** : le stock est recredite quand une commande `paid` est annulee. +**Modele de stock (base sur le pourcentage, trois bandes)** : le seuil d'alerte absolu est remplace par un +modele en pourcentage ancre sur `stock_capacity` (la reference 100%). Le pourcentage de stock est +calcule, non stocke : `stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Le +`CHECK > 0` sur `stock_capacity` protege cette division contre la division par zero. Trois bandes : +- **Normal** — au-dessus de la bande d’alerte : rien n'est signale. +- **Low** — `stock_quantity <= stock_capacity * low_stock_pct/100` : commandable + alerte manager. + Le manager retire le produit via `product.is_available=0`, ou reapprovisionne pour lever l'alerte. +- **Critical** — `stock_quantity <= stock_capacity * critical_stock_pct/100` : le produit + passe automatiquement en rupture (disponibilite calculee, voir regle RG-T21 dans `mlt.md`) ; aucune colonne stockee supplementaire. --- ### 3.7 `product_ingredient` -Default composition of a product (burger, wrap, etc.) in terms of ingredients. -Carries customization metadata for the ingredient configurator. +Composition par defaut d'un produit (burger, wrap, etc.) en termes d'ingredients. +Porte les metadonnees de personnalisation pour le configurateur d'ingredients. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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) | +| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | RESTRICT : impossible de retirer un ingredient encore reference dans une recette de produit | +| `quantity_normal` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | unites consommees en format Normal (ex. 2 pour double cheese) | +| `quantity_maxi` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | unites consommees en format Maxi. Egale `quantity_normal` pour les ingredients invariants au format (burger, sauce) ; superieure pour les ingredients d'accompagnement et de boisson (le Maxi agrandit uniquement l'accompagnement + la boisson). Voir note 7. | +| `is_removable` | TINYINT(1) | NO | 1 | — | le client peut retirer cet ingredient sans frais | +| `is_addable` | TINYINT(1) | NO | 0 | — | le client peut ajouter une unite supplementaire de cet ingredient | +| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | supplement en centimes quand `is_addable=1` et que le client l'ajoute (0 = extra gratuit) | -**Primary key**: composite `(product_id, ingredient_id)`. +**Cle primaire** : composite `(product_id, ingredient_id)`. -**Volume**: ~5-10 ingredients per product, ~53 products = ~300-500 rows at seed. +**Volume** : ~5-10 ingredients par produit, ~53 produits = ~300-500 lignes au seed. --- ### 3.8 `allergen` -Catalogue of the 14 regulated allergens (INCO Regulation (EU) 1169/2011). +Catalogue des 14 allergenes reglementes (Reglement INCO (UE) 1169/2011). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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 | +| `code` | VARCHAR(30) | NO | — | UNIQUE | code lisible par machine, ex. `gluten`, `milk`, `nuts` | +| `name` | VARCHAR(80) | NO | — | — | nom d'affichage, ex. "Gluten", "Lait", "Fruits a coque" | +| `description` | TEXT | YES | NULL | — | guidance optionnelle pour le personnel | -**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. +**Volume** : 14 lignes au seed (fixe par le reglement UE 1169/2011, liste confirmee au moment du seed). +Les allergenes d'un produit sont **calcules** en joignant `product_ingredient` -> +`ingredient_allergen` -> `allergen` ; pas de ressaisie manuelle par produit. --- ### 3.9 `ingredient_allergen` -Maps which allergens each ingredient contains. Pure join table. +Indique quels allergenes contient chaque ingredient. Table de jointure pure. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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)`. +**Cle primaire** : 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.) +Transaction client : 1 commande = 1 panier valide a un instant donne. +(Rationale du nom de table : voir note de modelisation 3.) -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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 | +| `order_number` | VARCHAR(20) | NO | — | UNIQUE | format lisible par l'humain : `K`/`C`/`D`-YYYY-MM-DD-NNN. Prefixe par canal : K=kiosk, C=counter, D=drive. Voir note 4. | +| `idempotency_key` | VARCHAR(36) | YES | NULL | UNIQUE | UUID genere par le client pour dedupliquer un `POST /api/orders` reessaye (anti-double-charge). UNIQUE rejette les doublons ; plusieurs NULL autorises. Security-by-design, voir note 13 | +| `source` | ENUM('kiosk','counter','drive') | NO | — | INDEX | canal de saisie (qui a saisi la commande). Valeurs en anglais, voir note 5. | +| `acting_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | personnel back-office (counter/drive) ayant cree la commande, capture sous PIN. NULL pour `kiosk` (anonyme). Imputabilite ciblee sans imposer un login par personne sur la borne. Voir note 13 | +| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | — | — | mode de consommation, conserve pour les stats/KPI uniquement. Aucun role fiscal (voir note 9). La source `drive` implique le service_mode `drive` (contrainte croisee appliquee au niveau applicatif). | +| `status` | ENUM('pending_payment','paid','delivered','cancelled') | NO | 'pending_payment' | INDEX | machine a 4 etats : `pending_payment -> paid -> delivered` (+ `cancelled`). Voir note 6. | +| `total_ht_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | total hors TVA, snapshot a la validation de la commande | +| `total_vat_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | montant de TVA, snapshot | +| `total_ttc_cents` | INT UNSIGNED | NO | — | CHECK > 0 | total TTC ; doit egaler total_ht_cents + total_vat_cents (verifie a la couche MLT) | +| `paid_at` | DATETIME | YES | NULL | — | timestamp de la transition vers `paid` (NULL avant paiement) | +| `delivered_at` | DATETIME | YES | NULL | — | timestamp de la transition vers `delivered` (NULL avant la remise) | +| `cancelled_at` | DATETIME | YES | NULL | — | timestamp d'annulation (NULL si non annulee) | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | utilise pour les agregations de stats en direct ; sert aussi de base a `service_day` | | `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). +**Retire de v0.1** : `tva_taux_pourmille` (deplace au niveau ligne — `order_item.vat_rate_snapshot`), +`paye_a` (renomme `paid_at`). Etats machine `preparing` et `ready` retires (voir note 6). -**`service_day` computation** (KPI grouping): +**Calcul de `service_day`** (regroupement KPI) : ``` 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. +Calcule au moment de la requete, non stocke comme colonne (la formule de colonne generee avec `INTERVAL 4 HOUR +30 MINUTE` dans le MLD v0.1 etait incorrecte et est retiree). Coupure : 10:00. -**Volume**: ~100-300 orders/day at peak, ~10k rows over a 6-month demo. +**Volume** : ~100-300 commandes/jour au pic, ~10k lignes sur une demo de 6 mois. --- ### 3.11 `order_item` -Line of an order: a single product or a menu, with price, label, and VAT rate -snapshotted at transaction time. +Ligne d'une commande : un seul produit ou un menu, avec prix, libelle et taux de TVA +snapshotes au moment de la transaction. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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) | +| `item_type` | ENUM('product','menu') | NO | — | — | discriminateur | +| `product_id` | INT UNSIGNED | YES | NULL | FK -> `product(id)`, ON DELETE RESTRICT | non nul si `item_type = 'product'` | +| `menu_id` | INT UNSIGNED | YES | NULL | FK -> `menu(id)`, ON DELETE RESTRICT | non nul si `item_type = 'menu'` | +| `format` | ENUM('normal','maxi') | NO | 'normal' | — | s'applique aux items menu (Normal / Maxi). Pour les produits autonomes, la valeur est `normal` (pas d'agrandissement individuel dans ce modele). Voir note 7. | +| `label_snapshot` | VARCHAR(120) | NO | — | — | libelle au moment de la commande (preserve si le produit est renomme) | +| `unit_price_cents_snapshot` | INT UNSIGNED | NO | — | CHECK > 0 | prix unitaire TTC au moment de la commande | +| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | — | CHECK IN (55, 100) | taux de TVA en pour-mille au moment de la commande (snapshote depuis `product.vat_rate`) | +| `quantity` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | quantite commandee (ex. 3 Cocas = 1 ligne avec quantity=3) | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | -**CHECK constraint** (applicative or MariaDB CHECK >= 10.2): +**Contrainte CHECK** (applicative ou 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. +**Volume** : ~3-5 lignes par commande -> 30k-50k lignes sur 6 mois. --- ### 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`. +Les choix reels effectues par le client pour chaque slot d'une ligne de menu. +1 ligne = 1 slot rempli pour 1 order_item de type `menu`. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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 | +| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | doit referencer un order_item avec item_type='menu' | +| `menu_slot_id` | INT UNSIGNED | NO | — | FK -> `menu_slot(id)`, ON DELETE RESTRICT | quel slot a ete rempli | +| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | produit choisi par le client pour ce slot | +| `label_snapshot` | VARCHAR(120) | NO | — | — | libelle du produit au moment de la commande | -**Volume**: ~2-3 selections per menu line. -**KPI use**: enables analysis of which drink/side combinations are most chosen. +**Volume** : ~2-3 selections par ligne de menu. +**Usage KPI** : permet d'analyser quelles combinaisons boisson/accompagnement sont les plus choisies. --- ### 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). +Modifications au niveau ingredient appliquees par le client a un produit ou au burger fixe +d'un menu : retrait (gratuit) ou ajout (avec supplement optionnel). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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) | +| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | la ligne de commande modifiee (produit ou menu) | +| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | l'ingredient modifie | +| `action` | ENUM('remove','add') | NO | — | — | `remove` = retrait gratuit ; `add` = unite supplementaire (peut avoir un supplement) | +| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | snapshot de `product_ingredient.extra_price_cents` au moment de la commande (0 pour les retraits) | -**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 +**Regle de rattachement du modificateur** (voir note de modelisation 10) : +- Pour un produit autonome (`item_type='product'`) : le modificateur cible le produit + directement via `order_item_id`. +- Pour un menu (`item_type='menu'`) : le modificateur cible le burger fixe de la ligne de menu + via le meme `order_item_id`. Le burger est identifie par `menu.burger_product_id`, + permettant a l'affichage cuisine de resoudre sans ambiguite quels ingredients sont modifies. + Aucune FK supplementaire n'est necessaire : etant donne `order_item_id`, le burger est `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). +**Impact stock** : chaque modificateur affecte le stock d'ingredient a la transition `paid` +(`remove` -> pas de decrement pour cet ingredient ; `add` -> decrement supplementaire). --- ### 3.14 `user` -Back-office user (admin, manager, kitchen staff, counter, drive). Kiosk customers -are not authenticated and have no row here. +Utilisateur back-office (admin, manager, personnel cuisine, counter, drive). Les clients de la borne +ne sont pas authentifies et n'ont pas de ligne ici. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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 | +| `email` | VARCHAR(254) | NO | — | UNIQUE | longueur max selon RFC 5321 | +| `password_hash` | VARCHAR(255) | NO | — | — | hash argon2id (voir `PASSWORD_ALGO` dans `.env`) ; longueur typique 96 caracteres, marge jusqu'a 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 | +| `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE RESTRICT | un utilisateur ne peut exister sans role | +| `is_active` | TINYINT(1) | NO | 1 | — | desactivation sans suppression | +| `last_login_at` | DATETIME | YES | NULL | — | utile pour l'audit et la detection de comptes dormants | +| `pin_hash` | VARCHAR(255) | YES | NULL | — | hash argon2id du PIN par membre du personnel qui autorise les actions sensibles (prix/RBAC/utilisateur/annulation/inventaire). NULL = aucun PIN defini. Security-by-design, voir note 13 | +| `failed_login_attempts` | SMALLINT UNSIGNED | NO | 0 | — | logins echoues consecutifs ; pilote le throttling degressif (note 13) | +| `last_failed_login_at` | DATETIME | YES | NULL | — | timestamp du dernier login echoue | +| `lockout_until` | DATETIME | YES | NULL | — | fin de la fenetre de throttling courante (backoff degressif, pas un verrouillage dur indefini) | +| `password_reset_token_hash` | VARCHAR(255) | YES | NULL | — | hash du token de reset (pas le token brut) ; NULL quand aucun reset en attente | +| `password_reset_expires_at` | DATETIME | YES | NULL | — | expiration du token de reset | +| `anonymized_at` | DATETIME | YES | NULL | — | marqueur tombstone RGPD : quand renseigne, les colonnes PII sont mises a NULL/remplacees (note 13). La ligne est conservee pour l'integrite referentielle | | `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). +**Volume** : 5-20 lignes (equipe du restaurant + 1-2 admins). -RFC 5321 email length: local-part <= 64, domain <= 255, total <= 254 (including `@`). -VARCHAR(254) is the spec-compliant value. +Longueur d'email RFC 5321 : local-part <= 64, domaine <= 255, total <= 254 (incluant `@`). +VARCHAR(254) est la valeur conforme a la spec. -**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. +**Colonnes PII** : `email`, `first_name`, `last_name`. Soumises a l'anonymisation RGPD +(voir note 13). `password_hash` et `pin_hash` sont des credentials, tenus hors des logs et +des reponses d'API. --- ### 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. +Roles back-office (RBAC). Creables / modifiables / desactivables depuis l'UI admin. +Le seed fournit 5 roles ; des roles personnalises (ex. "chef-patissier") peuvent etre ajoutes sans deploiement. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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` | +| `code` | VARCHAR(40) | NO | — | UNIQUE | code machine, ex. `admin`, `manager`, `kitchen`, `counter`, `drive` | +| `label` | VARCHAR(80) | NO | — | — | nom d'affichage, ex. `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 | +| `default_route` | VARCHAR(120) | YES | NULL | — | ecran d'atterrissage pour ce role (ex. `/admin/orders`, `/kitchen/display`). Rend le routage dynamique — pas de noms de role en dur dans le routage front-end. | +| `order_source` | ENUM('kiosk','counter','drive') | YES | NULL | — | `source` auto-taggee quand ce role cree une commande (NULL pour admin/manager qui peuvent creer au nom de n'importe quel canal) | +| `is_active` | TINYINT(1) | NO | 1 | — | la desactivation preserve l'historique des utilisateurs ayant detenu ce role | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | -**Seed roles**: +**Roles du seed** : | Code | `default_route` | `order_source` | |---|---|---| | `admin` | `/admin/dashboard` | NULL | @@ -444,28 +444,28 @@ Seed provides 5 roles; custom roles (e.g., "chef-patissier") can be added withou | `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). +**Regle d'architecture RBAC (P2)** : le code applicatif teste les permissions, pas les noms de role. +Ajouter un nouveau role avec les bonnes permissions ne requiert aucun changement de code (pilote par permission, +non par nom de role — selon le modele RBAC Sandhu/NIST). --- ### 3.16 `role_visible_source` -Defines which order sources are visible on the preparation dashboard for a given role. -Pure join table. +Definit quelles sources de commande sont visibles sur le tableau de bord de preparation pour un role donne. +Table de jointure pure. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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 | +| `source` | ENUM('kiosk','counter','drive') | NO | — | — | source visible pour ce role sur l'affichage kitchen/counter/drive | -**Primary key**: composite `(role_id, source)`. +**Cle primaire** : composite `(role_id, source)`. -**Seed data**: -| Role | Visible sources | +**Donnees du seed** : +| Role | Sources visibles | |---|---| -| `kitchen` | kiosk, counter, drive (all) | +| `kitchen` | kiosk, counter, drive (toutes) | | `counter` | kiosk, counter | | `drive` | drive | @@ -473,19 +473,19 @@ Pure join table. ### 3.17 `permission` -Granular permissions assignable to roles. Catalogue is fixed at seed (no UI creation). +Permissions granulaires assignables aux roles. Le catalogue est fixe au seed (pas de creation via UI). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | | `code` | VARCHAR(60) | NO | — | UNIQUE | format `.` | -| `label` | VARCHAR(120) | NO | — | — | display name | +| `label` | VARCHAR(120) | NO | — | — | nom d'affichage | | `description` | TEXT | YES | NULL | — | | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | -**Fixed permission catalogue** (23 codes — frozen before DDL): +**Catalogue de permissions fixe** (23 codes — gele avant le DDL) : -| Code | Granted to (seed default) | +| Code | Accorde a (defaut seed) | |---|---| | `product.create` | admin, manager | | `product.read` | admin, manager, kitchen, counter, drive | @@ -511,248 +511,248 @@ Granular permissions assignable to roles. Catalogue is fixed at seed (no UI crea | `role.manage` | admin | | `stats.read` | admin, manager | -**Volume**: 23 rows at seed. +**Volume** : 23 lignes au seed. --- ### 3.18 `role_permission` -N-N mapping between roles and permissions. Pure join table. +Mapping N-N entre roles et permissions. Table de jointure pure. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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)`. +**Cle primaire** : composite `(role_id, permission_id)`. -**Volume**: ~50-100 rows at seed (admin covers all; others cover a subset). +**Volume** : ~50-100 lignes au seed (admin couvre tout ; les autres couvrent un sous-ensemble). --- ### 3.19 `stock_movement` -Append-only audit log of all stock changes per ingredient. -1 row per movement (sale, cancellation, restock, inventory correction). +Journal d'audit append-only de tous les changements de stock par ingredient. +1 ligne par mouvement (vente, annulation, reapprovisionnement, correction d'inventaire). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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 | +| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | ingredient affecte | +| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | — | INDEX | nature du mouvement | +| `delta` | INT | NO | — | — | changement signe : negatif pour la consommation (vente), positif pour reapprovisionnement/annulation/correction | +| `order_id` | INT UNSIGNED | YES | NULL | FK -> `customer_order(id)`, ON DELETE SET NULL | commande liee pour les mouvements `sale` et `cancellation` ; NULL pour restock/correction | +| `user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | utilisateur ayant declenche le mouvement (NULL pour les decrements de vente automatises) | +| `note` | VARCHAR(255) | YES | NULL | — | note humaine optionnelle (ex. raison de la correction, reference de pack) | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | timestamp immuable | -**Immutability**: no UPDATE or DELETE on this table. Corrections are new rows with -`movement_type='inventory_correction'` and a signed delta. +**Immuabilite** : aucun UPDATE ni DELETE sur cette table. Les corrections sont de nouvelles lignes avec +`movement_type='inventory_correction'` et un delta signe. -**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. +**Mouvements automatiques** (declenches aux transitions de statut) : +- transition `paid` : 1 ligne `sale` par unite d'ingredient consommee (en tenant compte des modificateurs). +- `cancelled` (depuis `paid`) : 1 ligne `cancellation` par unite d'ingredient recreditee. -**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). +**Mouvements manuels** : +- `restock` : le manager ou l'admin enregistre une livraison (`+= N * pack_size`). +- `inventory_correction` : comptage physique matin/soir ; le systeme enregistre l'ecart + (delta = reel - theorique). -**Volume**: ~5-15 movements per order across all ingredients; index on -`(ingredient_id, created_at)` is recommended for per-ingredient history queries. +**Volume** : ~5-15 mouvements par commande sur tous les ingredients ; un index sur +`(ingredient_id, created_at)` est recommande pour les requetes d'historique par ingredient. --- ### 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). +Journal append-only des **actions back-office sensibles**, pour l'imputabilite la ou elle importe +(menace interne, manipulation d'argent, changements RBAC). Complete `stock_movement` (specifique au +stock) ; couvre les evenements catalogue/prix, utilisateur, role/permission et annulation de commande. +Ajout security-by-design (voir note 13). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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 | +| `actor_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | personnel ayant effectue l'action, capture via PIN pour les operations sensibles. NULL si non attribuable a un individu | +| `actor_role_id` | INT UNSIGNED | YES | NULL | FK -> `role(id)`, ON DELETE SET NULL | contexte de role au moment de l'action (denormalise pour que la trace survive a l'anonymisation de l'utilisateur) | +| `action_code` | VARCHAR(60) | NO | — | INDEX | code d'operation MCT / de permission, ex. `product.update`, `order.cancel`, `role.manage`, `user.deactivate` | +| `entity_type` | VARCHAR(40) | YES | NULL | — | nom de la table affectee, ex. `product`, `customer_order`, `role`, `user` | +| `entity_id` | INT UNSIGNED | YES | NULL | — | PK de la ligne affectee | +| `summary` | VARCHAR(255) | YES | NULL | — | courte description non personnelle, ex. "price_cents 880 -> 920", "added permission stock.manage" | +| `details` | JSON | YES | NULL | — | diff before/after optionnel. Pour les actions ciblant un utilisateur, stocke les **noms de champs** modifies, pas les valeurs PII | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | timestamp immuable | -**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. +**Immuabilite** : aucun UPDATE ni DELETE au niveau applicatif (meme discipline que `stock_movement`). +**Index** : `(actor_user_id, created_at)`, `(entity_type, entity_id)`, `(action_code, created_at)`. +**Retention** : fenetre propre (~12 mois, interet legitime / tracabilite fiscale), decouplee +du cycle de vie des PII utilisateur (note 13). Une purge planifiee (cron) retire les lignes au-dela de la fenetre. -**Logged operations** (sensitive set): `UPDATE_PRODUCT` (8.2, incl. price), `DELETE_PRODUCT` +**Operations journalisees** (ensemble sensible) : `UPDATE_PRODUCT` (8.2, incl. prix), `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`. +**Volume** : faible (~10-50 actions sensibles/jour) — des ordres de grandeur sous `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). +Throttle anti-brute-force par IP source. Complete le compteur par compte deja present sur `user` +(`failed_login_attempts` / `lockout_until`), une ligne par IP source. Ajout security-by-design +(voir note 13). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | 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 | +| `ip_address` | VARCHAR(45) | NO | — | UNIQUE | IP source, une ligne par IP, upsertee ; 45 caracteres contiennent un litteral IPv6 complet | +| `failed_attempts` | SMALLINT UNSIGNED | NO | 0 | — | logins echoues consecutifs depuis cette IP dans la fenetre courante | +| `window_started_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | debut de la fenetre de comptage courante | +| `lockout_until` | DATETIME | YES | NULL | — | fin de la fenetre de backoff degressif ; NULL = non throttle | +| `last_attempt_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | timestamp de la derniere tentative echouee | -**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. +**Pas de FK** : une IP n'est pas une entite modelisee. Les lignes sont appended/upsertees par IP ; la fenetre se reinitialise +a son expiration. Un cron quotidien purge les lignes sans lockout actif dont le `last_attempt_at` est plus ancien +que 24h. --- -## 4. Modeling notes +## 4. Notes de modelisation -### Note 1 — Why `INT UNSIGNED` in cents for prices +### Note 1 — Pourquoi `INT UNSIGNED` en centimes pour les prix -Storing a price as `FLOAT` or `DECIMAL(10,2)` is technically valid but introduces two risks: +Stocker un prix en `FLOAT` ou `DECIMAL(10,2)` est techniquement valide mais introduit deux risques : -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. +1. **Arrondi FLOAT** : `0.1 + 0.2 = 0.30000000000000004` en virgule flottante IEEE 754. + Sommer 100 lignes de commande peut produire des ecarts au niveau du centime vs la realite metier. +2. **Conversion FLOAT-vers-chaine** : differentes versions de driver PHP/MariaDB peuvent serialiser les floats + avec une precision variable. -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)`. +Stocker en `INT UNSIGNED` (centimes : 880 pour 8,80 EUR) elimine ces risques. La conversion en EUR +pour l'affichage se fait en PHP a la sortie : `number_format($cents / 100, 2)`. -Reference: David Goldberg, *What Every Computer Scientist Should Know About Floating-Point +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 +### Note 2 — Pourquoi `ENUM` plutot qu'une table de reference -ENUMs (`service_mode`, `status`, `item_type`, `action`, `slot_type`) could have been reference -tables. Choice retained: ENUM. +Les ENUMs (`service_mode`, `status`, `item_type`, `action`, `slot_type`) auraient pu etre des tables de +reference. Choix retenu : 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'`. +Avantages dans ce contexte : +- Les valeurs sont stables et limitees (3-7 valeurs max), peu susceptibles d'evoluer frequemment. +- Contrainte au niveau SGBD au lieu d'une FK a l'execution ; requetes plus simples. +- Directement lisible en 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. +Cout d'un changement futur : `ALTER TABLE ... MODIFY COLUMN ... ENUM(...)` pour ajouter une valeur. +Acceptable etant donne que les changements sont attendus comme rares. -If these ENUMs later require multilingual labels or descriptions, they will be migrated to -reference tables. Not in scope for this iteration. +Si ces ENUMs requierent plus tard des libelles ou descriptions multilingues, ils seront migres vers des +tables de reference. Hors perimetre pour cette iteration. -### Note 3 — Why `customer_order` and not `order` +### Note 3 — Pourquoi `customer_order` et non `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. +`ORDER` est un mot reserve SQL (utilise dans `ORDER BY`). Trois approches existent : +- Quoter le nom partout : `` `order` `` — requiert un quoting dans chaque instruction SQL, + source d'erreurs et non portable entre dialectes SGBD. +- Utiliser un alias au niveau ORM : possible mais ajoute une couche de mapping. +- Renommer : `customer_order` (choisi) — sans ambiguite, auto-documente, sans quoting requis. -Alternative considered and rejected: `purchase` (less domain-specific), -`transaction` (also reserved or ambiguous). `customer_order` matches the domain language -and avoids all conflicts. +Alternative consideree et rejetee : `purchase` (moins specifique au domaine), +`transaction` (egalement reserve ou ambigu). `customer_order` correspond au langage du domaine +et evite tous les conflits. -`order_item` is retained as the line table name: `item` is not reserved, and the -`order_` prefix makes the parent relationship clear. +`order_item` est conserve comme nom de table de ligne : `item` n'est pas reserve, et le +prefixe `order_` rend claire la relation parent. -### Note 4 — Order number prefix by channel +### Note 4 — Prefixe de numero de commande par canal -Format: `K`/`C`/`D`-YYYY-MM-DD-NNN (kiosk / counter / drive). +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. +Rationale : le prefixe encode le canal, ce qui est utile pour une identification visuelle rapide +par le personnel cuisine et comptoir sans interroger la colonne `source`. Le compteur sequentiel NNN +repart chaque jour par canal. Sans collision sur une journee, vu le volume attendu. -Alternative rejected: neutral prefix `W-` for all channels (simpler, but loses channel -readability for staff). +Alternative rejetee : prefixe neutre `W-` pour tous les canaux (plus simple, mais perd la lisibilite +du canal pour le personnel). -### Note 5 — `source` vs `service_mode` (channel vs consumption mode) +### Note 5 — `source` vs `service_mode` (canal vs mode de consommation) -Two distinct dimensions, kept separate: +Deux dimensions distinctes, gardees separees : | | `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) | +| Nature | canal de saisie (qui a saisi la commande) | mode de consommation (ou le client mange) | +| Valeurs | kiosk, counter, drive | dine_in, takeaway, drive | +| Sert a | authentification, analytics, filtrage de permission | KPI, capacite (aucun role fiscal) | -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. +Les deux dimensions sont independantes pour `kiosk` et `counter` (un client borne peut choisir +`dine_in` ou `takeaway`). `drive` est le seul cas ou les deux dimensions s'alignent : +`source=drive` implique `service_mode=drive`. Cette contrainte croisee est verifiee au niveau applicatif. -### Note 6 — Reduced 4-state machine +### Note 6 — Machine a 4 etats reduite -v0.1 had 6 states (`pending_payment`, `paid`, `preparing`, `ready`, `delivered`, `cancelled`). -v0.2 reduces to 4 states: `pending_payment -> paid -> delivered` (+ `cancelled`). +v0.1 avait 6 etats (`pending_payment`, `paid`, `preparing`, `ready`, `delivered`, `cancelled`). +v0.2 reduit a 4 etats : `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. +Rationale (Decision 4 de `revue-alignement-p1.md` §7) : dans un contexte fast-food, l'affichage +cuisine (KDS) est un systeme visuel — le personnel voit le ticket et agit. `preparing` et `ready` etaient +des etats intermediaires qui ajoutaient de la complexite sans valeur metier proportionnelle. L'unique +action cuisine est `deliver` (le personnel counter/drive remet la commande), fusionnant +`preparing + ready + delivered` en un seul geste. Le KPI est le temps total : `delivered_at - paid_at` +(SLA ~10 min). Le codage couleur du KDS est calcule depuis `now - paid_at`, sans etat stocke supplementaire. -**Dropped states and timestamps**: `preparing_at`, `ready_at` are not stored. +**Etats et timestamps retires** : `preparing_at`, `ready_at` ne sont pas stockes. -### Note 7 — Normal / Maxi format cascade +### Note 7 — Cascade de format Normal / Maxi -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. +Le format Maxi agrandit uniquement l'accompagnement et la boisson. Le burger est inchange et la portion +de sauce est inchangee (un pot de sauce est identique dans les deux formats). Ce perimetre est explicite afin que le +modele de stock reste fidele. -**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. +**Cote prix** — non modelise au niveau du prix de composant individuel : +- `menu` porte deux prix : `price_normal_cents` et `price_maxi_cents`. +- `order_item.format` enregistre le format choisi par le client (`normal` ou `maxi`). +- `order_item.unit_price_cents_snapshot` capture le prix reellement paye (Normal ou Maxi). +- Aucun prix individuel par composant de slot n'est stocke ; le differentiel de prix est un attribut + au niveau menu, coherent avec la maniere dont les menus fast-food tendent a etre tarifes en pratique. -**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. +**Cote stock** — modelise via un multiplicateur de format sur la recette : +- `product_ingredient` porte `quantity_normal` et `quantity_maxi`. +- A la transition `paid`, le decrement utilise `quantity_maxi` quand `order_item.format='maxi'`, + sinon `quantity_normal`. +- Pour les ingredients burger et sauce, `quantity_maxi = quantity_normal` (invariants au format). +- Pour les ingredients accompagnement et boisson, `quantity_maxi > quantity_normal` (le Maxi consomme plus). +- Le format se propage de la ligne de menu (`order_item.format`) a ses selections de slot ; une + ligne de produit autonome est par defaut a `normal`. +- Un seul produit par choix (ex. un produit `Fries`), pas de produits medium/large separes. -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 : le supplement Maxi est dans la fourchette ~1,50 EUR pour ce modele (derive de +donnees internes ; recoupe avec l'ordre de grandeur du marche pour plausibilite. Wakdo est un pastiche +fictif, donc les prix exacts ne sont pas copies d'une chaine reelle). -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 : le supplement Maxi est dans la fourchette ~1,50 EUR pour ce modele (derive de +donnees internes ; recoupe avec l'ordre de grandeur du marche pour plausibilite. Wakdo est un pastiche +fictif, donc les prix exacts ne sont pas copies d'une chaine reelle). -### Note 8 — Image storage: path in VARCHAR vs BLOB in DB +### Note 8 — Stockage des images : chemin en VARCHAR vs BLOB en BDD -`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`). +Les colonnes `image_path` (`category`, `product`, `menu`) stockent un **chemin relatif** depuis la +racine publique (ex. `/uploads/products/classic-burger.jpg`), pas un chemin serveur absolu. +PHP resout via un prefixe depuis `.env` (`UPLOAD_DIR=public/uploads`). -BLOB storage was considered and rejected: +Le stockage BLOB a ete considere et rejete : -| Criterion | `image_path` VARCHAR (chosen) | BLOB in DB | +| Critere | `image_path` VARCHAR (choisi) | BLOB en BDD | |---|---|---| -| 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 | +| Performance borne | Apache sert les fichiers en ms (cache OS) | PHP lit la BDD + streame, latence multipliee | +| Cache HTTP | ETag, Last-Modified, cache navigateur, CDN natifs | doit etre reimplemente en PHP | +| Taille de backup BDD | Megaoctets (chemins seulement) | Gigaoctets (66 produits x ~200 Ko + variantes responsive) | +| Pipeline d'images | `convert`, `webp`, optimisation = outils standard du systeme de fichiers | doit etre reinvente en PHP | -Sources: OWASP File Upload Cheat Sheet; MariaDB Knowledge Base — LONGBLOB performance; -Apache HTTP Server documentation — serving static content. +Sources : OWASP File Upload Cheat Sheet ; MariaDB Knowledge Base — LONGBLOB performance ; +documentation Apache HTTP Server — serving static content. -### Note 9 — VAT rule in French fast-food (fact-checked) +### Note 9 — Regle de TVA dans le fast-food francais (fact-checked) ``` FACT-CHECK @@ -765,163 +765,163 @@ Actual rule : 10% for immediate consumption (dine-in OR hot takeaway); 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. +**Consequence sur le modele** : le taux de TVA est un attribut du `product` (`vat_rate` en pour-mille : +100 = 10%, 55 = 5,5%), pas de la commande ni du mode de service. Defaut : 100 (10%). +Le taux de 5,5% s'applique aux produits en contenants refermables (eau en bouteille, bouteilles de jus). +La TVA est calculee ligne par ligne ; le taux est snapshote sur `order_item.vat_rate_snapshot` +au moment de la transaction pour preserver l'integrite historique si la legislation change. -`service_mode` is retained on `customer_order` for stats and KPI only (capacity planning, -per-mode revenue breakdown). It has no fiscal computation role. +`service_mode` est conserve sur `customer_order` pour les stats et le KPI uniquement (planification de capacite, +repartition du chiffre d'affaires par mode). Il n'a aucun role de calcul fiscal. -### Note 10 — Ingredient configurator and modifier attachment +### Note 10 — Configurateur d'ingredients et rattachement du modificateur -`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. +`order_item_modifier` se rattache a une ligne `order_item` via `order_item_id`, que +la ligne soit un produit autonome ou un menu. -For a **standalone product** (`item_type='product'`): `order_item_id` directly identifies -the product being modified. +Pour un **produit autonome** (`item_type='product'`) : `order_item_id` identifie directement +le produit modifie. -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: +Pour un **menu** (`item_type='menu'`) : le produit modifiable est le burger fixe, identifie +via `order_item.menu_id -> menu.burger_product_id`. L'affichage cuisine resout : `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. +Aucune colonne FK supplementaire n'est necessaire sur `order_item_modifier`. Cela garde la table modificateur +simple et evite une colonne nullable `target_product_id` qui ne serait peuplee que pour les +lignes de menu. -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`. +Contrainte appliquee au niveau applicatif : les lignes `order_item_modifier` pour une ligne de menu referencent +uniquement des ingredients appartenant a `menu.burger_product_id` via `product_ingredient`. -### Note 11 — `menu_slot` eligibility: category filter vs explicit product list +### Note 11 — Eligibilite `menu_slot` : filtre par categorie vs liste de produits explicite -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. +Deux options ont ete considerees : +- **Filtre par categorie** : `menu_slot.category_id` pointe vers une categorie ; tous les produits de cette + categorie sont eligibles. Simple, mais une categorie peut contenir des produits non proposes dans ce slot + (ex. une boisson premium ajoutee a la categorie "drinks" ne devrait pas apparaitre automatiquement dans + tous les slots de menu). +- **Liste de produits explicite** `menu_slot_option(menu_slot_id, product_id)` (choisie) : chaque + produit eligible est liste explicitement par slot. Plus verbeux au moment du seed mais precis — + pas d'eligibilite accidentelle quand le catalogue grandit. Permet des overrides de tarification par slot + a l'avenir sans changement structurel. -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. +La liste explicite ajoute une entite (`menu_slot_option`, entite 3.5) mais elimine une classe +de bugs de justesse. Coherent avec l'ambition prod-like de ce modele. -### Note 12 — `commande_event` dropped +### Note 12 — `commande_event` retire -v0.1 carried a `commande_event` append-only audit table (event sourcing pattern). -Dropped in v0.2 (Decision 1, `revue-alignement-p1.md` §7). +v0.1 portait une table d'audit append-only `commande_event` (pattern event sourcing). +Retiree en 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. +Rationale : dans un contexte restaurant, le compte back-office est partage par poste de travail, non +individuel. L'attribution par personne d'une transition d'etat n'a aucune valeur metier. Le besoin reel +(durees de phase, stats par heure de la journee) est couvert par les timestamps de phase sur `customer_order` +(`paid_at`, `delivered_at`, `cancelled_at`) sans la complexite d'un 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 +La machine a 4 etats combinee a 3 timestamps de phase fournit toutes les donnees KPI necessaires : +- Temps de remise : `delivered_at - paid_at` +- Taux et timing d'annulation : `cancelled_at - created_at` +- Volume par heure : calcul `HOUR(created_at)` / `service_day` -For stock audit, `stock_movement` (entity 3.19) provides the append-only audit trail -where it is genuinely needed (inventory reconciliation). +Pour l'audit de stock, `stock_movement` (entite 3.19) fournit la trace d'audit append-only +la ou elle est genuinement necessaire (reconciliation d'inventaire). -### Note 13 — Security-by-design data additions (2026-06-11) +### Note 13 — Ajouts de donnees security-by-design (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. +Ces ajouts etendent le modele prod-like avec une couche security-by-design. Ils ne +remplacent aucune decision v0.2 ; ils ajoutent imputabilite, cycle de vie d'auth et resistance a l'abus. -**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. +**Imputabilite — compte partage hybride + PIN.** Les sessions back-office restent partagees par +poste de travail pour le flux de routine (un terminal fast-food est partage, les `equipiers` tournent). Un +PIN par membre du personnel (`user.pin_hash`, argon2id) autorise un ensemble defini d'**actions sensibles** +(editions prix/menu 8.2/8.3/8.6, annulation de commande 7.1, correction d'inventaire 9.2, gestion +des utilisateurs 10.1-10.3, RBAC 10.4). Ces actions ecrivent le `user_id` agissant dans `audit_log` +(3.20). Cela resout la justification circulaire qui avait retire `commande_event` en v0.1 +(les events etaient juges inutiles parce que les comptes etaient partages) : l'imputabilite est enregistree +la ou elle importe, a friction quasi nulle pour les 95% de routine. `customer_order.acting_user_id` +capture le personnel pour les commandes counter/drive prises sous PIN ; les commandes borne restent anonymes. -**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`. +**Cycle de vie d'auth.** `password_reset_token_hash` + `password_reset_expires_at` permettent un parcours +de reset (le token est stocke hashe, le token brut est envoye par e-mail une seule fois). La resistance au brute-force utilise +un throttling degressif plutot qu'un verrouillage dur indefini : `failed_login_attempts` + +`lockout_until` implementent un backoff degressif par (compte + IP source), de sorte qu'une serie +de fautes de frappe ne verrouille pas toute une cuisine en plein service (15 h continues). Les logins echoues sont +ecrits dans `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-@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. +**Anonymisation RGPD vs retention d'audit.** Les PII de `user` (`email`, `first_name`, `last_name`) +sont soumises au droit a l'effacement (Cr 3.d). L'effacement **anonymise** plutot qu'il ne supprime durement : +la ligne est conservee, `email` devient un placeholder unique non identifiant (`anon-@wakdo.invalid`, +domaine reserve RFC 2606), les noms sont effaces, `password_hash`/`pin_hash` sont invalides, et +`anonymized_at` est renseigne. `audit_log` conserve sa propre fenetre de retention (~12 mois, +interet legitime / tracabilite fiscale) et continue de pointer vers le principal anonymise, de sorte que +effacement et imputabilite coexistent sans casser l'integrite referentielle. -**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. +**Resistance a l'abus sur la borne anonyme.** `customer_order.idempotency_key` (UUID client, +UNIQUE) deduplique un `POST /api/orders` reessaye de sorte qu'un retry reseau ne cree pas de +commande payee dupliquee. Le stock est decremente avec une seule instruction atomique +(`UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id`) : aucune operation +ne depend d'une lecture de stock, donc la ligne s'auto-verrouille pour la duree de l'ecriture — pas de lost update et +pas de souci d'ordre des deadlocks. Cela remplace l'approche pessimiste anterieure `SELECT ... FOR UPDATE` +(regle de la couche traitement, voir `mlt.md`) ; elle n'ajoute aucune colonne ici. -**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. +**Modele de stock en pourcentage + disponibilite calculee.** `ingredient` porte `stock_capacity` (la +reference 100%), `low_stock_pct` (bande d’alerte) et `critical_stock_pct` (seuil de rupture +automatique) — voir 3.6. `stock_quantity` est signe et peut devenir negatif (ampleur de survente remontee aux +managers) ; le systeme ne bloque pas une commande sur le stock. La commandabilite effective du produit est +calculee (regle RG-T21 dans `mlt.md`) : `product.is_available = 1` ET chaque ingredient non retirable +(`is_removable=0`) de son `product_ingredient` a +`stock_quantity > stock_capacity * critical_stock_pct/100`. A la bande critique, un produit +passe automatiquement en rupture sans ecriture ni cascade ; un retrait manuel (`product.is_available=0`) est +une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend le produit a nouveau commandable de lui-meme. -**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. +**Throttle anti-brute-force par IP.** `login_throttle` (3.21) suit `failed_attempts` et +`lockout_until` par IP source (une ligne upsertee par IP), completant le compteur par compte +sur `user`. Cela ajoute une seconde dimension de throttling, de sorte qu'une seule IP martelant de nombreux comptes soit +ralentie independamment du compteur de n'importe quel compte. Un cron quotidien purge les lignes inactives et non verrouillees. -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). +References : `docs/notes/revue-alignement-p1.md` §7 (decisions D), carte d'impact security-by-design +(2026-06-11). Modele de menace et matrice de classification des donnees : `PROJECT_CONTEXT.md` §19 (a venir). --- -## 5. Entity count summary +## 5. Synthese du decompte des entites -| # | Entity | Type | Replaces / new | +| # | Entite | Type | Remplace / nouveau | |---|---|---|---| -| 1 | `category` | business | v0.1 `categorie` (renamed + translated) | +| 1 | `category` | business | v0.1 `categorie` (renommee + traduite) | | 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) | +| 3 | `menu` | business | v0.1 `menu` (+ FK burger, 2 prix) | +| 4 | `menu_slot` | business | nouveau — remplace la composition fixe `menu_produit` | +| 5 | `menu_slot_option` | join | nouveau — liste d'eligibilite par slot | +| 6 | `ingredient` | business | nouveau — configurateur d'ingredients + stock | +| 7 | `product_ingredient` | join | nouveau — recette + metadonnees de personnalisation | +| 8 | `allergen` | reference | nouveau — INCO 1169/2011 | +| 9 | `ingredient_allergen` | join | nouveau — mappe les allergenes aux ingredients | +| 10 | `customer_order` | business | v0.1 `commande` (renommee, machine a 4 etats, timestamps de phase) | | 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) | +| 12 | `order_item_selection` | business | nouveau — choix de slot de menu du client | +| 13 | `order_item_modifier` | business | nouveau — modifications au niveau ingredient | +| 14 | `user` | business | v0.1 `user` (noms de champs traduits) | | 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 | +| 16 | `role_visible_source` | join | nouveau — filtre de tableau de bord par role | +| 17 | `permission` | reference | v0.1 `permission` (traduite, catalogue gele) | +| 18 | `role_permission` | join | v0.1 `role_permission` (inchangee) | +| 19 | `stock_movement` | audit | nouveau — journal d'audit de stock append-only | +| 20 | `audit_log` | audit | nouveau (security-by-design) — journal append-only d'actions sensibles | +| 21 | `login_throttle` | security | nouveau (security-by-design) - throttle anti-brute-force par IP | -**Dropped from v0.1**: `commande_event` (replaced by phase timestamps on `customer_order`), -`menu_produit` (replaced by `menu_slot` + `menu_slot_option` model). +**Retire de v0.1** : `commande_event` (remplace par les timestamps de phase sur `customer_order`), +`menu_produit` (remplace par le modele `menu_slot` + `menu_slot_option`). -**Total: 21 entities** (19 prod-like v0.2 + `audit_log` and `login_throttle` from the -security-by-design layer). +**Total : 21 entites** (19 prod-like v0.2 + `audit_log` et `login_throttle` de la +couche security-by-design). -Security-by-design also adds columns (beyond the two new entities): `user` auth-lifecycle + +Le security-by-design ajoute aussi des colonnes (au-dela des deux nouvelles entites) : cycle de vie d'auth de `user` + `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. +et le modele de stock en pourcentage sur `ingredient` (3.6) — `stock_capacity`, `critical_stock_pct`, +plus le renommage de `low_stock_threshold` en `low_stock_pct`. `login_throttle` (3.21) est la 21e +entite. Voir note 13. --- -*For the ER diagram and cardinality justifications, see [`mcd.md`](mcd.md) — the diagram is -the single source of truth for graphical representation.* +*Pour le diagramme ER et les justifications de cardinalite, voir [`mcd.md`](mcd.md) — le diagramme est +la source de verite unique pour la representation graphique.* diff --git a/docs/merise/mcd.md b/docs/merise/mcd.md index e910e23..16fd019 100644 --- a/docs/merise/mcd.md +++ b/docs/merise/mcd.md @@ -1,97 +1,97 @@ -# Conceptual Data Model (MCD) — Wakdo +# Modele Conceptuel de Donnees (MCD) — Wakdo -**Merise phase** : P1 - Conception, step 2 (data dictionary first, mantra #33) -**Version** : v0.2 — prod-like, 21 entities (19 prod-like + security-by-design layer) -**Date** : 2026-06-04 (security-by-design additions 2026-06-11) -**Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer (audit_log + accountability/auth columns) in progress -**Author** : BYAN (methodology layer) +**Phase Merise** : P1 - Conception, etape 2 (data dictionary first, mantra #33) +**Version** : v0.2 — prod-like, 21 entites (19 prod-like + couche security-by-design) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design (audit_log + colonnes imputabilite/auth) en cours +**Auteur** : BYAN (couche methodologie) --- -## 1. Purpose of this document +## 1. Objectif de ce document -The MCD (Modele Conceptuel des Donnees) formalises the **entities** of the Wakdo domain, -their **associations**, and the **cardinalities** governing those associations. -It is the normalised translation of the data dictionary, and serves as the basis for the -MLD (relational mapping). +Le MCD (Modele Conceptuel des Donnees) formalise les **entites** du domaine Wakdo, +leurs **associations**, et les **cardinalites** qui regissent ces associations. +C'est la traduction normalisee du dictionnaire de donnees, et il sert de base au +MLD (mapping relationnel). -Unlike the dictionary (which details attributes and types), the MCD focuses on relational -structure: how many X per Y, whether participation is mandatory, whether associations carry -their own attributes. +Contrairement au dictionnaire (qui detaille les attributs et les types), le MCD se concentre sur la +structure relationnelle : combien de X par Y, si la participation est obligatoire, si les associations portent +leurs propres attributs. -**Sources**: -- `docs/merise/dictionary.md` (v0.2 — 21 entities, source of truth for all names, types, ENUMs) -- `docs/notes/revue-alignement-p1.md` §7 (decision table D1-D8 + stock) -- `docs/PROJECT_CONTEXT.md` (business rules: menu composition, order flow, RBAC, service modes) -- `docs/merise/_sources/` (school data: 9 categories, 53 products, 13 menus) +**Sources** : +- `docs/merise/dictionary.md` (v0.2 — 21 entites, source de verite pour tous les noms, types, ENUMs) +- `docs/notes/revue-alignement-p1.md` §7 (table de decisions D1-D8 + stock) +- `docs/PROJECT_CONTEXT.md` (regles metier : composition de menu, flux de commande, RBAC, modes de service) +- `docs/merise/_sources/` (donnees de l'ecole : 9 categories, 53 produits, 13 menus) --- -## 2. Merise notation used +## 2. Notation Merise utilisee -### Cardinalities at the association foot (French Merise style) +### Cardinalites au pied de l'association (style Merise francais) -At each end of an association, the cardinality `(min,max)` states how many times an -instance of the entity participates in the association. +A chaque extremite d'une association, la cardinalite `(min,max)` indique combien de fois une +instance de l'entite participe a l'association. ``` ENTITY_A (min,max) ----[ ASSOCIATION ]---- (min,max) ENTITY_B ``` -| Notation | Reading | Example | +| Notation | Lecture | Exemple | |---|---|---| -| `(0,1)` | Optional, at most 1 | A stock_movement links to (0,1) customer_order | -| `(1,1)` | Mandatory, exactly 1 | A product belongs to (1,1) category | -| `(0,N)` | Optional, unbounded | A category groups (0,N) products | -| `(1,N)` | At least 1, unbounded | An order contains (1,N) order_items | +| `(0,1)` | Optionnel, au plus 1 | Un stock_movement est lie a (0,1) customer_order | +| `(1,1)` | Obligatoire, exactement 1 | Un product appartient a (1,1) category | +| `(0,N)` | Optionnel, non borne | Une category regroupe (0,N) products | +| `(1,N)` | Au moins 1, non borne | Une commande contient (1,N) order_items | -Reading: "one instance of the source entity participates at least MIN times and at most -MAX times in the association". +Lecture : "une instance de l'entite source participe au moins MIN fois et au plus +MAX fois a l'association". -### Association naming convention +### Convention de nommage des associations -Active verb in business terms, e.g.: `groups`, `anchors`, `defines_slot`, `contains`, +Verbe d'action en termes metier, par exemple : `groups`, `anchors`, `defines_slot`, `contains`, `references_product`, `references_menu`, `fills_slot`, `modifies_ingredient`, `logs`, `holds`, `grants`, `filters_source`, `decrements`. -N-N associations that carry their own attributes become **associative entities** in the MLD -(join table with own columns). +Les associations N-N qui portent leurs propres attributs deviennent des **entites associatives** dans le MLD +(table de jointure avec colonnes propres). --- -## 3. Decomposition by sub-domain +## 3. Decomposition par sous-domaine -The 21-entity model is split into 4 sub-domains for readability. Beyond approximately -5 entities, a single flat diagram becomes difficult to read; decomposition is the standard -Merise practice for models of this size. +Le modele de 21 entites est divise en 4 sous-domaines pour la lisibilite. Au-dela d'environ +5 entites, un diagramme plat unique devient difficile a lire ; la decomposition est la pratique +Merise standard pour les modeles de cette taille. -| Sub-domain | Entities | Count | +| Sous-domaine | Entites | Nombre | |---|---|---| | Catalogue | category, product, menu, menu_slot, menu_slot_option | 5 | | Ingredients & Stock | ingredient, product_ingredient, allergen, ingredient_allergen, stock_movement | 5 | | Order | customer_order, order_item, order_item_selection, order_item_modifier | 4 | | RBAC & Audit | user, role, role_visible_source, permission, role_permission, audit_log, login_throttle | 7 | -> **Security-by-design layer (2026-06-11)**: `audit_log` (entity 20) is a cross-cutting, -> append-only log of sensitive actions; it is placed in the RBAC & Audit sub-domain because -> its references (`actor_user_id`, `actor_role_id`) are RBAC entities. `login_throttle` -> (entity 21) is a per-source-IP brute-force throttle, keyed by IP and carrying no FK; it sits -> in the same sub-domain because it guards the authentication path. New columns on existing -> entities: `user` auth-lifecycle + `pin_hash` + `anonymized_at`, `customer_order.acting_user_id` -> + `idempotency_key`. See dictionary note 13. +> **Couche security-by-design (2026-06-11)** : `audit_log` (entite 20) est un journal transverse, +> append-only des actions sensibles ; il est place dans le sous-domaine RBAC & Audit parce que +> ses references (`actor_user_id`, `actor_role_id`) sont des entites RBAC. `login_throttle` +> (entite 21) est un throttle anti-brute-force par IP source, indexe par IP et ne portant aucune FK ; il se situe +> dans le meme sous-domaine parce qu'il protege le chemin d'authentification. Nouvelles colonnes sur des entites existantes : +> `user` cycle de vie auth + `pin_hash` + `anonymized_at`, `customer_order.acting_user_id` +> + `idempotency_key`. Voir note 13 du dictionnaire. -**Note on the absence of a global diagram**: a single 21-entity ER diagram would be -unreadable and unmaintainable. The sub-domain decomposition below is the intentional -structural choice. Each sub-domain is a Mermaid `erDiagram` (authoritative, rendered -natively) with a portable SVG render in `docs/merise/_diagrams/`; see section 11 for the -sources and regeneration command. +**Note sur l'absence d'un diagramme global** : un unique diagramme ER de 21 entites serait +illisible et impossible a maintenir. La decomposition par sous-domaine ci-dessous est le choix +structurel intentionnel. Chaque sous-domaine est un `erDiagram` Mermaid (faisant autorite, rendu +nativement) avec un rendu SVG portable dans `docs/merise/_diagrams/` ; voir la section 11 pour les +sources et la commande de regeneration. --- -## 4. Sub-domain: Catalogue +## 4. Sous-domaine : Catalogue -### 4.1 Mermaid entity-relationship diagram +### 4.1 Diagramme entite-relation Mermaid ```mermaid erDiagram @@ -147,30 +147,30 @@ erDiagram product ||--o{ menu_slot_option : "is_eligible_for" ``` -### 4.2 Association cardinalities +### 4.2 Cardinalites des associations -| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | +| # | Association | Cote A | Cardinalite A | Cote B | Cardinalite B | Justification | |---|---|---|---|---|---|---| -| C1 | groups (product) | category | (0,N) | product | (1,1) | A category can exist with no products yet (created empty). A product must belong to exactly one category to appear on the kiosk. | -| C2 | groups (menu) | category | (0,N) | menu | (1,1) | Same rationale as C1 for menus. All 13 menus belong to the `menus` category. | -| C3 | anchors | menu | (1,1) | product | (0,N) | Each menu is built around exactly one fixed burger product (`burger_product_id`). A product may anchor 0 or more menus (a burger not used in a menu yet; or a popular burger anchoring several formats). | -| C4 | defines_slot | menu | (1,N) | menu_slot | (1,1) | A menu must define at least one slot (drink, side, sauce) to have customisable composition. A slot belongs to exactly one menu. | -| C5 | lists | menu_slot | (1,N) | menu_slot_option | (1,1) | A slot must list at least one eligible product (otherwise the customer cannot fill it). Each option row belongs to exactly one slot. | -| C6 | is_eligible_for | product | (0,N) | menu_slot_option | (1,1) | A product may be eligible for any number of slots across all menus, or none if it is only sold a la carte. Each option row references exactly one product. | +| C1 | groups (product) | category | (0,N) | product | (1,1) | Une categorie peut exister sans aucun produit pour l'instant (creee vide). Un produit doit appartenir a exactement une categorie pour apparaitre sur la borne. | +| C2 | groups (menu) | category | (0,N) | menu | (1,1) | Meme raisonnement que C1 pour les menus. Les 13 menus appartiennent a la categorie `menus`. | +| C3 | anchors | menu | (1,1) | product | (0,N) | Chaque menu est construit autour d'exactement un produit burger fixe (`burger_product_id`). Un produit peut ancrer 0 ou plusieurs menus (un burger pas encore utilise dans un menu ; ou un burger populaire ancrant plusieurs formats). | +| C4 | defines_slot | menu | (1,N) | menu_slot | (1,1) | Un menu doit definir au moins un slot (boisson, accompagnement, sauce) pour avoir une composition personnalisable. Un slot appartient a exactement un menu. | +| C5 | lists | menu_slot | (1,N) | menu_slot_option | (1,1) | Un slot doit lister au moins un produit eligible (sinon le client ne peut pas le remplir). Chaque ligne d'option appartient a exactement un slot. | +| C6 | is_eligible_for | product | (0,N) | menu_slot_option | (1,1) | Un produit peut etre eligible pour un nombre quelconque de slots a travers tous les menus, ou aucun s'il n'est vendu qu'a la carte. Chaque ligne d'option reference exactement un produit. | -### 4.3 Notes on the Catalogue sub-domain +### 4.3 Notes sur le sous-domaine Catalogue -**`menu_slot` vs category filter**: the explicit eligibility list `menu_slot_option(menu_slot_id, product_id)` was chosen over a category-based filter (`menu_slot.category_id`). Rationale: a product added to the `drinks` category should not automatically appear in every drink slot of every menu. The explicit list avoids accidental eligibility when the catalogue grows (see dictionary note 11). +**`menu_slot` vs filtre par categorie** : la liste d'eligibilite explicite `menu_slot_option(menu_slot_id, product_id)` a ete choisie plutot qu'un filtre base sur la categorie (`menu_slot.category_id`). Raisonnement : un produit ajoute a la categorie `drinks` ne devrait pas apparaitre automatiquement dans chaque slot boisson de chaque menu. La liste explicite evite une eligibilite accidentelle quand le catalogue s'agrandit (voir note 11 du dictionnaire). -**`menu.burger_product_id` as anchor**: the menu references a specific burger product, not a generic slot. This allows the ingredient configurator (sub-domain Ingredients & Stock) to resolve which ingredients are modifiable for a menu line, via `menu -> burger_product_id -> product_ingredient`. +**`menu.burger_product_id` comme ancre** : le menu reference un produit burger specifique, pas un slot generique. Cela permet au configurateur d'ingredients (sous-domaine Ingredients & Stock) de resoudre quels ingredients sont modifiables pour une ligne de menu, via `menu -> burger_product_id -> product_ingredient`. -**Normal / Maxi format**: two prices (`price_normal_cents`, `price_maxi_cents`) on `menu`; format recorded at `order_item.format`. No individual slot-level price differential is stored (see dictionary note 7). +**Format Normal / Maxi** : deux prix (`price_normal_cents`, `price_maxi_cents`) sur `menu` ; format enregistre au niveau de `order_item.format`. Aucun differentiel de prix au niveau du slot individuel n'est stocke (voir note 7 du dictionnaire). --- -## 5. Sub-domain: Ingredients & Stock +## 5. Sous-domaine : Ingredients & Stock -### 5.1 Mermaid entity-relationship diagram +### 5.1 Diagramme entite-relation Mermaid ```mermaid erDiagram @@ -236,35 +236,35 @@ erDiagram user |o--o{ stock_movement : "logs" ``` -### 5.2 Association cardinalities +### 5.2 Cardinalites des associations -| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | +| # | Association | Cote A | Cardinalite A | Cote B | Cardinalite B | Justification | |---|---|---|---|---|---|---| -| I1 | is_composed_of | product | (0,N) | product_ingredient | (1,1) | A product may have no ingredients entered in the system yet (catalogue row exists before recipe is entered). A recipe row belongs to exactly one product. | -| I2 | appears_in | ingredient | (1,N) | product_ingredient | (1,1) | An ingredient in active use appears in at least one product recipe. Each recipe row references exactly one ingredient. Newly created ingredients with no recipe row yet are modelled as (0,N) from a pure structural standpoint; the business rule of (1,N) applies to ingredients in production use. | -| I3 | contains (allergens) | ingredient | (0,N) | ingredient_allergen | (1,1) | An ingredient may contain no regulated allergens (e.g., pure salt). Each allergen-link row belongs to one ingredient. | -| I4 | is_present_in | allergen | (0,N) | ingredient_allergen | (1,1) | An allergen may initially have no linked ingredients (seed: allergen catalogue is complete before recipe data is entered). Each link row references one allergen. | -| I5 | decrements | ingredient | (0,N) | stock_movement | (1,1) | All movements affect exactly one ingredient. An ingredient may have no stock movement rows yet if it was recently created and no orders have been placed. Each movement row references exactly one ingredient. | -| I6 | triggers | customer_order | (0,1) | stock_movement | (0,N) | A `sale` or `cancellation` movement references the originating order. A `restock` or `inventory_correction` has no order (NULL). A given order triggers movements across all its ingredients; an order still `pending_payment` has triggered no movement yet. | -| I7 | logs | user | (0,1) | stock_movement | (0,N) | Automated sale decrements have no user (NULL). Manual restocks and corrections are attributed to a user. A user may log any number of movements. | +| I1 | is_composed_of | product | (0,N) | product_ingredient | (1,1) | Un produit peut n'avoir aucun ingredient encore saisi dans le systeme (la ligne de catalogue existe avant que la recette ne soit saisie). Une ligne de recette appartient a exactement un produit. | +| I2 | appears_in | ingredient | (1,N) | product_ingredient | (1,1) | Un ingredient en usage actif apparait dans au moins une recette de produit. Chaque ligne de recette reference exactement un ingredient. Les ingredients nouvellement crees sans ligne de recette sont modelises en (0,N) d'un point de vue purement structurel ; la regle metier de (1,N) s'applique aux ingredients en usage de production. | +| I3 | contains (allergens) | ingredient | (0,N) | ingredient_allergen | (1,1) | Un ingredient peut ne contenir aucun allergene reglemente (par exemple, du sel pur). Chaque ligne de lien d'allergene appartient a un ingredient. | +| I4 | is_present_in | allergen | (0,N) | ingredient_allergen | (1,1) | Un allergene peut initialement n'avoir aucun ingredient lie (seed : le catalogue d'allergenes est complet avant que les donnees de recette ne soient saisies). Chaque ligne de lien reference un allergene. | +| I5 | decrements | ingredient | (0,N) | stock_movement | (1,1) | Tous les mouvements affectent exactement un ingredient. Un ingredient peut n'avoir encore aucune ligne de mouvement de stock s'il a ete cree recemment et qu'aucune commande n'a ete passee. Chaque ligne de mouvement reference exactement un ingredient. | +| I6 | triggers | customer_order | (0,1) | stock_movement | (0,N) | Un mouvement `sale` ou `cancellation` reference la commande d'origine. Un `restock` ou `inventory_correction` n'a pas de commande (NULL). Une commande donnee declenche des mouvements sur tous ses ingredients ; une commande encore `pending_payment` n'a declenche aucun mouvement. | +| I7 | logs | user | (0,1) | stock_movement | (0,N) | Les decrements de vente automatises n'ont pas d'utilisateur (NULL). Les reapprovisionnements et corrections manuels sont attribues a un utilisateur. Un utilisateur peut journaliser un nombre quelconque de mouvements. | -### 5.3 Notes on the Ingredients & Stock sub-domain +### 5.3 Notes sur le sous-domaine Ingredients & Stock -**`product_ingredient` as an associative entity**: the N-N association between `product` and `ingredient` carries five attributes (`quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`). It becomes a join table in the MLD with composite PK `(product_id, ingredient_id)`. +**`product_ingredient` comme entite associative** : l'association N-N entre `product` et `ingredient` porte cinq attributs (`quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`). Elle devient une table de jointure dans le MLD avec une PK composite `(product_id, ingredient_id)`. -**`ingredient_allergen` as a pure join table**: no own attributes. The allergen set for a product is computed at query time by joining `product_ingredient -> ingredient_allergen -> allergen`; no manual per-product entry is needed. +**`ingredient_allergen` comme table de jointure pure** : aucun attribut propre. L'ensemble des allergenes d'un produit est calcule au moment de la requete en joignant `product_ingredient -> ingredient_allergen -> allergen` ; aucune saisie manuelle par produit n'est necessaire. -**`stock_movement` immutability**: this table is append-only. No UPDATE or DELETE is permitted at application layer. Corrections are new rows with `movement_type = 'inventory_correction'` and a signed `delta`. +**Immuabilite de `stock_movement`** : cette table est append-only. Aucun UPDATE ni DELETE n'est autorise au niveau applicatif. Les corrections sont de nouvelles lignes avec `movement_type = 'inventory_correction'` et un `delta` signe. -**Percentage-based stock model**: stock health is anchored on a per-ingredient `stock_capacity` (the 100% reference, `CHECK > 0`). `stock_quantity` is signed and may go negative when sales outrun counted stock; the system does not block an order on a low stock read. `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` is computed, not stored. Two percentage thresholds drive a three-band behaviour: `low_stock_pct` (warning band, default 10%) and `critical_stock_pct` (auto-out-of-stock floor, default 5%), with the table-level invariant `critical_stock_pct < low_stock_pct`. Above the low band is normal; between critical and low the product stays orderable and a manager alert is raised (the manager either pulls the product via `product.is_available = 0` or restocks to clear the alert); at or below the critical band the product auto-goes out-of-stock (computed, see below). +**Modele de stock base sur les pourcentages** : la sante du stock est ancree sur une `stock_capacity` par ingredient (la reference 100%, `CHECK > 0`). `stock_quantity` est signe et peut devenir negatif quand les ventes depassent le stock compte ; le systeme ne bloque pas une commande sur une lecture de stock bas. `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` est calcule, pas stocke. Deux seuils en pourcentage pilotent un comportement a trois bandes : `low_stock_pct` (bande d'alerte, defaut 10%) et `critical_stock_pct` (plancher de mise en rupture automatique, defaut 5%), avec l'invariant au niveau de la table `critical_stock_pct < low_stock_pct`. Au-dessus de la bande d'alerte, c'est normal ; entre critique et bas, le produit reste commandable et une alerte manager est levee (le manager soit retire le produit via `product.is_available = 0`, soit reapprovisionne pour lever l'alerte) ; au niveau ou en dessous de la bande critique, le produit passe automatiquement en rupture (calcule, voir ci-dessous). -**Computed product availability (rule RG-T21, see `mlt.md`)**: effective orderability is derived, not stored. A product 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`. A required ingredient reaching the critical band makes the product auto-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. A removable/optional ingredient at the critical band does not block the product (only its add-on becomes unavailable). The dashboard distinguishes a manual pull from a stock-driven OOS. +**Disponibilite produit calculee (regle RG-T21, voir `mlt.md`)** : la commandabilite effective est derivee, pas stockee. Un produit est commandable quand `product.is_available = 1` ET que chaque ingredient non retirable (`is_removable = 0`) de son `product_ingredient` a `stock_quantity > stock_capacity * critical_stock_pct / 100`. Un ingredient requis atteignant la bande critique met le produit en rupture automatique sans ecriture et sans cascade ; un retrait manuel (`product.is_available = 0`) est une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend le produit commandable a nouveau de lui-meme. Un ingredient retirable/optionnel a la bande critique ne bloque pas le produit (seul son supplement devient indisponible). Le tableau de bord distingue un retrait manuel d'une rupture pilotee par le stock. --- -## 6. Sub-domain: Order +## 6. Sous-domaine : Order -### 6.1 Mermaid entity-relationship diagram +### 6.1 Diagramme entite-relation Mermaid ```mermaid erDiagram @@ -336,55 +336,55 @@ erDiagram ingredient ||--o{ order_item_modifier : "modified_by" ``` -### 6.2 Association cardinalities +### 6.2 Cardinalites des associations -| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | +| # | Association | Cote A | Cardinalite A | Cote B | Cardinalite B | Justification | |---|---|---|---|---|---|---| -| O1 | contains | customer_order | (1,N) | order_item | (1,1) | An order without at least one line has no business meaning. A line belongs to exactly one order. ON DELETE CASCADE: if the order is purged, its lines go with it. | -| O2 | references_product | order_item | (0,1) | product | (0,N) | When `item_type = 'product'`, `product_id` is non-null (1 product referenced). When `item_type = 'menu'`, `product_id` is NULL (0). A product may appear in any number of order lines across history. | -| O3 | references_menu | order_item | (0,1) | menu | (0,N) | Symmetric to O2 for the menu discriminator branch. Exactly one of O2/O3 is active per line (CHECK constraint in MLD). | -| O4 | fills_slot | order_item | (0,N) | order_item_selection | (1,1) | A `menu`-type order line has one selection per slot (typically 2-3). A `product`-type line has no selections (0). Each selection row belongs to exactly one order line. | -| O5 | slot_filled_by | menu_slot | (0,N) | order_item_selection | (1,1) | A slot definition may have been chosen many times across historical orders (0,N). Each selection row references exactly one slot. ON DELETE RESTRICT: preserves historical records if the slot definition is later changed. | -| O6 | chosen_for_slot | product | (0,N) | order_item_selection | (1,1) | A product may have been selected for many slot choices across history. Each selection references one product. | -| O7 | modifies_ingredient | order_item | (0,N) | order_item_modifier | (1,1) | An order line may have any number of ingredient modifications (remove onion, add cheese). Each modifier row belongs to one order line. | -| O8 | modified_by | ingredient | (0,N) | order_item_modifier | (1,1) | An ingredient may have been modified in many order lines across history. Each modifier references one ingredient. | +| O1 | contains | customer_order | (1,N) | order_item | (1,1) | Une commande sans au moins une ligne n'a aucun sens metier. Une ligne appartient a exactement une commande. ON DELETE CASCADE : si la commande est purgee, ses lignes partent avec elle. | +| O2 | references_product | order_item | (0,1) | product | (0,N) | Quand `item_type = 'product'`, `product_id` est non nul (1 produit reference). Quand `item_type = 'menu'`, `product_id` est NULL (0). Un produit peut apparaitre dans un nombre quelconque de lignes de commande a travers l'historique. | +| O3 | references_menu | order_item | (0,1) | menu | (0,N) | Symetrique a O2 pour la branche du discriminateur menu. Exactement un de O2/O3 est actif par ligne (contrainte CHECK dans le MLD). | +| O4 | fills_slot | order_item | (0,N) | order_item_selection | (1,1) | Une ligne de commande de type `menu` a une selection par slot (typiquement 2-3). Une ligne de type `product` n'a aucune selection (0). Chaque ligne de selection appartient a exactement une ligne de commande. | +| O5 | slot_filled_by | menu_slot | (0,N) | order_item_selection | (1,1) | Une definition de slot peut avoir ete choisie de nombreuses fois a travers les commandes historiques (0,N). Chaque ligne de selection reference exactement un slot. ON DELETE RESTRICT : preserve les enregistrements historiques si la definition de slot est modifiee ulterieurement. | +| O6 | chosen_for_slot | product | (0,N) | order_item_selection | (1,1) | Un produit peut avoir ete selectionne pour de nombreux choix de slot a travers l'historique. Chaque selection reference un produit. | +| O7 | modifies_ingredient | order_item | (0,N) | order_item_modifier | (1,1) | Une ligne de commande peut avoir un nombre quelconque de modifications d'ingredients (retirer l'oignon, ajouter du fromage). Chaque ligne de modificateur appartient a une ligne de commande. | +| O8 | modified_by | ingredient | (0,N) | order_item_modifier | (1,1) | Un ingredient peut avoir ete modifie dans de nombreuses lignes de commande a travers l'historique. Chaque modificateur reference un ingredient. | -### 6.3 Notes on the Order sub-domain +### 6.3 Notes sur le sous-domaine Order -**Polymorphism on `order_item`**: each line references either a `product` or a `menu` (not both, not neither). The discriminator `item_type` ENUM drives which FK is populated. The mutual exclusivity is enforced by a CHECK constraint in the MLD. This pattern (2 nullable FKs + discriminator + CHECK) is a standard relational approach to single-table inheritance without a separate table per type. +**Polymorphisme sur `order_item`** : chaque ligne reference soit un `product`, soit un `menu` (ni les deux, ni aucun). Le discriminateur `item_type` ENUM pilote quelle FK est renseignee. L'exclusivite mutuelle est imposee par une contrainte CHECK dans le MLD. Ce pattern (2 FK nullables + discriminateur + CHECK) est une approche relationnelle standard de l'heritage en table unique sans table separee par type. -**`order_item_selection` (menu slot choices)**: captures which product the customer chose for each slot of a menu line. One row per slot filled. Used for KPI analysis (most popular drink/side combinations). The `label_snapshot` preserves the product name at transaction time. +**`order_item_selection` (choix de slot de menu)** : capture quel produit le client a choisi pour chaque slot d'une ligne de menu. Une ligne par slot rempli. Utilise pour l'analyse de KPI (combinaisons boisson/accompagnement les plus populaires). Le `label_snapshot` preserve le nom du produit au moment de la transaction. -**`order_item_modifier` (ingredient modifications)**: attaches to an `order_item` regardless of whether the line is a standalone product or a menu. For a menu line, the modifiable product is the fixed burger, resolved via `order_item.menu_id -> menu.burger_product_id` (see dictionary note 10). No additional FK column is needed on `order_item_modifier`. +**`order_item_modifier` (modifications d'ingredients)** : se rattache a un `order_item` que la ligne soit un produit autonome ou un menu. Pour une ligne de menu, le produit modifiable est le burger fixe, resolu via `order_item.menu_id -> menu.burger_product_id` (voir note 10 du dictionnaire). Aucune colonne FK supplementaire n'est necessaire sur `order_item_modifier`. -**Price snapshots**: `label_snapshot`, `unit_price_cents_snapshot`, and `vat_rate_snapshot` on `order_item` preserve the state at transaction time. If a product is later renamed or repriced, historical order data remains consistent. ON DELETE RESTRICT on `product_id` and `menu_id` is a secondary safeguard. +**Snapshots de prix** : `label_snapshot`, `unit_price_cents_snapshot`, et `vat_rate_snapshot` sur `order_item` preservent l'etat au moment de la transaction. Si un produit est ulterieurement renomme ou retarife, les donnees de commande historiques restent coherentes. ON DELETE RESTRICT sur `product_id` et `menu_id` est une protection secondaire. -**`service_day` computation** (KPI grouping): not stored as a column. Computed at query time: +**Calcul de `service_day`** (regroupement KPI) : non stocke comme colonne. Calcule au moment de la requete : ```sql CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END ``` -Cutoff: 10:00. The generated-column formula with `INTERVAL 4 HOUR 30 MINUTE` from the v0.1 MLD -was incorrect and is dropped (decision D6, `revue-alignement-p1.md` §7). +Seuil : 10:00. La formule de colonne generee avec `INTERVAL 4 HOUR 30 MINUTE` du MLD v0.1 +etait incorrecte et est abandonnee (decision D6, `revue-alignement-p1.md` §7). -**`source = 'drive' => service_mode = 'drive'`**: cross-constraint. A drive-channel order can -only have `service_mode = 'drive'`. Enforced at application layer (and optionally as a CHECK in -the MLD). +**`source = 'drive' => service_mode = 'drive'`** : contrainte croisee. Une commande du canal drive ne peut +avoir que `service_mode = 'drive'`. Imposee au niveau applicatif (et optionnellement comme CHECK dans +le MLD). -**4-state machine** (`pending_payment -> paid -> delivered` + `cancelled`): -`preparing` and `ready` are dropped (decision D4, `revue-alignement-p1.md` §7). KPI timing is -`delivered_at - paid_at`; KDS colour coding is computed from `NOW() - paid_at`. +**Machine a 4 etats** (`pending_payment -> paid -> delivered` + `cancelled`) : +`preparing` et `ready` sont abandonnes (decision D4, `revue-alignement-p1.md` §7). Le timing KPI est +`delivered_at - paid_at` ; le codage couleur KDS est calcule a partir de `NOW() - paid_at`. -**Security-by-design columns (2026-06-11)**: `idempotency_key` (client UUID, UNIQUE) -deduplicates a retried `POST /api/orders`. `acting_user_id` (FK -> `user`, ON DELETE SET NULL) -records the counter/drive staff who took the order under PIN; NULL for anonymous kiosk orders. -This adds a `customer_order |o--o| user : "taken_by"` association (cardinality: an order is -taken by (0,1) user; a user takes (0,N) orders). See dictionary note 13. +**Colonnes security-by-design (2026-06-11)** : `idempotency_key` (UUID client, UNIQUE) +deduplique un `POST /api/orders` rejoue. `acting_user_id` (FK -> `user`, ON DELETE SET NULL) +enregistre l'employe de comptoir/drive qui a pris la commande sous PIN ; NULL pour les commandes anonymes de la borne. +Cela ajoute une association `customer_order |o--o| user : "taken_by"` (cardinalite : une commande est +prise par (0,1) user ; un user prend (0,N) commandes). Voir note 13 du dictionnaire. --- -## 7. Sub-domain: RBAC +## 7. Sous-domaine : RBAC -### 7.1 Mermaid entity-relationship diagram +### 7.1 Diagramme entite-relation Mermaid ```mermaid erDiagram @@ -453,175 +453,175 @@ erDiagram role |o--o{ audit_log : "context_of" ``` -> `login_throttle` is a standalone entity with no association: it is keyed by source IP -> (`ip_address UNIQUE`), not by a modelled actor, so it carries no FK and connects to no -> other entity in the diagram. +> `login_throttle` est une entite autonome sans association : elle est indexee par IP source +> (`ip_address UNIQUE`), pas par un acteur modelise, donc elle ne porte aucune FK et ne se connecte a aucune +> autre entite du diagramme. -### 7.2 Association cardinalities +### 7.2 Cardinalites des associations -| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | +| # | Association | Cote A | Cardinalite A | Cote B | Cardinalite B | Justification | |---|---|---|---|---|---|---| -| R1 | holds | user | (1,1) | role | (0,N) | A user must have exactly one role to access the back-office. A role may have no current users (created but not yet assigned). ON DELETE RESTRICT on `role_id`: a role cannot be deleted while users hold it. | -| R2 | sees_source | role | (0,N) | role_visible_source | (1,1) | A role may see 0 or more order sources on the preparation dashboard (admin/manager use a global view with no source filter). Each visibility row belongs to exactly one role. | -| R3 | grants | role | (0,N) | role_permission | (1,1) | A role may have no permissions (a newly created role before assignment) or many. Each mapping row belongs to one role. | -| R4 | granted_to | permission | (0,N) | role_permission | (1,1) | A permission may be granted to no roles yet (declared at seed, not yet distributed) or to several. Each mapping row references one permission. | -| R5 | performs | user | (0,1) | audit_log | (0,N) | A sensitive action captured under PIN records its acting user; automated/non-attributable entries carry NULL. A user may have logged any number of actions. ON DELETE SET NULL preserves the trail on user anonymisation/removal. | -| R6 | context_of | role | (0,1) | audit_log | (0,N) | Each audit row may denormalise the actor's role at action time (NULL allowed). A role may be the context of many audit rows. ON DELETE SET NULL preserves the trail. | +| R1 | holds | user | (1,1) | role | (0,N) | Un utilisateur doit avoir exactement un role pour acceder au back-office. Un role peut n'avoir aucun utilisateur actuel (cree mais pas encore assigne). ON DELETE RESTRICT sur `role_id` : un role ne peut etre supprime tant que des utilisateurs le detiennent. | +| R2 | sees_source | role | (0,N) | role_visible_source | (1,1) | Un role peut voir 0 ou plusieurs sources de commande sur le tableau de bord de preparation (admin/manager utilisent une vue globale sans filtre de source). Chaque ligne de visibilite appartient a exactement un role. | +| R3 | grants | role | (0,N) | role_permission | (1,1) | Un role peut n'avoir aucune permission (un role nouvellement cree avant assignation) ou plusieurs. Chaque ligne de mapping appartient a un role. | +| R4 | granted_to | permission | (0,N) | role_permission | (1,1) | Une permission peut n'etre encore accordee a aucun role (declaree au seed, pas encore distribuee) ou a plusieurs. Chaque ligne de mapping reference une permission. | +| R5 | performs | user | (0,1) | audit_log | (0,N) | Une action sensible capturee sous PIN enregistre son utilisateur agissant ; les entrees automatisees/non attribuables portent NULL. Un utilisateur peut avoir journalise un nombre quelconque d'actions. ON DELETE SET NULL preserve la trace lors de l'anonymisation/suppression de l'utilisateur. | +| R6 | context_of | role | (0,1) | audit_log | (0,N) | Chaque ligne d'audit peut denormaliser le role de l'acteur au moment de l'action (NULL autorise). Un role peut etre le contexte de nombreuses lignes d'audit. ON DELETE SET NULL preserve la trace. | -### 7.3 Notes on the RBAC sub-domain +### 7.3 Notes sur le sous-domaine RBAC -**RBAC architecture**: roles are dynamic (creatable and modifiable via admin UI). Permissions are static (declared in migration, tied to application code). Application code tests permissions, not role names: adding a new role with the right permissions requires no code change (permission-driven, per Sandhu/NIST RBAC model — decision D4, `revue-alignement-p1.md` §7). +**Architecture RBAC** : les roles sont dynamiques (creables et modifiables via l'UI admin). Les permissions sont statiques (declarees en migration, liees au code applicatif). Le code applicatif teste les permissions, pas les noms de role : ajouter un nouveau role avec les bonnes permissions ne necessite aucun changement de code (permission-driven, selon le modele RBAC Sandhu/NIST — decision D4, `revue-alignement-p1.md` §7). -**`role.order_source`**: when a counter or drive staff member creates an order, the `source` column on `customer_order` is automatically populated from their role's `order_source`. NULL for admin and manager (they can create on behalf of any channel). +**`role.order_source`** : quand un employe de comptoir ou de drive cree une commande, la colonne `source` sur `customer_order` est automatiquement renseignee a partir de l'`order_source` de son role. NULL pour admin et manager (ils peuvent creer pour le compte de n'importe quel canal). -**`role.default_route`**: the landing screen for each role, stored in the database. Front-end routing reads this value at login; no role name is hardcoded in routing logic. +**`role.default_route`** : l'ecran d'arrivee pour chaque role, stocke en base de donnees. Le routage front-end lit cette valeur au login ; aucun nom de role n'est code en dur dans la logique de routage. -**`role_visible_source`**: a pure join table linking a role to the set of order sources visible on the preparation dashboard. A `kitchen` role sees all three sources; a `counter` role sees `kiosk` and `counter`; a `drive` role sees only `drive`. +**`role_visible_source`** : une table de jointure pure liant un role a l'ensemble des sources de commande visibles sur le tableau de bord de preparation. Un role `kitchen` voit les trois sources ; un role `counter` voit `kiosk` et `counter` ; un role `drive` ne voit que `drive`. -**`role_permission`** and **`role_visible_source`** both use composite PKs. ON DELETE CASCADE on both FKs of `role_permission` (deleting a role or a permission removes its mappings). ON DELETE CASCADE on `role_id` of `role_visible_source`. +**`role_permission`** et **`role_visible_source`** utilisent tous deux des PK composites. ON DELETE CASCADE sur les deux FK de `role_permission` (supprimer un role ou une permission retire ses mappings). ON DELETE CASCADE sur le `role_id` de `role_visible_source`. -**Seed roles** (5 roles, frozen at DDL; extendable without code change): +**Roles de seed** (5 roles, figes au DDL ; extensibles sans changement de code) : `admin`, `manager`, `kitchen`, `counter`, `drive`. -**`audit_log` (security-by-design)**: append-only log of sensitive actions, immutable like -`stock_movement`. Both FKs (`actor_user_id`, `actor_role_id`) are nullable with ON DELETE -SET NULL, so the trail survives user anonymisation (RGPD) and role removal. The `actor_role_id` -is denormalised on purpose: even if the user is later anonymised, the role context of the -action is preserved. It carries no PII (the `details` JSON stores changed field names, not -values for user-targeted actions). See dictionary 3.20 and note 13. +**`audit_log` (security-by-design)** : journal append-only des actions sensibles, immuable comme +`stock_movement`. Les deux FK (`actor_user_id`, `actor_role_id`) sont nullables avec ON DELETE +SET NULL, de sorte que la trace survit a l'anonymisation de l'utilisateur (RGPD) et a la suppression de role. Le `actor_role_id` +est denormalise a dessein : meme si l'utilisateur est ulterieurement anonymise, le contexte de role de +l'action est preserve. Il ne porte aucune PII (le JSON `details` stocke les noms des champs modifies, pas les +valeurs pour les actions ciblant un utilisateur). Voir dictionnaire 3.20 et note 13. -**`login_throttle` (security-by-design)**: per-source-IP brute-force throttle, complementing -the per-account counter already on `user` (`failed_login_attempts` / `lockout_until`). One row -per IP (`ip_address VARCHAR(45) UNIQUE`, 45 chars to hold a full IPv6 literal), upserted on each -failed login: `failed_attempts` counts consecutive failures from this IP in the current window, -`window_started_at` marks the start of that window (which resets when expired), `lockout_until` -holds the end of the degressive backoff (NULL = not throttled), `last_attempt_at` the timestamp -of the last failed attempt. It has no FK (an IP is not a modelled entity) and no association. A -daily cron purges rows with no active lockout whose `last_attempt_at` is older than 24h. See -dictionary 3.21 and note 13. +**`login_throttle` (security-by-design)** : throttle anti-brute-force par IP source, complementant +le compteur par compte deja present sur `user` (`failed_login_attempts` / `lockout_until`). Une ligne +par IP (`ip_address VARCHAR(45) UNIQUE`, 45 caracteres pour contenir un litteral IPv6 complet), upsertee a chaque +echec de login : `failed_attempts` compte les echecs consecutifs depuis cette IP dans la fenetre courante, +`window_started_at` marque le debut de cette fenetre (qui se reinitialise a son expiration), `lockout_until` +contient la fin du backoff degressif (NULL = non throttle), `last_attempt_at` l'horodatage +de la derniere tentative echouee. Elle n'a aucune FK (une IP n'est pas une entite modelisee) et aucune association. Un +cron quotidien purge les lignes sans lockout actif dont le `last_attempt_at` est plus ancien que 24h. Voir +dictionnaire 3.21 et note 13. --- -## 8. Cross-validation MCD <-> dictionary +## 8. Validation croisee MCD <-> dictionnaire -Verification that all 21 dictionary entities appear in the MCD and vice versa. +Verification que les 21 entites du dictionnaire apparaissent dans le MCD et reciproquement. -| # | Dictionary entity (section 3) | Sub-domain in MCD | Present | +| # | Entite du dictionnaire (section 3) | Sous-domaine dans le MCD | Presente | |---|---|---|---| -| 1 | `category` (3.1) | Catalogue | Yes | -| 2 | `product` (3.2) | Catalogue + Ingredients + Order | Yes | -| 3 | `menu` (3.3) | Catalogue + Order | Yes | -| 4 | `menu_slot` (3.4) | Catalogue + Order | Yes | -| 5 | `menu_slot_option` (3.5) | Catalogue | Yes | -| 6 | `ingredient` (3.6) | Ingredients + Order | Yes | -| 7 | `product_ingredient` (3.7) | Ingredients | Yes | -| 8 | `allergen` (3.8) | Ingredients | Yes | -| 9 | `ingredient_allergen` (3.9) | Ingredients | Yes | -| 10 | `customer_order` (3.10) | Order | Yes | -| 11 | `order_item` (3.11) | Order | Yes | -| 12 | `order_item_selection` (3.12) | Order | Yes | -| 13 | `order_item_modifier` (3.13) | Order | Yes | -| 14 | `user` (3.14) | RBAC | Yes | -| 15 | `role` (3.15) | RBAC | Yes | -| 16 | `role_visible_source` (3.16) | RBAC | Yes | -| 17 | `permission` (3.17) | RBAC | Yes | -| 18 | `role_permission` (3.18) | RBAC | Yes | -| 19 | `stock_movement` (3.19) | Ingredients & Stock | Yes | -| 20 | `audit_log` (3.20) | RBAC & Audit | Yes | -| 21 | `login_throttle` (3.21) | RBAC & Audit | Yes | +| 1 | `category` (3.1) | Catalogue | Oui | +| 2 | `product` (3.2) | Catalogue + Ingredients + Order | Oui | +| 3 | `menu` (3.3) | Catalogue + Order | Oui | +| 4 | `menu_slot` (3.4) | Catalogue + Order | Oui | +| 5 | `menu_slot_option` (3.5) | Catalogue | Oui | +| 6 | `ingredient` (3.6) | Ingredients + Order | Oui | +| 7 | `product_ingredient` (3.7) | Ingredients | Oui | +| 8 | `allergen` (3.8) | Ingredients | Oui | +| 9 | `ingredient_allergen` (3.9) | Ingredients | Oui | +| 10 | `customer_order` (3.10) | Order | Oui | +| 11 | `order_item` (3.11) | Order | Oui | +| 12 | `order_item_selection` (3.12) | Order | Oui | +| 13 | `order_item_modifier` (3.13) | Order | Oui | +| 14 | `user` (3.14) | RBAC | Oui | +| 15 | `role` (3.15) | RBAC | Oui | +| 16 | `role_visible_source` (3.16) | RBAC | Oui | +| 17 | `permission` (3.17) | RBAC | Oui | +| 18 | `role_permission` (3.18) | RBAC | Oui | +| 19 | `stock_movement` (3.19) | Ingredients & Stock | Oui | +| 20 | `audit_log` (3.20) | RBAC & Audit | Oui | +| 21 | `login_throttle` (3.21) | RBAC & Audit | Oui | -**Result**: 21/21 entities traced (19 prod-like + `audit_log` and `login_throttle` -security-by-design). No entity from the dictionary is absent from the MCD. No entity in the MCD -falls outside the dictionary. +**Resultat** : 21/21 entites tracees (19 prod-like + `audit_log` et `login_throttle` +security-by-design). Aucune entite du dictionnaire n'est absente du MCD. Aucune entite du MCD +ne tombe en dehors du dictionnaire. -**Entities appearing in multiple sub-domains** (cross-domain shared entities): -- `product`: Catalogue (sold item, slot eligibility) + Ingredients (recipe) + Order (line reference, slot choice) -- `menu`: Catalogue (definition, slots) + Order (line reference) -- `menu_slot`: Catalogue (slot definition) + Order (slot choices via `order_item_selection`) -- `ingredient`: Ingredients (recipe, stock) + Order (modifiers) -- `customer_order`: Order (order lifecycle) + Ingredients (stock movement trigger) + RBAC & Audit (taken_by staff via `acting_user_id`) -- `user`: RBAC (authentication) + Ingredients (stock movement author) + Order (`acting_user_id` on counter/drive orders) + Audit (actor of `audit_log`) -- `role`: RBAC (permissions, visible sources) + Audit (denormalised `actor_role_id` context on `audit_log`) +**Entites apparaissant dans plusieurs sous-domaines** (entites partagees inter-domaines) : +- `product` : Catalogue (article vendu, eligibilite de slot) + Ingredients (recette) + Order (reference de ligne, choix de slot) +- `menu` : Catalogue (definition, slots) + Order (reference de ligne) +- `menu_slot` : Catalogue (definition de slot) + Order (choix de slot via `order_item_selection`) +- `ingredient` : Ingredients (recette, stock) + Order (modificateurs) +- `customer_order` : Order (cycle de vie de la commande) + Ingredients (declencheur de mouvement de stock) + RBAC & Audit (employe taken_by via `acting_user_id`) +- `user` : RBAC (authentification) + Ingredients (auteur de mouvement de stock) + Order (`acting_user_id` sur les commandes comptoir/drive) + Audit (acteur de `audit_log`) +- `role` : RBAC (permissions, sources visibles) + Audit (contexte `actor_role_id` denormalise sur `audit_log`) -This is expected in a normalised model. The sub-domain split is for readability; the actual -relational schema is a unified graph. +C'est attendu dans un modele normalise. La division par sous-domaine est pour la lisibilite ; le schema +relationnel reel est un graphe unifie. --- -## 9. Decisions deferred to the MLD +## 9. Decisions reportees au MLD -The MCD remains at the conceptual level. The following decisions are deferred to the MLD: +Le MCD reste au niveau conceptuel. Les decisions suivantes sont reportees au MLD : -1. **Resolution of associative entities into tables**: `product_ingredient`, `menu_slot_option`, - `ingredient_allergen`, `role_visible_source`, `role_permission` become join tables with - composite PKs. -2. **Technical PK vs business identifier**: `id INT UNSIGNED AUTO_INCREMENT` on all main entities. - `customer_order` additionally carries `order_number VARCHAR(20) UNIQUE` (human-readable, - format `K/C/D-YYYY-MM-DD-NNN` per channel). -3. **ON DELETE rules**: CASCADE vs RESTRICT vs SET NULL. Detailed in the MLD. -4. **CHECK constraints**: polymorphism exclusivity on `order_item`, cross-constraint - `source/service_mode` on `customer_order`, arithmetic invariant on totals. -5. **Indexes**: not discussed at MCD level. Defined in the MLD for frequent query patterns. -6. **`service_day` formula**: applicative CASE expression, not a stored generated column. - Documented in the MLD. +1. **Resolution des entites associatives en tables** : `product_ingredient`, `menu_slot_option`, + `ingredient_allergen`, `role_visible_source`, `role_permission` deviennent des tables de jointure avec + des PK composites. +2. **PK technique vs identifiant metier** : `id INT UNSIGNED AUTO_INCREMENT` sur toutes les entites principales. + `customer_order` porte en plus `order_number VARCHAR(20) UNIQUE` (lisible par un humain, + format `K/C/D-YYYY-MM-DD-NNN` par canal). +3. **Regles ON DELETE** : CASCADE vs RESTRICT vs SET NULL. Detaillees dans le MLD. +4. **Contraintes CHECK** : exclusivite de polymorphisme sur `order_item`, contrainte croisee + `source/service_mode` sur `customer_order`, invariant arithmetique sur les totaux. +5. **Index** : non discutes au niveau MCD. Definis dans le MLD pour les patterns de requete frequents. +6. **Formule `service_day`** : expression applicative CASE, pas une colonne generee stockee. + Documentee dans le MLD. --- -## 10. MCD <-> MCT coherence (mantra #34) +## 10. Coherence MCD <-> MCT (mantra #34) -Pre-validation: each entity participates in at least one treatment. +Pre-validation : chaque entite participe a au moins un traitement. -| Entity | Expected treatment(s) | +| Entite | Traitement(s) attendu(s) | |---|---| -| `category` | Admin CRUD | -| `product` | Admin CRUD + kiosk cart add | -| `menu` | Admin CRUD + kiosk cart add | -| `menu_slot` | Admin CRUD (menu composition) | -| `menu_slot_option` | Admin CRUD (slot eligibility management) | -| `ingredient` | Admin CRUD + stock movements | -| `product_ingredient` | Admin recipe management | -| `allergen` | Admin CRUD (seed: read-only catalogue) | -| `ingredient_allergen` | Admin allergen mapping | -| `customer_order` | Full order lifecycle (create -> pay -> deliver / cancel) | -| `order_item` | Cart building, line creation at validation | -| `order_item_selection` | Menu slot selection during cart building | -| `order_item_modifier` | Ingredient modification during cart building | -| `user` | Admin CRUD + login | -| `role` | Admin CRUD + user assignment | -| `role_visible_source` | Admin role configuration | -| `permission` | Admin permission matrix management | -| `role_permission` | Admin permission matrix management | -| `stock_movement` | Automatic at `paid` transition; manual restock and inventory correction | -| `audit_log` | Written by sensitive operations: UPDATE/DELETE product/menu (8.2/8.3/8.6), CANCEL_ORDER (7.1), RESTOCK/INVENTORY_COUNT (9.1/9.2), user ops (10.1-10.3), MANAGE_RBAC (10.4), and failed/successful logins (12.1) | -| `login_throttle` | Read and written by AUTHENTICATE_USER (12.1): per-source-IP throttle upserted on each failed login, read to enforce the backoff window, purged by a daily cron | +| `category` | CRUD admin | +| `product` | CRUD admin + ajout au panier borne | +| `menu` | CRUD admin + ajout au panier borne | +| `menu_slot` | CRUD admin (composition de menu) | +| `menu_slot_option` | CRUD admin (gestion de l'eligibilite des slots) | +| `ingredient` | CRUD admin + mouvements de stock | +| `product_ingredient` | Gestion des recettes admin | +| `allergen` | CRUD admin (seed : catalogue en lecture seule) | +| `ingredient_allergen` | Mapping des allergenes admin | +| `customer_order` | Cycle de vie complet de la commande (create -> pay -> deliver / cancel) | +| `order_item` | Construction du panier, creation de ligne a la validation | +| `order_item_selection` | Selection de slot de menu pendant la construction du panier | +| `order_item_modifier` | Modification d'ingredient pendant la construction du panier | +| `user` | CRUD admin + login | +| `role` | CRUD admin + assignation d'utilisateur | +| `role_visible_source` | Configuration de role admin | +| `permission` | Gestion de la matrice de permissions admin | +| `role_permission` | Gestion de la matrice de permissions admin | +| `stock_movement` | Automatique a la transition `paid` ; reapprovisionnement manuel et correction d'inventaire | +| `audit_log` | Ecrit par les operations sensibles : UPDATE/DELETE product/menu (8.2/8.3/8.6), CANCEL_ORDER (7.1), RESTOCK/INVENTORY_COUNT (9.1/9.2), operations utilisateur (10.1-10.3), MANAGE_RBAC (10.4), et logins echoues/reussis (12.1) | +| `login_throttle` | Lu et ecrit par AUTHENTICATE_USER (12.1) : throttle par IP source upserte a chaque echec de login, lu pour imposer la fenetre de backoff, purge par un cron quotidien | -Cross-validation MCD <-> MCT (mantra #34) to be completed exhaustively in `mct.md` -once the MCT incorporates the security-by-design operations (PIN-gated sensitive actions, -audit writes, reset/lockout, anonymisation). The treatment-layer additions are tracked there. +La validation croisee MCD <-> MCT (mantra #34) sera completee de maniere exhaustive dans `mct.md` +une fois que le MCT integrera les operations security-by-design (actions sensibles protegees par PIN, +ecritures d'audit, reset/lockout, anonymisation). Les ajouts de la couche traitements y sont suivis. --- -## 11. Diagram sources and regeneration +## 11. Sources des diagrammes et regeneration -The authoritative graphical model is the set of Mermaid `erDiagram` blocks in sections 4-7, -one per sub-domain. They render natively on Forgejo and GitHub. The MCD is decomposed by -sub-domain on purpose: a single 21-entity diagram cannot be laid out without crossing -relationship lines (intrinsic planarity limit, and `erDiagram` offers no manual layout -control). Each sub-domain stays at 5-8 entities, which auto-layout handles cleanly. The -integrated view across sub-domains is the cross-validation table in section 8. +Le modele graphique faisant autorite est l'ensemble des blocs `erDiagram` Mermaid des sections 4-7, +un par sous-domaine. Ils s'affichent nativement sur Forgejo et GitHub. Le MCD est decompose par +sous-domaine a dessein : un unique diagramme de 21 entites ne peut etre dispose sans croisement de +lignes de relation (limite de planarite intrinseque, et `erDiagram` n'offre aucun controle de mise en page +manuel). Chaque sous-domaine reste a 5-8 entites, ce que la mise en page automatique gere proprement. La +vue integree a travers les sous-domaines est la table de validation croisee de la section 8. -Portable SVG renders live in `docs/merise/_diagrams/` (for PDF export / offline viewing): +Des rendus SVG portables se trouvent dans `docs/merise/_diagrams/` (pour l'export PDF / consultation hors ligne) : -| Sub-domain | Source | Render | +| Sous-domaine | Source | Rendu | |---|---|---| | Catalogue | `mcd-catalogue.mmd` | `mcd-catalogue.svg` | | Ingredients & Stock | `mcd-ingredients-stock.mmd` | `mcd-ingredients-stock.svg` | | Order | `mcd-order.mmd` | `mcd-order.svg` | | RBAC | `mcd-rbac.mmd` | `mcd-rbac.svg` | -The `.mmd` files are extracted from the `erDiagram` blocks above; the `.svg` are produced by -`make docs-render` (mmdc). If a block here changes, re-extract the matching `.mmd` and re-run -`make docs-render`. The legacy v0.1 `.drawio` sources have been removed: drawio gave manual -layout control but required hand-editing and did not render in the Markdown previews, whereas -the decomposed Mermaid blocks are version-controlled, render everywhere, and stay in sync with -this document. +Les fichiers `.mmd` sont extraits des blocs `erDiagram` ci-dessus ; les `.svg` sont produits par +`make docs-render` (mmdc). Si un bloc ici change, re-extraire le `.mmd` correspondant et relancer +`make docs-render`. Les anciennes sources `.drawio` v0.1 ont ete supprimees : drawio offrait un controle de mise en page +manuel mais necessitait une edition a la main et ne s'affichait pas dans les apercus Markdown, alors que +les blocs Mermaid decomposes sont versionnes, s'affichent partout, et restent synchronises avec +ce document. diff --git a/docs/merise/mct.md b/docs/merise/mct.md index e618deb..1e1dff0 100644 --- a/docs/merise/mct.md +++ b/docs/merise/mct.md @@ -1,44 +1,44 @@ -# Model of Conceptual Treatments (MCT) — Wakdo +# Modele Conceptuel des Traitements (MCT) — Wakdo -**Merise phase** : P1 - Conception, step 3 (after MCD) -**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 ops added (ERASE_USER_PII, RESET_PASSWORD, PIN-gated sensitive set, audit_log writes, auth throttling) — 28 operations -**Author** : BYAN (methodology layer) +**Phase Merise** : P1 - Conception, etape 3 (apres le MCD) +**Version** : v0.2 — prod-like, machine a 4 etats (+ couche security-by-design 2026-06-11) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; operations security-by-design ajoutees (ERASE_USER_PII, RESET_PASSWORD, ensemble sensible protege par PIN, ecritures audit_log, throttling d'authentification) — 28 operations +**Auteur** : BYAN (couche methodologie) --- -## 1. Purpose +## 1. Objectif -The MCT (Model of Conceptual Treatments) describes the **business operations** of the Wakdo -domain in the canonical Merise form: **triggering event -> operation -> emitted result**. +Le MCT (Modele Conceptuel des Traitements) decrit les **operations metier** du domaine +Wakdo sous la forme canonique Merise : **evenement declencheur -> operation -> resultat emis**. -It answers the question: what happens in the domain, and when? -It does not answer: who does what, on which workstation, in which organisational order -(the MOT level is intentionally skipped — agile shortcut, consistent with the solo RNCP -framework). +Il repond a la question : que se passe-t-il dans le domaine, et quand ? +Il ne repond pas a : qui fait quoi, sur quel poste de travail, dans quel ordre organisationnel +(le niveau MOT est volontairement saute — raccourci agile, coherent avec le cadre RNCP +solo). -The MCT covers: -- The order lifecycle end-to-end (kiosk, counter, drive) -- Catalogue management (manager / admin) -- User and role management (admin) -- Back-office authentication (all back-office actors) +Le MCT couvre : +- Le cycle de vie de la commande de bout en bout (kiosk, comptoir, drive) +- La gestion du catalogue (manager / admin) +- La gestion des utilisateurs et des roles (admin) +- L'authentification back-office (tous les acteurs back-office) -**Identified actors**: +**Acteurs identifies** : -| Actor | Code | Interface | +| Acteur | Code | Interface | |-------|------|-----------| -| Customer (kiosk) | CUSTOMER | Touch kiosk (public, unauthenticated) | -| Counter staff | COUNTER | Back-office, role `counter` | -| Drive staff | DRIVE | Back-office, role `drive` | -| Kitchen staff | KITCHEN | Back-office, role `kitchen` (read-only on orders) | +| Client (kiosk) | CUSTOMER | Borne tactile (public, non authentifie) | +| Personnel comptoir | COUNTER | Back-office, role `counter` | +| Personnel drive | DRIVE | Back-office, role `drive` | +| Personnel cuisine | KITCHEN | Back-office, role `kitchen` (lecture seule sur les commandes) | | Manager | MANAGER | Back-office, role `manager` | -| Administrator | ADMIN | Back-office, role `admin` | -| System | SYS | Internal API / PHP logic | +| Administrateur | ADMIN | Back-office, role `admin` | +| Systeme | SYS | API interne / logique PHP | -**MCD cross-reference**: each operation references entities from the MCD (section 14). -The MCT is consistent with the `customer_order.status` state machine: +**Reference croisee MCD** : chaque operation reference des entites du MCD (section 14). +Le MCT est coherent avec la machine a etats de `customer_order.status` : ``` pending_payment -> paid -> delivered @@ -46,32 +46,32 @@ pending_payment -> paid -> delivered +--------------+-----------> cancelled (from any non-terminal state) ``` -**Dropped states** (compared to v0.1): `preparing` and `ready` are removed. -Rationale: in a fast-food context the kitchen display (KDS) is a visual system; staff read -the ticket and act. The single staff gesture is "deliver". KPI is total time -`delivered_at - paid_at` (SLA approx. 10 min). KDS colour coding is computed from -`now - paid_at`; no additional stored state is required. +**Etats supprimes** (par rapport a v0.1) : `preparing` et `ready` sont retires. +Justification : dans un contexte fast-food, l'affichage cuisine (KDS) est un systeme visuel ; +le personnel lit le ticket et agit. L'unique geste du personnel est « delivrer ». Le KPI est +le temps total `delivered_at - paid_at` (SLA approx. 10 min). Le code couleur du KDS est calcule a partir de +`now - paid_at` ; aucun etat stocke supplementaire n'est requis. -**Dropped operations** (compared to v0.1): `MARK_IN_PREPARATION` (`MARQUER_EN_PREPARATION`) -and `MARK_READY` (`MARQUER_PRETE`) are removed because their intermediate states no longer -exist. `DELIVER_ORDER` becomes the sole status-advancing action for counter/drive staff. +**Operations supprimees** (par rapport a v0.1) : `MARK_IN_PREPARATION` (`MARQUER_EN_PREPARATION`) +et `MARK_READY` (`MARQUER_PRETE`) sont retirees car leurs etats intermediaires n'existent plus. +`DELIVER_ORDER` devient la seule action faisant avancer le statut pour le personnel comptoir/drive. -**Security-by-design layer (2026-06-11)**: two operations are added — `RESET_PASSWORD` (12.3) -and `ERASE_USER_PII` (10.5, RGPD anonymisation). A subset of operations is **PIN-gated**: -back-office sessions stay shared per workstation, but a per-staff PIN re-authorises the -sensitive set — `CANCEL_ORDER` (7.1), `UPDATE_PRODUCT`/`DELETE_PRODUCT` (8.2/8.3), -`DELETE_MENU` (8.6), `INVENTORY_COUNT` (9.2), user management (10.1-10.3), `MANAGE_RBAC` -(10.4), `ERASE_USER_PII` (10.5). These non-stock actions append an immutable `audit_log` row -(actor, action, target); stock actions record attribution in `stock_movement`. The treatment -logic (PIN, audit, throttling, idempotency, atomic stock decrement, computed product -availability) is specified in `mlt.md` (rules RG-T13-T21). This adds entities 20 `audit_log` -and 21 `login_throttle` to the model. +**Couche security-by-design (2026-06-11)** : deux operations sont ajoutees — `RESET_PASSWORD` (12.3) +et `ERASE_USER_PII` (10.5, anonymisation RGPD). Un sous-ensemble d'operations est **protege par PIN** : +les sessions back-office restent partagees par poste de travail, mais un PIN par membre du personnel +re-autorise l'ensemble sensible — `CANCEL_ORDER` (7.1), `UPDATE_PRODUCT`/`DELETE_PRODUCT` (8.2/8.3), +`DELETE_MENU` (8.6), `INVENTORY_COUNT` (9.2), gestion des utilisateurs (10.1-10.3), `MANAGE_RBAC` +(10.4), `ERASE_USER_PII` (10.5). Ces actions hors stock ajoutent une ligne `audit_log` immuable +(acteur, action, cible) ; les actions de stock enregistrent l'attribution dans `stock_movement`. La logique +de traitement (PIN, audit, throttling, idempotence, decrement atomique du stock, disponibilite produit +calculee) est specifiee dans `mlt.md` (regles RG-T13-T21). Cela ajoute les entites 20 `audit_log` +et 21 `login_throttle` au modele. --- -## 2. Representation conventions +## 2. Conventions de representation -### Operation format +### Format des operations ``` [TRIGGERING EVENT(S)] @@ -84,467 +84,467 @@ and 21 `login_throttle` to the model. [EMITTED RESULT(S)] ``` -**Synchronisations**: -- `AND`: all events must be present simultaneously to trigger the operation. -- `OR`: any one of the events is sufficient. +**Synchronisations** : +- `AND` : tous les evenements doivent etre presents simultanement pour declencher l'operation. +- `OR` : l'un quelconque des evenements suffit. -**Conditions**: expressed in square brackets `[condition]` on the incoming arc. +**Conditions** : exprimees entre crochets `[condition]` sur l'arc entrant. -### Textual notation +### Notation textuelle -For each operation the document provides: -- **Triggering event(s)**: what occurs and causes the operation. -- **Actor(s)**: who initiates (or validates). -- **Synchronisation**: `AND` / `OR` if multiple events, plus condition. -- **Operation**: name and description of what it does. -- **MCD entities touched**: read (R) or write (W). -- **Result(s)**: what is emitted or produced. +Pour chaque operation, le document fournit : +- **Evenement(s) declencheur(s)** : ce qui survient et provoque l'operation. +- **Acteur(s)** : qui initie (ou valide). +- **Synchronisation** : `AND` / `OR` si plusieurs evenements, plus la condition. +- **Operation** : nom et description de ce qu'elle fait. +- **Entites MCD touchees** : lecture (R) ou ecriture (W). +- **Resultat(s)** : ce qui est emis ou produit. --- -## 3. Domain 1 — Order lifecycle (kiosk) +## 3. Domaine 1 — Cycle de vie de la commande (kiosk) ### 3.1 LOAD_CATALOGUE -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Customer opens the kiosk (connection to the kiosk endpoint) | -| **Actor** | CUSTOMER | -| **Synchronisation** | None (single event) | -| **Condition** | The kiosk is in service (within business hours 10:00-01:00) | +| **Evenement declencheur** | Le client ouvre le kiosk (connexion a l'endpoint du kiosk) | +| **Acteur** | CUSTOMER | +| **Synchronisation** | Aucune (evenement unique) | +| **Condition** | Le kiosk est en service (dans les horaires d'ouverture 10:00-01:00) | | **Operation** | LOAD_CATALOGUE | -| **Description** | Retrieval of active categories, available products, and available menus (with their slots and eligible options) for display on the kiosk screen. Product availability is COMPUTED: a product is orderable only if its `is_available` flag is set AND each non-removable (`is_removable=0`) ingredient in its `product_ingredient` is above the critical band (`stock_quantity > stock_capacity * critical_stock_pct/100`). See rule RG-T21 in `mlt.md`. | -| **MCD entities** | R: `category` (is_active=1), `product` (is_available=1), `menu` (is_available=1), `menu_slot`, `menu_slot_option`, `ingredient` (is_active=1), `allergen`, `ingredient_allergen` | -| **Result** | Catalogue loaded; kiosk displays the home screen | +| **Description** | Recuperation des categories actives, des produits disponibles et des menus disponibles (avec leurs slots et options eligibles) pour affichage sur l'ecran du kiosk. La disponibilite des produits est CALCULEE : un produit est commandable seulement si son flag `is_available` est positionne ET que chaque ingredient non retirable (`is_removable=0`) de son `product_ingredient` est au-dessus de la bande critique (`stock_quantity > stock_capacity * critical_stock_pct/100`). Voir la regle RG-T21 dans `mlt.md`. | +| **Entites MCD** | R: `category` (is_active=1), `product` (is_available=1), `menu` (is_available=1), `menu_slot`, `menu_slot_option`, `ingredient` (is_active=1), `allergen`, `ingredient_allergen` | +| **Resultat** | Catalogue charge ; le kiosk affiche l'ecran d'accueil | --- ### 3.2 COMPOSE_CART -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Customer selects a product or a menu on the kiosk | -| **Actor** | CUSTOMER | -| **Synchronisation** | Repeatable event (OR: add product, add menu, change quantity, remove item, choose menu slot, choose format Normal/Maxi, add/remove ingredient modifier) | -| **Condition** | The selected product or menu has `is_available=1` | +| **Evenement declencheur** | Le client selectionne un produit ou un menu sur le kiosk | +| **Acteur** | CUSTOMER | +| **Synchronisation** | Evenement repetable (OR : ajouter produit, ajouter menu, modifier quantite, retirer un article, choisir un slot de menu, choisir le format Normal/Maxi, ajouter/retirer un modificateur d'ingredient) | +| **Condition** | Le produit ou le menu selectionne a `is_available=1` | | **Operation** | COMPOSE_CART | -| **Description** | In-memory cart construction: add an item (standalone product or menu), select slot products (`order_item_selection`), optionally modify ingredients (`order_item_modifier`), choose Normal or Maxi format for menus, recalculate TTC total. The cart is a volatile client-side structure; no database write at this stage. | -| **MCD entities** | R: `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` — W: none (volatile front-end state) | -| **Result** | Cart updated, total recalculated, summary displayed | +| **Description** | Construction du panier en memoire : ajouter un article (produit autonome ou menu), selectionner les produits des slots (`order_item_selection`), modifier optionnellement les ingredients (`order_item_modifier`), choisir le format Normal ou Maxi pour les menus, recalculer le total TTC. Le panier est une structure volatile cote client ; aucune ecriture en base a ce stade. | +| **Entites MCD** | R: `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` — W: aucune (etat volatile front-end) | +| **Resultat** | Panier mis a jour, total recalcule, recapitulatif affiche | --- ### 3.3 CREATE_ORDER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering events** | 1. Customer confirms cart (presses "Validate") AND 2. Customer enters their order number (RNCP payment substitute) | -| **Actor** | CUSTOMER | -| **Synchronisation** | AND (both actions required) | -| **Condition** | Cart contains at least 1 item. The order number entered is non-empty. | +| **Evenements declencheurs** | 1. Le client confirme le panier (appuie sur « Valider ») AND 2. Le client saisit son numero de commande (substitut de paiement RNCP) | +| **Acteur** | CUSTOMER | +| **Synchronisation** | AND (les deux actions requises) | +| **Condition** | Le panier contient au moins 1 article. Le numero de commande saisi est non vide. | | **Operation** | CREATE_ORDER | -| **Description** | Atomic order creation: INSERT `customer_order` with status `pending_payment`, source `kiosk`, snapshot of HT/VAT/TTC totals (computed line by line using `vat_rate` snapshotted per item). INSERT `order_item` lines with `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot`. INSERT `order_item_selection` for each slot filled in a menu item. INSERT `order_item_modifier` for each ingredient modification. Decrement `ingredient.stock_quantity` for each ingredient consumed (adjusted by modifiers: remove => no decrement; add => extra decrement); INSERT one `stock_movement` row of type `sale` per affected ingredient unit. Stock decrements and order insert are within the same transaction. After the customer enters their order number, the status transitions `pending_payment -> paid` within the same transaction; `paid_at` is set. The system generates the order number in format `K-YYYY-MM-DD-NNN`. | -| **MCD entities** | R: `product`, `menu`, `ingredient`, `product_ingredient` (snapshot) — W: `customer_order` (INSERT status `pending_payment` then UPDATE status `paid`, `paid_at`), `order_item` (INSERT N lines), `order_item_selection` (INSERT per menu slot chosen), `order_item_modifier` (INSERT per modification), `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `sale` per unit) | -| **Result** | Order created (status `paid` at end of operation), order number displayed to customer, logical event ORDER_CREATED emitted toward the preparation domain | +| **Description** | Creation atomique de la commande : INSERT `customer_order` avec statut `pending_payment`, source `kiosk`, snapshot des totaux HT/TVA/TTC (calcules ligne par ligne en utilisant `vat_rate` snapshote par article). INSERT des lignes `order_item` avec `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot`. INSERT `order_item_selection` pour chaque slot rempli dans un article de menu. INSERT `order_item_modifier` pour chaque modification d'ingredient. Decrement de `ingredient.stock_quantity` pour chaque ingredient consomme (ajuste par les modificateurs : retrait => pas de decrement ; ajout => decrement supplementaire) ; INSERT d'une ligne `stock_movement` de type `sale` par unite d'ingredient affectee. Les decrements de stock et l'insertion de la commande sont dans la meme transaction. Apres que le client a saisi son numero de commande, le statut passe `pending_payment -> paid` dans la meme transaction ; `paid_at` est positionne. Le systeme genere le numero de commande au format `K-YYYY-MM-DD-NNN`. | +| **Entites MCD** | R: `product`, `menu`, `ingredient`, `product_ingredient` (snapshot) — W: `customer_order` (INSERT status `pending_payment` then UPDATE status `paid`, `paid_at`), `order_item` (INSERT N lines), `order_item_selection` (INSERT per menu slot chosen), `order_item_modifier` (INSERT per modification), `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `sale` per unit) | +| **Resultat** | Commande creee (statut `paid` en fin d'operation), numero de commande affiche au client, evenement logique ORDER_CREATED emis vers le domaine de preparation | --- ### 3.4 DISPLAY_CONFIRMATION -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | ORDER_CREATED (API response 201 after CREATE_ORDER) | -| **Actor** | SYS | -| **Synchronisation** | None | -| **Condition** | API response contains an id, an order_number and status `paid` | +| **Evenement declencheur** | ORDER_CREATED (reponse API 201 apres CREATE_ORDER) | +| **Acteur** | SYS | +| **Synchronisation** | Aucune | +| **Condition** | La reponse API contient un id, un order_number et le statut `paid` | | **Operation** | DISPLAY_CONFIRMATION | -| **Description** | Display of the confirmation screen on the kiosk with the order number. The kiosk then resets for the next customer. | -| **MCD entities** | R: none (data is in the API response) | -| **Result** | Confirmation screen displayed; kiosk available for next order | +| **Description** | Affichage de l'ecran de confirmation sur le kiosk avec le numero de commande. Le kiosk se reinitialise ensuite pour le client suivant. | +| **Entites MCD** | R: aucune (les donnees sont dans la reponse API) | +| **Resultat** | Ecran de confirmation affiche ; kiosk disponible pour la commande suivante | --- -## 4. Domain 2 — Order lifecycle (counter and drive) +## 4. Domaine 2 — Cycle de vie de la commande (comptoir et drive) ### 4.1 CREATE_COUNTER_ORDER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | A counter or drive staff member initiates a new order from the back-office | -| **Actor** | COUNTER or DRIVE | -| **Synchronisation** | None | -| **Condition** | The actor is authenticated and holds permission `order.create`. The `source` is `counter` or `drive` (auto-tagged from `role.order_source`). | +| **Evenement declencheur** | Un membre du personnel comptoir ou drive initie une nouvelle commande depuis le back-office | +| **Acteur** | COUNTER ou DRIVE | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur est authentifie et detient la permission `order.create`. La `source` est `counter` ou `drive` (auto-taggee depuis `role.order_source`). | | **Operation** | CREATE_COUNTER_ORDER | -| **Description** | Manual order composition via the back-office: select products and menus, choose service mode (`dine_in`/`takeaway`/`drive`), fill menu slots, add ingredient modifiers. Identical creation logic to CREATE_ORDER (snapshot, stock decrement in same transaction, atomic `pending_payment -> paid` transition). The `source` is auto-tagged from `role.order_source` (counter -> `counter`, drive -> `drive`). Order number format: `C-YYYY-MM-DD-NNN` (counter) or `D-YYYY-MM-DD-NNN` (drive). Cross-constraint: if `source = 'drive'` then `service_mode = 'drive'` (verified at creation). | -| **MCD entities** | R: `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` — W: `customer_order` (INSERT status `pending_payment` then UPDATE status `paid`, `paid_at`), `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient` (stock decrement), `stock_movement` (INSERT type `sale`) | -| **Result** | Order created (status `paid`), order number communicated to customer | +| **Description** | Composition manuelle de la commande via le back-office : selectionner produits et menus, choisir le mode de service (`dine_in`/`takeaway`/`drive`), remplir les slots de menu, ajouter des modificateurs d'ingredient. Logique de creation identique a CREATE_ORDER (snapshot, decrement de stock dans la meme transaction, transition atomique `pending_payment -> paid`). La `source` est auto-taggee depuis `role.order_source` (counter -> `counter`, drive -> `drive`). Format du numero de commande : `C-YYYY-MM-DD-NNN` (comptoir) ou `D-YYYY-MM-DD-NNN` (drive). Contrainte croisee : si `source = 'drive'` alors `service_mode = 'drive'` (verifie a la creation). | +| **Entites MCD** | R: `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` — W: `customer_order` (INSERT status `pending_payment` then UPDATE status `paid`, `paid_at`), `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient` (stock decrement), `stock_movement` (INSERT type `sale`) | +| **Resultat** | Commande creee (statut `paid`), numero de commande communique au client | --- -## 5. Domain 3 — Preparation display (kitchen) +## 5. Domaine 3 — Affichage de preparation (cuisine) ### 5.1 LIST_ORDERS_DISPLAY -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Kitchen staff accesses or refreshes the preparation display | -| **Actor** | KITCHEN (or COUNTER, DRIVE, ADMIN) | -| **Synchronisation** | None | -| **Condition** | The actor is authenticated and holds permission `order.read`. | +| **Evenement declencheur** | Le personnel cuisine accede a l'affichage de preparation ou le rafraichit | +| **Acteur** | KITCHEN (ou COUNTER, DRIVE, ADMIN) | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur est authentifie et detient la permission `order.read`. | | **Operation** | LIST_ORDERS_DISPLAY | -| **Description** | Read `customer_order` rows with status `paid`, filtered by sources visible to the actor's role (from `role_visible_source`): kitchen sees all sources; counter sees kiosk+counter; drive sees drive. Orders are sorted by `paid_at` ascending (oldest first). For each order, display: order number, source, content (`order_item` with `label_snapshot`, `quantity`, format, slot selections, ingredient modifiers). KDS colour is computed from `now - paid_at` against the SLA threshold (approx. 10 min), not stored. Kitchen staff performs no status transition — this is a read-only operation. | -| **MCD entities** | R: `customer_order` (status=`paid`), `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` | -| **Result** | Preparation display list shown, sorted by payment time ascending | +| **Description** | Lecture des lignes `customer_order` avec statut `paid`, filtrees par les sources visibles selon le role de l'acteur (depuis `role_visible_source`) : la cuisine voit toutes les sources ; le comptoir voit kiosk+counter ; le drive voit drive. Les commandes sont triees par `paid_at` ascendant (les plus anciennes en premier). Pour chaque commande, afficher : numero de commande, source, contenu (`order_item` avec `label_snapshot`, `quantity`, format, selections de slots, modificateurs d'ingredient). La couleur KDS est calculee a partir de `now - paid_at` par rapport au seuil de SLA (approx. 10 min), non stockee. Le personnel cuisine n'effectue aucune transition de statut — c'est une operation en lecture seule. | +| **Entites MCD** | R: `customer_order` (status=`paid`), `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` | +| **Resultat** | Liste d'affichage de preparation montree, triee par heure de paiement ascendante | --- -## 6. Domain 4 — Delivery to customer +## 6. Domaine 4 — Livraison au client ### 6.1 DELIVER_ORDER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering events** | 1. The order is at status `paid` AND 2. Counter or drive staff clicks "Delivered" | -| **Actor** | COUNTER or DRIVE | +| **Evenements declencheurs** | 1. La commande est au statut `paid` AND 2. Le personnel comptoir ou drive clique sur « Livre » | +| **Acteur** | COUNTER ou DRIVE | | **Synchronisation** | AND | -| **Condition** | The order has status `paid`. The actor holds permission `order.deliver`. The actor's role is consistent with the order source (counter staff handles kiosk+counter orders; drive staff handles drive orders — filtered by role_visible_source). | +| **Condition** | La commande a le statut `paid`. L'acteur detient la permission `order.deliver`. Le role de l'acteur est coherent avec la source de la commande (le personnel comptoir traite les commandes kiosk+counter ; le personnel drive traite les commandes drive — filtre par role_visible_source). | | **Operation** | DELIVER_ORDER | -| **Description** | Single-gesture transition `paid -> delivered`. Sets `delivered_at = NOW()`. The order moves to history. This operation replaces the v0.1 two-step sequence (mark-ready then deliver); the kitchen's visual confirmation (KDS) is sufficient before this action. | -| **MCD entities** | W: `customer_order` (UPDATE status `paid` -> `delivered`, `delivered_at = NOW()`) | -| **Result** | Order at status `delivered`, lifecycle complete | +| **Description** | Transition en geste unique `paid -> delivered`. Positionne `delivered_at = NOW()`. La commande passe en historique. Cette operation remplace la sequence en deux etapes de v0.1 (marquer-prete puis livrer) ; la confirmation visuelle de la cuisine (KDS) suffit avant cette action. | +| **Entites MCD** | W: `customer_order` (UPDATE status `paid` -> `delivered`, `delivered_at = NOW()`) | +| **Resultat** | Commande au statut `delivered`, cycle de vie complet | --- -## 7. Domain 5 — Cancellation +## 7. Domaine 5 — Annulation ### 7.1 CANCEL_ORDER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | An authorised actor requests cancellation of an order | -| **Actor** | COUNTER, DRIVE, or ADMIN | -| **Synchronisation** | None | -| **Condition** | The order exists. `customer_order.status` is in `['pending_payment', 'paid']`. Terminal statuses `delivered` and `cancelled` cannot transition to `cancelled`. The actor holds permission `order.cancel`. | +| **Evenement declencheur** | Un acteur autorise demande l'annulation d'une commande | +| **Acteur** | COUNTER, DRIVE ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | La commande existe. `customer_order.status` est dans `['pending_payment', 'paid']`. Les statuts terminaux `delivered` et `cancelled` ne peuvent pas transiter vers `cancelled`. L'acteur detient la permission `order.cancel`. | | **Operation** | CANCEL_ORDER | -| **Description** | Transition from current status to `cancelled`. Sets `cancelled_at = NOW()`. The order is retained in the database for history and stats (no physical deletion). If the current status is `paid`, stock is re-credited: for each ingredient consumed by the order (accounting for modifiers), `ingredient.stock_quantity` is incremented; one `stock_movement` row of type `cancellation` is inserted per affected ingredient unit. Stock re-credit and status update are within the same transaction. | -| **MCD entities** | R: `order_item`, `order_item_modifier`, `ingredient`, `product_ingredient` — W: `customer_order` (UPDATE status -> `cancelled`, `cancelled_at = NOW()`), `ingredient` (UPDATE stock_quantity, conditional on status `paid`), `stock_movement` (INSERT type `cancellation`, conditional on status `paid`) | -| **Result** | Order at status `cancelled`, visible in admin history | +| **Description** | Transition du statut courant vers `cancelled`. Positionne `cancelled_at = NOW()`. La commande est conservee en base pour l'historique et les stats (pas de suppression physique). Si le statut courant est `paid`, le stock est recredite : pour chaque ingredient consomme par la commande (en tenant compte des modificateurs), `ingredient.stock_quantity` est incremente ; une ligne `stock_movement` de type `cancellation` est inseree par unite d'ingredient affectee. Le recredit du stock et la mise a jour du statut sont dans la meme transaction. | +| **Entites MCD** | R: `order_item`, `order_item_modifier`, `ingredient`, `product_ingredient` — W: `customer_order` (UPDATE status -> `cancelled`, `cancelled_at = NOW()`), `ingredient` (UPDATE stock_quantity, conditional on status `paid`), `stock_movement` (INSERT type `cancellation`, conditional on status `paid`) | +| **Resultat** | Commande au statut `cancelled`, visible dans l'historique admin | --- -## 8. Domain 6 — Catalogue management +## 8. Domaine 6 — Gestion du catalogue ### 8.1 CREATE_PRODUCT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager submits the product creation form | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `product.create`. Target category exists and `is_active=1`. `name` is non-empty. `price_cents > 0`. | +| **Evenement declencheur** | L'admin ou le manager soumet le formulaire de creation de produit | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `product.create`. La categorie cible existe et `is_active=1`. `name` est non vide. `price_cents > 0`. | | **Operation** | CREATE_PRODUCT | -| **Description** | INSERT a new `product` with its category, name, price in cents, VAT rate in per-mille (`vat_rate`: 100=10%, 55=5.5%, default 100), optional image path. `is_available=1` by default. | -| **MCD entities** | R: `category` (FK validation) — W: `product` (INSERT) | -| **Result** | Product created, redirect to product list | +| **Description** | INSERT d'un nouveau `product` avec sa categorie, son nom, son prix en centimes, son taux de TVA en pour-mille (`vat_rate` : 100=10%, 55=5.5%, defaut 100), chemin d'image optionnel. `is_available=1` par defaut. | +| **Entites MCD** | R: `category` (FK validation) — W: `product` (INSERT) | +| **Resultat** | Produit cree, redirection vers la liste des produits | --- ### 8.2 UPDATE_PRODUCT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager submits the product update form | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `product.update`. Product exists. New values respect constraints (`price_cents > 0`, non-empty name). | +| **Evenement declencheur** | L'admin ou le manager soumet le formulaire de modification de produit | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `product.update`. Le produit existe. Les nouvelles valeurs respectent les contraintes (`price_cents > 0`, nom non vide). | | **Operation** | UPDATE_PRODUCT | -| **Description** | UPDATE modifiable columns (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`). Snapshots already stored in `order_item` are not affected (historical integrity guaranteed by design). | -| **MCD entities** | W: `product` (UPDATE) | -| **Result** | Product updated, product list refreshed | +| **Description** | UPDATE des colonnes modifiables (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`). Les snapshots deja stockes dans `order_item` ne sont pas affectes (integrite historique garantie par conception). | +| **Entites MCD** | W: `product` (UPDATE) | +| **Resultat** | Produit mis a jour, liste des produits rafraichie | --- ### 8.3 DELETE_PRODUCT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin confirms deletion of a product | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `product.delete`. Product is not a slot option in any `menu_slot_option` (FK `ON DELETE RESTRICT`). Product is not referenced in any `order_item` historical line (FK `ON DELETE RESTRICT`). Preliminary check required. | +| **Evenement declencheur** | L'admin confirme la suppression d'un produit | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `product.delete`. Le produit n'est option de slot dans aucun `menu_slot_option` (FK `ON DELETE RESTRICT`). Le produit n'est reference dans aucune ligne historique `order_item` (FK `ON DELETE RESTRICT`). Verification prealable requise. | | **Operation** | DELETE_PRODUCT | -| **Description** | Physical deletion of the product if no FK constraint blocks. If the product is referenced in a menu slot or historical order line, deletion is blocked. The recommended alternative is to deactivate (`is_available=0`). Also blocks if the product is the `burger_product_id` of any `menu`. | -| **MCD entities** | W: `product` (DELETE — blocked if referenced in `menu_slot_option`, `order_item`, or `menu.burger_product_id`) | -| **Result** | Product deleted OR error "product in use" | +| **Description** | Suppression physique du produit si aucune contrainte FK ne la bloque. Si le produit est reference dans un slot de menu ou une ligne de commande historique, la suppression est bloquee. L'alternative recommandee est de le desactiver (`is_available=0`). Bloque egalement si le produit est le `burger_product_id` d'un `menu`. | +| **Entites MCD** | W: `product` (DELETE — blocked if referenced in `menu_slot_option`, `order_item`, or `menu.burger_product_id`) | +| **Resultat** | Produit supprime OU erreur « produit utilise » | --- ### 8.4 CREATE_MENU -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager submits the menu creation form with its slot configuration | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `menu.create`. `name` is non-empty. `price_normal_cents > 0`, `price_maxi_cents > 0`. `burger_product_id` references an existing product. At least one slot is defined with at least one option. | +| **Evenement declencheur** | L'admin ou le manager soumet le formulaire de creation de menu avec sa configuration de slots | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `menu.create`. `name` est non vide. `price_normal_cents > 0`, `price_maxi_cents > 0`. `burger_product_id` reference un produit existant. Au moins un slot est defini avec au moins une option. | | **Operation** | CREATE_MENU | -| **Description** | Transaction: INSERT `menu` (with `burger_product_id`, `price_normal_cents`, `price_maxi_cents`), then INSERT `menu_slot` rows (one per slot: drink, side, sauce...), then INSERT `menu_slot_option` rows (eligible products per slot). | -| **MCD entities** | R: `product` (burger FK validation, slot options validation), `category` — W: `menu` (INSERT), `menu_slot` (INSERT), `menu_slot_option` (INSERT) | -| **Result** | Menu created with its slot configuration, visible on the kiosk | +| **Description** | Transaction : INSERT `menu` (avec `burger_product_id`, `price_normal_cents`, `price_maxi_cents`), puis INSERT des lignes `menu_slot` (une par slot : boisson, accompagnement, sauce...), puis INSERT des lignes `menu_slot_option` (produits eligibles par slot). | +| **Entites MCD** | R: `product` (burger FK validation, slot options validation), `category` — W: `menu` (INSERT), `menu_slot` (INSERT), `menu_slot_option` (INSERT) | +| **Resultat** | Menu cree avec sa configuration de slots, visible sur le kiosk | --- ### 8.5 UPDATE_MENU -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager submits the menu update form | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `menu.update`. Menu exists. Updated configuration preserves at least one slot with at least one option. | +| **Evenement declencheur** | L'admin ou le manager soumet le formulaire de modification de menu | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `menu.update`. Le menu existe. La configuration mise a jour preserve au moins un slot avec au moins une option. | | **Operation** | UPDATE_MENU | -| **Description** | UPDATE `menu` columns. If slot configuration is modified: DELETE all `menu_slot_option` rows for this menu's slots, DELETE `menu_slot` rows, then re-INSERT (delete-and-reinsert pattern, atomic in transaction). Snapshots in `order_item` are not affected. | -| **MCD entities** | W: `menu` (UPDATE), `menu_slot` (DELETE + INSERT), `menu_slot_option` (DELETE + INSERT) | -| **Result** | Menu updated | +| **Description** | UPDATE des colonnes `menu`. Si la configuration des slots est modifiee : DELETE de toutes les lignes `menu_slot_option` pour les slots de ce menu, DELETE des lignes `menu_slot`, puis re-INSERT (pattern delete-and-reinsert, atomique en transaction). Les snapshots dans `order_item` ne sont pas affectes. | +| **Entites MCD** | W: `menu` (UPDATE), `menu_slot` (DELETE + INSERT), `menu_slot_option` (DELETE + INSERT) | +| **Resultat** | Menu mis a jour | --- ### 8.6 DELETE_MENU -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin confirms deletion of a menu | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `menu.delete`. Menu is not referenced in any `order_item` historical line (FK `ON DELETE RESTRICT`). Preliminary check required. | +| **Evenement declencheur** | L'admin confirme la suppression d'un menu | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `menu.delete`. Le menu n'est reference dans aucune ligne historique `order_item` (FK `ON DELETE RESTRICT`). Verification prealable requise. | | **Operation** | DELETE_MENU | -| **Description** | If no `order_item` references this menu: DELETE `menu_slot_option` (CASCADE from `menu_slot`), DELETE `menu_slot` (CASCADE from `menu`), DELETE `menu`. If historical references exist, propose deactivation (`is_available=0`) instead. | -| **MCD entities** | W: `menu_slot_option` (DELETE CASCADE), `menu_slot` (DELETE CASCADE), `menu` (DELETE — blocked if referenced in `order_item`) | -| **Result** | Menu deleted OR error "menu present in historical orders" | +| **Description** | Si aucun `order_item` ne reference ce menu : DELETE `menu_slot_option` (CASCADE from `menu_slot`), DELETE `menu_slot` (CASCADE from `menu`), DELETE `menu`. Si des references historiques existent, proposer la desactivation (`is_available=0`) a la place. | +| **Entites MCD** | W: `menu_slot_option` (DELETE CASCADE), `menu_slot` (DELETE CASCADE), `menu` (DELETE — blocked if referenced in `order_item`) | +| **Resultat** | Menu supprime OU erreur « menu present dans des commandes historiques » | --- ### 8.7 MANAGE_CATEGORY -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager creates, updates, or deactivates a category | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | OR (create, update, deactivation) | -| **Condition** | Actor holds permission `category.manage`. For deactivation: products and menus in the category are not auto-deactivated in DB (no CASCADE on `is_active`); the application layer proposes deactivating child products/menus. | +| **Evenement declencheur** | L'admin ou le manager cree, modifie ou desactive une categorie | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | OR (creation, modification, desactivation) | +| **Condition** | L'acteur detient la permission `category.manage`. Pour la desactivation : les produits et menus de la categorie ne sont pas auto-desactives en base (pas de CASCADE sur `is_active`) ; la couche applicative propose de desactiver les produits/menus enfants. | | **Operation** | MANAGE_CATEGORY | -| **Description** | CRUD on `category`. Deactivation (`is_active=0`) hides the category and its products from the kiosk without physical deletion. Physical deletion is blocked if products or menus reference this category (FK `ON DELETE RESTRICT`). | -| **MCD entities** | W: `category` (INSERT / UPDATE / conditional DELETE) | -| **Result** | Category created / updated / deactivated | +| **Description** | CRUD sur `category`. La desactivation (`is_active=0`) masque la categorie et ses produits du kiosk sans suppression physique. La suppression physique est bloquee si des produits ou des menus referencent cette categorie (FK `ON DELETE RESTRICT`). | +| **Entites MCD** | W: `category` (INSERT / UPDATE / conditional DELETE) | +| **Resultat** | Categorie creee / modifiee / desactivee | --- ### 8.8 MANAGE_INGREDIENT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager creates, updates, or deactivates an ingredient; or manages product composition (`product_ingredient`) or allergen mapping (`ingredient_allergen`) | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | OR (create ingredient, update ingredient, update composition, update allergen mapping) | -| **Condition** | Actor holds permission `ingredient.manage`. | +| **Evenement declencheur** | L'admin ou le manager cree, modifie ou desactive un ingredient ; ou gere la composition produit (`product_ingredient`) ou le mapping allergene (`ingredient_allergen`) | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | OR (creer ingredient, modifier ingredient, modifier composition, modifier mapping allergene) | +| **Condition** | L'acteur detient la permission `ingredient.manage`. | | **Operation** | MANAGE_INGREDIENT | -| **Description** | CRUD on `ingredient` (name, unit, pack_size, pack_label, stock_capacity, low_stock_pct, critical_stock_pct, is_active). Manage `product_ingredient` composition (quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) for any product. Manage `ingredient_allergen` mapping (14 EU regulated allergens). Deactivating an ingredient (`is_active=0`) hides it from the configurator without deletion. Physical deletion of `ingredient` is blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). | -| **MCD entities** | R: `product` (FK validation), `allergen` (FK validation) — W: `ingredient` (INSERT/UPDATE/DELETE conditional), `product_ingredient` (INSERT/UPDATE/DELETE), `ingredient_allergen` (INSERT/DELETE) | -| **Result** | Ingredient / composition / allergen mapping updated | +| **Description** | CRUD sur `ingredient` (name, unit, pack_size, pack_label, stock_capacity, low_stock_pct, critical_stock_pct, is_active). Gestion de la composition `product_ingredient` (quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) pour tout produit. Gestion du mapping `ingredient_allergen` (14 allergenes reglementes UE). Desactiver un ingredient (`is_active=0`) le masque du configurateur sans suppression. La suppression physique de `ingredient` est bloquee s'il est reference dans `product_ingredient` (FK `ON DELETE RESTRICT`) ou `stock_movement` (FK `ON DELETE RESTRICT`). | +| **Entites MCD** | R: `product` (FK validation), `allergen` (FK validation) — W: `ingredient` (INSERT/UPDATE/DELETE conditional), `product_ingredient` (INSERT/UPDATE/DELETE), `ingredient_allergen` (INSERT/DELETE) | +| **Resultat** | Ingredient / composition / mapping allergene mis a jour | --- -## 9. Domain 7 — Stock management +## 9. Domaine 7 — Gestion du stock ### 9.1 RESTOCK -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Manager or admin records a delivery of ingredient packs | -| **Actor** | MANAGER or ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `stock.manage`. Ingredient exists and `is_active=1`. Number of packs `N >= 1`. | +| **Evenement declencheur** | Le manager ou l'admin enregistre une livraison de packs d'ingredient | +| **Acteur** | MANAGER ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `stock.manage`. L'ingredient existe et `is_active=1`. Nombre de packs `N >= 1`. | | **Operation** | RESTOCK | -| **Description** | UPDATE `ingredient.stock_quantity += N * pack_size`. INSERT one `stock_movement` row: type `restock`, delta `+= N * pack_size`, `user_id` of the actor, optional `note` (e.g. delivery reference). Both writes are in the same transaction. | -| **MCD entities** | R: `ingredient` — W: `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `restock`) | -| **Result** | Stock incremented, movement logged | +| **Description** | UPDATE `ingredient.stock_quantity += N * pack_size`. INSERT d'une ligne `stock_movement` : type `restock`, delta `+= N * pack_size`, `user_id` de l'acteur, `note` optionnelle (ex. reference de livraison). Les deux ecritures sont dans la meme transaction. | +| **Entites MCD** | R: `ingredient` — W: `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `restock`) | +| **Resultat** | Stock incremente, mouvement journalise | --- ### 9.2 INVENTORY_COUNT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | A staff member or manager records the result of a physical inventory count | -| **Actor** | KITCHEN, COUNTER, DRIVE, MANAGER, or ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `stock.count`. Ingredient exists. Physical count `actual_quantity >= 0`. | +| **Evenement declencheur** | Un membre du personnel ou un manager enregistre le resultat d'un inventaire physique | +| **Acteur** | KITCHEN, COUNTER, DRIVE, MANAGER ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `stock.count`. L'ingredient existe. Comptage physique `actual_quantity >= 0`. | | **Operation** | INVENTORY_COUNT | -| **Description** | Compute `delta = actual_quantity - ingredient.stock_quantity` (may be negative or positive). UPDATE `ingredient.stock_quantity = actual_quantity`. INSERT one `stock_movement` row: type `inventory_correction`, delta = computed discrepancy, `user_id` of the actor, optional `note`. Both writes in the same transaction. | -| **MCD entities** | R: `ingredient` (read current stock_quantity) — W: `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `inventory_correction`) | -| **Result** | Stock reconciled to physical count, discrepancy logged | +| **Description** | Calcul de `delta = actual_quantity - ingredient.stock_quantity` (peut etre negatif ou positif). UPDATE `ingredient.stock_quantity = actual_quantity`. INSERT d'une ligne `stock_movement` : type `inventory_correction`, delta = ecart calcule, `user_id` de l'acteur, `note` optionnelle. Les deux ecritures dans la meme transaction. | +| **Entites MCD** | R: `ingredient` (read current stock_quantity) — W: `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `inventory_correction`) | +| **Resultat** | Stock reconcilie au comptage physique, ecart journalise | --- ### 9.3 READ_STOCK -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | An authorised actor accesses the stock view | -| **Actor** | KITCHEN, COUNTER, DRIVE, MANAGER, or ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `stock.read`. | +| **Evenement declencheur** | Un acteur autorise accede a la vue du stock | +| **Acteur** | KITCHEN, COUNTER, DRIVE, MANAGER ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `stock.read`. | | **Operation** | READ_STOCK | -| **Description** | Read `ingredient` list with current `stock_quantity`, `stock_capacity`, computed `stock_pct`, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`. Stock bands computed at display time: `low_stock` when `stock_quantity <= stock_capacity * low_stock_pct/100`, `critical_stock` when `stock_quantity <= stock_capacity * critical_stock_pct/100`. Optional: read `stock_movement` history for a given ingredient, filtered by date range. | -| **MCD entities** | R: `ingredient`, `stock_movement` (optional history) | -| **Result** | Stock list displayed with low-stock indicators | +| **Description** | Lecture de la liste `ingredient` avec le `stock_quantity` courant, `stock_capacity`, `stock_pct` calcule, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`. Bandes de stock calculees au moment de l'affichage : `low_stock` lorsque `stock_quantity <= stock_capacity * low_stock_pct/100`, `critical_stock` lorsque `stock_quantity <= stock_capacity * critical_stock_pct/100`. Optionnel : lecture de l'historique `stock_movement` pour un ingredient donne, filtre par plage de dates. | +| **Entites MCD** | R: `ingredient`, `stock_movement` (optional history) | +| **Resultat** | Liste du stock affichee avec indicateurs de stock bas | --- -## 10. Domain 8 — User and role management (admin) +## 10. Domaine 8 — Gestion des utilisateurs et des roles (admin) ### 10.1 CREATE_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin submits the user creation form | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `user.create`. Email does not already exist in `user.email` (UNIQUE constraint). A valid and active `role_id` is selected. | +| **Evenement declencheur** | L'admin soumet le formulaire de creation d'utilisateur | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `user.create`. L'email n'existe pas deja dans `user.email` (contrainte UNIQUE). Un `role_id` valide et actif est selectionne. | | **Operation** | CREATE_USER | -| **Description** | INSERT user with argon2id password hash. Email is unique. `role_id` is mandatory (FK NOT NULL). `is_active=1` by default. `last_login_at=NULL` at creation. | -| **MCD entities** | R: `role` (FK validation) — W: `user` (INSERT) | -| **Result** | User created, can log into the back-office | +| **Description** | INSERT de l'utilisateur avec un hash de mot de passe argon2id. L'email est unique. `role_id` est obligatoire (FK NOT NULL). `is_active=1` par defaut. `last_login_at=NULL` a la creation. | +| **Entites MCD** | R: `role` (FK validation) — W: `user` (INSERT) | +| **Resultat** | Utilisateur cree, peut se connecter au back-office | --- ### 10.2 UPDATE_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin submits the user update form | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `user.update`. User exists. If a new password is provided, it is re-hashed. | +| **Evenement declencheur** | L'admin soumet le formulaire de modification d'utilisateur | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `user.update`. L'utilisateur existe. Si un nouveau mot de passe est fourni, il est re-hashe. | | **Operation** | UPDATE_USER | -| **Description** | UPDATE modifiable fields (`first_name`, `last_name`, `email`, `role_id`, `is_active`). If a new password is supplied, it replaces the existing hash (argon2id rehash). | -| **MCD entities** | W: `user` (UPDATE) | -| **Result** | User updated | +| **Description** | UPDATE des champs modifiables (`first_name`, `last_name`, `email`, `role_id`, `is_active`). Si un nouveau mot de passe est fourni, il remplace le hash existant (rehash argon2id). | +| **Entites MCD** | W: `user` (UPDATE) | +| **Resultat** | Utilisateur mis a jour | --- ### 10.3 DEACTIVATE_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin clicks "Deactivate" for a user | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `user.deactivate`. Admin cannot deactivate their own account (application-level protection). | +| **Evenement declencheur** | L'admin clique sur « Desactiver » pour un utilisateur | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `user.deactivate`. L'admin ne peut pas desactiver son propre compte (protection au niveau applicatif). | | **Operation** | DEACTIVATE_USER | -| **Description** | UPDATE `is_active=0`. The user's active session is invalidated on next access (middleware checks `is_active=1` on each authenticated request). User is not deleted; history remains traceable. | -| **MCD entities** | W: `user` (UPDATE is_active=0) | -| **Result** | User deactivated, back-office access blocked | +| **Description** | UPDATE `is_active=0`. La session active de l'utilisateur est invalidee au prochain acces (le middleware verifie `is_active=1` a chaque requete authentifiee). L'utilisateur n'est pas supprime ; l'historique reste tracable. | +| **Entites MCD** | W: `user` (UPDATE is_active=0) | +| **Resultat** | Utilisateur desactive, acces back-office bloque | --- ### 10.4 MANAGE_RBAC -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin modifies permission assignments for a role, or creates / updates a custom role | -| **Actor** | ADMIN | -| **Synchronisation** | OR (update role permissions, create custom role, update role attributes) | -| **Condition** | Actor holds permission `role.manage`. Selected permissions exist in the `permission` catalogue. | +| **Evenement declencheur** | L'admin modifie les affectations de permissions pour un role, ou cree / modifie un role personnalise | +| **Acteur** | ADMIN | +| **Synchronisation** | OR (modifier les permissions du role, creer un role personnalise, modifier les attributs du role) | +| **Condition** | L'acteur detient la permission `role.manage`. Les permissions selectionnees existent dans le catalogue `permission`. | | **Operation** | MANAGE_RBAC | -| **Description** | Update `role_permission` for a given role: DELETE existing assignments, INSERT new ones (delete-and-reinsert, atomic in transaction). Permissions themselves are static (declared in migration, not modifiable via UI). Also covers: CREATE/UPDATE custom `role` (code, label, description, default_route, order_source), UPDATE `role_visible_source` (visible dashboard sources for the role). RBAC architecture rule: application code tests permissions, not role names — adding a new role with correct permissions requires no code change. | -| **MCD entities** | R: `role`, `permission` — W: `role_permission` (DELETE + INSERT), `role` (INSERT/UPDATE for custom roles), `role_visible_source` (INSERT/DELETE) | -| **Result** | RBAC matrix updated, effective immediately for new requests of users bearing this role | +| **Description** | Mise a jour de `role_permission` pour un role donne : DELETE des affectations existantes, INSERT des nouvelles (delete-and-reinsert, atomique en transaction). Les permissions elles-memes sont statiques (declarees en migration, non modifiables via l'UI). Couvre egalement : CREATE/UPDATE d'un `role` personnalise (code, label, description, default_route, order_source), UPDATE de `role_visible_source` (sources de tableau de bord visibles pour le role). Regle d'architecture RBAC : le code applicatif teste les permissions, pas les noms de role — ajouter un nouveau role avec les bonnes permissions ne requiert aucun changement de code. | +| **Entites MCD** | R: `role`, `permission` — W: `role_permission` (DELETE + INSERT), `role` (INSERT/UPDATE for custom roles), `role_visible_source` (INSERT/DELETE) | +| **Resultat** | Matrice RBAC mise a jour, effective immediatement pour les nouvelles requetes des utilisateurs porteurs de ce role | --- ### 10.5 ERASE_USER_PII (security-by-design) -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | A RGPD erasure request is processed for a back-office user | -| **Actor** | ADMIN (PIN-gated) | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `user.update` and has re-authorised via PIN. Target user exists and is not already anonymised. | +| **Evenement declencheur** | Une demande d'effacement RGPD est traitee pour un utilisateur back-office | +| **Acteur** | ADMIN (protege par PIN) | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `user.update` et s'est re-autorise via PIN. L'utilisateur cible existe et n'est pas deja anonymise. | | **Operation** | ERASE_USER_PII | -| **Description** | RGPD right-to-erasure honoured by **anonymisation**, not physical deletion: PII (`email`, `first_name`, `last_name`) is cleared/replaced by a non-identifying placeholder, credentials invalidated, `anonymized_at` set. The row persists so referential links (`stock_movement`, `customer_order`, `audit_log`) stay valid and resolve to an anonymised principal. See `mlt.md` 10.5 and dictionary note 13. | -| **MCD entities** | W: `user` (UPDATE — PII cleared, `anonymized_at` set), `audit_log` (INSERT) | -| **Result** | User anonymised; PII removed; accountability links preserved; one `audit_log` row recorded | +| **Description** | Le droit a l'effacement RGPD est honore par **anonymisation**, non par suppression physique : les PII (`email`, `first_name`, `last_name`) sont effacees/remplacees par un placeholder non identifiant, les identifiants invalides, `anonymized_at` positionne. La ligne persiste afin que les liens referentiels (`stock_movement`, `customer_order`, `audit_log`) restent valides et se resolvent vers un principal anonymise. Voir `mlt.md` 10.5 et la note 13 du dictionnaire. | +| **Entites MCD** | W: `user` (UPDATE — PII cleared, `anonymized_at` set), `audit_log` (INSERT) | +| **Resultat** | Utilisateur anonymise ; PII supprimees ; liens d'imputabilite preserves ; une ligne `audit_log` enregistree | --- -## 11. Domain 9 — Stats and KPI +## 11. Domaine 9 — Stats et KPI ### 11.1 READ_STATS -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Manager or admin accesses the stats dashboard | -| **Actor** | MANAGER or ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `stats.read`. | +| **Evenement declencheur** | Le manager ou l'admin accede au tableau de bord des stats | +| **Acteur** | MANAGER ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `stats.read`. | | **Operation** | READ_STATS | -| **Description** | Aggregate queries on `customer_order` and `order_item`. Key aggregations: order count and revenue (TTC) by `service_day` (computed with CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END; cutoff at 10:00); top products by `label_snapshot` COUNT in `order_item`; cancellation rate; average delivery time `delivered_at - paid_at`; breakdown by `source` and `service_mode`. Queries exclude cancelled orders from revenue sums but include them in volume counts. No additional stored column for `service_day`; computation at query time. | -| **MCD entities** | R: `customer_order`, `order_item` | -| **Result** | Stats dashboard displayed | +| **Description** | Requetes d'agregation sur `customer_order` et `order_item`. Agregations cles : nombre de commandes et chiffre d'affaires (TTC) par `service_day` (calcule avec CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END ; coupure a 10:00) ; top produits par COUNT de `label_snapshot` dans `order_item` ; taux d'annulation ; temps de livraison moyen `delivered_at - paid_at` ; ventilation par `source` et `service_mode`. Les requetes excluent les commandes annulees des sommes de chiffre d'affaires mais les incluent dans les comptages de volume. Pas de colonne stockee supplementaire pour `service_day` ; calcul au moment de la requete. | +| **Entites MCD** | R: `customer_order`, `order_item` | +| **Resultat** | Tableau de bord des stats affiche | --- -## 12. Domain 10 — Back-office authentication +## 12. Domaine 10 — Authentification back-office ### 12.1 AUTHENTICATE_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | An actor submits the login form | -| **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | -| **Synchronisation** | None | -| **Condition** | Account not in a throttling window (`lockout_until`). Email exists in database. Password matches argon2id hash. User `is_active=1`. | +| **Evenement declencheur** | Un acteur soumet le formulaire de connexion | +| **Acteur** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | Le compte n'est pas dans une fenetre de throttling (`lockout_until`). L'email existe en base. Le mot de passe correspond au hash argon2id. L'utilisateur `is_active=1`. | | **Operation** | AUTHENTICATE_USER | -| **Description** | Credential verification. If valid: session ID regeneration (protection against session fixation), storage of `user_id` and `role_id` in session, UPDATE `last_login_at`, reset of the login failure counter. On failure: increment `failed_login_attempts` and apply a degressive backoff (`lockout_until`), enumeration-safe generic error. Idle timeout: 4h. Absolute timeout: 10h. Redirect to `role.default_route`. See `mlt.md` 12.1. | -| **MCD entities** | R: `user` (verification), `role` (load permissions, default_route), `role_permission`, `login_throttle` (the per-IP throttle gate) — W: `user` (UPDATE last_login_at, `failed_login_attempts`, `lockout_until`), `login_throttle` (upsert `failed_attempts`/`lockout_until` on failure, clear on success), `audit_log` (INSERT login success/failure) | -| **Result** | Session opened, redirect to role-specific default view; or throttled failure logged | +| **Description** | Verification des identifiants. Si valide : regeneration de l'ID de session (protection contre la fixation de session), stockage de `user_id` et `role_id` en session, UPDATE `last_login_at`, remise a zero du compteur d'echecs de connexion. En cas d'echec : incrementation de `failed_login_attempts` et application d'un backoff degressif (`lockout_until`), erreur generique resistante a l'enumeration. Idle timeout : 4h. Absolute timeout : 10h. Redirection vers `role.default_route`. Voir `mlt.md` 12.1. | +| **Entites MCD** | R: `user` (verification), `role` (load permissions, default_route), `role_permission`, `login_throttle` (the per-IP throttle gate) — W: `user` (UPDATE last_login_at, `failed_login_attempts`, `lockout_until`), `login_throttle` (upsert `failed_attempts`/`lockout_until` on failure, clear on success), `audit_log` (INSERT login success/failure) | +| **Resultat** | Session ouverte, redirection vers la vue par defaut specifique au role ; ou echec throttle journalise | --- ### 12.2 LOGOUT_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Actor clicks "Logout" OR session expires | -| **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN / SYS (expiry) | +| **Evenement declencheur** | L'acteur clique sur « Deconnexion » OU la session expire | +| **Acteur** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN / SYS (expiration) | | **Synchronisation** | OR | -| **Condition** | A valid session is open | +| **Condition** | Une session valide est ouverte | | **Operation** | LOGOUT_USER | -| **Description** | PHP session destruction (`session_destroy()`). Session deleted server-side. Session cookie invalidated. | -| **MCD entities** | No database write (session management is in PHP native, outside DB for this project) | -| **Result** | Session destroyed, redirect to login page | +| **Description** | Destruction de la session PHP (`session_destroy()`). Session supprimee cote serveur. Cookie de session invalide. | +| **Entites MCD** | Aucune ecriture en base (la gestion des sessions est en PHP natif, hors base pour ce projet) | +| **Resultat** | Session detruite, redirection vers la page de connexion | --- ### 12.3 RESET_PASSWORD (security-by-design) -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | A user requests a password reset, then confirms it via the emailed link | -| **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | -| **Synchronisation** | Sequential two-phase: request, then confirm | -| **Condition** | Request: the submitted email is processed enumeration-safely (same neutral response whether or not it exists). Confirm: a valid, non-expired token is presented. | +| **Evenement declencheur** | Un utilisateur demande une reinitialisation de mot de passe, puis la confirme via le lien envoye par email | +| **Acteur** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | +| **Synchronisation** | Sequentielle en deux phases : demande, puis confirmation | +| **Condition** | Demande : l'email soumis est traite de maniere resistante a l'enumeration (meme reponse neutre qu'il existe ou non). Confirmation : un token valide et non expire est presente. | | **Operation** | RESET_PASSWORD | -| **Description** | Request phase generates a random token, stores its hash + expiry, and e-mails the raw token once. Confirm phase validates the token hash + expiry, replaces `password_hash` (argon2id), clears the token, and resets the login failure counter. See `mlt.md` 12.3. | -| **MCD entities** | W: `user` (UPDATE `password_reset_token_hash` + `password_reset_expires_at` on request; UPDATE `password_hash`, clear token, reset `failed_login_attempts`/`lockout_until` on confirm), `audit_log` (INSERT) | -| **Result** | Password reset via a one-time, time-bound token; one `audit_log` row recorded | +| **Description** | La phase de demande genere un token aleatoire, stocke son hash + expiration, et envoie le token brut une seule fois par email. La phase de confirmation valide le hash du token + expiration, remplace `password_hash` (argon2id), efface le token et remet a zero le compteur d'echecs de connexion. Voir `mlt.md` 12.3. | +| **Entites MCD** | W: `user` (UPDATE `password_reset_token_hash` + `password_reset_expires_at` on request; UPDATE `password_hash`, clear token, reset `failed_login_attempts`/`lockout_until` on confirm), `audit_log` (INSERT) | +| **Resultat** | Mot de passe reinitialise via un token a usage unique et a duree limitee ; une ligne `audit_log` enregistree | --- -## 13. State machine — customer_order.status +## 13. Machine a etats — customer_order.status -Summary of transitions covered by MCT operations. +Recapitulatif des transitions couvertes par les operations MCT. ``` [CUSTOMER / COUNTER / DRIVE] @@ -573,20 +573,20 @@ Summary of transitions covered by MCT operations. [ cancelled ] (terminal) ``` -**Note on the `pending_payment -> paid` transition**: in the RNCP context, payment is -replaced by the customer entering their order number (kiosk) or by staff validation -(counter/drive). The transition is atomic within CREATE_ORDER and CREATE_COUNTER_ORDER. -The `pending_payment` status is not observable outside the transaction. +**Note sur la transition `pending_payment -> paid`** : dans le contexte RNCP, le paiement est +remplace par la saisie du numero de commande par le client (kiosk) ou par la validation du personnel +(comptoir/drive). La transition est atomique au sein de CREATE_ORDER et CREATE_COUNTER_ORDER. +Le statut `pending_payment` n'est pas observable en dehors de la transaction. -**Dropped from v0.1**: `preparing` and `ready` states; `MARK_IN_PREPARATION` and `MARK_READY` -operations. Kitchen staff have a read-only view of `paid` orders (LIST_ORDERS_DISPLAY). The -single delivery action (DELIVER_ORDER) collapses the v0.1 three-step sequence into one gesture. +**Supprime de v0.1** : etats `preparing` et `ready` ; operations `MARK_IN_PREPARATION` et `MARK_READY`. +Le personnel cuisine a une vue en lecture seule des commandes `paid` (LIST_ORDERS_DISPLAY). L'unique +action de livraison (DELIVER_ORDER) condense la sequence en trois etapes de v0.1 en un seul geste. --- -## 14. Operations summary table +## 14. Tableau recapitulatif des operations -| # | Operation | Domain | Actor | W Entities | R Entities | +| # | Operation | Domaine | Acteur | Entites W | Entites R | |---|-----------|--------|-------|------------|------------| | 1 | LOAD_CATALOGUE | Order kiosk | CUSTOMER | — | category, product, menu, menu_slot, menu_slot_option, ingredient, allergen, ingredient_allergen | | 2 | COMPOSE_CART | Order kiosk | CUSTOMER | — (volatile) | product, menu, menu_slot, menu_slot_option, ingredient, product_ingredient | @@ -617,22 +617,22 @@ single delivery action (DELIVER_ORDER) collapses the v0.1 three-step sequence in | 27 | ERASE_USER_PII | RBAC | ADMIN | user, audit_log | user | | 28 | RESET_PASSWORD | Auth | ALL BACK | user, audit_log | user | -**Total: 28 operations** (26 prod-like + `ERASE_USER_PII` and `RESET_PASSWORD` from the -security-by-design layer). +**Total : 28 operations** (26 prod-like + `ERASE_USER_PII` et `RESET_PASSWORD` de la +couche security-by-design). -**Audit log writes (security-by-design)**: the sensitive operations 7.1 (cancel), 8.2/8.3 -(product update/delete), 8.6 (menu delete), 10.1-10.5 (user/RBAC/erasure) and 12.1 (login) -also write an `audit_log` row (W entity not repeated per row above to keep the table legible). -Stock operations 9.1/9.2 record their attribution via `stock_movement.user_id`. PIN-gated set -per `mlt.md` RG-T13. +**Ecritures du journal d'audit (security-by-design)** : les operations sensibles 7.1 (annulation), 8.2/8.3 +(modification/suppression de produit), 8.6 (suppression de menu), 10.1-10.5 (utilisateur/RBAC/effacement) et 12.1 (connexion) +ecrivent egalement une ligne `audit_log` (entite W non repetee par ligne ci-dessus pour garder le tableau lisible). +Les operations de stock 9.1/9.2 enregistrent leur attribution via `stock_movement.user_id`. Ensemble protege par PIN +selon `mlt.md` RG-T13. --- -## 15. MCT -> MCD cross-validation (mantra #34) +## 15. Verification croisee MCT -> MCD (mantra #34) -Verification that each MCD entity participates in at least one MCT operation. +Verification que chaque entite MCD participe a au moins une operation MCT. -| MCD entity | Operations that read | Operations that write | Coverage | +| Entite MCD | Operations en lecture | Operations en ecriture | Couverture | |------------|---------------------|----------------------|----------| | `category` | 1, 9, 12, 15 | 15 | OK | | `product` | 1, 2, 3, 5, 9, 11, 12 | 9, 10, 11 | OK | @@ -653,20 +653,20 @@ Verification that each MCD entity participates in at least one MCT operation. | `permission` | 23 | — (static seed) | OK (*) | | `role_permission` | 25 | 23 | OK | | `stock_movement` | 19 | 3, 5, 8, 17, 18 | OK | -| `audit_log` | (admin audit view) | 8, 10, 11, 14, 20, 21, 22, 23, 25, 27, 28 | OK | +| `audit_log` | (vue d'audit admin) | 8, 10, 11, 14, 20, 21, 22, 23, 25, 27, 28 | OK | | `login_throttle` | 25 | 25 | OK | -(*) `allergen` and `permission` are read-only at the MCT level: their values are declared -in seed migrations and are not modifiable via the UI. `allergen` is managed indirectly -via `ingredient_allergen` in MANAGE_INGREDIENT. +(*) `allergen` et `permission` sont en lecture seule au niveau MCT : leurs valeurs sont declarees +dans les migrations de seed et ne sont pas modifiables via l'UI. `allergen` est gere indirectement +via `ingredient_allergen` dans MANAGE_INGREDIENT. -(**) `audit_log` (entity 20, security-by-design) is write-mostly: it is appended by the -sensitive operations above and read through an admin audit view (a dedicated read operation -can be formalised when the audit UI is specified at P3). +(**) `audit_log` (entite 20, security-by-design) est principalement en ecriture : il est ajoute par les +operations sensibles ci-dessus et lu via une vue d'audit admin (une operation de lecture dediee +peut etre formalisee lorsque l'UI d'audit sera specifiee en P3). -(***) `login_throttle` (entity 21, security-by-design) is the per-source-IP brute-force -throttle gate: it is read AND written (upserted) by `AUTHENTICATE_USER` (25). Its daily purge -of stale rows is a cron, documented in `mlt.md`, outside MCT operation scope. +(***) `login_throttle` (entite 21, security-by-design) est le verrou de throttling anti-force-brute par IP source : +il est lu ET ecrit (upserte) par `AUTHENTICATE_USER` (25). Sa purge quotidienne +des lignes obsoletes est un cron, documente dans `mlt.md`, hors du perimetre des operations MCT. -**Conclusion**: 21/21 entities covered (19 prod-like + `audit_log` + `login_throttle`). MCT <-> MCD consistency -validated. +**Conclusion** : 21/21 entites couvertes (19 prod-like + `audit_log` + `login_throttle`). Coherence MCT <-> MCD +validee. diff --git a/docs/merise/mld.md b/docs/merise/mld.md index c701f63..7f84821 100644 --- a/docs/merise/mld.md +++ b/docs/merise/mld.md @@ -1,39 +1,39 @@ -# Logical Data Model (MLD) — Wakdo +# Modele Logique de Donnees (MLD) — Wakdo -**Merise phase** : P1 - Conception, step 5 (after MCD, MCT, MLT) -**Version** : v0.2 — prod-like, 21 tables (19 prod-like + security-by-design layer) -**Date** : 2026-06-04 (security-by-design additions 2026-06-11) -**Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer (audit_log + accountability/auth columns) in progress -**Author** : BYAN (methodology layer) +**Phase Merise** : P1 - Conception, etape 5 (apres MCD, MCT, MLT) +**Version** : v0.2 — prod-like, 21 tables (19 prod-like + couche security-by-design) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design (audit_log + colonnes imputabilite/auth) en cours +**Auteur** : BYAN (couche methodologique) --- -## 1. Purpose of this document +## 1. Objectif de ce document -The MLD transcribes the MCD into a formal relational schema: 1 entity -> 1 table, each -association translated according to its cardinality, referential constraints materialised, -indexes sized for frequent access patterns. +Le MLD transcrit le MCD en un schema relationnel formel : 1 entite -> 1 table, chaque +association traduite selon sa cardinalite, contraintes referentielles materialisees, +index dimensionnes pour les patterns d'acces frequents. -This is the step that transforms conceptual modelling into an implementable specification. -The DDL SQL (`db/migrations/0001_init_schema.sql`) will be derived directly from this -document at P2. +C'est l'etape qui transforme la modelisation conceptuelle en une specification implementable. +Le DDL SQL (`db/migrations/0001_init_schema.sql`) sera derive directement de ce +document en P2. -**Sources**: -- `docs/merise/dictionary.md` (v0.2 — types and constraints per attribute, source of truth) -- `docs/merise/mcd.md` (v0.2 — entities + cardinalities + deferred decisions) -- `docs/notes/revue-alignement-p1.md` §7 (decision table D1-D8 + stock) +**Sources** : +- `docs/merise/dictionary.md` (v0.2 — types et contraintes par attribut, source de verite) +- `docs/merise/mcd.md` (v0.2 — entites + cardinalites + decisions reportees) +- `docs/notes/revue-alignement-p1.md` §7 (table de decisions D1-D8 + stock) -**Target platform**: +**Plateforme cible** : - MariaDB 11.4 LTS (cf. `docker-compose.yml` service `wakdo-db`) -- Engine InnoDB (ACID, FK support, row-level locking, CHECK from 10.2.1) +- Moteur InnoDB (ACID, support des FK, verrouillage au niveau ligne, CHECK depuis 10.2.1) - Charset `utf8mb4`, collation `utf8mb4_unicode_ci` --- -## 2. Notation conventions +## 2. Conventions de notation -### Relational notation +### Notation relationnelle ``` table_name (col1, col2, #col_fk, [col_nullable]) @@ -45,68 +45,68 @@ table_name (col1, col2, #col_fk, [col_nullable]) CHK : ``` -| Symbol | Meaning | +| Symbole | Signification | |---|---| -| `col` | NOT NULL column | -| `[col]` | Nullable column | -| `#col` | FK column | +| `col` | Colonne NOT NULL | +| `[col]` | Colonne nullable | +| `#col` | Colonne FK | -Notation follows Merise French usage (Nanci/Espinasse convention adapted for ASCII). +La notation suit l'usage Merise francais (convention Nanci/Espinasse adaptee a l'ASCII). -### Type summary +### Resume des types -All exact types are defined in `dictionary.md` section 2. Conventions retained: -- `INT UNSIGNED AUTO_INCREMENT` for all technical PKs -- `INT UNSIGNED` for all monetary amounts in cents (anti-FLOAT, see dictionary note 1) -- `SMALLINT UNSIGNED` for `vat_rate` per-mille values (55 or 100) -- `ENUM(...)` for stable business values (see dictionary note 2) -- `DATETIME` for timestamps (not TIMESTAMP, which implicitly converts to UTC in MariaDB) +Tous les types exacts sont definis dans `dictionary.md` section 2. Conventions retenues : +- `INT UNSIGNED AUTO_INCREMENT` pour toutes les PK techniques +- `INT UNSIGNED` pour tous les montants monetaires en centimes (anti-FLOAT, voir note 1 du dictionnaire) +- `SMALLINT UNSIGNED` pour les valeurs pour-mille de `vat_rate` (55 ou 100) +- `ENUM(...)` pour les valeurs metier stables (voir note 2 du dictionnaire) +- `DATETIME` pour les horodatages (pas TIMESTAMP, qui se convertit implicitement en UTC dans MariaDB) --- -## 3. MCD -> MLD translation rules applied +## 3. Regles de traduction MCD -> MLD appliquees -### 3.1 Entity -> Table +### 3.1 Entite -> Table -Each MCD entity becomes one table. The conceptual identifier `id` becomes PK -`INT UNSIGNED AUTO_INCREMENT`. Attributes retain their names and types. +Chaque entite MCD devient une table. L'identifiant conceptuel `id` devient une PK +`INT UNSIGNED AUTO_INCREMENT`. Les attributs conservent leurs noms et types. -### 3.2 `(1,1) - (1,N)` association -> simple FK +### 3.2 Association `(1,1) - (1,N)` -> FK simple -The entity on the `(1,1)` side carries the FK toward the `(0,N)` or `(1,N)` entity. +L'entite du cote `(1,1)` porte la FK vers l'entite `(0,N)` ou `(1,N)`. -### 3.3 `(0,N) - (0,N)` or `(1,N) - (1,N)` association -> join table +### 3.3 Association `(0,N) - (0,N)` ou `(1,N) - (1,N)` -> table de jointure -The association becomes its own table with a composite PK of the two FKs. Applied to: +L'association devient sa propre table avec une PK composite des deux FK. Applique a : `product_ingredient`, `menu_slot_option`, `ingredient_allergen`, `role_visible_source`, `role_permission`. -### 3.4 Associative entity with own attributes -> join table with columns +### 3.4 Entite associative avec attributs propres -> table de jointure avec colonnes -When an N-N association carries its own attributes, it becomes a table with those attributes -in addition to the composite FK PK. Applied to `product_ingredient`. +Quand une association N-N porte ses propres attributs, elle devient une table avec ces attributs +en plus de la PK composite des FK. Applique a `product_ingredient`. -### 3.5 Polymorphism -> 2 nullable FKs + discriminator + CHECK +### 3.5 Polymorphisme -> 2 FK nullables + discriminateur + CHECK -`order_item` references either `product` or `menu`. Translated as 2 nullable FK columns + -1 discriminator ENUM + 1 CHECK constraint enforcing mutual exclusivity. +`order_item` reference soit `product` soit `menu`. Traduit en 2 colonnes FK nullables + +1 discriminateur ENUM + 1 contrainte CHECK imposant l'exclusivite mutuelle. --- -## 4. Relational schema (21 tables) +## 4. Schema relationnel (21 tables) -Tables are ordered by dependency (no-FK tables first, then tables that depend on them). +Les tables sont ordonnees par dependance (tables sans FK d'abord, puis tables qui en dependent). -### Relational diagrams (by sub-domain) +### Diagrammes relationnels (par sous-domaine) -The relational schema is shown as four Mermaid `erDiagram` views, one per sub-domain (same -decomposition as the MCD; a single 21-table diagram would not lay out cleanly). These differ -from the MCD: associative entities are resolved into join tables with composite PKs, the -`order_item` polymorphism appears as two nullable FKs (`product_id` / `menu_id`), and every -foreign key is explicit. Audit timestamps (`created_at` / `updated_at`) are present on most -tables (see the per-table sections below) but omitted from the diagrams to keep them readable. -Relationship labels carry the FK column and its `ON DELETE` behaviour. Cross-sub-domain FK -targets are shown as stub tables (id + name). Portable SVG renders live in `_diagrams/` +Le schema relationnel est presente sous forme de quatre vues Mermaid `erDiagram`, une par sous-domaine (meme +decomposition que le MCD ; un unique diagramme de 21 tables ne se disposerait pas proprement). Elles different +du MCD : les entites associatives sont resolues en tables de jointure avec PK composites, le +polymorphisme de `order_item` apparait sous forme de deux FK nullables (`product_id` / `menu_id`), et chaque +cle etrangere est explicite. Les horodatages d'audit (`created_at` / `updated_at`) sont presents sur la plupart des +tables (voir les sections par table ci-dessous) mais omis des diagrammes pour les garder lisibles. +Les libelles de relation portent la colonne FK et son comportement `ON DELETE`. Les cibles de FK +inter-sous-domaines sont representees comme des tables stub (id + name). Les rendus SVG portables sont dans `_diagrams/` (`mld-catalogue.svg`, `mld-ingredients-stock.svg`, `mld-order.svg`, `mld-rbac.svg`). #### Catalogue @@ -160,7 +160,7 @@ erDiagram product ||--o{ menu_slot_option : "product_id (RESTRICT)" ``` -#### Ingredients & Stock +#### Ingredients et Stock ```mermaid erDiagram @@ -224,7 +224,7 @@ erDiagram user ||--o{ stock_movement : "user_id (SET NULL, nullable)" ``` -#### Order +#### Commande ```mermaid erDiagram @@ -301,7 +301,7 @@ erDiagram ingredient ||--o{ order_item_modifier : "ingredient_id (RESTRICT)" ``` -#### RBAC & security +#### RBAC & securite ```mermaid erDiagram @@ -367,7 +367,7 @@ erDiagram role ||--o{ audit_log : "actor_role_id (SET NULL, nullable)" ``` -> `login_throttle` has no FK (an IP is not a modelled entity); it stands alone, keyed by +> `login_throttle` n'a pas de FK (une IP n'est pas une entite modelisee) ; elle est autonome, cle par > `ip_address`. --- @@ -382,18 +382,18 @@ category (id, name, slug, [image_path], display_order, is_active, created_at, up UK : slug ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `name` | VARCHAR(60) | NO | Unique display name (see dict 3.1) | -| `slug` | VARCHAR(60) | NO | URL slug, e.g. `burgers` | -| `image_path` | VARCHAR(255) | YES | Relative path from public root | -| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Kiosk display order | -| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Soft deactivation | +| `name` | VARCHAR(60) | NO | Nom d'affichage unique (voir dict 3.1) | +| `slug` | VARCHAR(60) | NO | Slug d'URL, p. ex. `burgers` | +| `image_path` | VARCHAR(255) | YES | Chemin relatif depuis la racine publique | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage borne | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Desactivation logique | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -No FK. Root table for the Catalogue sub-domain. +Pas de FK. Table racine du sous-domaine Catalogue. --- @@ -410,22 +410,22 @@ product (id, #category_id, name, [description], price_cents, vat_rate, CHK : vat_rate IN (55, 100) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `category_id` | INT UNSIGNED | NO | FK -> category | -| `name` | VARCHAR(120) | NO | Product label | -| `description` | TEXT | YES | Optional long description | -| `price_cents` | INT UNSIGNED | NO | A la carte price, incl. VAT, in cents | -| `vat_rate` | SMALLINT UNSIGNED | NO | Per-mille: 100 = 10%, 55 = 5.5% | -| `image_path` | VARCHAR(255) | YES | Relative path from public root | -| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Manual availability toggle | -| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Within-category display order | +| `name` | VARCHAR(120) | NO | Libelle du produit | +| `description` | TEXT | YES | Description longue optionnelle | +| `price_cents` | INT UNSIGNED | NO | Prix a la carte, TVA incluse, en centimes | +| `vat_rate` | SMALLINT UNSIGNED | NO | Pour-mille : 100 = 10%, 55 = 5.5% | +| `image_path` | VARCHAR(255) | YES | Chemin relatif depuis la racine publique | +| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Bascule de disponibilite manuelle | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage au sein de la categorie | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -**ON DELETE RESTRICT** on `category_id`: a category with products cannot be deleted. Prevents -orphaned products. +**ON DELETE RESTRICT** sur `category_id` : une categorie avec des produits ne peut pas etre supprimee. Empeche les +produits orphelins. --- @@ -444,23 +444,23 @@ menu (id, #category_id, #burger_product_id, name, [description], CHK : price_maxi_cents > 0 ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `category_id` | INT UNSIGNED | NO | FK -> category (typically the `menus` category) | -| `burger_product_id` | INT UNSIGNED | NO | FK -> product — the fixed burger that anchors this menu | -| `name` | VARCHAR(120) | NO | e.g. "Menu Le 280" | -| `description` | TEXT | YES | Optional | -| `price_normal_cents` | INT UNSIGNED | NO | Normal format price in cents | -| `price_maxi_cents` | INT UNSIGNED | NO | Maxi format price in cents (~+150 cents) | -| `image_path` | VARCHAR(255) | YES | Typically reuses the burger image | -| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Availability toggle | -| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Display order | +| `category_id` | INT UNSIGNED | NO | FK -> category (typiquement la categorie `menus`) | +| `burger_product_id` | INT UNSIGNED | NO | FK -> product — le burger fixe qui ancre ce menu | +| `name` | VARCHAR(120) | NO | p. ex. "Menu Le 280" | +| `description` | TEXT | YES | Optionnel | +| `price_normal_cents` | INT UNSIGNED | NO | Prix du format Normal en centimes | +| `price_maxi_cents` | INT UNSIGNED | NO | Prix du format Maxi en centimes (~+150 centimes) | +| `image_path` | VARCHAR(255) | YES | Reutilise typiquement l'image du burger | +| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Bascule de disponibilite | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -**ON DELETE RESTRICT** on both FKs: prevents deletion of a category or burger product that -is still referenced by a menu definition. +**ON DELETE RESTRICT** sur les deux FK : empeche la suppression d'une categorie ou d'un produit burger +encore reference par une definition de menu. --- @@ -474,25 +474,25 @@ menu_slot (id, #menu_id, name, slot_type, is_required, display_order) IDX : (menu_id, display_order) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `menu_id` | INT UNSIGNED | NO | FK -> menu | -| `name` | VARCHAR(80) | NO | e.g. "Drink", "Side", "Sauce" | -| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | Semantic role | -| `is_required` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Whether the customer must fill this slot | -| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Display order within menu builder | +| `name` | VARCHAR(80) | NO | p. ex. "Drink", "Side", "Sauce" | +| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | Role semantique | +| `is_required` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Indique si le client doit remplir ce slot | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage dans le constructeur de menu | -**No audit fields**: a slot is part of menu definition; created and updated together with -the menu. +**Pas de champs d'audit** : un slot fait partie de la definition du menu ; cree et mis a jour en meme temps que +le menu. -**ON DELETE CASCADE** on `menu_id`: if a menu is deleted, its slots are deleted with it. +**ON DELETE CASCADE** sur `menu_id` : si un menu est supprime, ses slots sont supprimes avec lui. --- ### 4.5 `menu_slot_option` -Pure join table. Composite PK. +Table de jointure pure. PK composite. ``` menu_slot_option (#menu_slot_id, #product_id) @@ -502,16 +502,16 @@ menu_slot_option (#menu_slot_id, #product_id) FK : product_id -> product(id) ON DELETE RESTRICT ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot | | `product_id` | INT UNSIGNED | NO | FK -> product | -**ON DELETE CASCADE** on `menu_slot_id`: if a slot is deleted, its eligibility list goes with it. -**ON DELETE RESTRICT** on `product_id`: a product listed as eligible in a slot cannot be -deleted without first removing it from the slot options. Prevents silent breakage of menus. +**ON DELETE CASCADE** sur `menu_slot_id` : si un slot est supprime, sa liste d'eligibilite disparait avec lui. +**ON DELETE RESTRICT** sur `product_id` : un produit liste comme eligible dans un slot ne peut pas etre +supprime sans le retirer d'abord des options du slot. Empeche la rupture silencieuse des menus. -No timestamps. Pure join table. +Pas d'horodatages. Table de jointure pure. --- @@ -530,48 +530,48 @@ ingredient (id, name, unit, stock_quantity, stock_capacity, pack_size, [pack_lab CHK : critical_stock_pct < low_stock_pct ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `name` | VARCHAR(120) | NO | Unique name, e.g. "Sesame Bun" | -| `unit` | VARCHAR(40) | NO | Packaging unit label (free-form, not ENUM) | -| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Current stock. Signed INT that may go negative when sales outrun counted stock (oversell magnitude, surfaced to managers); the system does not block an order on stock | -| `stock_capacity` | INT NOT NULL | NO | Reference "full" level in units = the 100% used to compute the stock percentage; CHECK > 0 also guards the percentage division against divide-by-zero | -| `pack_size` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units per restocking pack | -| `pack_label` | VARCHAR(80) | YES | Human label of the pack | -| `low_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 10 | NO | Warning band, percent of capacity (CHECK BETWEEN 0 AND 100) | -| `critical_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 5 | NO | Auto-out-of-stock floor, percent of capacity (CHECK BETWEEN 0 AND 100; table CHECK `critical_stock_pct < low_stock_pct`) | -| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivate obsolete ingredients | +| `name` | VARCHAR(120) | NO | Nom unique, p. ex. "Sesame Bun" | +| `unit` | VARCHAR(40) | NO | Libelle d'unite de conditionnement (libre, pas ENUM) | +| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Stock courant. INT signe pouvant devenir negatif quand les ventes depassent le stock compte (ampleur de la survente, remontee aux managers) ; le systeme ne bloque pas une commande sur le stock | +| `stock_capacity` | INT NOT NULL | NO | Niveau "plein" de reference en unites = le 100% utilise pour calculer le pourcentage de stock ; CHECK > 0 protege aussi la division du pourcentage contre la division par zero | +| `pack_size` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Unites par lot de reapprovisionnement | +| `pack_label` | VARCHAR(80) | YES | Libelle humain du lot | +| `low_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 10 | NO | Bande d’alerte, pourcentage de la capacite (CHECK BETWEEN 0 AND 100) | +| `critical_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 5 | NO | Plancher de rupture automatique, pourcentage de la capacite (CHECK BETWEEN 0 AND 100 ; CHECK de table `critical_stock_pct < low_stock_pct`) | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Desactiver les ingredients obsoletes | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -No FK. Root table for the Ingredients & Stock sub-domain. +Pas de FK. Table racine du sous-domaine Ingredients & Stock. -**Percentage-based stock model**: the stock state is computed (NOT stored) as -`stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Two bands derive from it: -`LOW` when `stock_quantity <= stock_capacity * low_stock_pct/100`, and -`CRITICAL` when `stock_quantity <= stock_capacity * critical_stock_pct/100`. -Three-band behaviour: above `low` = normal; between `critical` and `low` = orderable -plus manager alert (the manager either pulls the product via `product.is_available=0`, or -restocks to clear the alert); at or below `critical` = auto out-of-stock (computed, rule -RG-T21). `stock_quantity` is signed and may go negative; the system does not block an order -on stock, so a negative value records the oversell magnitude for managers. +**Modele de stock base sur les pourcentages** : l'etat de stock est calcule (PAS stocke) comme +`stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Deux bandes en derivent : +`LOW` quand `stock_quantity <= stock_capacity * low_stock_pct/100`, et +`CRITICAL` quand `stock_quantity <= stock_capacity * critical_stock_pct/100`. +Comportement a trois bandes : au-dessus de `low` = normal ; entre `critical` et `low` = commandable +plus alerte manager (le manager soit retire le produit via `product.is_available=0`, soit +reapprovisionne pour lever l'alerte) ; au niveau ou en dessous de `critical` = rupture automatique (calculee, regle +RG-T21). `stock_quantity` est signe et peut devenir negatif ; le systeme ne bloque pas une commande +sur le stock, donc une valeur negative enregistre l'ampleur de la survente pour les managers. -**Computed availability (rule RG-T21)**: a product is effectively orderable when -`product.is_available = 1` AND each non-removable (`is_removable=0`) ingredient in its -`product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct/100`. At the -critical band a product auto-goes out-of-stock with no write and no cascade; a manual pull -(`product.is_available=0`) is a hard override; a restock above the critical band makes the -product orderable again on its own; a removable/optional ingredient at the critical band does -not block the product (only its add-on becomes unavailable). The dashboard distinguishes a -manual pull (`is_available=0`) from a stock-driven OOS (`is_available=1` but a required -ingredient is critical). +**Disponibilite calculee (regle RG-T21)** : un produit est effectivement commandable quand +`product.is_available = 1` ET chaque ingredient non-retirable (`is_removable=0`) de son +`product_ingredient` a `stock_quantity > stock_capacity * critical_stock_pct/100`. A la +bande critique, un produit passe automatiquement en rupture sans ecriture ni cascade ; un retrait manuel +(`product.is_available=0`) est une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend le +produit a nouveau commandable de lui-meme ; un ingredient retirable/optionnel a la bande critique ne +bloque pas le produit (seul son supplement devient indisponible). Le tableau de bord distingue un +retrait manuel (`is_available=0`) d'une rupture pilotee par le stock (`is_available=1` mais un ingredient +requis est critique). --- ### 4.7 `product_ingredient` -Associative table carrying recipe and customisation metadata. Composite PK. +Table associative portant les metadonnees de recette et de personnalisation. PK composite. ``` product_ingredient (#product_id, #ingredient_id, quantity_normal, quantity_maxi, @@ -585,21 +585,21 @@ product_ingredient (#product_id, #ingredient_id, quantity_normal, quantity_maxi, CHK : extra_price_cents >= 0 ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `product_id` | INT UNSIGNED | NO | FK -> product | | `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient | -| `quantity_normal` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units consumed in Normal format | -| `quantity_maxi` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units consumed in Maxi format; equals `quantity_normal` for burger/sauce (format-invariant), higher for side/drink | -| `is_removable` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Customer may remove at no cost | -| `is_addable` | TINYINT(1) NOT NULL DEFAULT 0 | NO | Customer may add an extra unit | -| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Surcharge if `is_addable=1` and customer adds it | +| `quantity_normal` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Unites consommees au format Normal | +| `quantity_maxi` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Unites consommees au format Maxi ; egal a `quantity_normal` pour burger/sauce (invariant au format), superieur pour side/drink | +| `is_removable` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Le client peut retirer sans frais | +| `is_addable` | TINYINT(1) NOT NULL DEFAULT 0 | NO | Le client peut ajouter une unite supplementaire | +| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Supplement si `is_addable=1` et que le client l'ajoute | -**ON DELETE CASCADE** on `product_id`: if a product is deleted, its recipe rows are deleted. -**ON DELETE RESTRICT** on `ingredient_id`: cannot delete an ingredient still referenced in a -recipe. Admin must remove the product-ingredient link first. +**ON DELETE CASCADE** sur `product_id` : si un produit est supprime, ses lignes de recette sont supprimees. +**ON DELETE RESTRICT** sur `ingredient_id` : impossible de supprimer un ingredient encore reference dans une +recette. L'administrateur doit d'abord retirer le lien produit-ingredient. -No timestamps. Join table with attributes. +Pas d'horodatages. Table de jointure avec attributs. --- @@ -612,21 +612,21 @@ allergen (id, code, name, [description]) UK : code ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `code` | VARCHAR(30) | NO | Machine code, e.g. `gluten`, `milk` | -| `name` | VARCHAR(80) | NO | Display name | -| `description` | TEXT | YES | Optional guidance | +| `code` | VARCHAR(30) | NO | Code machine, p. ex. `gluten`, `milk` | +| `name` | VARCHAR(80) | NO | Nom d'affichage | +| `description` | TEXT | YES | Indication optionnelle | -No FK. Reference table; 14 rows at seed (INCO Regulation (EU) 1169/2011). -No `updated_at`: allergen catalogue is considered stable (additions require a migration, not a UI action). +Pas de FK. Table de reference ; 14 lignes au seed (Reglement INCO (UE) 1169/2011). +Pas de `updated_at` : le catalogue d'allergenes est considere stable (les ajouts requierent une migration, pas une action UI). --- ### 4.9 `ingredient_allergen` -Pure join table. Composite PK. +Table de jointure pure. PK composite. ``` ingredient_allergen (#ingredient_id, #allergen_id) @@ -636,21 +636,21 @@ ingredient_allergen (#ingredient_id, #allergen_id) FK : allergen_id -> allergen(id) ON DELETE RESTRICT ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient | | `allergen_id` | INT UNSIGNED | NO | FK -> allergen | -**ON DELETE CASCADE** on `ingredient_id`: if an ingredient is deleted, its allergen links go with it. -**ON DELETE RESTRICT** on `allergen_id`: an allergen in the regulated catalogue cannot be deleted. +**ON DELETE CASCADE** sur `ingredient_id` : si un ingredient est supprime, ses liens d'allergenes disparaissent avec lui. +**ON DELETE RESTRICT** sur `allergen_id` : un allergene du catalogue reglemente ne peut pas etre supprime. -No timestamps. Pure join table. +Pas d'horodatages. Table de jointure pure. --- ### 4.10 `role` -Placed before `user` because `user` depends on `role`. +Placee avant `user` car `user` depend de `role`. ``` role (id, code, label, [description], [default_route], [order_source], @@ -660,19 +660,19 @@ role (id, code, label, [description], [default_route], [order_source], UK : code ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `code` | VARCHAR(40) | NO | Machine code: `admin`, `manager`, `kitchen`, `counter`, `drive` | -| `label` | VARCHAR(80) | NO | Display name | -| `description` | TEXT | YES | Optional | -| `default_route` | VARCHAR(120) | YES | Landing screen, e.g. `/admin/dashboard` | -| `order_source` | ENUM('kiosk','counter','drive') | YES | Auto-tagged source when this role creates an order; NULL for admin/manager | -| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivation preserves history | +| `code` | VARCHAR(40) | NO | Code machine : `admin`, `manager`, `kitchen`, `counter`, `drive` | +| `label` | VARCHAR(80) | NO | Nom d'affichage | +| `description` | TEXT | YES | Optionnel | +| `default_route` | VARCHAR(120) | YES | Ecran d'arrivee, p. ex. `/admin/dashboard` | +| `order_source` | ENUM('kiosk','counter','drive') | YES | Source auto-etiquetee quand ce role cree une commande ; NULL pour admin/manager | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | La desactivation preserve l'historique | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -No FK. Root table for RBAC. +Pas de FK. Table racine pour le RBAC. --- @@ -690,40 +690,40 @@ user (id, email, password_hash, [pin_hash], first_name, last_name, #role_id, IDX : (is_active, role_id) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `email` | VARCHAR(254) | NO | RFC 5321 max length. PII (RGPD anonymisation, see below) | -| `password_hash` | VARCHAR(255) | NO | argon2id hash | -| `pin_hash` | VARCHAR(255) | YES | argon2id hash of the per-staff PIN (sensitive-action authorisation). Security-by-design | +| `email` | VARCHAR(254) | NO | Longueur max RFC 5321. PII (anonymisation RGPD, voir ci-dessous) | +| `password_hash` | VARCHAR(255) | NO | hash argon2id | +| `pin_hash` | VARCHAR(255) | YES | hash argon2id du PIN par membre du personnel (autorisation d'action sensible). Security-by-design | | `first_name` | VARCHAR(60) | NO | PII | | `last_name` | VARCHAR(60) | NO | PII | | `role_id` | INT UNSIGNED | NO | FK -> role | -| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivation without deletion | -| `last_login_at` | DATETIME | YES | Audit, dormant account detection | -| `failed_login_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Brute-force counter (degressive throttling) | -| `last_failed_login_at` | DATETIME | YES | Timestamp of last failed login | -| `lockout_until` | DATETIME | YES | End of current throttling window (backoff, not indefinite lock) | -| `password_reset_token_hash` | VARCHAR(255) | YES | Hash of the reset token (not the raw token) | -| `password_reset_expires_at` | DATETIME | YES | Reset token expiry | -| `anonymized_at` | DATETIME | YES | RGPD tombstone marker; PII nulled/replaced when set | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Desactivation sans suppression | +| `last_login_at` | DATETIME | YES | Audit, detection de compte dormant | +| `failed_login_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Compteur de force brute (throttling degressif) | +| `last_failed_login_at` | DATETIME | YES | Horodatage de la derniere connexion echouee | +| `lockout_until` | DATETIME | YES | Fin de la fenetre de throttling courante (backoff, pas un verrou indefini) | +| `password_reset_token_hash` | VARCHAR(255) | YES | Hash du token de reinitialisation (pas le token brut) | +| `password_reset_expires_at` | DATETIME | YES | Expiration du token de reinitialisation | +| `anonymized_at` | DATETIME | YES | Marqueur tombstone RGPD ; PII annulees/remplacees quand defini | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -**ON DELETE RESTRICT** on `role_id`: a role cannot be deleted while users hold it. -Deactivate the role first (`is_active = 0`), then reassign users before deleting. +**ON DELETE RESTRICT** sur `role_id` : un role ne peut pas etre supprime tant que des utilisateurs le detiennent. +Desactivez d'abord le role (`is_active = 0`), puis reaffectez les utilisateurs avant suppression. -**RGPD anonymisation** (security-by-design, dict. note 13): the right to erasure is honoured by -anonymising, not hard-deleting. `email` becomes a unique non-identifying placeholder -(`anon-@wakdo.invalid`, RFC 2606 reserved domain — preserves the UNIQUE constraint), -`first_name`/`last_name` are cleared, `password_hash`/`pin_hash` are invalidated, `is_active=0`, -`anonymized_at = NOW()`. The row persists so `audit_log` and `stock_movement` FKs stay valid. +**Anonymisation RGPD** (security-by-design, note 13 du dict.) : le droit a l'effacement est honore en +anonymisant, pas en supprimant physiquement. `email` devient un placeholder unique non identifiant +(`anon-@wakdo.invalid`, domaine reserve RFC 2606 — preserve la contrainte UNIQUE), +`first_name`/`last_name` sont effaces, `password_hash`/`pin_hash` sont invalides, `is_active=0`, +`anonymized_at = NOW()`. La ligne persiste pour que les FK `audit_log` et `stock_movement` restent valides. --- ### 4.12 `role_visible_source` -Pure join table. Composite PK. +Table de jointure pure. PK composite. ``` role_visible_source (#role_id, source) @@ -732,20 +732,20 @@ role_visible_source (#role_id, source) FK : role_id -> role(id) ON DELETE CASCADE ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `role_id` | INT UNSIGNED | NO | FK -> role | -| `source` | ENUM('kiosk','counter','drive') | NO | Order source visible on dashboard | +| `source` | ENUM('kiosk','counter','drive') | NO | Source de commande visible sur le tableau de bord | -**ON DELETE CASCADE** on `role_id`: if a role is deleted, its dashboard source filters go with it. +**ON DELETE CASCADE** sur `role_id` : si un role est supprime, ses filtres de source du tableau de bord disparaissent avec lui. -No timestamps. Pure join table. +Pas d'horodatages. Table de jointure pure. -Seed data: -- `kitchen`: kiosk, counter, drive -- `counter`: kiosk, counter -- `drive`: drive -- `admin`, `manager`: no rows (global view, no source filter) +Donnees de seed : +- `kitchen` : kiosk, counter, drive +- `counter` : kiosk, counter +- `drive` : drive +- `admin`, `manager` : pas de lignes (vue globale, pas de filtre de source) --- @@ -758,22 +758,22 @@ permission (id, code, label, [description], created_at) UK : code ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `code` | VARCHAR(60) | NO | Format `.` | -| `label` | VARCHAR(120) | NO | Display name | -| `description` | TEXT | YES | Optional | +| `label` | VARCHAR(120) | NO | Nom d'affichage | +| `description` | TEXT | YES | Optionnel | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | -No `updated_at`: permissions are declared in migration and not modified via UI. -Catalogue is frozen at 23 codes (see dictionary section 3.17). +Pas de `updated_at` : les permissions sont declarees en migration et non modifiees via l'UI. +Le catalogue est fige a 23 codes (voir dictionnaire section 3.17). --- ### 4.14 `role_permission` -Pure join table. Composite PK. +Table de jointure pure. PK composite. ``` role_permission (#role_id, #permission_id) @@ -784,16 +784,16 @@ role_permission (#role_id, #permission_id) IDX : permission_id ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `role_id` | INT UNSIGNED | NO | FK -> role | | `permission_id` | INT UNSIGNED | NO | FK -> permission | -**ON DELETE CASCADE** on both FKs: deleting a role or a permission removes its mappings. -The secondary index on `permission_id` supports the reverse query "which roles have this -permission?" without scanning the full table. +**ON DELETE CASCADE** sur les deux FK : supprimer un role ou une permission retire ses associations. +L'index secondaire sur `permission_id` supporte la requete inverse "quels roles ont cette +permission ?" sans scanner la table entiere. -No timestamps. Pure join table. +Pas d'horodatages. Table de jointure pure. --- @@ -820,49 +820,49 @@ customer_order (id, order_number, [idempotency_key], source, [#acting_user_id], CHK : source != 'drive' OR service_mode = 'drive' ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `order_number` | VARCHAR(20) | NO | Format `K/C/D-YYYY-MM-DD-NNN` by channel | -| `idempotency_key` | VARCHAR(36) | YES | Client UUID, UNIQUE; deduplicates retried POST (security-by-design) | -| `source` | ENUM('kiosk','counter','drive') | NO | Input channel | -| `acting_user_id` | INT UNSIGNED | YES | FK -> user; counter/drive staff under PIN; NULL for kiosk | -| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Consumption mode (stats only, no fiscal role) | -| `status` | ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment' | NO | 4-state machine | -| `total_ht_cents` | INT UNSIGNED | NO | Ex-VAT total snapshot | -| `total_vat_cents` | INT UNSIGNED | NO | VAT amount snapshot | -| `total_ttc_cents` | INT UNSIGNED | NO | Incl.-VAT total; must equal HT + VAT | -| `paid_at` | DATETIME | YES | Timestamp of transition to `paid` | -| `delivered_at` | DATETIME | YES | Timestamp of transition to `delivered` | -| `cancelled_at` | DATETIME | YES | Timestamp of cancellation | -| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Used as `service_day` base | +| `order_number` | VARCHAR(20) | NO | Format `K/C/D-YYYY-MM-DD-NNN` par canal | +| `idempotency_key` | VARCHAR(36) | YES | UUID client, UNIQUE ; deduplique un POST reessaye (security-by-design) | +| `source` | ENUM('kiosk','counter','drive') | NO | Canal de saisie | +| `acting_user_id` | INT UNSIGNED | YES | FK -> user ; personnel counter/drive sous PIN ; NULL pour kiosk | +| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Mode de consommation (stats uniquement, pas de role fiscal) | +| `status` | ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment' | NO | Machine a 4 etats | +| `total_ht_cents` | INT UNSIGNED | NO | Snapshot du total HT | +| `total_vat_cents` | INT UNSIGNED | NO | Snapshot du montant de TVA | +| `total_ttc_cents` | INT UNSIGNED | NO | Total TTC ; doit egaler HT + TVA | +| `paid_at` | DATETIME | YES | Horodatage de la transition vers `paid` | +| `delivered_at` | DATETIME | YES | Horodatage de la transition vers `delivered` | +| `cancelled_at` | DATETIME | YES | Horodatage de l'annulation | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Utilise comme base de `service_day` | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -**Staff attribution (security-by-design)**: `acting_user_id` (FK -> `user`, ON DELETE SET NULL) -records the counter/drive staff who took the order under PIN; NULL for anonymous kiosk orders. -Kiosk orders stay anonymous by design. `stock_movement.user_id` covers attribution of stock -actions. `idempotency_key` (UNIQUE, nullable) deduplicates a retried `POST /api/orders` -(multiple NULLs allowed by the UNIQUE index, so non-idempotent legacy paths are tolerated). +**Attribution du personnel (security-by-design)** : `acting_user_id` (FK -> `user`, ON DELETE SET NULL) +enregistre le personnel counter/drive qui a pris la commande sous PIN ; NULL pour les commandes kiosk anonymes. +Les commandes kiosk restent anonymes par conception. `stock_movement.user_id` couvre l'attribution des actions +de stock. `idempotency_key` (UNIQUE, nullable) deduplique un `POST /api/orders` reessaye +(plusieurs NULL autorises par l'index UNIQUE, donc les chemins legacy non idempotents sont toleres). -**4-state machine**: `pending_payment -> paid -> delivered` (+ `cancelled`). States `preparing` -and `ready` are dropped (decision D4). KPI: `delivered_at - paid_at` (target SLA ~10 min). +**Machine a 4 etats** : `pending_payment -> paid -> delivered` (+ `cancelled`). Les etats `preparing` +et `ready` sont abandonnes (decision D4). KPI : `delivered_at - paid_at` (SLA cible ~10 min). -**`service_day` computation** (used in stats queries — NOT a stored column): +**Calcul de `service_day`** (utilise dans les requetes de stats — PAS une colonne stockee) : ```sql CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END ``` -Cutoff: 10:00. The generated-column formula with `INTERVAL 4 HOUR 30 MINUTE` from v0.1 was -incorrect and is dropped (decision D6). +Coupure : 10:00. La formule de colonne generee avec `INTERVAL 4 HOUR 30 MINUTE` de la v0.1 etait +incorrecte et est abandonnee (decision D6). -**VAT calculation**: totals on `customer_order` are the sum of line-level calculations. -Line-level VAT: `unit_price_cents_snapshot * quantity` is the TTC amount per line; -HT = `ROUND(ttc_cents * 100 / (100 + vat_rate_per_cent))` where `vat_rate_per_cent` -is `vat_rate_snapshot / 10`. Computed at application layer at cart validation. +**Calcul de TVA** : les totaux sur `customer_order` sont la somme des calculs au niveau ligne. +TVA au niveau ligne : `unit_price_cents_snapshot * quantity` est le montant TTC par ligne ; +HT = `ROUND(ttc_cents * 100 / (100 + vat_rate_per_cent))` ou `vat_rate_per_cent` +vaut `vat_rate_snapshot / 10`. Calcule au niveau applicatif a la validation du panier. -**`source = 'drive' => service_mode = 'drive'`**: the CHECK enforces this at DB level. +**`source = 'drive' => service_mode = 'drive'`** : le CHECK l'impose au niveau de la BD. --- @@ -885,32 +885,32 @@ order_item (id, #order_id, item_type, [#product_id], [#menu_id], format, OR (item_type = 'menu' AND menu_id IS NOT NULL AND product_id IS NULL) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `order_id` | INT UNSIGNED | NO | FK -> customer_order | -| `item_type` | ENUM('product','menu') | NO | Discriminator | -| `product_id` | INT UNSIGNED | YES | Non-null if `item_type = 'product'`, NULL otherwise | -| `menu_id` | INT UNSIGNED | YES | Non-null if `item_type = 'menu'`, NULL otherwise | -| `format` | ENUM('normal','maxi') NOT NULL DEFAULT 'normal' | NO | Menu format. For standalone products, value is `normal` | -| `label_snapshot` | VARCHAR(120) | NO | Label at time of order | -| `unit_price_cents_snapshot` | INT UNSIGNED | NO | Unit price incl. VAT at time of order | -| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | VAT rate per-mille at time of order | -| `quantity` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Quantity (e.g. 3 drinks = 1 line, quantity=3) | +| `item_type` | ENUM('product','menu') | NO | Discriminateur | +| `product_id` | INT UNSIGNED | YES | Non-null si `item_type = 'product'`, NULL sinon | +| `menu_id` | INT UNSIGNED | YES | Non-null si `item_type = 'menu'`, NULL sinon | +| `format` | ENUM('normal','maxi') NOT NULL DEFAULT 'normal' | NO | Format du menu. Pour les produits autonomes, la valeur est `normal` | +| `label_snapshot` | VARCHAR(120) | NO | Libelle au moment de la commande | +| `unit_price_cents_snapshot` | INT UNSIGNED | NO | Prix unitaire TVA incluse au moment de la commande | +| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | Taux de TVA pour-mille au moment de la commande | +| `quantity` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Quantite (p. ex. 3 boissons = 1 ligne, quantity=3) | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | -**ON DELETE CASCADE** on `order_id`: lines are deleted with the order. -**ON DELETE RESTRICT** on `product_id` and `menu_id`: a product or menu referenced in an -historical order line cannot be deleted. The snapshot makes the FK reference non-critical -for display, but RESTRICT avoids silent orphaning of the relational structure. +**ON DELETE CASCADE** sur `order_id` : les lignes sont supprimees avec la commande. +**ON DELETE RESTRICT** sur `product_id` et `menu_id` : un produit ou menu reference dans une +ligne de commande historique ne peut pas etre supprime. Le snapshot rend la reference FK non critique +pour l'affichage, mais RESTRICT evite l'orphelinage silencieux de la structure relationnelle. -**Polymorphism exclusivity CHECK**: MariaDB 10.2+ enforces this at INSERT/UPDATE time. +**CHECK d'exclusivite du polymorphisme** : MariaDB 10.2+ l'impose au moment de l'INSERT/UPDATE. --- ### 4.17 `order_item_selection` -Customer's choice for one slot of a menu order line. +Choix du client pour un slot d'une ligne de commande de menu. ``` order_item_selection (id, #order_item_id, #menu_slot_id, #product_id, label_snapshot) @@ -922,27 +922,27 @@ order_item_selection (id, #order_item_id, #menu_slot_id, #product_id, label_snap IDX : order_item_id ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `order_item_id` | INT UNSIGNED | NO | FK -> order_item (must be a menu-type line) | -| `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot (which slot was filled) | -| `product_id` | INT UNSIGNED | NO | FK -> product (chosen by customer) | -| `label_snapshot` | VARCHAR(120) | NO | Product label at time of order | +| `order_item_id` | INT UNSIGNED | NO | FK -> order_item (doit etre une ligne de type menu) | +| `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot (quel slot a ete rempli) | +| `product_id` | INT UNSIGNED | NO | FK -> product (choisi par le client) | +| `label_snapshot` | VARCHAR(120) | NO | Libelle du produit au moment de la commande | -**ON DELETE CASCADE** on `order_item_id`: if the parent order line is deleted, its slot -selections go with it. -**ON DELETE RESTRICT** on `menu_slot_id` and `product_id`: historical slot choice records -must not be silently broken by catalogue changes. +**ON DELETE CASCADE** sur `order_item_id` : si la ligne de commande parente est supprimee, ses +selections de slot disparaissent avec elle. +**ON DELETE RESTRICT** sur `menu_slot_id` et `product_id` : les enregistrements historiques de choix de slot +ne doivent pas etre silencieusement rompus par des changements de catalogue. -Note: the business constraint that `order_item_id` references a line with `item_type='menu'` -is enforced at application layer (not in MariaDB without a trigger or deferred constraint). +Note : la contrainte metier voulant que `order_item_id` reference une ligne avec `item_type='menu'` +est imposee au niveau applicatif (pas dans MariaDB sans trigger ou contrainte differee). --- ### 4.18 `order_item_modifier` -Ingredient-level modification applied by the customer to a product or the fixed burger of a menu. +Modification au niveau ingredient appliquee par le client a un produit ou au burger fixe d'un menu. ``` order_item_modifier (id, #order_item_id, #ingredient_id, action, extra_price_cents) @@ -954,27 +954,27 @@ order_item_modifier (id, #order_item_id, #ingredient_id, action, extra_price_cen CHK : extra_price_cents >= 0 ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `order_item_id` | INT UNSIGNED | NO | FK -> order_item | | `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient | -| `action` | ENUM('remove','add') | NO | `remove` = free removal; `add` = extra unit | -| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Snapshot of surcharge at time of order (0 for removals) | +| `action` | ENUM('remove','add') | NO | `remove` = retrait gratuit ; `add` = unite supplementaire | +| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Snapshot du supplement au moment de la commande (0 pour les retraits) | -**ON DELETE CASCADE** on `order_item_id`: if the order line is deleted, its modifiers go with it. -**ON DELETE RESTRICT** on `ingredient_id`: an ingredient referenced in a historical modifier -cannot be deleted. +**ON DELETE CASCADE** sur `order_item_id` : si la ligne de commande est supprimee, ses modificateurs disparaissent avec elle. +**ON DELETE RESTRICT** sur `ingredient_id` : un ingredient reference dans un modificateur historique +ne peut pas etre supprime. -**Modifier attachment for menu lines**: the modifiable product is the fixed burger, resolved -via `order_item.menu_id -> menu.burger_product_id`. No additional FK column is needed on -this table (see dictionary note 10). +**Rattachement du modificateur pour les lignes de menu** : le produit modifiable est le burger fixe, resolu +via `order_item.menu_id -> menu.burger_product_id`. Aucune colonne FK supplementaire n'est necessaire sur +cette table (voir note 10 du dictionnaire). --- ### 4.19 `stock_movement` -Append-only audit log of all stock changes per ingredient. +Journal d'audit append-only de tous les changements de stock par ingredient. ``` stock_movement (id, #ingredient_id, movement_type, delta, @@ -988,34 +988,34 @@ stock_movement (id, #ingredient_id, movement_type, delta, IDX : (movement_type, created_at) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient | -| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | Nature of movement | -| `delta` | INT | NO | Signed change: negative for consumption, positive for restock/cancellation/correction | -| `order_id` | INT UNSIGNED | YES | FK -> customer_order; non-null for `sale` and `cancellation` | -| `user_id` | INT UNSIGNED | YES | FK -> user; null for automated sale decrements | -| `note` | VARCHAR(255) | YES | Optional human note | -| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Immutable timestamp | +| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | Nature du mouvement | +| `delta` | INT | NO | Changement signe : negatif pour consommation, positif pour reapprovisionnement/annulation/correction | +| `order_id` | INT UNSIGNED | YES | FK -> customer_order ; non-null pour `sale` et `cancellation` | +| `user_id` | INT UNSIGNED | YES | FK -> user ; null pour les decrements de vente automatises | +| `note` | VARCHAR(255) | YES | Note humaine optionnelle | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage immuable | -**ON DELETE RESTRICT** on `ingredient_id`: an ingredient with a movement history cannot be -deleted. Admin must archive the ingredient (`is_active = 0`) instead. -**ON DELETE SET NULL** on `order_id`: if an order is purged from the system, its movement -records remain with `order_id = NULL`. The audit log is preserved; only the order link is lost. -**ON DELETE SET NULL** on `user_id`: if a user is deleted, movement records remain with -`user_id = NULL`. Audit is preserved; individual attribution is lost. +**ON DELETE RESTRICT** sur `ingredient_id` : un ingredient avec un historique de mouvements ne peut pas etre +supprime. L'administrateur doit plutot archiver l'ingredient (`is_active = 0`). +**ON DELETE SET NULL** sur `order_id` : si une commande est purgee du systeme, ses enregistrements de +mouvement restent avec `order_id = NULL`. Le journal d'audit est preserve ; seul le lien de commande est perdu. +**ON DELETE SET NULL** sur `user_id` : si un utilisateur est supprime, les enregistrements de mouvement restent avec +`user_id = NULL`. L'audit est preserve ; l'attribution individuelle est perdue. -**Immutability rule**: no UPDATE or DELETE at application layer. Corrections are new rows -with `movement_type = 'inventory_correction'` and a signed `delta`. +**Regle d'immuabilite** : aucun UPDATE ni DELETE au niveau applicatif. Les corrections sont de nouvelles lignes +avec `movement_type = 'inventory_correction'` et un `delta` signe. -No `updated_at`. Immutable append-only table. +Pas de `updated_at`. Table immuable append-only. --- ### 4.20 `audit_log` -Append-only log of sensitive back-office actions (security-by-design, dict. 3.20). +Journal append-only des actions back-office sensibles (security-by-design, dict. 3.20). ``` audit_log (id, [#actor_user_id], [#actor_role_id], action_code, @@ -1029,34 +1029,34 @@ audit_log (id, [#actor_user_id], [#actor_role_id], action_code, IDX : (action_code, created_at) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `actor_user_id` | INT UNSIGNED | YES | FK -> user; acting staff (PIN-captured) or NULL if not attributable | -| `actor_role_id` | INT UNSIGNED | YES | FK -> role; denormalised role context (survives user anonymisation) | -| `action_code` | VARCHAR(60) | NO | MCT operation / permission code, e.g. `product.update`, `order.cancel` | -| `entity_type` | VARCHAR(40) | YES | Affected table name | -| `entity_id` | INT UNSIGNED | YES | PK of the affected row | -| `summary` | VARCHAR(255) | YES | Short non-personal change description | -| `details` | JSON | YES | Optional before/after diff (field names for user-targeted actions, not PII values) | -| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Immutable timestamp | +| `actor_user_id` | INT UNSIGNED | YES | FK -> user ; personnel agissant (capture par PIN) ou NULL si non attribuable | +| `actor_role_id` | INT UNSIGNED | YES | FK -> role ; contexte de role denormalise (survit a l'anonymisation de l'utilisateur) | +| `action_code` | VARCHAR(60) | NO | Code d'operation MCT / permission, p. ex. `product.update`, `order.cancel` | +| `entity_type` | VARCHAR(40) | YES | Nom de la table affectee | +| `entity_id` | INT UNSIGNED | YES | PK de la ligne affectee | +| `summary` | VARCHAR(255) | YES | Description courte non personnelle du changement | +| `details` | JSON | YES | Diff avant/apres optionnel (noms de champs pour les actions ciblant un utilisateur, pas les valeurs PII) | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage immuable | -**ON DELETE SET NULL** on both FKs: the trail is preserved when a user is anonymised/removed -or a role deleted; only the link is severed (the denormalised `actor_role_id` keeps the role -context even after user anonymisation). +**ON DELETE SET NULL** sur les deux FK : la piste est preservee quand un utilisateur est anonymise/supprime +ou un role supprime ; seul le lien est rompu (le `actor_role_id` denormalise conserve le contexte de +role meme apres l'anonymisation de l'utilisateur). -**Immutability rule**: no UPDATE or DELETE at application layer. **Retention**: a scheduled -cron purge removes rows older than the retention window (~12 months, legitimate-interest / -fiscal traceability), decoupled from the user PII lifecycle (dict. note 13). +**Regle d'immuabilite** : aucun UPDATE ni DELETE au niveau applicatif. **Retention** : une purge cron +planifiee retire les lignes plus anciennes que la fenetre de retention (~12 mois, interet legitime / +tracabilite fiscale), decouplee du cycle de vie des PII de l'utilisateur (note 13 du dict.). -No `updated_at`. Immutable append-only table. +Pas de `updated_at`. Table immuable append-only. --- ### 4.21 `login_throttle` -Per-source-IP brute-force throttle (security-by-design). Complements the per-account counter -already on `user` (`failed_login_attempts` / `lockout_until`). +Throttle de force brute par IP source (security-by-design). Complete le compteur par compte +deja present sur `user` (`failed_login_attempts` / `lockout_until`). ``` login_throttle (id, ip_address, failed_attempts, window_started_at, @@ -1067,254 +1067,254 @@ login_throttle (id, ip_address, failed_attempts, window_started_at, IDX : lockout_until ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `ip_address` | VARCHAR(45) | NO | Source IP, one row per IP, upserted; 45 chars holds a full IPv6 literal. UNIQUE | -| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Consecutive failed logins from this IP in the current window | -| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Start of the current counting window | -| `lockout_until` | DATETIME | YES | End of the degressive backoff window; NULL = not throttled | -| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Timestamp of the last failed attempt | +| `ip_address` | VARCHAR(45) | NO | IP source, une ligne par IP, upsertee ; 45 caracteres contiennent un litteral IPv6 complet. UNIQUE | +| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Connexions echouees consecutives depuis cette IP dans la fenetre courante | +| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Debut de la fenetre de comptage courante | +| `lockout_until` | DATETIME | YES | Fin de la fenetre de backoff degressif ; NULL = pas throttle | +| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage de la derniere tentative echouee | -No FK: an IP is not a modelled entity. Append/upsert by IP; the window resets when expired. A -daily cron purges rows with no active lockout whose `last_attempt_at` is older than 24h. +Pas de FK : une IP n'est pas une entite modelisee. Append/upsert par IP ; la fenetre se reinitialise a expiration. Un +cron quotidien purge les lignes sans verrouillage actif dont le `last_attempt_at` est plus ancien que 24h. -No `updated_at`: rows are upserted by IP, not edited through a UI. +Pas de `updated_at` : les lignes sont upsertees par IP, pas editees via une UI. --- -## 5. Referential integrity summary +## 5. Resume de l'integrite referentielle -| FK column | References | ON DELETE | Rationale | +| Colonne FK | References | ON DELETE | Justification | |---|---|---|---| -| `product.category_id` | `category(id)` | RESTRICT | No orphaned product | -| `menu.category_id` | `category(id)` | RESTRICT | Same | -| `menu.burger_product_id` | `product(id)` | RESTRICT | Menu definition requires its anchor burger | -| `menu_slot.menu_id` | `menu(id)` | CASCADE | Slots have no meaning without their menu | -| `menu_slot_option.menu_slot_id` | `menu_slot(id)` | CASCADE | Eligibility list disappears with the slot | -| `menu_slot_option.product_id` | `product(id)` | RESTRICT | Removing a product must not silently break menus | -| `product_ingredient.product_id` | `product(id)` | CASCADE | Recipe disappears with the product | -| `product_ingredient.ingredient_id` | `ingredient(id)` | RESTRICT | Cannot remove ingredient still in a recipe | -| `ingredient_allergen.ingredient_id` | `ingredient(id)` | CASCADE | Allergen links disappear with the ingredient | -| `ingredient_allergen.allergen_id` | `allergen(id)` | RESTRICT | Regulated allergen catalogue is immutable | -| `user.role_id` | `role(id)` | RESTRICT | A user cannot exist without a role | -| `role_visible_source.role_id` | `role(id)` | CASCADE | Dashboard filters disappear with the role | -| `role_permission.role_id` | `role(id)` | CASCADE | Permission mappings disappear with the role | -| `role_permission.permission_id` | `permission(id)` | CASCADE | Permission mappings disappear with the permission | -| `order_item.order_id` | `customer_order(id)` | CASCADE | Lines disappear with the order | -| `order_item.product_id` | `product(id)` | RESTRICT | Historical reference must not be silently orphaned | -| `order_item.menu_id` | `menu(id)` | RESTRICT | Same | -| `order_item_selection.order_item_id` | `order_item(id)` | CASCADE | Slot choices disappear with the line | -| `order_item_selection.menu_slot_id` | `menu_slot(id)` | RESTRICT | Historical slot record preserved | -| `order_item_selection.product_id` | `product(id)` | RESTRICT | Historical choice record preserved | -| `order_item_modifier.order_item_id` | `order_item(id)` | CASCADE | Modifiers disappear with the line | -| `order_item_modifier.ingredient_id` | `ingredient(id)` | RESTRICT | Historical modifier record preserved | -| `stock_movement.ingredient_id` | `ingredient(id)` | RESTRICT | Ingredient with history cannot be deleted | -| `stock_movement.order_id` | `customer_order(id)` | SET NULL | Audit preserved, order link lost | -| `stock_movement.user_id` | `user(id)` | SET NULL | Audit preserved, user attribution lost | -| `customer_order.acting_user_id` | `user(id)` | SET NULL | Staff attribution preserved as anonymised principal; order kept | -| `audit_log.actor_user_id` | `user(id)` | SET NULL | Audit trail preserved on user anonymisation; only the link is severed | -| `audit_log.actor_role_id` | `role(id)` | SET NULL | Role context kept until role deletion; denormalised so it survives user anonymisation | +| `product.category_id` | `category(id)` | RESTRICT | Pas de produit orphelin | +| `menu.category_id` | `category(id)` | RESTRICT | Idem | +| `menu.burger_product_id` | `product(id)` | RESTRICT | La definition du menu requiert son burger d'ancrage | +| `menu_slot.menu_id` | `menu(id)` | CASCADE | Les slots n'ont pas de sens sans leur menu | +| `menu_slot_option.menu_slot_id` | `menu_slot(id)` | CASCADE | La liste d'eligibilite disparait avec le slot | +| `menu_slot_option.product_id` | `product(id)` | RESTRICT | Retirer un produit ne doit pas rompre silencieusement les menus | +| `product_ingredient.product_id` | `product(id)` | CASCADE | La recette disparait avec le produit | +| `product_ingredient.ingredient_id` | `ingredient(id)` | RESTRICT | Impossible de retirer un ingredient encore dans une recette | +| `ingredient_allergen.ingredient_id` | `ingredient(id)` | CASCADE | Les liens d'allergenes disparaissent avec l'ingredient | +| `ingredient_allergen.allergen_id` | `allergen(id)` | RESTRICT | Le catalogue d'allergenes reglemente est immuable | +| `user.role_id` | `role(id)` | RESTRICT | Un utilisateur ne peut pas exister sans role | +| `role_visible_source.role_id` | `role(id)` | CASCADE | Les filtres du tableau de bord disparaissent avec le role | +| `role_permission.role_id` | `role(id)` | CASCADE | Les associations de permission disparaissent avec le role | +| `role_permission.permission_id` | `permission(id)` | CASCADE | Les associations de permission disparaissent avec la permission | +| `order_item.order_id` | `customer_order(id)` | CASCADE | Les lignes disparaissent avec la commande | +| `order_item.product_id` | `product(id)` | RESTRICT | La reference historique ne doit pas etre silencieusement orphelinee | +| `order_item.menu_id` | `menu(id)` | RESTRICT | Idem | +| `order_item_selection.order_item_id` | `order_item(id)` | CASCADE | Les choix de slot disparaissent avec la ligne | +| `order_item_selection.menu_slot_id` | `menu_slot(id)` | RESTRICT | Enregistrement historique de slot preserve | +| `order_item_selection.product_id` | `product(id)` | RESTRICT | Enregistrement historique de choix preserve | +| `order_item_modifier.order_item_id` | `order_item(id)` | CASCADE | Les modificateurs disparaissent avec la ligne | +| `order_item_modifier.ingredient_id` | `ingredient(id)` | RESTRICT | Enregistrement historique de modificateur preserve | +| `stock_movement.ingredient_id` | `ingredient(id)` | RESTRICT | Un ingredient avec historique ne peut pas etre supprime | +| `stock_movement.order_id` | `customer_order(id)` | SET NULL | Audit preserve, lien de commande perdu | +| `stock_movement.user_id` | `user(id)` | SET NULL | Audit preserve, attribution utilisateur perdue | +| `customer_order.acting_user_id` | `user(id)` | SET NULL | Attribution du personnel preservee comme principal anonymise ; commande conservee | +| `audit_log.actor_user_id` | `user(id)` | SET NULL | Piste d'audit preservee a l'anonymisation de l'utilisateur ; seul le lien est rompu | +| `audit_log.actor_role_id` | `role(id)` | SET NULL | Contexte de role conserve jusqu'a la suppression du role ; denormalise donc il survit a l'anonymisation de l'utilisateur | -**Key used**: CASCADE = child has no meaning without parent; RESTRICT = parent deletion -blocked while children exist; SET NULL = child is preserved, only the link is severed. +**Cle utilisee** : CASCADE = l'enfant n'a pas de sens sans le parent ; RESTRICT = la suppression du parent +est bloquee tant que des enfants existent ; SET NULL = l'enfant est preserve, seul le lien est rompu. --- -## 6. CHECK constraints summary +## 6. Resume des contraintes CHECK -| Table | CHECK expression | Purpose | +| Table | Expression CHECK | Objectif | |---|---|---| -| `product` | `price_cents > 0` | Zero or negative price is a bug | -| `product` | `vat_rate IN (55, 100)` | Only two legal VAT rates for this model | -| `menu` | `price_normal_cents > 0` | Same as product | -| `menu` | `price_maxi_cents > 0` | Same | -| `ingredient` | `stock_capacity > 0` | The 100% reference must be positive; also guards the percentage division against divide-by-zero | -| `ingredient` | `pack_size > 0` | Pack size of zero makes restock logic incoherent | -| `ingredient` | `low_stock_pct BETWEEN 0 AND 100` | Warning band is a percent of capacity | -| `ingredient` | `critical_stock_pct BETWEEN 0 AND 100` | Auto-out-of-stock floor is a percent of capacity | -| `ingredient` | `critical_stock_pct < low_stock_pct` | Critical floor sits below the warning band | -| `product_ingredient` | `quantity_normal > 0` | Recipe quantity of zero is meaningless | -| `product_ingredient` | `quantity_maxi >= quantity_normal` | Maxi consumes at least as much as Normal (side/drink more, burger/sauce equal) | -| `product_ingredient` | `extra_price_cents >= 0` | No negative surcharge | -| `customer_order` | `total_ht_cents >= 0` | Zero is allowed (edge case during cart building) | -| `customer_order` | `total_vat_cents >= 0` | Same | -| `customer_order` | `total_ttc_cents > 0` | A validated order must have a positive total | -| `customer_order` | `total_ttc_cents = total_ht_cents + total_vat_cents` | Arithmetic invariant; defence-in-depth vs application bugs | -| `customer_order` | `source != 'drive' OR service_mode = 'drive'` | Cross-dimension constraint (dict. note 5) | -| `order_item` | `unit_price_cents_snapshot > 0` | Non-zero price at transaction time | -| `order_item` | `vat_rate_snapshot IN (55, 100)` | Snapshot must match allowed rates | -| `order_item` | `quantity > 0` | Non-zero quantity | -| `order_item` | `(item_type='product' AND product_id IS NOT NULL AND menu_id IS NULL) OR (item_type='menu' AND menu_id IS NOT NULL AND product_id IS NULL)` | Polymorphism: exactly one FK populated per discriminator value | -| `order_item_modifier` | `extra_price_cents >= 0` | Snapshot of surcharge; cannot be negative | +| `product` | `price_cents > 0` | Un prix nul ou negatif est un bug | +| `product` | `vat_rate IN (55, 100)` | Seuls deux taux de TVA legaux pour ce modele | +| `menu` | `price_normal_cents > 0` | Comme pour product | +| `menu` | `price_maxi_cents > 0` | Idem | +| `ingredient` | `stock_capacity > 0` | La reference 100% doit etre positive ; protege aussi la division du pourcentage contre la division par zero | +| `ingredient` | `pack_size > 0` | Une taille de lot nulle rend la logique de reapprovisionnement incoherente | +| `ingredient` | `low_stock_pct BETWEEN 0 AND 100` | La bande d’alerte est un pourcentage de la capacite | +| `ingredient` | `critical_stock_pct BETWEEN 0 AND 100` | Le plancher de rupture automatique est un pourcentage de la capacite | +| `ingredient` | `critical_stock_pct < low_stock_pct` | Le plancher critique se situe sous la bande d’alerte | +| `product_ingredient` | `quantity_normal > 0` | Une quantite de recette nulle n'a pas de sens | +| `product_ingredient` | `quantity_maxi >= quantity_normal` | Maxi consomme au moins autant que Normal (side/drink plus, burger/sauce egal) | +| `product_ingredient` | `extra_price_cents >= 0` | Pas de supplement negatif | +| `customer_order` | `total_ht_cents >= 0` | Zero est autorise (cas limite pendant la construction du panier) | +| `customer_order` | `total_vat_cents >= 0` | Idem | +| `customer_order` | `total_ttc_cents > 0` | Une commande validee doit avoir un total positif | +| `customer_order` | `total_ttc_cents = total_ht_cents + total_vat_cents` | Invariant arithmetique ; defense en profondeur contre les bugs applicatifs | +| `customer_order` | `source != 'drive' OR service_mode = 'drive'` | Contrainte inter-dimensions (note 5 du dict.) | +| `order_item` | `unit_price_cents_snapshot > 0` | Prix non nul au moment de la transaction | +| `order_item` | `vat_rate_snapshot IN (55, 100)` | Le snapshot doit correspondre aux taux autorises | +| `order_item` | `quantity > 0` | Quantite non nulle | +| `order_item` | `(item_type='product' AND product_id IS NOT NULL AND menu_id IS NULL) OR (item_type='menu' AND menu_id IS NOT NULL AND product_id IS NULL)` | Polymorphisme : exactement une FK renseignee par valeur de discriminateur | +| `order_item_modifier` | `extra_price_cents >= 0` | Snapshot du supplement ; ne peut pas etre negatif | --- -## 7. Recommended indexes (beyond PK / UK / FK auto-indexes) +## 7. Index recommandes (au-dela des auto-index PK / UK / FK) -MariaDB InnoDB creates an index automatically for each FK declaration (if no usable index -exists). The following additional indexes target frequent query patterns identified in the +MariaDB InnoDB cree automatiquement un index pour chaque declaration de FK (s'il n'existe pas d'index +utilisable). Les index supplementaires suivants ciblent les patterns de requete frequents identifies dans le MCT / MLT. -| Table | Index columns | Query pattern | +| Table | Colonnes d'index | Pattern de requete | |---|---|---| -| `product` | `(category_id, is_available, display_order)` | Kiosk catalogue load: filter by category + availability, sort by order | -| `menu` | `(category_id, is_available, display_order)` | Same pattern for menus | -| `menu_slot` | `(menu_id, display_order)` | Menu builder: load all slots of a menu in order | -| `customer_order` | `(status, created_at)` | Active orders queue: pending/paid orders sorted by time | -| `customer_order` | `(source, created_at)` | Per-channel analytics and order filtering | -| `customer_order` | `created_at` | Time-range aggregations (hourly stats, `service_day`) | -| `order_item` | `order_id` | Retrieve all lines of an order | -| `order_item_selection` | `order_item_id` | Retrieve slot choices for a menu line | -| `order_item_modifier` | `order_item_id` | Retrieve ingredient modifications for a line | -| `stock_movement` | `(ingredient_id, created_at)` | Per-ingredient stock history (dict. section 3.19) | -| `stock_movement` | `(movement_type, created_at)` | Stats: cancellations per week, restocks per month | -| `role_permission` | `permission_id` | Reverse query: "which roles have this permission?" | -| `user` | `(is_active, role_id)` | Login check + permission resolution | -| `audit_log` | `(actor_user_id, created_at)` | Per-actor audit history | -| `audit_log` | `(entity_type, entity_id)` | "what happened to this product/order/user?" | -| `audit_log` | `(action_code, created_at)` | Audit by action type over a time range | -| `login_throttle` | `lockout_until` | Daily cron purge of rows with no active lockout | +| `product` | `(category_id, is_available, display_order)` | Chargement du catalogue borne : filtre par categorie + disponibilite, tri par ordre | +| `menu` | `(category_id, is_available, display_order)` | Meme pattern pour les menus | +| `menu_slot` | `(menu_id, display_order)` | Constructeur de menu : charger tous les slots d'un menu dans l'ordre | +| `customer_order` | `(status, created_at)` | File des commandes actives : commandes pending/paid triees par temps | +| `customer_order` | `(source, created_at)` | Analytics par canal et filtrage des commandes | +| `customer_order` | `created_at` | Agregations par plage de temps (stats horaires, `service_day`) | +| `order_item` | `order_id` | Recuperer toutes les lignes d'une commande | +| `order_item_selection` | `order_item_id` | Recuperer les choix de slot d'une ligne de menu | +| `order_item_modifier` | `order_item_id` | Recuperer les modifications d'ingredient d'une ligne | +| `stock_movement` | `(ingredient_id, created_at)` | Historique de stock par ingredient (dict. section 3.19) | +| `stock_movement` | `(movement_type, created_at)` | Stats : annulations par semaine, reapprovisionnements par mois | +| `role_permission` | `permission_id` | Requete inverse : "quels roles ont cette permission ?" | +| `user` | `(is_active, role_id)` | Verification de connexion + resolution des permissions | +| `audit_log` | `(actor_user_id, created_at)` | Historique d'audit par acteur | +| `audit_log` | `(entity_type, entity_id)` | "qu'est-il arrive a ce produit/commande/utilisateur ?" | +| `audit_log` | `(action_code, created_at)` | Audit par type d'action sur une plage de temps | +| `login_throttle` | `lockout_until` | Purge cron quotidienne des lignes sans verrouillage actif | -**Indexes not added** (intentional): -- `customer_order.order_number`: UK index is sufficient; no range query expected on this column. -- `customer_order.service_mode`: low cardinality (3 values); full scan on the status index - with a `service_mode` filter is acceptable at expected volume. -- `customer_order.paid_at`: NULL for most in-flight rows; sparse index provides limited benefit. +**Index non ajoutes** (intentionnel) : +- `customer_order.order_number` : l'index UK suffit ; aucune requete de plage attendue sur cette colonne. +- `customer_order.service_mode` : faible cardinalite (3 valeurs) ; un scan complet sur l'index de status + avec un filtre `service_mode` est acceptable au volume attendu. +- `customer_order.paid_at` : NULL pour la plupart des lignes en cours ; un index clairseme apporte un benefice limite. --- -## 8. Cross-validation MLD <-> MCD +## 8. Validation croisee MLD <-> MCD -Verification that all 21 MCD entities (19 prod-like + 2 security-by-design) map to a table, -and that all tables trace to the MCD. +Verification que les 21 entites MCD (19 prod-like + 2 security-by-design) correspondent a une table, +et que toutes les tables se rattachent au MCD. -| MCD entity | MLD table | Mapping type | Notes | +| Entite MCD | Table MLD | Type de mapping | Notes | |---|---|---|---| -| `category` (C1) | `category` (4.1) | 1:1 entity | | -| `product` (C2) | `product` (4.2) | 1:1 entity | | -| `menu` (C3) | `menu` (4.3) | 1:1 entity | New: `burger_product_id`, `price_normal_cents`, `price_maxi_cents` | -| `menu_slot` (C4) | `menu_slot` (4.4) | 1:1 entity | New entity (v0.2) | -| `menu_slot_option` (C5) | `menu_slot_option` (4.5) | Join table (composite PK) | New entity (v0.2) | -| `ingredient` (C6) | `ingredient` (4.6) | 1:1 entity | New entity (v0.2) | -| `product_ingredient` (C7) | `product_ingredient` (4.7) | Join table with attributes | New entity (v0.2) | -| `allergen` (C8) | `allergen` (4.8) | 1:1 entity | New entity (v0.2) | -| `ingredient_allergen` (C9) | `ingredient_allergen` (4.9) | Join table (composite PK) | New entity (v0.2) | -| `role` (C10) | `role` (4.10) | 1:1 entity | New: `default_route`, `order_source` | -| `user` (C11) | `user` (4.11) | 1:1 entity | Columns renamed to English | -| `role_visible_source` (C12) | `role_visible_source` (4.12) | Join table (composite PK) | New entity (v0.2) | -| `permission` (C13) | `permission` (4.13) | 1:1 entity | | -| `role_permission` (C14) | `role_permission` (4.14) | Join table (composite PK) | | -| `customer_order` (C15) | `customer_order` (4.15) | 1:1 entity | Renamed from `commande`; 4-state machine; phase timestamps | -| `order_item` (C16) | `order_item` (4.16) | 1:1 entity | New: `format`, `vat_rate_snapshot`; polymorphism CHECK | -| `order_item_selection` (C17) | `order_item_selection` (4.17) | 1:1 entity | New entity (v0.2) | -| `order_item_modifier` (C18) | `order_item_modifier` (4.18) | 1:1 entity | New entity (v0.2) | -| `stock_movement` (C19) | `stock_movement` (4.19) | 1:1 entity | New entity (v0.2) | -| `audit_log` (R5/R6) | `audit_log` (4.20) | 1:1 entity | New entity (security-by-design) | -| `login_throttle` (R7) | `login_throttle` (4.21) | 1:1 entity | New entity (security-by-design) | +| `category` (C1) | `category` (4.1) | entite 1:1 | | +| `product` (C2) | `product` (4.2) | entite 1:1 | | +| `menu` (C3) | `menu` (4.3) | entite 1:1 | Nouveau : `burger_product_id`, `price_normal_cents`, `price_maxi_cents` | +| `menu_slot` (C4) | `menu_slot` (4.4) | entite 1:1 | Nouvelle entite (v0.2) | +| `menu_slot_option` (C5) | `menu_slot_option` (4.5) | Table de jointure (PK composite) | Nouvelle entite (v0.2) | +| `ingredient` (C6) | `ingredient` (4.6) | entite 1:1 | Nouvelle entite (v0.2) | +| `product_ingredient` (C7) | `product_ingredient` (4.7) | Table de jointure avec attributs | Nouvelle entite (v0.2) | +| `allergen` (C8) | `allergen` (4.8) | entite 1:1 | Nouvelle entite (v0.2) | +| `ingredient_allergen` (C9) | `ingredient_allergen` (4.9) | Table de jointure (PK composite) | Nouvelle entite (v0.2) | +| `role` (C10) | `role` (4.10) | entite 1:1 | Nouveau : `default_route`, `order_source` | +| `user` (C11) | `user` (4.11) | entite 1:1 | Colonnes renommees en anglais | +| `role_visible_source` (C12) | `role_visible_source` (4.12) | Table de jointure (PK composite) | Nouvelle entite (v0.2) | +| `permission` (C13) | `permission` (4.13) | entite 1:1 | | +| `role_permission` (C14) | `role_permission` (4.14) | Table de jointure (PK composite) | | +| `customer_order` (C15) | `customer_order` (4.15) | entite 1:1 | Renommee depuis `commande` ; machine a 4 etats ; horodatages de phase | +| `order_item` (C16) | `order_item` (4.16) | entite 1:1 | Nouveau : `format`, `vat_rate_snapshot` ; CHECK de polymorphisme | +| `order_item_selection` (C17) | `order_item_selection` (4.17) | entite 1:1 | Nouvelle entite (v0.2) | +| `order_item_modifier` (C18) | `order_item_modifier` (4.18) | entite 1:1 | Nouvelle entite (v0.2) | +| `stock_movement` (C19) | `stock_movement` (4.19) | entite 1:1 | Nouvelle entite (v0.2) | +| `audit_log` (R5/R6) | `audit_log` (4.20) | entite 1:1 | Nouvelle entite (security-by-design) | +| `login_throttle` (R7) | `login_throttle` (4.21) | entite 1:1 | Nouvelle entite (security-by-design) | -**Result**: 21/21 entities mapped (19 prod-like + `audit_log` + `login_throttle`). No entity -without a table; no table outside the MCD. New columns on existing tables: `user` -(auth-lifecycle + `pin_hash` + `anonymized_at`), `customer_order` (`idempotency_key`, -`acting_user_id`), `ingredient` (`stock_capacity`, `low_stock_pct`, `critical_stock_pct`; -`low_stock_threshold` repurposed). +**Resultat** : 21/21 entites mappees (19 prod-like + `audit_log` + `login_throttle`). Aucune entite +sans table ; aucune table hors du MCD. Nouvelles colonnes sur les tables existantes : `user` +(cycle de vie auth + `pin_hash` + `anonymized_at`), `customer_order` (`idempotency_key`, +`acting_user_id`), `ingredient` (`stock_capacity`, `low_stock_pct`, `critical_stock_pct` ; +`low_stock_threshold` reaffecte). -**Dropped from v0.1**: `commande_event` (replaced by `paid_at`, `delivered_at`, `cancelled_at` -phase timestamps on `customer_order` — decision 2.A); `menu_produit` fixed-composition model -(replaced by `menu_slot` + `menu_slot_option` — decision D1). +**Abandonne depuis v0.1** : `commande_event` (remplace par les horodatages de phase `paid_at`, `delivered_at`, `cancelled_at` +sur `customer_order` — decision 2.A) ; le modele de composition fixe `menu_produit` +(remplace par `menu_slot` + `menu_slot_option` — decision D1). --- -## 9. Volume estimation (6 months) +## 9. Estimation de volume (6 mois) -| Table | Rows at 6 months | Avg row size | Est. size | +| Table | Lignes a 6 mois | Taille moyenne de ligne | Taille est. | |---|---|---|---| -| `category` | ~10 | 200 bytes | < 1 KB | -| `product` | ~55 | 400 bytes | ~22 KB | -| `menu` | ~13 | 450 bytes | ~6 KB | -| `menu_slot` | ~40 | 150 bytes | ~6 KB | -| `menu_slot_option` | ~150 | 30 bytes | ~5 KB | -| `ingredient` | ~100 | 300 bytes | ~30 KB | -| `product_ingredient` | ~400 | 40 bytes | ~16 KB | -| `allergen` | 14 | 200 bytes | ~3 KB | -| `ingredient_allergen` | ~200 | 20 bytes | ~4 KB | -| `role` | ~5 | 200 bytes | ~1 KB | -| `user` | ~20 | 500 bytes | ~10 KB | -| `role_visible_source` | ~7 | 15 bytes | < 1 KB | -| `permission` | 23 | 250 bytes | ~6 KB | -| `role_permission` | ~80 | 15 bytes | ~2 KB | -| `customer_order` | ~30k | 300 bytes | ~9 MB | -| `order_item` | ~150k | 250 bytes | ~37 MB | -| `order_item_selection` | ~300k | 150 bytes | ~45 MB | -| `order_item_modifier` | ~150k | 80 bytes | ~12 MB | -| `stock_movement` | ~500k | 180 bytes | ~90 MB | -| `audit_log` | ~5k-10k | 200 bytes | ~2 MB | -| `login_throttle` | ~100-1k | 80 bytes | < 1 MB | +| `category` | ~10 | 200 octets | < 1 KB | +| `product` | ~55 | 400 octets | ~22 KB | +| `menu` | ~13 | 450 octets | ~6 KB | +| `menu_slot` | ~40 | 150 octets | ~6 KB | +| `menu_slot_option` | ~150 | 30 octets | ~5 KB | +| `ingredient` | ~100 | 300 octets | ~30 KB | +| `product_ingredient` | ~400 | 40 octets | ~16 KB | +| `allergen` | 14 | 200 octets | ~3 KB | +| `ingredient_allergen` | ~200 | 20 octets | ~4 KB | +| `role` | ~5 | 200 octets | ~1 KB | +| `user` | ~20 | 500 octets | ~10 KB | +| `role_visible_source` | ~7 | 15 octets | < 1 KB | +| `permission` | 23 | 250 octets | ~6 KB | +| `role_permission` | ~80 | 15 octets | ~2 KB | +| `customer_order` | ~30k | 300 octets | ~9 MB | +| `order_item` | ~150k | 250 octets | ~37 MB | +| `order_item_selection` | ~300k | 150 octets | ~45 MB | +| `order_item_modifier` | ~150k | 80 octets | ~12 MB | +| `stock_movement` | ~500k | 180 octets | ~90 MB | +| `audit_log` | ~5k-10k | 200 octets | ~2 MB | +| `login_throttle` | ~100-1k | 80 octets | < 1 MB | -**Estimated total**: ~190 MB data + ~60-80 MB for indexes = ~250-270 MB over 6 months -(`audit_log` is negligible: sensitive actions are orders of magnitude rarer than orders). -Manageable on the MariaDB container (`wakdo_db_data` named volume in `docker-compose.yml`). +**Total estime** : ~190 MB de donnees + ~60-80 MB pour les index = ~250-270 MB sur 6 mois +(`audit_log` est negligeable : les actions sensibles sont d'un ordre de grandeur plus rares que les commandes). +Gerable sur le conteneur MariaDB (volume nomme `wakdo_db_data` dans `docker-compose.yml`). -`stock_movement` is the highest-volume table (~5-15 rows per order across all ingredients). -The `(ingredient_id, created_at)` index is the primary query path for per-ingredient -history; it will carry meaningful write amplification at scale. +`stock_movement` est la table au plus fort volume (~5-15 lignes par commande tous ingredients confondus). +L'index `(ingredient_id, created_at)` est le chemin de requete principal pour l'historique par +ingredient ; il portera une amplification d'ecriture significative a l'echelle. --- -## 10. Decisions deferred to DDL and P2 +## 10. Decisions reportees au DDL et a P2 -1. **MariaDB generated column** for `service_day`: a `VIRTUAL GENERATED` column is technically - possible in MariaDB 5.7+ syntax. If stats queries prove burdensome without a materialised - column, a `STORED GENERATED` column could be added as a migration. For this model, the - applicative CASE expression is retained (simpler, avoids generated-column edge cases). -2. **Partitioning**: `stock_movement` could be partitioned by month if volume exceeds - estimates. Not in scope for the initial DDL. -3. **Triggers**: stock decrement on `paid` transition and re-credit on `cancelled` (from `paid`) - could be implemented as MariaDB triggers or as application-layer logic. To be decided at P2. -4. **Collation**: `utf8mb4_unicode_ci` retained (Unicode-compliant, case-insensitive). - If strict French alphabetical sort is needed, `utf8mb4_fr_0900_ai_ci` is available in - MySQL 8 but not MariaDB; `unicode_ci` is the portable choice. -5. **Migration tooling**: Phinx, Doctrine Migrations, or a plain PHP script. Decision at P2. -6. **`order_item_id` constraint for selections**: the business rule that - `order_item_selection.order_item_id` must reference a line with `item_type='menu'` - is enforced at application layer. A MariaDB trigger could reinforce this at DB level if - needed. +1. **Colonne generee MariaDB** pour `service_day` : une colonne `VIRTUAL GENERATED` est techniquement + possible avec la syntaxe MariaDB 5.7+. Si les requetes de stats s'averent lourdes sans colonne + materialisee, une colonne `STORED GENERATED` pourrait etre ajoutee en migration. Pour ce modele, + l'expression CASE applicative est retenue (plus simple, evite les cas limites des colonnes generees). +2. **Partitionnement** : `stock_movement` pourrait etre partitionnee par mois si le volume depasse les + estimations. Hors perimetre pour le DDL initial. +3. **Triggers** : decrement de stock a la transition `paid` et re-credit a `cancelled` (depuis `paid`) + pourraient etre implementes en triggers MariaDB ou en logique applicative. A decider en P2. +4. **Collation** : `utf8mb4_unicode_ci` retenue (conforme Unicode, insensible a la casse). + Si un tri alphabetique francais strict est necessaire, `utf8mb4_fr_0900_ai_ci` est disponible dans + MySQL 8 mais pas MariaDB ; `unicode_ci` est le choix portable. +5. **Outillage de migration** : Phinx, Doctrine Migrations, ou un simple script PHP. Decision en P2. +6. **Contrainte `order_item_id` pour les selections** : la regle metier voulant que + `order_item_selection.order_item_id` reference une ligne avec `item_type='menu'` + est imposee au niveau applicatif. Un trigger MariaDB pourrait renforcer cela au niveau de la BD si + necessaire. --- -## 11. Next steps (DDL + Seed) +## 11. Prochaines etapes (DDL + Seed) -1. **DDL** (`db/migrations/0001_init_schema.sql`): transcribe this MLD into executable - `CREATE TABLE` statements, in dependency order: +1. **DDL** (`db/migrations/0001_init_schema.sql`) : transcrire ce MLD en instructions + `CREATE TABLE` executables, dans l'ordre de dependance : - `category` -> `product`, `ingredient`, `allergen`, `role` - - `menu` (depends on `category`, `product`) - - `menu_slot` (depends on `menu`), `menu_slot_option` (depends on `menu_slot`, `product`) - - `product_ingredient` (depends on `product`, `ingredient`) - - `ingredient_allergen` (depends on `ingredient`, `allergen`) - - `user` (depends on `role`), `role_visible_source` (depends on `role`) - - `permission`, `role_permission` (depends on `role`, `permission`) + - `menu` (depend de `category`, `product`) + - `menu_slot` (depend de `menu`), `menu_slot_option` (depend de `menu_slot`, `product`) + - `product_ingredient` (depend de `product`, `ingredient`) + - `ingredient_allergen` (depend de `ingredient`, `allergen`) + - `user` (depend de `role`), `role_visible_source` (depend de `role`) + - `permission`, `role_permission` (depend de `role`, `permission`) - `customer_order` - - `order_item` (depends on `customer_order`, `product`, `menu`) - - `order_item_selection` (depends on `order_item`, `menu_slot`, `product`) - - `order_item_modifier` (depends on `order_item`, `ingredient`) - - `stock_movement` (depends on `ingredient`, `customer_order`, `user`) - - `audit_log` (depends on `user`, `role`) - - `login_throttle` (no FK, can be created at any point) + - `order_item` (depend de `customer_order`, `product`, `menu`) + - `order_item_selection` (depend de `order_item`, `menu_slot`, `product`) + - `order_item_modifier` (depend de `order_item`, `ingredient`) + - `stock_movement` (depend de `ingredient`, `customer_order`, `user`) + - `audit_log` (depend de `user`, `role`) + - `login_throttle` (pas de FK, peut etre cree a n'importe quel moment) - Note: `customer_order` now carries `acting_user_id -> user`, so `user` must be created - before `customer_order` (already the case: the RBAC block precedes `customer_order`). + Note : `customer_order` porte desormais `acting_user_id -> user`, donc `user` doit etre cree + avant `customer_order` (deja le cas : le bloc RBAC precede `customer_order`). -2. **Seed** (`db/seeds/0001_demo_data.sql`): - - 9 categories + 53 products + 13 menus from JSON sources (`docs/merise/_sources/`) - - 13 menus with slots and slot options - - 14 allergens (INCO EU 1169/2011) - - Sample ingredient catalogue with recipes - - 5 roles with `role_permission` matrix and `role_visible_source` data - - 1 admin user - - Sample orders for demo +2. **Seed** (`db/seeds/0001_demo_data.sql`) : + - 9 categories + 53 produits + 13 menus depuis les sources JSON (`docs/merise/_sources/`) + - 13 menus avec slots et options de slot + - 14 allergenes (INCO UE 1169/2011) + - Catalogue d'ingredients exemple avec recettes + - 5 roles avec matrice `role_permission` et donnees `role_visible_source` + - 1 utilisateur admin + - Commandes exemple pour la demo -3. **Fallback JSON export** (`scripts/export-fallback.{sh|php}`): extract seed data to - `src/public/borne/data/*.json` for isolated kiosk mode (Bloc 1 without DB). +3. **Export JSON de fallback** (`scripts/export-fallback.{sh|php}`) : extraire les donnees de seed vers + `src/public/borne/data/*.json` pour le mode borne isole (Bloc 1 sans BD). -4. **DDL validation tests**: confirm CHECK constraints trigger as expected; confirm - ON DELETE CASCADE / RESTRICT / SET NULL behaviours match specification. +4. **Tests de validation DDL** : confirmer que les contraintes CHECK se declenchent comme attendu ; confirmer + que les comportements ON DELETE CASCADE / RESTRICT / SET NULL correspondent a la specification. diff --git a/docs/merise/mlt.md b/docs/merise/mlt.md index 60954c4..db30820 100644 --- a/docs/merise/mlt.md +++ b/docs/merise/mlt.md @@ -1,715 +1,715 @@ -# Model of Logical Treatments (MLT) — Wakdo +# Modele Logique des Traitements (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) +**Phase Merise** : P1 - Conception, etape 4 (derivee du MCT) +**Version** : v0.2 — prod-like, machine a 4 etats (+ couche security-by-design 2026-06-11) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; regles security-by-design ajoutees (RG-T13-T21 : PIN, audit, escaping, allowlists, idempotence, decrement atomique, disponibilite produit calculee (RG-T21) ; ops RESET_PASSWORD, ERASE_USER_PII, throttling d'authentification ; table de throttle par IP `login_throttle`) +**Auteur** : BYAN (couche methodologie) --- -## 1. Purpose +## 1. Objectif -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 +Le MLT (Modele Logique des Traitements) affine chaque operation du MCT en specifiant : +- **preconditions** — ce qui doit etre vrai avant l'execution +- **regles de gestion** — validation, calcul, logique metier +- **postconditions** — l'etat garanti apres succes +- **sorties** — donnees produites ou evenements emis +- **cas d'erreur** — sorties alternatives lorsqu'une condition echoue -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. +Il fait le lien entre le MCT (niveau conceptuel) et l'implementation PHP/SQL (niveau physique). +Toutes les references aux entites/attributs utilisent les noms de `docs/merise/dictionary.md` (anglais, +snake_case). Tous les montants monetaires sont en centimes entiers. -**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 +**Conventions de tags** : +- `[PRE]` — precondition ; doit etre satisfaite pour que l'operation s'execute +- `[RG]` — regle de gestion ; logique appliquee pendant l'execution +- `[POST]` — postcondition ; etat de la base garanti apres succes +- `[OUT]` — sortie ; donnee ou evenement produit +- `[ERR]` — cas d'erreur ; sortie alternative lorsqu'une condition echoue --- -## 2. Transverse business rules +## 2. Regles de gestion transverses -These rules apply to multiple operations and are centralised here to avoid repetition. +Ces regles s'appliquent a plusieurs operations et sont centralisees ici pour eviter la repetition. -| Rule code | Label | Operations concerned | +| Code de regle | Libelle | Operations concernees | |-----------|-------|----------------------| -| **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 = ` 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 | +| **RG-T01** | Token CSRF verifie sur chaque formulaire POST/PUT/DELETE du back-office | AUTH, toutes ops admin | +| **RG-T02** | Session active + `user.is_active = 1` verifies a chaque requete authentifiee | Tous domaines 3-10 | +| **RG-T03** | Permission verifiee via `role_permission` avant l'execution de l'operation | Tous domaines 3-10 | +| **RG-T04** | Tous les montants monetaires sont manipules en centimes entiers ; conversion EUR uniquement en sortie | 3.3, 4.1, 8.1, 8.4 | +| **RG-T05** | Les snapshots (`label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot`) sur `order_item` ne sont pas modifies apres l'INSERT (integrite historique des commandes passees — garantie de conception) | 3.3, 4.1, 8.2, 8.5 | +| **RG-T06** | Toutes les requetes SQL utilisent PDO avec des requetes preparees ; aucune donnee utilisateur concatenee dans le SQL | Toutes operations | +| **RG-T07** | Les instructions UPDATE de transition d'etat incluent `AND status = ` dans la clause WHERE (protection de concurrence optimiste contre la double transition) | 6.1, 7.1 | +| **RG-T08** | Les operations touchant plusieurs tables s'executent dans une transaction de base de donnees atomique ; un echec partiel declenche un rollback complet | 3.3, 4.1, 7.1, 8.4, 9.1, 9.2 | +| **RG-T09** | Contrainte croisee sur `customer_order` : `source = 'drive'` implique `service_mode = 'drive'` ; verifiee a la creation de la commande. Materialisable en CHECK MariaDB : `CHECK (source != 'drive' OR service_mode = 'drive')`. | 3.3, 4.1 | +| **RG-T10** | Le calcul de TVA se fait ligne par ligne : chaque `order_item` porte son propre `vat_rate_snapshot` (entier pour-mille snapshote depuis `product.vat_rate`). Les totaux de commande (`total_ht_cents`, `total_vat_cents`, `total_ttc_cents`) sont la somme des montants au niveau des lignes. | 3.3, 4.1 | +| **RG-T11** | Le decrement de stock a la transition `pending_payment -> paid` et le re-credit a `paid -> cancelled` sont dans la meme transaction de base de donnees que la mise a jour du statut (pas de decrement orphelin). | 3.3, 4.1, 7.1 | +| **RG-T12** | Filtre du tableau de bord par source : les sources visibles de chaque role sont lues depuis `role_visible_source` ; la requete utilise `WHERE customer_order.source IN (role_visible_sources)`. | 6.1 | +| **RG-T13** | **PIN d'action sensible** (security-by-design) : l'ensemble des operations sensibles requiert une re-autorisation par PIN propre a chaque membre du personnel avant l'execution : verifier le PIN soumis contre `user.pin_hash` (`password_verify`, argon2id). En cas de succes, le `user_id` agissant est capture pour le journal d'audit ; en cas d'echec, l'operation est rejetee. Ensemble sensible : 7.1 (annulation), 8.2/8.3 (mise a jour/suppression produit), 8.6 (suppression menu), 9.2 (correction d'inventaire), 10.1/10.2/10.3 (gestion utilisateur), 10.4 (RBAC), 10.5 (effacement PII). Les sessions restent partagees par poste de travail pour les 95% de routine. | 7.1, 8.2, 8.3, 8.6, 9.2, 10.1-10.5 | +| **RG-T14** | **Ecriture du journal d'audit** : les operations sensibles hors stock ajoutent une ligne `audit_log` immuable dans la meme transaction que leur effet : `actor_user_id` (issu du PIN RG-T13), `actor_role_id`, `action_code` (code de permission/operation), `entity_type` + `entity_id` de la ligne affectee, `summary` (description de changement non personnelle), `details` JSON (**noms** des champs modifies pour les actions ciblant un utilisateur, pas les valeurs PII). Aucun UPDATE/DELETE sur `audit_log`. Les actions de stock (9.1 restock, 9.2 inventaire) enregistrent leur attribution via `stock_movement.user_id` (capture par PIN), qui fournit deja la trace de stock append-only — elles ne sont pas doublement journalisees. | 7.1, 8.2, 8.3, 8.6, 10.1-10.5, 12.1 | +| **RG-T15** | **Echappement en sortie** (anti-XSS) : les champs de texte libre (`product.name`/`description`, `ingredient.name`, `user.first_name`/`last_name`, notes) sont echappes selon le contexte au rendu. Les vues admin rendues cote serveur utilisent `htmlspecialchars($v, ENT_QUOTES, 'UTF-8')` ; le kiosk en vanilla-JS injecte le texte via `textContent` (ou un echappeur explicite), pas `innerHTML`. | Toutes les vues rendant du texte stocke | +| **RG-T16** | **Allowlist d'affectation de masse** : les instructions INSERT/UPDATE ne lient qu'une allowlist de colonnes explicite par operation issue de la requete ; les champs supplementaires/inconnus sont ecartes. Empeche l'alteration de `price_cents`, `vat_rate`, `role_id`, `is_active`, `status` via des champs de formulaire injectes. | 8.1, 8.2, 8.4, 8.5, 10.1, 10.2 | +| **RG-T17** | **Allowlist d'identifiants dynamiques** : les tokens de colonne/direction utilises dans un `ORDER BY` / `GROUP BY` dynamique sont resolus contre une allowlist fixe de noms de colonnes avant la construction de la requete (RG-T06 couvre les valeurs via les parametres lies ; les identifiants SQL ne peuvent pas etre lies, ils sont donc en allowlist). | 5.1, 9.3, 11.1 | +| **RG-T18** | **Validation cote serveur et bornes de longueur** : chaque entree est re-validee cote serveur independamment des verifications cote client — type, plage, longueur max (correspondant aux tailles VARCHAR du dictionnaire), appartenance a l'enum, existence de FK. La validation cote client est une aide UX, pas une frontiere de confiance. | Toutes operations d'ecriture | +| **RG-T19** | **Idempotence** : `POST /api/orders` porte un `idempotency_key` (UUID) genere par le client. Avant de creer, le rechercher sur `customer_order.idempotency_key` (UNIQUE) ; si une ligne existe, retourner cette commande au lieu de creer un doublon (retry reseau rejoue). | 3.3, 4.1 | +| **RG-T20** | **Decrement de stock atomique** : pendant la transition `paid`, chaque `ingredient` affecte est decremente par une unique instruction auto-verrouillante `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` — pas de lecture-gate prealable, pas de `SELECT ... FOR UPDATE`. Les commandes concurrentes sur le meme ingredient appliquent leurs deltas sans perte de mise a jour et sans souci d'ordonnancement de deadlock. `stock_quantity` est signe et peut devenir negatif quand les ventes depassent le stock compte (l'ampleur de la survente est remontee aux managers) ; le decrement ne bloque pas sur un plancher. | 3.3, 4.1 | +| **RG-T21** | **Disponibilite produit calculee** : la commandabilite effective d'un produit est calculee, pas stockee. Il est commandable lorsque `product.is_available = 1` ET que chaque ingredient non retirable (`is_removable = 0`) de son `product_ingredient` a `stock_quantity > stock_capacity * critical_stock_pct / 100`. A la bande critique, un ingredient requis met le produit en rupture sans ecriture et sans cascade ; un reapprovisionnement au-dessus de la bande critique le rend commandable a nouveau de lui-meme ; un retrait manuel (`product.is_available = 0`) est une surcharge forte ; un ingredient retirable/optionnel a la bande critique ne bloque pas le produit (seul son supplement devient indisponible). | 3.1, 3.3, 4.1, 5.1 | --- -## 3. Domain 1 — Order lifecycle (kiosk) +## 3. Domaine 1 — Cycle de vie de la commande (kiosk) ### 3.1 LOAD_CATALOGUE -**Corresponds to MCT section 3.1** +**Correspond a la section 3.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | La requete provient de l'endpoint kiosk (public, aucune authentification requise) | +| **[PRE-2]** | L'heure courante est dans la fenetre de service (10:00-01:00) ; en dehors de la fenetre le kiosk affiche un message de fermeture | +| **[RG-1]** | Lire toutes les lignes `category` avec `is_active = 1`, triees par `category.display_order ASC` | +| **[RG-2]** | Pour chaque categorie, lire les lignes `product` avec `is_available = 1` et `category_id` correspondant, triees par `product.display_order ASC` | +| **[RG-3]** | Lire toutes les lignes `menu` avec `is_available = 1` ; pour chaque menu, charger les lignes `menu_slot` triees par `menu_slot.display_order ASC` ; pour chaque slot, charger les produits eligibles via `menu_slot_option JOIN product` (ou `product.is_available = 1`) | +| **[RG-4]** | Pour chaque produit, calculer les allergenes en joignant `product_ingredient -> ingredient_allergen -> allergen` (pas de ressaisie manuelle par produit) | +| **[RG-5]** | Pour chaque produit avec des lignes `product_ingredient`, charger la composition `ingredient` (pour le configurateur) | +| **[RG-6]** | Les prix sont retournes en centimes entiers ; la conversion EUR est effectuee cote client | +| **[POST-1]** | Aucune ecriture en base ; etat de la base inchange | +| **[OUT-1]** | Reponse JSON : `{data: {categories: [...], products: {...}, menus: [{..., slots: [{..., options: [...]}]}]}}` | +| **[ERR-1]** | Base inaccessible : reponse `{data: null, error: {code: "DB_ERROR"}}` et le front-end bascule sur un JSON statique | --- ### 3.2 COMPOSE_CART -**Corresponds to MCT section 3.2** +**Correspond a la section 3.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | Catalogue charge en memoire front-end (LOAD_CATALOGUE termine) | +| **[PRE-2]** | L'article selectionne (produit ou menu) est present dans le catalogue charge avec `is_available = 1` | +| **[RG-1]** | Le panier est une structure JavaScript en memoire (tableau d'articles) ; aucune persistance en base a ce stade | +| **[RG-2]** | Chaque article contient : `type` (`product` ou `menu`), `item_id`, `label`, `unit_price_cents` (snapshot depuis le catalogue), `quantity`, `format` (`normal` ou `maxi`, pour les menus), `slot_selections` (tableau de `{menu_slot_id, product_id, label}` pour les articles menu), `modifiers` (tableau de `{ingredient_id, action, extra_price_cents}`) | +| **[RG-3]** | Format Normal/Maxi (articles menu uniquement) : `normal` utilise `menu.price_normal_cents` ; `maxi` utilise `menu.price_maxi_cents`. Aucun changement de prix de composant individuel n'est stocke ; le differentiel de prix est au niveau du menu. | +| **[RG-4]** | Regles de modificateur d'ingredient : `action = 'remove'` requiert `is_removable = 1` sur `product_ingredient` (gratuit) ; `action = 'add'` requiert `is_addable = 1` (peut porter un `extra_price_cents`). Ces contraintes sont verifiees au moment de la composition du panier contre le catalogue charge. | +| **[RG-5]** | Si un article avec les memes `(type, item_id, format, slot_selections, modifiers)` existe deja dans le panier, sa quantite est incrementee plutot que d'ajouter un nouvel article | +| **[RG-6]** | Total du panier recalcule apres chaque changement : `SUM(unit_price_cents * quantity + modifier_extras)` sur tous les articles | +| **[POST-1]** | Aucune ecriture en base ; etat en memoire du panier mis a jour | +| **[OUT-1]** | Recapitulatif du panier affiche avec total TTC | +| **[ERR-1]** | Si un produit passe a `is_available = 0` entre le chargement du catalogue et la soumission de la commande, la validation cote serveur dans CREATE_ORDER le detecte | --- ### 3.3 CREATE_ORDER -**Corresponds to MCT section 3.3** +**Correspond a la section 3.3 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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"}}` | +| **[PRE-1]** | Le panier contient au moins 1 article (`items.length >= 1`) | +| **[PRE-2]** | Le numero de commande saisi par le client est non vide (validation front-end) | +| **[PRE-3]** | Le corps JSON du POST est valide (validation de schema a la couche API) | +| **[RG-1]** | Verification de disponibilite cote serveur : pour chaque article, verifier `product.is_available = 1` ou `menu.is_available = 1`. Si un article est indisponible, rejeter avec la liste des articles indisponibles. | +| **[RG-2 — service_day]** | Le `service_day` d'une commande donnee est calcule a l'execution de la requete comme : `CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END`. La coupure est a 10:00. Ce n'est PAS stocke comme colonne — calcule uniquement a l'execution de la requete. La formule v0.1 avec `INTERVAL 4 HOUR 30 MINUTE` etait incorrecte et est abandonnee. | +| **[RG-3 — order number]** | Format du numero de commande : `K-YYYY-MM-DD-NNN` ou NNN est le compteur sequentiel pour le service_day courant pour la source `kiosk` (SELECT COUNT + 1 avec un verrou au niveau table ou un insert serialise pour eviter une generation en double sous concurrence). La source est `kiosk` (definie par l'endpoint kiosk, derivee du point d'entree public). | +| **[RG-4 — VAT by line]** | Pour chaque `order_item` : `vat_rate_snapshot` est copie depuis `product.vat_rate`. Montants de ligne : `unit_ttc = unit_price_cents_snapshot` ; `unit_ht = ROUND(unit_ttc * 1000 / (1000 + vat_rate_snapshot))` ; `unit_vat = unit_ttc - unit_ht`. Totaux de commande : `total_ttc_cents = SUM(unit_ttc * quantity)` sur toutes les lignes ; `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` (verifie avant l'INSERT). | +| **[RG-5 — atomic transaction]** | Toutes les ecritures dans une seule transaction de base de donnees : (1) INSERT `customer_order` (status `pending_payment`, source `kiosk`, service_mode depuis le panier, totaux calcules) ; (2) INSERT des lignes `order_item` (label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot, quantity, format, item_type, product_id ou menu_id) ; (3) INSERT des lignes `order_item_selection` pour chaque slot rempli dans un article menu (order_item_id, menu_slot_id, product_id, label_snapshot) ; (4) INSERT des lignes `order_item_modifier` pour chaque modification d'ingredient (order_item_id, ingredient_id, action, extra_price_cents snapshot) ; (5) pour chaque ingredient consomme : calculer units = `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, ajuste par les modificateurs (remove => pas de decrement pour cet ingredient ; add => decrement supplementaire) ; appliquer le decrement atomique `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (instruction unique auto-verrouillante, sans lecture-gate prealable, RG-T20) ; `stock_quantity` est signe et peut devenir negatif (ampleur de survente, remontee aux managers) — le decrement ne se conditionne pas a un plancher ; INSERT `stock_movement` (type `sale`, delta = -units, order_id, user_id = NULL pour le kiosk) ; (6) UPDATE `customer_order` SET status = `paid`, `paid_at = NOW()`. Les six etapes committent ensemble ou sont entierement annulees. | +| **[RG-6 — cross-constraint]** | La source `kiosk` n'implique aucune contrainte particuliere de service_mode ; le client selectionne `dine_in` ou `takeaway`. La contrainte croisee drive (RG-T09) ne s'applique pas aux commandes provenant du kiosk. | +| **[RG-7 — immutability]** | Apres l'INSERT, `label_snapshot`, `unit_price_cents_snapshot` et `vat_rate_snapshot` ne sont pas modifies meme si le produit source est renomme ou voit son prix change plus tard (voir RG-T05). | +| **[RG-8 — idempotency]** | Le corps porte un `idempotency_key` client (UUID). Avant toute ecriture, `SELECT id, order_number, status FROM customer_order WHERE idempotency_key = :key`. Si trouve, sauter la creation et retourner cette commande (deduplique un retry rejoue — RG-T19). La cle est stockee sur la nouvelle ligne `customer_order`. | +| **[RG-9 — server-side modificateur re-validation]** | Les modificateurs d'ingredient dans le corps sont re-valides cote serveur contre `product_ingredient` : un `action='remove'` requiert `is_removable=1` ; un `action='add'` requiert `is_addable=1` et snapshote le `extra_price_cents` courant. Les verifications cote client (3.2 RG-4) ne sont pas dignes de confiance ; un POST forge ajoutant un ingredient non addable est rejete (HTTP 422). | +| **[RG-10 — atomic stock decrement]** | Aucune operation ne se conditionne a une lecture de stock, donc le decrement est une instruction atomique unique `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (RG-T20). La ligne s'auto-verrouille pour la duree de la mise a jour, donc les commandes kiosk concurrentes sur le meme ingredient appliquent leurs deltas sans perte de mise a jour et sans souci d'ordonnancement de deadlock ; `stock_quantity` est signe et peut devenir negatif (ampleur de survente remontee aux managers). | +| **[POST-1]** | Une ligne `customer_order` existe avec `status = 'paid'`, `source = 'kiosk'`, tous les totaux calcules, `paid_at` defini, `idempotency_key` stocke. La phase `pending_payment` n'est pas observable hors de la transaction. | +| **[POST-2]** | N lignes `order_item` existent, chacune referencant soit un `product_id` (item_type='product') soit un `menu_id` (item_type='menu') — contrainte d'exclusivite verifiee. | +| **[POST-3]** | `customer_order.order_number` est unique dans la base (contrainte UNIQUE). | +| **[POST-4]** | `ingredient.stock_quantity` decremente pour chaque unite d'ingredient consommee ; une ligne `stock_movement` de type `sale` par ingredient affecte. | +| **[OUT-1]** | HTTP 201 : `{data: {id: int, order_number: string, status: 'paid'}}` | +| **[OUT-2]** | Evenement logique ORDER_CREATED disponible pour le domaine de preparation (l'affichage de preparation se rafraichit via polling ou push serveur selon l'implementation) | +| **[ERR-1]** | Panier vide : HTTP 422, `{error: {code: "EMPTY_CART"}}` | +| **[ERR-2]** | Article indisponible : HTTP 422, `{error: {code: "ITEM_UNAVAILABLE", items: [...]}}` | +| **[ERR-3]** | Erreur DB / timeout : HTTP 500 avec rollback, `{error: {code: "DB_ERROR"}}` | --- ### 3.4 DISPLAY_CONFIRMATION -**Corresponds to MCT section 3.4** +**Correspond a la section 3.4 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | CREATE_ORDER a retourne HTTP 201 avec `{id, order_number, status: 'paid'}` | +| **[RG-1]** | Numero de commande affiche de maniere proeminente sur l'ecran de confirmation | +| **[RG-2]** | Apres un delai configurable (suggestion : 15 secondes), le kiosk se reinitialise automatiquement pour le client suivant | +| **[POST-1]** | Aucune ecriture en base | +| **[OUT-1]** | Ecran de confirmation affiche avec le numero de commande | +| **[ERR-1]** | Si la reponse de l'API est une erreur : message d'erreur generique affiche avec une option de reessai | --- -## 4. Domain 2 — Order lifecycle (counter and drive) +## 4. Domaine 2 — Cycle de vie de la commande (comptoir et drive) ### 4.1 CREATE_COUNTER_ORDER -**Corresponds to MCT section 4.1** +**Correspond a la section 4.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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"}}` | +| **[PRE-1]** | L'acteur est authentifie (session valide, `user.is_active = 1`) | +| **[PRE-2]** | L'acteur detient la permission `order.create` (verifiee via `role_permission`) | +| **[PRE-3]** | Le panier contient au moins 1 article | +| **[RG-1]** | Logique de creation identique a CREATE_ORDER (RG-1 a RG-7 s'appliquent), avec les differences suivantes : `source` est auto-tagguee depuis `role.order_source` (role comptoir -> `counter`, role drive -> `drive`) ; `service_mode` est selectionne par le membre du personnel (`dine_in` / `takeaway` / `drive`) ; `user_id` est defini a l'id de l'utilisateur authentifie dans les lignes `stock_movement` (au lieu de NULL pour le kiosk). | +| **[RG-2 — cross-constraint]** | Si `source = 'drive'` alors `service_mode` doit etre `'drive'` (RG-T09) ; verifie avant l'INSERT. HTTP 422 si viole. | +| **[RG-3 — order number]** | Format : `C-YYYY-MM-DD-NNN` pour la source comptoir ; `D-YYYY-MM-DD-NNN` pour la source drive. Le compteur sequentiel NNN est par source par service_day. | +| **[RG-4 — stock]** | Meme logique de decrement de stock que CREATE_ORDER RG-5 ; `stock_movement.user_id` est defini a l'id du membre du personnel authentifie. | +| **[RG-5 — staff attribution + decrement]** | `customer_order.acting_user_id` est defini a l'id du membre du personnel authentifie (imputabilite ciblee sur les commandes comptoir/drive ; les commandes kiosk restent NULL). La re-validation des modificateurs cote serveur (3.3 RG-9), l'idempotence (RG-T19) et le decrement de stock atomique (RG-T20) s'appliquent a l'identique. Aucun PIN n'est requis pour creer une commande (la permission `order.create` suffit) ; la creation de commande n'est pas dans l'ensemble des actions sensibles. | +| **[POST-1]** | Une ligne `customer_order` avec `status = 'paid'`, `source = 'counter'` ou `'drive'`, `paid_at` defini, `acting_user_id` defini. | +| **[POST-2]** | N lignes `order_item` avec snapshots. Selections de slot et modificateurs ecrits a l'identique du flux kiosk. | +| **[POST-3]** | Stock decremente ; mouvements journalises avec l'acteur `user_id`. | +| **[OUT-1]** | HTTP 201 : `{data: {id: int, order_number: string, status: 'paid'}}`. Numero de commande communique au client. | +| **[ERR-1]** | Memes cas d'erreur que CREATE_ORDER (ERR-1, ERR-2, ERR-3) | +| **[ERR-2]** | Violation de contrainte croisee (`source = drive` mais `service_mode != drive`) : HTTP 422, `{error: {code: "INVALID_SERVICE_MODE"}}` | --- -## 5. Domain 3 — Preparation display (kitchen) +## 5. Domaine 3 — Affichage de preparation (cuisine) ### 5.1 LIST_ORDERS_DISPLAY -**Corresponds to MCT section 5.1** +**Correspond a la section 5.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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`. | +| **[PRE-1]** | L'acteur est authentifie, `is_active = 1` | +| **[PRE-2]** | L'acteur detient la permission `order.read` | +| **[RG-1 — source filter]** | Recuperer les sources visibles pour le role de l'acteur : `SELECT source FROM role_visible_source WHERE role_id = :role_id`. La cuisine voit les trois ; le comptoir voit `kiosk` et `counter` ; le drive voit `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) | +| **[RG-3 — item detail]** | Pour chaque ligne de commande de type `menu`, charger aussi les lignes `order_item_selection` (choix de slot). Pour toutes les lignes, charger les lignes `order_item_modifier` (modifications d'ingredient). L'affichage utilise les snapshots (`label_snapshot`, `quantity`, `format`) ; aucune re-jointure sur les tables `product` ou `menu` necessaire. | +| **[RG-4 — KDS colour]** | Indicateur de couleur calcule au rendu : `elapsed = NOW() - customer_order.paid_at` ; vert si elapsed < seuil SLA (configurable, approx. 10 min) ; ambre si en approche ; rouge si depasse. Non stocke ; calcule cote client ou en PHP avant la reponse. | +| **[RG-5 — read only]** | Le personnel de cuisine n'effectue aucune transition de statut depuis cette vue. Aucun UPDATE n'est emis par cette operation. | +| **[POST-1]** | Aucune ecriture en base | +| **[OUT-1]** | Liste des commandes au statut `paid`, filtree par role, triee par `paid_at` croissant, avec le detail complet des articles (selections, modificateurs, couleur KDS) | --- -## 6. Domain 4 — Delivery to customer +## 6. Domaine 4 — Remise au client ### 6.1 DELIVER_ORDER -**Corresponds to MCT section 6.1** +**Correspond a la section 6.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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`) | +| **[PRE-1]** | L'acteur est authentifie, detient la permission `order.deliver` | +| **[PRE-2]** | La commande ciblee existe et `status = 'paid'` | +| **[PRE-3]** | La source de la commande est dans les sources visibles de l'acteur (verifiee 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"}}` | +| **[RG-2 — concurrency]** | La clause `AND status = 'paid'` dans l'UPDATE protege contre une double remise concurrente : si deux membres du personnel cliquent simultanement, seul le premier reussit (le second recoit 0 ligne affectee). | +| **[RG-3]** | `delivered` est un statut terminal : aucune transition ulterieure n'est definie depuis ce statut (contrainte applicative, pas appliquee comme trigger DB). | +| **[POST-1]** | `customer_order.status = 'delivered'`, `delivered_at` defini, cycle de vie complet. La commande passe a l'historique. | +| **[OUT-1]** | HTTP 200 avec confirmation. La commande disparait de la file `paid`. | +| **[ERR-1]** | Transition invalide (le statut n'etait pas `paid` au moment de l'execution de l'UPDATE — concurrence) : HTTP 409, `{error: {code: "INVALID_TRANSITION"}}` | +| **[ERR-2]** | Source de commande hors des sources visibles de l'acteur : HTTP 403, `{error: {code: "FORBIDDEN"}}` | --- -## 7. Domain 5 — Cancellation +## 7. Domaine 5 — Annulation ### 7.1 CANCEL_ORDER -**Corresponds to MCT section 7.1** +**Correspond a la section 7.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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`. | +| **[PRE-1]** | L'acteur est authentifie, detient la permission `order.cancel` | +| **[PRE-2]** | La commande ciblee existe | +| **[PRE-3]** | `customer_order.status` est dans `['pending_payment', 'paid']`. Les statuts terminaux `delivered` et `cancelled` ne peuvent pas transiter vers `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"}}` | +| **[RG-2 — concurrency]** | La clause `AND status IN (...)` protege contre une annulation concurrente (voir RG-T07). | +| **[RG-3 — stock re-credit — conditional]** | Le re-credit ne s'applique que si la commande etait au statut `paid` avant l'annulation. Les commandes a `pending_payment` n'avaient pas encore decremente le stock (le decrement a lieu a la transition `paid`). Pour chaque ligne `order_item` d'une commande `paid`, recalculer les unites d'ingredient consommees : `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, ajuste par les lignes `order_item_modifier` (modificateur remove -> l'ingredient n'a pas ete decremente, donc pas de re-credit ; modificateur add -> l'ingredient avait un decrement supplementaire, donc re-credit supplementaire). UPDATE `ingredient.stock_quantity += units`. INSERT `stock_movement` (type `cancellation`, delta = +units, order_id, user_id de l'acteur). | +| **[RG-4 — transaction]** | La mise a jour du statut et le re-credit de stock (quand applicable) s'executent dans la meme transaction de base de donnees (RG-T11). | +| **[RG-5 — history]** | La commande n'est pas physiquement supprimee ; conservee pour l'historique et les stats. Les commandes annulees sont exclues des totaux de chiffre d'affaires mais incluses dans les comptes de volume dans READ_STATS. Les lignes `order_item` ne sont pas supprimees (ON DELETE CASCADE n'est pas declenche) ; elles permettent de reconstruire ce qui a ete commande. | +| **[RG-6 — PIN + audit]** | L'annulation est une action sensible de manipulation d'argent : elle requiert le PIN propre a chaque membre du personnel (RG-T13) et ecrit une ligne `audit_log` dans la meme transaction (RG-T14) : `action_code='order.cancel'`, `entity_type='customer_order'`, `entity_id=:id`, `summary` avec le statut anterieur et le montant re-credite. | +| **[POST-1]** | `customer_order.status = 'cancelled'`, `cancelled_at` defini, etat terminal. Une ligne `audit_log` enregistree avec le personnel agissant. | +| **[POST-2]** | Si le statut anterieur etait `paid` : `ingredient.stock_quantity` re-credite ; une ligne `stock_movement` de type `cancellation` par ingredient affecte. | +| **[OUT-1]** | HTTP 200 avec confirmation d'annulation | +| **[ERR-1]** | Tentative d'annulation d'une commande livree ou deja annulee : HTTP 422, `{error: {code: "CANNOT_CANCEL_IN_STATE", current_status: "..."}}` | +| **[ERR-2]** | Annulation concurrente (0 ligne affectee par l'UPDATE) : HTTP 409, `{error: {code: "INVALID_TRANSITION"}}` | --- -## 8. Domain 6 — Catalogue management +## 8. Domaine 6 — Gestion du catalogue ### 8.1 CREATE_PRODUCT -**Corresponds to MCT section 8.1** +**Correspond a la section 8.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | Acteur authentifie, detient la permission `product.create` | +| **[PRE-2]** | `category_id` reference une categorie existante avec `is_active = 1` | +| **[RG-1]** | Validation du formulaire : `name` non vide, `price_cents > 0`, `category_id` valide, `vat_rate` dans `(55, 100)` | +| **[RG-2]** | Upload d'image (optionnel) : valider le type MIME (JPEG, PNG, WEBP), taille max configurable (suggestion : 2 MB), stocker sous `UPLOAD_DIR/products/`, enregistrer le chemin relatif dans `image_path` | +| **[RG-3]** | `is_available = 1` par defaut a l'INSERT | +| **[RG-4]** | `display_order` defini a `MAX(display_order) + 1` pour la categorie cible, ou 0 si premier produit | +| **[POST-1]** | Une ligne `product` dans la base avec tous les champs valides | +| **[OUT-1]** | Redirection vers la liste des produits de la categorie avec message de succes | +| **[ERR-1]** | Echec de validation : erreurs de champ affichees en ligne | +| **[ERR-2]** | Image invalide (type ou taille) : message d'erreur specifique | --- ### 8.2 UPDATE_PRODUCT -**Corresponds to MCT section 8.2** +**Correspond a la section 8.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | Acteur authentifie, detient la permission `product.update` | +| **[PRE-2]** | Le `product.id` cible existe | +| **[RG-1]** | Memes validations que CREATE_PRODUCT sur les champs modifies | +| **[RG-2]** | Si une nouvelle image est uploadee, l'ancien fichier image est supprime du systeme de fichiers (nettoyage du volume) | +| **[RG-3]** | `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot` dans les lignes `order_item` historiques ne sont pas modifies (voir RG-T05) | +| **[RG-4 — PIN + audit + allowlist]** | Un changement de prix/TVA est une action sensible : il requiert le PIN propre a chaque membre du personnel (RG-T13) et ecrit une ligne `audit_log` (RG-T14) avec `action_code='product.update'`, `entity_type='product'`, `entity_id=:id`, et un `summary` enregistrant les valeurs modifiees (ex. `price_cents 880 -> 920`). Seules les colonnes en allowlist (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`) sont liees depuis la requete (RG-T16). | +| **[POST-1]** | `product` mis a jour, `updated_at` rafraichi ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection vers la liste des produits avec message de succes | --- ### 8.3 DELETE_PRODUCT -**Corresponds to MCT section 8.3** +**Correspond a la section 8.3 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | Acteur authentifie, detient la permission `product.delete` | +| **[PRE-2]** | Le `product.id` cible existe | +| **[RG-1]** | Pre-verification (PHP) : le produit est-il reference dans `menu_slot_option.product_id` ? Si oui, afficher un message bloquant listant les menus. | +| **[RG-2]** | Pre-verification (PHP) : le produit est-il le `burger_product_id` d'un `menu` ? Si oui, bloquer avec un message invitant a supprimer ou reaffecter le menu d'abord. | +| **[RG-3]** | Pre-verification (PHP) : le produit est-il reference dans `order_item.product_id` (commandes historiques) ? La FK `ON DELETE RESTRICT` bloque au niveau DB. Reponse recommandee : proposer la desactivation (`is_available=0`) plutot que la suppression. | +| **[RG-4]** | Les contraintes FK (`menu_slot_option.product_id ON DELETE RESTRICT`, `order_item.product_id ON DELETE RESTRICT`) appliquent la contrainte meme si la verification PHP est contournee. | +| **[RG-5 — PIN + audit]** | La suppression est une action sensible : elle requiert le PIN propre a chaque membre du personnel (RG-T13) et ecrit une ligne `audit_log` (RG-T14) avec `action_code='product.delete'`, `entity_type='product'`, `entity_id=:id`, `summary` capturant le nom du produit avant suppression (enregistre avant que la ligne ne soit retiree). | +| **[POST-1]** | Produit supprime si aucune contrainte FK ne bloquait ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection vers la liste des produits avec message de succes | +| **[ERR-1]** | Produit dans un slot de menu : HTTP 422 ou message en ligne avec la liste des menus bloquants | +| **[ERR-2]** | Produit dans des commandes historiques : message proposant la desactivation a la place | --- ### 8.4 CREATE_MENU -**Corresponds to MCT section 8.4** +**Correspond a la section 8.4 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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) | +| **[PRE-1]** | Acteur authentifie, detient la permission `menu.create` | +| **[PRE-2]** | `burger_product_id` reference un produit existant et disponible | +| **[PRE-3]** | Au moins un `menu_slot` est defini avec au moins une `menu_slot_option` | +| **[RG-1]** | Validation : `name` non vide, `price_normal_cents > 0`, `price_maxi_cents > 0`, `burger_product_id` valide, toutes les valeurs `product_id` des options de slot existent | +| **[RG-2]** | Transaction : INSERT `menu`, puis INSERT des lignes `menu_slot` (name, slot_type, is_required, display_order), puis INSERT des lignes `menu_slot_option` (menu_slot_id, product_id) | +| **[RG-3]** | Valeurs `slot_type` valides (depuis l'ENUM du dictionnaire) : `drink`, `side`, `sauce`, `dessert`, `extra` | +| **[POST-1]** | Une ligne `menu`, N lignes `menu_slot`, M lignes `menu_slot_option` dans la base | +| **[OUT-1]** | Redirection vers la liste des menus avec message de succes | +| **[ERR-1]** | Configuration invalide (pas de slot, pas d'option) : message d'erreur metier | +| **[ERR-2]** | Produit d'option de slot indisponible : avertissement (le menu peut etre cree ; la disponibilite du produit est verifiee au moment de la commande) | --- ### 8.5 UPDATE_MENU -**Corresponds to MCT section 8.5** +**Correspond a la section 8.5 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | Acteur authentifie, detient la permission `menu.update` | +| **[PRE-2]** | Le `menu.id` cible existe | +| **[RG-1]** | Memes validations que CREATE_MENU sur les champs modifies | +| **[RG-2]** | Si la configuration de slot est modifiee : `DELETE FROM menu_slot_option WHERE menu_slot_id IN (SELECT id FROM menu_slot WHERE menu_id = :id)`, puis `DELETE FROM menu_slot WHERE menu_id = :id`, puis re-INSERT (pattern delete-and-reinsert, atomique en transaction) | +| **[RG-3]** | Les valeurs `label_snapshot` dans les lignes `order_item_selection` historiques ne sont pas affectees (voir RG-T05) | +| **[POST-1]** | `menu` mis a jour ; `menu_slot` et `menu_slot_option` reconstruits | +| **[OUT-1]** | Redirection avec message de succes | --- ### 8.6 DELETE_MENU -**Corresponds to MCT section 8.6** +**Correspond a la section 8.6 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | Acteur authentifie, detient la permission `menu.delete` | +| **[PRE-2]** | Le `menu.id` cible existe | +| **[RG-1]** | Pre-verification (PHP) : le menu est-il reference dans `order_item.menu_id` ? FK `ON DELETE RESTRICT`. Si oui, proposer la desactivation (`is_available=0`) au lieu de la suppression. | +| **[RG-2]** | Si aucune reference historique : DELETE `menu` declenche un CASCADE vers `menu_slot` (qui cascade vers `menu_slot_option`) | +| **[RG-3 — PIN + audit]** | La suppression est une action sensible : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14), `action_code='menu.delete'`, `entity_type='menu'`, `entity_id=:id`, `summary` capturant le nom du menu avant suppression. | +| **[POST-1]** | `menu`, ses lignes `menu_slot` et ses lignes `menu_slot_option` supprimes ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection avec message de succes | +| **[ERR-1]** | Menu dans des commandes historiques : message proposant la desactivation a la place | --- ### 8.7 MANAGE_CATEGORY -**Corresponds to MCT section 8.7** +**Correspond a la section 8.7 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | Acteur authentifie, detient la permission `category.manage` | +| **[RG-CREATE]** | `name` et `slug` non vides et uniques dans la base ; `display_order` defini a 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 | +| **[RG-DEACTIVATE]** | La desactivation (`is_active=0`) ne desactive pas automatiquement les produits/menus enfants dans la DB (pas de CASCADE sur `is_active`). La couche PHP propose a l'admin de desactiver aussi les produits/menus enfants, ou le filtre kiosk sur `category.is_active = 1` les masque implicitement. | +| **[RG-DELETE]** | Suppression physique bloquee si `product.category_id` ou `menu.category_id` reference cette categorie (FK `ON DELETE RESTRICT`). Proposer la desactivation. | +| **[POST-CREATE]** | Nouvelle ligne `category` dans la base | +| **[POST-UPDATE]** | `category` mise a jour, `updated_at` rafraichi | +| **[OUT-1]** | Confirmation, redirection vers la liste des categories | --- ### 8.8 MANAGE_INGREDIENT -**Corresponds to MCT section 8.8** +**Correspond a la section 8.8 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | Acteur authentifie, detient la permission `ingredient.manage` | +| **[RG-CREATE-ING]** | `name` non vide et UNIQUE ; `unit` non vide ; `pack_size >= 1` ; `stock_capacity >= 1` (la reference 100%) ; `low_stock_pct` et `critical_stock_pct` dans 0-100 avec `critical_stock_pct < low_stock_pct` (defauts 10 / 5) ; `stock_quantity` par defaut a 0 a la 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 | +| **[RG-DEACTIVATE-ING]** | `is_active=0` masque l'ingredient du configurateur. Suppression physique bloquee si reference dans `product_ingredient` (FK `ON DELETE RESTRICT`) ou `stock_movement` (FK `ON DELETE RESTRICT`). | +| **[RG-COMPOSITION]** | UPDATE `product_ingredient` : pour chaque ingredient de la recette d'un produit, definir `quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`. Pattern delete-and-reinsert en transaction. | +| **[RG-ALLERGEN]** | Gerer `ingredient_allergen` : INSERT ou DELETE des paires `(ingredient_id, allergen_id)`. La liste des allergenes est en lecture seule (14 lignes fixees par le reglement UE 1169/2011). | +| **[POST-1]** | Lignes `ingredient` / `product_ingredient` / `ingredient_allergen` mises a jour | +| **[OUT-1]** | Confirmation, redirection vers la liste des ingredients ou le formulaire de composition de produit | --- -## 9. Domain 7 — Stock management +## 9. Domaine 7 — Gestion du stock ### 9.1 RESTOCK -**Corresponds to MCT section 9.1** +**Correspond a la section 9.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `stock.manage` | -| **[PRE-2]** | Target ingredient exists and `is_active = 1` | -| **[PRE-3]** | Number of packs `N >= 1` | +| **[PRE-1]** | Acteur authentifie, detient la permission `stock.manage` | +| **[PRE-2]** | L'ingredient cible existe et `is_active = 1` | +| **[PRE-3]** | Nombre de 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 | +| **[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=acteur, note=optionnelle) | +| **[RG-3]** | `stock_movement` est append-only : aucun UPDATE ou DELETE sur cette table (les corrections sont de nouvelles lignes) | +| **[POST-1]** | `ingredient.stock_quantity` incremente de `delta`. Une ligne `stock_movement` de type `restock` inseree. | +| **[OUT-1]** | Confirmation avec le nouveau niveau de stock affiche | --- ### 9.2 INVENTORY_COUNT -**Corresponds to MCT section 9.2** +**Correspond a la section 9.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | Acteur authentifie, detient la permission `stock.count` | +| **[PRE-2]** | L'ingredient cible existe | +| **[PRE-3]** | `actual_quantity >= 0` (le comptage physique est non negatif) | +| **[RG-1]** | `delta = actual_quantity - ingredient.stock_quantity` (peut etre negatif si actual < theorique) | +| **[RG-2]** | Transaction : `UPDATE ingredient SET stock_quantity = :actual_quantity WHERE id = :id` ; INSERT `stock_movement` (ingredient_id, movement_type=`inventory_correction`, delta=calcule, order_id=NULL, user_id=acteur, note=optionnelle) | +| **[RG-3]** | `delta = 0` est une correction valide (le comptage physique correspond au theorique) ; une ligne de mouvement est tout de meme inseree pour la completude de l'audit | +| **[RG-4 — PIN attribution]** | Une correction d'inventaire peut masquer de la demarque, elle requiert donc le PIN propre a chaque membre du personnel (RG-T13). Le `user_id` capture par PIN est ecrit dans `stock_movement.user_id`, rendant la correction imputable a une personne meme sur un poste de travail partage. Pas de ligne `audit_log` separee (la trace `stock_movement` l'enregistre deja). | +| **[POST-1]** | `ingredient.stock_quantity = actual_quantity`. Une ligne `stock_movement` de type `inventory_correction` inseree avec le `user_id` agissant. | +| **[OUT-1]** | Confirmation avec le niveau de stock reconcilie et l'ecart affiches | --- ### 9.3 READ_STOCK -**Corresponds to MCT section 9.3** +**Correspond a la section 9.3 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `stock.read` | +| **[PRE-1]** | Acteur authentifie, detient la 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 | +| **[RG-2]** | Bandes de stock calculees au rendu depuis les seuils en pourcentage : `low_stock: true` quand `stock_quantity <= stock_capacity * low_stock_pct / 100`, `critical_stock: true` quand `stock_quantity <= stock_capacity * critical_stock_pct / 100` ; `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` est aussi retourne. Non stockees comme colonnes. | +| **[RG-3]** | Historique optionnel des mouvements pour un ingredient donne : `SELECT * FROM stock_movement WHERE ingredient_id = :id ORDER BY created_at DESC LIMIT :n` | +| **[RG-4 — attribution visibility]** | Le `stock_movement.user_id` (qui a reapprovisionne / qui a corrige) est inclus pour `manager`/`admin` uniquement ; le personnel de ligne (`kitchen`/`counter`/`drive`) voit les deltas de mouvement sans l'identite de l'acteur. Cela limite l'exposition intra-equipe tout en preservant l'imputabilite pour ceux qui gerent. L'allowlist `details` est appliquee a la couche de requete/serialisation. | +| **[POST-1]** | Aucune ecriture en base | +| **[OUT-1]** | Liste des ingredients avec `stock_quantity`, `stock_capacity`, `stock_pct` calcule, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`, drapeaux `low_stock` / `critical_stock` ; historique des mouvements avec l'acteur visible pour manager/admin uniquement | --- -## 10. Domain 8 — User and role management +## 10. Domaine 8 — Gestion des utilisateurs et des roles ### 10.1 CREATE_USER -**Corresponds to MCT section 10.1** +**Correspond a la section 10.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | Acteur authentifie, detient la permission `user.create` | +| **[PRE-2]** | L'email n'existe pas deja dans `user.email` (contrainte UNIQUE) | +| **[PRE-3]** | `role_id` reference un role existant et actif | +| **[RG-1]** | Validation : `email` conforme a la RFC 5321 (PHP `FILTER_VALIDATE_EMAIL`), `first_name` et `last_name` non vides, `role_id` valide | +| **[RG-2]** | Hachage du mot de passe : `password_hash($password, PASSWORD_ARGON2ID)`. Longueur minimale du mot de passe : 8 caracteres. | +| **[RG-3]** | `is_active = 1` par defaut ; `last_login_at = NULL` a la creation | +| **[RG-4 — PIN + audit + allowlist]** | Creer un compte back-office est une action sensible : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14), `action_code='user.create'`, `entity_type='user'`, `entity_id=:new_id`, `details` enregistrant le `role_id` assigne (noms de champs/role, pas le mot de passe). Seules les colonnes en allowlist sont liees (RG-T16) : `email`, `first_name`, `last_name`, `role_id` (+ le mot de passe hache) ; `is_active` et tout autre champ sont definis cote serveur, pas lies a la requete. | +| **[POST-1]** | Une ligne `user` avec `password_hash` argon2id, `role_id` valide ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection vers la liste des utilisateurs avec message de succes | +| **[ERR-1]** | Email en doublon : message "Cet email est deja utilise" | +| **[ERR-2]** | Mot de passe trop court : message de validation en ligne | --- ### 10.2 UPDATE_USER -**Corresponds to MCT section 10.2** +**Correspond a la section 10.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | Acteur authentifie, detient la permission `user.update` | +| **[PRE-2]** | Le `user.id` cible existe | +| **[RG-1]** | Si un nouveau mot de passe est fourni (champ non vide) : re-hacher via `PASSWORD_ARGON2ID` et remplacer le hash existant | +| **[RG-2]** | Si le champ mot de passe est vide : le hash existant est preserve inchange | +| **[RG-3]** | Mise a jour d'email soumise a la contrainte UNIQUE (pre-verification avant l'UPDATE) | +| **[RG-4 — PIN + audit + allowlist]** | Editer un compte (incl. `role_id`, le vecteur d'escalade de privileges) est sensible : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14), `action_code='user.update'`, `entity_type='user'`, `entity_id=:id`, `details` listant les noms des champs modifies (pas les valeurs, pas de PII). Seules les colonnes en allowlist sont liees (RG-T16) : `first_name`, `last_name`, `email`, `role_id`, `is_active` (+ re-hachage optionnel du mot de passe). | +| **[POST-1]** | `user` mis a jour, `updated_at` rafraichi ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection avec message de succes | --- ### 10.3 DEACTIVATE_USER -**Corresponds to MCT section 10.3** +**Correspond a la section 10.3 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `user.deactivate` | -| **[PRE-2]** | Actor is not targeting their own account (`$targetUserId !== $currentUserId`) | +| **[PRE-1]** | Acteur authentifie, detient la permission `user.deactivate` | +| **[PRE-2]** | L'acteur ne cible pas son propre compte (`$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"}}` | +| **[RG-2]** | La session potentiellement active de l'utilisateur est invalidee a la requete suivante : le middleware verifie `user.is_active = 1` a chaque requete authentifiee | +| **[RG-3 — PIN + audit]** | Action sensible : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14), `action_code='user.deactivate'`, `entity_type='user'`, `entity_id=:id`. | +| **[POST-1]** | `user.is_active = 0` ; l'utilisateur ne peut plus se connecter ; l'historique reste intact ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection avec message de succes | +| **[ERR-1]** | Tentative d'auto-desactivation : HTTP 403, `{error: {code: "SELF_DEACTIVATION_FORBIDDEN"}}` | --- ### 10.4 MANAGE_RBAC -**Corresponds to MCT section 10.4** +**Correspond a la section 10.4 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | Acteur authentifie, detient la permission `role.manage` | +| **[PRE-2]** | Le `role.id` cible existe (pour la mise a jour des permissions) ou les champs du role sont valides (pour la creation de role) | +| **[PRE-3]** | Toutes les valeurs `permission_id` soumises existent dans le catalogue `permission` | +| **[RG-1 — permissions]** | Transaction : `DELETE FROM role_permission WHERE role_id = :id` ; INSERT des nouvelles paires `(role_id, permission_id)` pour chaque permission selectionnee | +| **[RG-2]** | Les permissions ne sont pas modifiables via cette operation : elles sont en lecture seule pour peupler le formulaire de selection. Le catalogue de permissions est fige au seed. | +| **[RG-3]** | L'effet est immediat pour les nouvelles requetes ; les sessions des utilisateurs portant ce role voient le changement a la prochaine verification de permission (les sessions stockent `role_id` ; les permissions sont rechargees depuis la DB a chaque verification). | +| **[RG-4 — custom role]** | Creer un role personnalise : INSERT `role` (code UNIQUE, label, description, default_route nullable, order_source nullable) ; INSERT des lignes `role_visible_source` selon le besoin. | +| **[RG-5 — order_source]** | `role.order_source` controle l'auto-tagging de `customer_order.source` lorsque ce role cree une commande. NULL pour admin et manager (ils peuvent creer au nom de n'importe quel canal). | +| **[RG-6 — PIN + audit change-log]** | Les changements RBAC sont a fort impact (escalade de privileges) : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14) par changement, `action_code='role.manage'`, `entity_type='role'`, `entity_id=:role_id`. Comme les permissions sont reecrites en delete-and-reinsert (RG-1), le `details` JSON enregistre le **diff** — codes de permission ajoutes et retires — calcule avant la reecriture, de sorte que la trace montre exactement quelles capacites un role a gagnees ou perdues et qui les a accordees. | +| **[POST-1]** | `role_permission` reflete exactement les permissions selectionnees pour ce role ; une ligne `audit_log` enregistree avec le diff de permissions | +| **[OUT-1]** | Redirection avec message de succes | --- -### 10.5 ERASE_USER_PII (RGPD anonymisation) +### 10.5 ERASE_USER_PII (anonymisation RGPD) -**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).** +**Operation security-by-design (pas de predecesseur MCT v0.1 / v0.2). Honore le droit a +l'effacement RGPD (Cr 3.d) sans casser l'integrite referentielle ni la trace d'audit (note 13 du dict.).** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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"}}` | +| **[PRE-1]** | Acteur authentifie, detient la permission `user.update` (l'effacement est une operation admin) | +| **[PRE-2]** | PIN propre a chaque membre du personnel verifie (RG-T13) — action sensible | +| **[PRE-3]** | Le `user.id` cible existe et `anonymized_at IS NULL` (pas deja anonymise) | +| **[RG-1 — anonymise, not delete]** | En une 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`. Le domaine placeholder est reserve par la RFC 2606 (`.invalid`), garde `email` UNIQUE et non identifiant. | +| **[RG-2 — preserve links]** | La ligne persiste, donc les FK pointant vers elle (`stock_movement.user_id`, `customer_order.acting_user_id`, `audit_log.actor_user_id`) restent valides et resolvent desormais vers un principal anonymise. L'imputabilite des actions passees est preservee dans sa forme (qui-en-tant-qu'id) sans conserver de PII. | +| **[RG-3 — audit]** | Une ligne `audit_log` (RG-T14) : `action_code='user.erase_pii'`, `entity_type='user'`, `entity_id=:id`. Le `summary`/`details` enregistrent l'evenement d'effacement et sa base legale, pas les valeurs effacees. | +| **[POST-1]** | Ligne `user` anonymisee : champs PII vides/placeholders, identifiants invalides, `anonymized_at` defini, `is_active = 0`. Liens referentiels intacts. | +| **[OUT-1]** | Confirmation ; l'utilisateur disparait des listes actives, demeure comme tombstone anonymise dans l'historique. | +| **[ERR-1]** | Deja anonymise : HTTP 409, `{error: {code: "ALREADY_ANONYMISED"}}` | --- -## 11. Domain 9 — Stats and KPI +## 11. Domaine 9 — Stats et KPI ### 11.1 READ_STATS -**Corresponds to MCT section 11.1** +**Correspond a la section 11.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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. | +| **[PRE-1]** | Acteur authentifie, detient la permission `stats.read` | +| **[RG-1 — service_day]** | Expression `service_day` utilisee dans toutes les agregations de stats : `CASE WHEN HOUR(customer_order.created_at) < 10 THEN DATE(customer_order.created_at) - INTERVAL 1 DAY ELSE DATE(customer_order.created_at) END`. Coupure a 10:00. Pas de colonne stockee. La formule v0.1 avec `INTERVAL 4 HOUR 30 MINUTE` est abandonnee. | +| **[RG-2 — revenue]** | Les requetes de chiffre d'affaires filtrent `status != 'cancelled'` ; elles somment `total_ttc_cents` depuis `customer_order`. Les commandes annulees sont exclues du chiffre d'affaires mais apparaissent dans les comptes de volume avec le filtre `status = 'cancelled'`. | | **[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 | +| **[RG-4 — delivery time KPI]** | Temps de livraison moyen : `AVG(TIMESTAMPDIFF(SECOND, paid_at, delivered_at))` sur les commandes avec `status = 'delivered'`. Reference SLA approx. 10 min (configurable). | +| **[RG-5 — breakdown]** | Ventilations disponibles par `source` (kiosk/counter/drive) et `service_mode` (dine_in/takeaway/drive) pour la planification de capacite. `service_mode` ne porte aucun role fiscal (voir note 9 du dictionnaire). | +| **[POST-1]** | Aucune ecriture en base | +| **[OUT-1]** | Donnees du tableau de bord de stats : chiffre d'affaires par service_day, comptes de commandes, top produits, taux d'annulation, temps de livraison moyen, ventilation par source/service_mode | --- -## 12. Domain 10 — Back-office authentication +## 12. Domaine 10 — Authentification back-office ### 12.1 AUTHENTICATE_USER -**Corresponds to MCT section 12.1** +**Correspond a la section 12.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[PRE-1]** | Formulaire de connexion soumis avec email et mot de passe | +| **[PRE-2]** | Le token CSRF du formulaire est valide (protection anti-CSRF) | +| **[PRE-3 — throttle gate]** | Si le compte est dans une fenetre de throttling (`user.lockout_until IS NOT NULL AND lockout_until > NOW()`), rejeter avec l'erreur generique avant toute verification de mot de passe. Le throttling est aussi cle par IP source via la table `login_throttle` : si une ligne existe pour l'IP source avec `lockout_until IS NOT NULL AND lockout_until > NOW()`, rejeter avec la meme erreur generique, de sorte que les tentatives distribuees sur de nombreux comptes sont ralenties elles aussi. | +| **[RG-1]** | Recherche : `SELECT * FROM user WHERE email = :email AND is_active = 1 LIMIT 1` | +| **[RG-2]** | Verification du mot de passe : `password_verify($password, $user->password_hash)`. En cas d'echec : meme erreur generique que l'email n'existe pas ou que le mot de passe soit faux (protection contre l'enumeration d'emails). Pour garder un timing comparable lorsque l'email est inconnu, un `password_verify` factice contre un hash leurre fixe est execute. | +| **[RG-3]** | En cas de succes : `session_regenerate(true)` (regeneration de l'ID de session, protection contre la fixation de session) | +| **[RG-4]** | Stockage de session : `$_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]** | Timeouts de session : timeout d'inactivite 4h (detection via timestamp de derniere activite en session) ; timeout absolu 10h (detection via `logged_in_at`) | +| **[RG-7]** | La cible de redirection est `role.default_route` (dynamique ; aucun nom de role en dur dans la logique de routage) | +| **[RG-8 — failure handling, degressive backoff]** | A une verification echouee, le compteur par compte sur `user` : `UPDATE user SET failed_login_attempts = failed_login_attempts + 1, last_failed_login_at = NOW()`, et une fois un seuil atteint (suggestion : 5) definir `lockout_until = NOW() + INTERVAL (base * 2^(attempts - threshold)) SECOND`, plafonne (suggestion : plafond de quelques minutes). Dans la meme etape, la dimension par IP est enregistree dans la table `login_throttle` : upsert de la ligne cle sur `ip_address` (insert si absente, sinon incrementer `failed_attempts` ; reinitialiser la fenetre quand elle a expire via `window_started_at`), mettre a jour `last_attempt_at = NOW()`, et une fois le seuil IP atteint definir `lockout_until` avec le meme backoff degressif. C'est un backoff degressif, pas un verrouillage indefini — il ralentit la force brute sans laisser une serie de fautes de frappe priver de service une cuisine en plein rush. Ecrire une ligne `audit_log` (`action_code='auth.login_failed'`, `actor_user_id` si l'email a ete resolu, sinon NULL). | +| **[RG-9 — success reset]** | En cas de succes, reinitialiser le compteur par compte `failed_login_attempts = 0`, effacer `lockout_until = NULL`, et effacer aussi la ligne `login_throttle` par IP pour l'IP source (reinitialiser `failed_attempts = 0`, `lockout_until = NULL`, redemarrer `window_started_at`), puis ecrire une ligne `audit_log` (`action_code='auth.login_success'`, `actor_user_id`, `actor_role_id`). | +| **[POST-1]** | Session PHP ouverte avec `user_id` et `role_id` ; `user.last_login_at` mis a jour ; `failed_login_attempts` reinitialise | +| **[OUT-1]** | Redirection vers `role.default_route` | +| **[ERR-1]** | Identifiants incorrects ou compte inactif : message generique "Email ou mot de passe incorrect" (aucune distinction pour eviter l'enumeration) ; compteur d'echec incremente (RG-8) | +| **[ERR-2]** | Token CSRF invalide : HTTP 403 | +| **[ERR-3]** | Compte dans une fenetre de throttling (PRE-3) : meme message generique ; la tentative ne revele pas que le compte existe ou est verrouille | --- ### 12.2 LOGOUT_USER -**Corresponds to MCT section 12.2** +**Correspond a la section 12.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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)` | +| **[PRE-1]** | Session valide ouverte (`session_id()` non vide, `$_SESSION['user_id']` present) | +| **[RG-1]** | `$_SESSION = []` (effacer les donnees de session) | +| **[RG-2]** | Si un cookie de session existe, l'expirer : `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 | +| **[POST-1]** | Session PHP detruite ; aucun acces authentifie possible avec l'ancien cookie | +| **[OUT-1]** | Redirection vers la page de connexion | --- ### 12.3 RESET_PASSWORD -**Security-by-design operation (no v0.1 predecessor). Two phases: request, then confirm.** +**Operation security-by-design (pas de predecesseur v0.1). Deux phases : demande, puis confirmation.** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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) | +| **[PRE-1]** | Phase de demande : un `user` soumet le formulaire "mot de passe oublie" avec un email ; token CSRF valide | +| **[RG-1 — request, enumeration-safe]** | Rechercher l'email. La meme reponse neutre ("si le compte existe, un email a ete envoye") est retournee que l'email existe ou non, pour eviter l'enumeration de compte. | +| **[RG-2 — token generation]** | Si l'email resout vers un utilisateur actif : generer un token aleatoire cryptographique (ex. 32 octets depuis un CSPRNG) ; stocker son **hash** dans `password_reset_token_hash` et `password_reset_expires_at = NOW() + INTERVAL 1 HOUR`. Le token **brut** est envoye une seule fois dans le lien de reinitialisation (pas stocke en clair). | +| **[PRE-2]** | Phase de confirmation : l'utilisateur ouvre le lien de reinitialisation avec le token brut et soumet un nouveau mot de passe ; token CSRF valide | +| **[RG-3 — confirm]** | Hacher le token soumis et le comparer a `password_reset_token_hash` ou `password_reset_expires_at > NOW()`. En cas de correspondance : `password_hash = password_hash($new, PASSWORD_ARGON2ID)` (longueur min 8), puis effacer `password_reset_token_hash = NULL` et `password_reset_expires_at = NULL`, et reinitialiser `failed_login_attempts = 0`, `lockout_until = NULL`. Usage unique. | +| **[RG-4 — audit]** | Ecrire une ligne `audit_log` (RG-T14), `action_code='auth.password_reset'`, `actor_user_id = :id`. | +| **[POST-1]** | Mot de passe remplace par un nouveau hash argon2id ; token de reinitialisation consomme et efface | +| **[OUT-1]** | Confirmation ; redirection vers la connexion | +| **[ERR-1]** | Token invalide ou expire : message generique invitant a une nouvelle demande de reinitialisation (aucun detail sur la condition qui a echoue) | -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. +Ces traitements sont executes par le conteneur de service `wakdo-cron` dans la fenetre de +maintenance 01:30-09:30 (hors service actif). Ils sont hors du perimetre du MCT (traitements +techniques, pas de declencheur utilisateur) mais sont documentes ici par coherence avec PROJECT_CONTEXT. -### 13.1 Stats aggregation (cron 04:30) +### 13.1 Agregation des stats (cron 04:30) -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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) | +| **[TRIGGER]** | Cron : `30 4 * * *` | +| **[RG-1]** | `service_day` a agreger : calcule par commande (voir RG-1 de READ_STATS). A 04:30 le service_day en cours est le jour calendaire precedent. | +| **[RG-2]** | Agregations par `service_day` : nombre de commandes, chiffre d'affaires TTC (somme `total_ttc_cents` ou `status != 'cancelled'`), top produits (par `label_snapshot`, COUNT dans `order_item`) | +| **[POST-1]** | Stats disponibles pour le tableau de bord admin (requetes directes sur `customer_order` filtrees par `service_day`, ou une table d'agregation si implementee) | -### 13.2 Expired sessions purge (cron every 15 min) +### 13.2 Purge des sessions expirees (cron toutes les 15 min) -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[TRIGGER]** | Cron : `*/15 * * * *` | +| **[RG-1]** | Sessions basees fichier (par defaut) : `find /tmp/sessions -mmin +240 -delete` | +| **[RG-2]** | Sessions basees DB (option) : `DELETE FROM php_sessions WHERE updated_at < NOW() - INTERVAL 4 HOUR` | +| **[POST-1]** | Sessions expirees supprimees ; les utilisateurs inactifs depuis plus de 4h sont forces de se reconnecter | -### 13.3 DB backup (cron 03:00) +### 13.3 Sauvegarde DB (cron 03:00) -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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 | +| **[TRIGGER]** | Cron : `0 3 * * *` | +| **[RG-1]** | `mysqldump` de la base `wakdo` vers un fichier date dans le volume de sauvegarde | +| **[RG-2]** | Retention : garder les 7 derniers dumps ; supprimer les plus anciens | +| **[POST-1]** | Dump SQL disponible pour restauration | -### 13.4 Audit log retention purge (cron daily) +### 13.4 Purge de retention du journal d'audit (cron quotidien) -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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. | +| **[TRIGGER]** | Cron : `15 4 * * *` (fenetre de maintenance) | +| **[RG-1]** | `DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL :retention_months MONTH` (suggestion : 12 mois, interet legitime / tracabilite fiscale — configurable dans `.env`). | +| **[RG-2]** | La fenetre est decouplee du cycle de vie des PII utilisateur : l'anonymisation (10.5) retire les PII immediatement sur demande, tandis que la trace d'audit vieillit selon son propre calendrier (note 13 du dict.). | +| **[POST-1]** | Lignes `audit_log` plus anciennes que la fenetre de retention retirees ; imputabilite recente preservee. | -### 13.5 login_throttle purge (cron daily) +### 13.5 Purge de login_throttle (cron quotidien) -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[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. | +| **[TRIGGER]** | Cron : `45 4 * * *` (fenetre de maintenance) | +| **[RG-1]** | `DELETE FROM login_throttle WHERE (lockout_until IS NULL OR lockout_until < NOW()) AND last_attempt_at < NOW() - INTERVAL 24 HOUR` — purger les lignes sans verrouillage actif dont la derniere tentative echouee est plus ancienne que 24h. | +| **[RG-2]** | Les lignes servant encore un verrouillage actif sont conservees ; le compteur par IP (S1) est borne par cette purge de sorte que la table ne croit pas de maniere illimitee a cause de tentatives ponctuelles. | +| **[POST-1]** | Lignes `login_throttle` obsoletes retirees ; throttles actifs et activite recente preserves. | --- -## 14. State machine — consistency recap (MLT) +## 14. Machine a etats — recapitulatif de coherence (MLT) -Summary of `customer_order.status` transitions covered in the MLT, with corresponding -operations, SQL condition, concurrency protection, and phase timestamp set. +Recapitulatif des transitions de `customer_order.status` couvertes dans le MLT, avec les operations +correspondantes, la condition SQL, la protection de concurrence et le timestamp de phase defini. -| Transition | MLT operation | SQL condition | Concurrency protection | Phase timestamp set | +| Transition | Operation MLT | Condition SQL | Protection concurrence | Timestamp de phase pose | |------------|--------------|---------------|------------------------|---------------------| -| `-> 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` | +| `-> pending_payment` (creation) | CREATE_ORDER (3.3), CREATE_COUNTER_ORDER (4.1) | INSERT avec statut `pending_payment` | Transaction atomique | `created_at` | +| `pending_payment -> paid` | CREATE_ORDER (3.3), CREATE_COUNTER_ORDER (4.1) | UPDATE dans la meme transaction | Transaction atomique | `paid_at` | +| `paid -> delivered` | DELIVER_ORDER (6.1) | `WHERE status = 'paid'` | status dans le WHERE (clause AND) | `delivered_at` | +| `pending_payment/paid -> cancelled` | CANCEL_ORDER (7.1) | `WHERE status IN ('pending_payment', 'paid')` | status dans le WHERE (clause AND) | `cancelled_at` | -Terminal statuses (no further transition defined from these states): `delivered`, `cancelled`. +Statuts terminaux (aucune transition ulterieure definie depuis ces etats) : `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`. +**Abandonnes depuis v0.1** : +- Transitions `paid -> preparing` et `preparing -> ready` — etats intermediaires retires. +- MARQUER_EN_PREPARATION (section 4.2 du MLT v0.1) — abandonnee. +- MARQUER_PRETE (section 4.3 du MLT v0.1) — abandonnee. +- `preparing` et `ready` dans l'ensemble des etats annulables — l'ensemble annulable est desormais + `['pending_payment', 'paid']` uniquement. +- Table `commande_event` et RG-T10 v0.1 — remplacees par les timestamps de phase sur `customer_order`. --- -## 15. Residual notes and open points +## 15. Notes residuelles et points ouverts -### 15.1 `service_day` — not materialised as a column +### 15.1 `service_day` — non materialise comme colonne -The `service_day` computation is documented (RG-2 of CREATE_ORDER, RG-1 of READ_STATS): +Le calcul de `service_day` est documente (RG-2 de CREATE_ORDER, RG-1 de 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. +(coupure 10:00). Il est calcule a l'execution de la requete, pas stocke. Pour les requetes de stats +a haute frequence, une colonne generee MariaDB `VIRTUAL` ou `STORED` pourrait etre ajoutee au moment du DDL pour eviter +un recalcul par ligne, mais ce n'est pas un bloquant pour le perimetre RNCP. +La formule v0.1 avec `INTERVAL 4 HOUR 30 MINUTE` etait incorrecte et est abandonnee. -### 15.2 `order_item_modifier` for menu items +### 15.2 `order_item_modifier` pour les articles menu -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. +Pour une ligne de menu (`item_type='menu'`), les modificateurs ciblent le burger fixe identifie via +`order_item.menu_id -> menu.burger_product_id`. La contrainte que les modificateurs ne referencent +que des ingredients appartenant au `product_ingredient` du burger est appliquee a la +couche applicative, pas a la couche FK de la DB (voir note 10 du dictionnaire). C'est un +compromis connu : une FK multi-colonnes ou un trigger DB serait necessaire pour l'appliquer au niveau DB. +Le documenter comme un invariant applicatif est l'approche retenue pour le perimetre de ce projet. -### 15.3 Order number NNN counter — concurrency +### 15.3 Compteur NNN de numero de commande — concurrence -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. +Le compteur sequentiel NNN par `(source, service_day)` pourrait produire des doublons sous +forte concurrence s'il est implemente naivement comme `SELECT COUNT + 1`. L'implementation +recommandee au moment du DDL/code est soit : (a) un verrou consultatif au niveau table autour de la +sequence count-and-insert ; soit (b) une table de sequence dediee avec un increment atomique. +La contrainte UNIQUE sur `order_number` fournit le garde-fou de dernier recours (l'INSERT echouerait +et l'application reessaie). Ce n'est pas un bloquant pour le volume de la demo RNCP.