1366 lines
61 KiB
Markdown
1366 lines
61 KiB
Markdown
# Modele Logique de Donnees (MLD) — Wakdo
|
||
|
||
**Phase Merise** : P1 - Conception, etape 5 (apres MCD, MCT, MLT)
|
||
**Version** : v0.3 — prod-like, 22 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. Objectif de ce document
|
||
|
||
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.
|
||
|
||
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 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)
|
||
|
||
**Plateforme cible** :
|
||
- MariaDB 11.4 LTS (cf. `docker-compose.yml` service `wakdo-db`)
|
||
- Moteur InnoDB (ACID, support des FK, verrouillage au niveau ligne, CHECK depuis 10.2.1)
|
||
- Charset `utf8mb4`, collation `utf8mb4_unicode_ci`
|
||
|
||
---
|
||
|
||
## 2. Conventions de notation
|
||
|
||
### Notation relationnelle
|
||
|
||
```
|
||
table_name (col1, col2, #col_fk, [col_nullable])
|
||
|
||
PK : col1
|
||
UK : col2
|
||
FK : col_fk -> other_table(id) ON DELETE <rule>
|
||
IDX : (col_a, col_b)
|
||
CHK : <expression>
|
||
```
|
||
|
||
| Symbole | Signification |
|
||
|---|---|
|
||
| `col` | Colonne NOT NULL |
|
||
| `[col]` | Colonne nullable |
|
||
| `#col` | Colonne FK |
|
||
|
||
La notation suit l'usage Merise francais (convention Nanci/Espinasse adaptee a l'ASCII).
|
||
|
||
### Resume des types
|
||
|
||
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. Regles de traduction MCD -> MLD appliquees
|
||
|
||
### 3.1 Entite -> Table
|
||
|
||
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 Association `(1,1) - (1,N)` -> FK simple
|
||
|
||
L'entite du cote `(1,1)` porte la FK vers l'entite `(0,N)` ou `(1,N)`.
|
||
|
||
### 3.3 Association `(0,N) - (0,N)` ou `(1,N) - (1,N)` -> table de jointure
|
||
|
||
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 Entite associative avec attributs propres -> table de jointure avec colonnes
|
||
|
||
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 Polymorphisme -> 2 FK nullables + discriminateur + CHECK
|
||
|
||
`order_item` reference soit `product` soit `menu`. Traduit en 2 colonnes FK nullables +
|
||
1 discriminateur ENUM + 1 contrainte CHECK imposant l'exclusivite mutuelle.
|
||
|
||
---
|
||
|
||
## 4. Schema relationnel (22 tables)
|
||
|
||
Les tables sont ordonnees par dependance (tables sans FK d'abord, puis tables qui en dependent).
|
||
|
||
### Diagrammes relationnels (par sous-domaine)
|
||
|
||
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 22 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
|
||
|
||
```mermaid
|
||
erDiagram
|
||
category {
|
||
int id PK
|
||
varchar name UK
|
||
varchar slug UK
|
||
smallint display_order
|
||
tinyint is_active
|
||
}
|
||
product {
|
||
int id PK
|
||
int category_id FK
|
||
varchar name
|
||
int price_cents
|
||
smallint vat_rate
|
||
tinyint is_available
|
||
smallint display_order
|
||
}
|
||
menu {
|
||
int id PK
|
||
int category_id FK
|
||
int burger_product_id FK
|
||
varchar name
|
||
int price_normal_cents
|
||
int price_maxi_cents
|
||
tinyint is_available
|
||
smallint display_order
|
||
}
|
||
menu_slot {
|
||
int id PK
|
||
int menu_id FK
|
||
varchar name
|
||
enum slot_type
|
||
tinyint is_required
|
||
smallint display_order
|
||
}
|
||
menu_slot_option {
|
||
int menu_slot_id PK,FK
|
||
int product_id PK,FK
|
||
}
|
||
|
||
category ||--o{ product : "category_id (RESTRICT)"
|
||
category ||--o{ menu : "category_id (RESTRICT)"
|
||
product ||--o{ menu : "burger_product_id (RESTRICT)"
|
||
menu ||--o{ menu_slot : "menu_id (CASCADE)"
|
||
menu_slot ||--o{ menu_slot_option : "menu_slot_id (CASCADE)"
|
||
product ||--o{ menu_slot_option : "product_id (RESTRICT)"
|
||
```
|
||
|
||
#### Ingredients et Stock
|
||
|
||
```mermaid
|
||
erDiagram
|
||
ingredient {
|
||
int id PK
|
||
varchar name UK
|
||
varchar unit
|
||
int stock_quantity
|
||
int stock_capacity
|
||
smallint pack_size
|
||
smallint low_stock_pct
|
||
smallint critical_stock_pct
|
||
tinyint is_active
|
||
}
|
||
product_ingredient {
|
||
int product_id PK,FK
|
||
int ingredient_id PK,FK
|
||
smallint quantity_normal
|
||
smallint quantity_maxi
|
||
tinyint is_removable
|
||
tinyint is_addable
|
||
int extra_price_cents
|
||
}
|
||
allergen {
|
||
int id PK
|
||
varchar code UK
|
||
varchar name
|
||
}
|
||
ingredient_allergen {
|
||
int ingredient_id PK,FK
|
||
int allergen_id PK,FK
|
||
}
|
||
stock_movement {
|
||
int id PK
|
||
int ingredient_id FK
|
||
enum movement_type
|
||
int delta
|
||
int order_id FK
|
||
int user_id FK
|
||
varchar note
|
||
}
|
||
product {
|
||
int id PK
|
||
varchar name
|
||
}
|
||
customer_order {
|
||
int id PK
|
||
varchar order_number
|
||
}
|
||
user {
|
||
int id PK
|
||
varchar email
|
||
}
|
||
|
||
product ||--o{ product_ingredient : "product_id (CASCADE)"
|
||
ingredient ||--o{ product_ingredient : "ingredient_id (RESTRICT)"
|
||
ingredient ||--o{ ingredient_allergen : "ingredient_id (CASCADE)"
|
||
allergen ||--o{ ingredient_allergen : "allergen_id (RESTRICT)"
|
||
ingredient ||--o{ stock_movement : "ingredient_id (RESTRICT)"
|
||
customer_order ||--o{ stock_movement : "order_id (SET NULL, nullable)"
|
||
user ||--o{ stock_movement : "user_id (SET NULL, nullable)"
|
||
```
|
||
|
||
#### Commande
|
||
|
||
```mermaid
|
||
erDiagram
|
||
customer_order {
|
||
int id PK
|
||
varchar order_number UK
|
||
varchar idempotency_key UK
|
||
enum source
|
||
int acting_user_id FK
|
||
enum service_mode
|
||
enum status
|
||
int total_ht_cents
|
||
int total_vat_cents
|
||
int total_ttc_cents
|
||
datetime paid_at
|
||
datetime delivered_at
|
||
datetime cancelled_at
|
||
}
|
||
order_item {
|
||
int id PK
|
||
int order_id FK
|
||
enum item_type
|
||
int product_id FK
|
||
int menu_id FK
|
||
enum format
|
||
varchar label_snapshot
|
||
int unit_price_cents_snapshot
|
||
smallint vat_rate_snapshot
|
||
smallint quantity
|
||
}
|
||
order_item_selection {
|
||
int id PK
|
||
int order_item_id FK
|
||
int menu_slot_id FK
|
||
int product_id FK
|
||
varchar label_snapshot
|
||
}
|
||
order_item_modifier {
|
||
int id PK
|
||
int order_item_id FK
|
||
int ingredient_id FK
|
||
enum action
|
||
int extra_price_cents
|
||
}
|
||
user {
|
||
int id PK
|
||
varchar email
|
||
}
|
||
product {
|
||
int id PK
|
||
varchar name
|
||
}
|
||
menu {
|
||
int id PK
|
||
varchar name
|
||
}
|
||
menu_slot {
|
||
int id PK
|
||
varchar name
|
||
}
|
||
ingredient {
|
||
int id PK
|
||
varchar name
|
||
}
|
||
|
||
user ||--o{ customer_order : "acting_user_id (SET NULL, nullable)"
|
||
customer_order ||--o{ order_item : "order_id (CASCADE)"
|
||
product ||--o{ order_item : "product_id (RESTRICT, polymorphic)"
|
||
menu ||--o{ order_item : "menu_id (RESTRICT, polymorphic)"
|
||
order_item ||--o{ order_item_selection : "order_item_id (CASCADE)"
|
||
menu_slot ||--o{ order_item_selection : "menu_slot_id (RESTRICT)"
|
||
product ||--o{ order_item_selection : "product_id (RESTRICT)"
|
||
order_item ||--o{ order_item_modifier : "order_item_id (CASCADE)"
|
||
ingredient ||--o{ order_item_modifier : "ingredient_id (RESTRICT)"
|
||
```
|
||
|
||
#### RBAC & securite
|
||
|
||
```mermaid
|
||
erDiagram
|
||
role {
|
||
int id PK
|
||
varchar code UK
|
||
varchar label
|
||
varchar default_route
|
||
enum order_source
|
||
tinyint is_active
|
||
}
|
||
user {
|
||
int id PK
|
||
varchar email UK
|
||
varchar password_hash
|
||
varchar pin_hash
|
||
varchar first_name
|
||
varchar last_name
|
||
int role_id FK
|
||
tinyint is_active
|
||
smallint failed_login_attempts
|
||
datetime lockout_until
|
||
datetime anonymized_at
|
||
}
|
||
role_visible_source {
|
||
int role_id PK,FK
|
||
enum source PK
|
||
}
|
||
permission {
|
||
int id PK
|
||
varchar code UK
|
||
varchar label
|
||
}
|
||
role_permission {
|
||
int role_id PK,FK
|
||
int permission_id PK,FK
|
||
}
|
||
audit_log {
|
||
int id PK
|
||
int actor_user_id FK
|
||
int actor_role_id FK
|
||
varchar action_code
|
||
varchar entity_type
|
||
int entity_id
|
||
varchar summary
|
||
json details
|
||
datetime created_at
|
||
}
|
||
login_throttle {
|
||
int id PK
|
||
varchar ip_address UK
|
||
smallint failed_attempts
|
||
datetime window_started_at
|
||
datetime lockout_until
|
||
datetime last_attempt_at
|
||
}
|
||
pin_throttle {
|
||
int id PK
|
||
int actor_user_id FK,UK
|
||
smallint failed_attempts
|
||
datetime window_started_at
|
||
datetime lockout_until
|
||
datetime last_attempt_at
|
||
}
|
||
|
||
role ||--o{ user : "role_id (RESTRICT)"
|
||
role ||--o{ role_visible_source : "role_id (CASCADE)"
|
||
role ||--o{ role_permission : "role_id (CASCADE)"
|
||
permission ||--o{ role_permission : "permission_id (CASCADE)"
|
||
user ||--o{ audit_log : "actor_user_id (SET NULL, nullable)"
|
||
role ||--o{ audit_log : "actor_role_id (SET NULL, nullable)"
|
||
user ||--o{ pin_throttle : "actor_user_id (CASCADE)"
|
||
```
|
||
|
||
> `login_throttle` n'a pas de FK (une IP n'est pas une entite modelisee) ; elle est autonome, cle par
|
||
> `ip_address`. `pin_throttle` (RG-T22) est cle par `actor_user_id` (FK -> `user`, ON DELETE CASCADE) :
|
||
> le throttle du PIN porte sur l'utilisateur AGISSANT, dimension distincte du login.
|
||
|
||
---
|
||
|
||
### 4.1 `category`
|
||
|
||
```
|
||
category (id, name, slug, [image_path], display_order, is_active, created_at, updated_at)
|
||
|
||
PK : id
|
||
UK : name
|
||
UK : slug
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `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 |
|
||
|
||
Pas de FK. Table racine du sous-domaine Catalogue.
|
||
|
||
---
|
||
|
||
### 4.2 `product`
|
||
|
||
```
|
||
product (id, #category_id, name, [description], price_cents, vat_rate,
|
||
[image_path], is_available, display_order, created_at, updated_at)
|
||
|
||
PK : id
|
||
FK : category_id -> category(id) ON DELETE RESTRICT
|
||
IDX : (category_id, is_available, display_order)
|
||
CHK : price_cents > 0
|
||
CHK : vat_rate IN (55, 100)
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `category_id` | INT UNSIGNED | NO | FK -> category |
|
||
| `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** sur `category_id` : une categorie avec des produits ne peut pas etre supprimee. Empeche les
|
||
produits orphelins.
|
||
|
||
---
|
||
|
||
### 4.3 `menu`
|
||
|
||
```
|
||
menu (id, #category_id, #burger_product_id, name, [description],
|
||
price_normal_cents, price_maxi_cents, [image_path],
|
||
is_available, display_order, created_at, updated_at)
|
||
|
||
PK : id
|
||
FK : category_id -> category(id) ON DELETE RESTRICT
|
||
FK : burger_product_id -> product(id) ON DELETE RESTRICT
|
||
IDX : (category_id, is_available, display_order)
|
||
CHK : price_normal_cents > 0
|
||
CHK : price_maxi_cents > 0
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `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** sur les deux FK : empeche la suppression d'une categorie ou d'un produit burger
|
||
encore reference par une definition de menu.
|
||
|
||
---
|
||
|
||
### 4.4 `menu_slot`
|
||
|
||
```
|
||
menu_slot (id, #menu_id, name, slot_type, is_required, display_order)
|
||
|
||
PK : id
|
||
FK : menu_id -> menu(id) ON DELETE CASCADE
|
||
IDX : (menu_id, display_order)
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `menu_id` | INT UNSIGNED | NO | FK -> menu |
|
||
| `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 |
|
||
|
||
**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** sur `menu_id` : si un menu est supprime, ses slots sont supprimes avec lui.
|
||
|
||
---
|
||
|
||
### 4.5 `menu_slot_option`
|
||
|
||
Table de jointure pure. PK composite.
|
||
|
||
```
|
||
menu_slot_option (#menu_slot_id, #product_id)
|
||
|
||
PK : (menu_slot_id, product_id)
|
||
FK : menu_slot_id -> menu_slot(id) ON DELETE CASCADE
|
||
FK : product_id -> product(id) ON DELETE RESTRICT
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot |
|
||
| `product_id` | INT UNSIGNED | NO | FK -> product |
|
||
|
||
**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.
|
||
|
||
Pas d'horodatages. Table de jointure pure.
|
||
|
||
---
|
||
|
||
### 4.6 `ingredient`
|
||
|
||
```
|
||
ingredient (id, name, unit, stock_quantity, stock_capacity, pack_size, [pack_label],
|
||
low_stock_pct, critical_stock_pct, is_active, created_at, updated_at)
|
||
|
||
PK : id
|
||
UK : name
|
||
CHK : stock_capacity > 0
|
||
CHK : pack_size > 0
|
||
CHK : low_stock_pct BETWEEN 0 AND 100
|
||
CHK : critical_stock_pct BETWEEN 0 AND 100
|
||
CHK : critical_stock_pct < low_stock_pct
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `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 |
|
||
|
||
Pas de FK. Table racine du sous-domaine Ingredients & Stock.
|
||
|
||
**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.
|
||
|
||
**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`
|
||
|
||
Table associative portant les metadonnees de recette et de personnalisation. PK composite.
|
||
|
||
```
|
||
product_ingredient (#product_id, #ingredient_id, quantity_normal, quantity_maxi,
|
||
is_removable, is_addable, extra_price_cents)
|
||
|
||
PK : (product_id, ingredient_id)
|
||
FK : product_id -> product(id) ON DELETE CASCADE
|
||
FK : ingredient_id -> ingredient(id) ON DELETE RESTRICT
|
||
CHK : quantity_normal > 0
|
||
CHK : quantity_maxi >= quantity_normal
|
||
CHK : extra_price_cents >= 0
|
||
```
|
||
|
||
| 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 | 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** 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.
|
||
|
||
Pas d'horodatages. Table de jointure avec attributs.
|
||
|
||
---
|
||
|
||
### 4.8 `allergen`
|
||
|
||
```
|
||
allergen (id, code, name, [description])
|
||
|
||
PK : id
|
||
UK : code
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `code` | VARCHAR(30) | NO | Code machine, p. ex. `gluten`, `milk` |
|
||
| `name` | VARCHAR(80) | NO | Nom d'affichage |
|
||
| `description` | TEXT | YES | Indication optionnelle |
|
||
|
||
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`
|
||
|
||
Table de jointure pure. PK composite.
|
||
|
||
```
|
||
ingredient_allergen (#ingredient_id, #allergen_id)
|
||
|
||
PK : (ingredient_id, allergen_id)
|
||
FK : ingredient_id -> ingredient(id) ON DELETE CASCADE
|
||
FK : allergen_id -> allergen(id) ON DELETE RESTRICT
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient |
|
||
| `allergen_id` | INT UNSIGNED | NO | FK -> allergen |
|
||
|
||
**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.
|
||
|
||
Pas d'horodatages. Table de jointure pure.
|
||
|
||
---
|
||
|
||
### 4.10 `role`
|
||
|
||
Placee avant `user` car `user` depend de `role`.
|
||
|
||
```
|
||
role (id, code, label, [description], [default_route], [order_source],
|
||
is_active, created_at, updated_at)
|
||
|
||
PK : id
|
||
UK : code
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `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 |
|
||
|
||
Pas de FK. Table racine pour le RBAC.
|
||
|
||
---
|
||
|
||
### 4.11 `user`
|
||
|
||
```
|
||
user (id, email, password_hash, [pin_hash], first_name, last_name, #role_id,
|
||
is_active, [last_login_at], failed_login_attempts, [last_failed_login_at],
|
||
[lockout_until], [password_reset_token_hash], [password_reset_expires_at],
|
||
[anonymized_at], created_at, updated_at)
|
||
|
||
PK : id
|
||
UK : email
|
||
FK : role_id -> role(id) ON DELETE RESTRICT
|
||
IDX : (is_active, role_id)
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `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 | 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** 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.
|
||
|
||
**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-<id>@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`
|
||
|
||
Table de jointure pure. PK composite.
|
||
|
||
```
|
||
role_visible_source (#role_id, source)
|
||
|
||
PK : (role_id, source)
|
||
FK : role_id -> role(id) ON DELETE CASCADE
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `role_id` | INT UNSIGNED | NO | FK -> role |
|
||
| `source` | ENUM('kiosk','counter','drive') | NO | Source de commande visible sur le tableau de bord |
|
||
|
||
**ON DELETE CASCADE** sur `role_id` : si un role est supprime, ses filtres de source du tableau de bord disparaissent avec lui.
|
||
|
||
Pas d'horodatages. Table de jointure pure.
|
||
|
||
Donnees de seed :
|
||
- `kitchen` : kiosk, counter, drive
|
||
- `counter` : kiosk, counter
|
||
- `drive` : drive
|
||
- `admin`, `manager` : pas de lignes (vue globale, pas de filtre de source)
|
||
|
||
---
|
||
|
||
### 4.13 `permission`
|
||
|
||
```
|
||
permission (id, code, label, [description], created_at)
|
||
|
||
PK : id
|
||
UK : code
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `code` | VARCHAR(60) | NO | Format `<resource>.<action>` |
|
||
| `label` | VARCHAR(120) | NO | Nom d'affichage |
|
||
| `description` | TEXT | YES | Optionnel |
|
||
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit |
|
||
|
||
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`
|
||
|
||
Table de jointure pure. PK composite.
|
||
|
||
```
|
||
role_permission (#role_id, #permission_id)
|
||
|
||
PK : (role_id, permission_id)
|
||
FK : role_id -> role(id) ON DELETE CASCADE
|
||
FK : permission_id -> permission(id) ON DELETE CASCADE
|
||
IDX : permission_id
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `role_id` | INT UNSIGNED | NO | FK -> role |
|
||
| `permission_id` | INT UNSIGNED | NO | FK -> permission |
|
||
|
||
**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.
|
||
|
||
Pas d'horodatages. Table de jointure pure.
|
||
|
||
---
|
||
|
||
### 4.15 `customer_order`
|
||
|
||
```
|
||
customer_order (id, order_number, [idempotency_key], source, [#acting_user_id],
|
||
service_mode, status,
|
||
total_ht_cents, total_vat_cents, total_ttc_cents,
|
||
[paid_at], [delivered_at], [cancelled_at],
|
||
created_at, updated_at)
|
||
|
||
PK : id
|
||
UK : order_number
|
||
UK : idempotency_key
|
||
FK : acting_user_id -> user(id) ON DELETE SET NULL
|
||
IDX : (status, created_at)
|
||
IDX : (source, created_at)
|
||
IDX : created_at
|
||
CHK : total_ht_cents >= 0
|
||
CHK : total_vat_cents >= 0
|
||
CHK : total_ttc_cents > 0
|
||
CHK : total_ttc_cents = total_ht_cents + total_vat_cents
|
||
CHK : source != 'drive' OR service_mode = 'drive'
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `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 |
|
||
|
||
**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).
|
||
|
||
**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).
|
||
|
||
**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
|
||
```
|
||
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).
|
||
|
||
**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'`** : le CHECK l'impose au niveau de la BD.
|
||
|
||
---
|
||
|
||
### 4.16 `order_item`
|
||
|
||
```
|
||
order_item (id, #order_id, item_type, [#product_id], [#menu_id], format,
|
||
label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot,
|
||
quantity, created_at)
|
||
|
||
PK : id
|
||
FK : order_id -> customer_order(id) ON DELETE CASCADE
|
||
FK : product_id -> product(id) ON DELETE RESTRICT
|
||
FK : menu_id -> menu(id) ON DELETE RESTRICT
|
||
IDX : order_id
|
||
CHK : unit_price_cents_snapshot > 0
|
||
CHK : vat_rate_snapshot IN (55, 100)
|
||
CHK : quantity > 0
|
||
CHK : (item_type = 'product' AND product_id IS NOT NULL AND menu_id IS NULL)
|
||
OR (item_type = 'menu' AND menu_id IS NOT NULL AND product_id IS NULL)
|
||
```
|
||
|
||
| 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 | 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** 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.
|
||
|
||
**CHECK d'exclusivite du polymorphisme** : MariaDB 10.2+ l'impose au moment de l'INSERT/UPDATE.
|
||
|
||
---
|
||
|
||
### 4.17 `order_item_selection`
|
||
|
||
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)
|
||
|
||
PK : id
|
||
FK : order_item_id -> order_item(id) ON DELETE CASCADE
|
||
FK : menu_slot_id -> menu_slot(id) ON DELETE RESTRICT
|
||
FK : product_id -> product(id) ON DELETE RESTRICT
|
||
IDX : order_item_id
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `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** 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 : 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`
|
||
|
||
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)
|
||
|
||
PK : id
|
||
FK : order_item_id -> order_item(id) ON DELETE CASCADE
|
||
FK : ingredient_id -> ingredient(id) ON DELETE RESTRICT
|
||
IDX : order_item_id
|
||
CHK : extra_price_cents >= 0
|
||
```
|
||
|
||
| 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` = 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** 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.
|
||
|
||
**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`
|
||
|
||
Journal d'audit append-only de tous les changements de stock par ingredient.
|
||
|
||
```
|
||
stock_movement (id, #ingredient_id, movement_type, delta,
|
||
[#order_id], [#user_id], [note], created_at)
|
||
|
||
PK : id
|
||
FK : ingredient_id -> ingredient(id) ON DELETE RESTRICT
|
||
FK : order_id -> customer_order(id) ON DELETE SET NULL
|
||
FK : user_id -> user(id) ON DELETE SET NULL
|
||
IDX : (ingredient_id, created_at)
|
||
IDX : (movement_type, created_at)
|
||
```
|
||
|
||
| 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 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** 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.
|
||
|
||
**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.
|
||
|
||
Pas de `updated_at`. Table immuable append-only.
|
||
|
||
---
|
||
|
||
### 4.20 `audit_log`
|
||
|
||
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,
|
||
[entity_type], [entity_id], [summary], [details], created_at)
|
||
|
||
PK : id
|
||
FK : actor_user_id -> user(id) ON DELETE SET NULL
|
||
FK : actor_role_id -> role(id) ON DELETE SET NULL
|
||
IDX : (actor_user_id, created_at)
|
||
IDX : (entity_type, entity_id)
|
||
IDX : (action_code, created_at)
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `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** 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).
|
||
|
||
**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.).
|
||
|
||
Pas de `updated_at`. Table immuable append-only.
|
||
|
||
---
|
||
|
||
### 4.21 `login_throttle`
|
||
|
||
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,
|
||
[lockout_until], last_attempt_at)
|
||
|
||
PK : id
|
||
UK : ip_address
|
||
IDX : lockout_until
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `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 |
|
||
|
||
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.
|
||
|
||
Pas de `updated_at` : les lignes sont upsertees par IP, pas editees via une UI.
|
||
|
||
---
|
||
|
||
### 4.22 `pin_throttle`
|
||
|
||
Throttle du PIN d'action sensible par utilisateur AGISSANT (security-by-design, RG-T22). Separe des
|
||
compteurs de connexion (`user.failed_login_attempts` / `lockout_until` / `login_throttle`) : un echec de
|
||
PIN n'incremente aucun compteur de login.
|
||
|
||
```
|
||
pin_throttle (id, actor_user_id, failed_attempts, window_started_at,
|
||
[lockout_until], last_attempt_at)
|
||
|
||
PK : id
|
||
UK : actor_user_id
|
||
IDX : lockout_until
|
||
FK : actor_user_id -> user(id) ON DELETE CASCADE
|
||
```
|
||
|
||
| Colonne | Type | NULL | Notes |
|
||
|---|---|---|---|
|
||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||
| `actor_user_id` | INT UNSIGNED | NO | Utilisateur agissant (session), une ligne par acteur, upsertee. UNIQUE. FK -> `user(id)` ON DELETE CASCADE |
|
||
| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Echecs de PIN consecutifs de cet acteur 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 |
|
||
|
||
FK ON DELETE CASCADE (contrairement a `login_throttle`) : la cle est un user back-office authentifie, donc
|
||
supprimer/anonymiser le compte purge sa ligne de throttle. Append/upsert par acteur ; meme purge cron que
|
||
`login_throttle`. Pas de `updated_at` (lignes upsertees, pas editees via une UI).
|
||
|
||
---
|
||
|
||
## 5. Resume de l'integrite referentielle
|
||
|
||
| Colonne FK | References | ON DELETE | Justification |
|
||
|---|---|---|---|
|
||
| `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 |
|
||
| `pin_throttle.actor_user_id` | `user(id)` | CASCADE | Etat de throttle ephemere : il part avec le compte agissant supprime/anonymise (contrairement a l'audit, permanent) |
|
||
|
||
**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. Resume des contraintes CHECK
|
||
|
||
| Table | Expression CHECK | Objectif |
|
||
|---|---|---|
|
||
| `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. Index recommandes (au-dela des auto-index PK / UK / FK)
|
||
|
||
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 | Colonnes d'index | Pattern de requete |
|
||
|---|---|---|
|
||
| `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 |
|
||
| `pin_throttle` | `lockout_until` | Purge cron quotidienne des lignes sans verrouillage actif (RG-T22) |
|
||
|
||
**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. Validation croisee MLD <-> MCD
|
||
|
||
Verification que les 22 entites MCD (19 prod-like + 3 security-by-design) correspondent a une table,
|
||
et que toutes les tables se rattachent au MCD.
|
||
|
||
| Entite MCD | Table MLD | Type de mapping | Notes |
|
||
|---|---|---|---|
|
||
| `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) |
|
||
| `pin_throttle` (R9) | `pin_throttle` (4.22) | entite 1:1 | Nouvelle entite (security-by-design, RG-T22) |
|
||
|
||
**Resultat** : 22/22 entites mappees (19 prod-like + `audit_log` + `login_throttle` + `pin_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).
|
||
|
||
**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. Estimation de volume (6 mois)
|
||
|
||
| Table | Lignes a 6 mois | Taille moyenne de ligne | Taille est. |
|
||
|---|---|---|---|
|
||
| `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 |
|
||
| `pin_throttle` | ~10-100 | 80 octets | < 1 MB (1 ligne par user back-office) |
|
||
|
||
**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` 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 reportees au DDL et a P2
|
||
|
||
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. Prochaines etapes (DDL + Seed)
|
||
|
||
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` (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` (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)
|
||
- `pin_throttle` (FK `actor_user_id -> user`, donc apres le bloc `user`)
|
||
|
||
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 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. **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. **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.
|