Compare commits

...
Sign in to create a new pull request.

20 commits

Author SHA1 Message Date
Imugiii
fce6dae428 feat(stock): reglage rapide capacite + seuils depuis la page Stock (modale + endpoint dedie)
All checks were successful
CI / secret-scan (push) Successful in 19s
CI / php-lint (push) Successful in 43s
CI / static-tests (push) Successful in 1m46s
CI / js-tests (push) Successful in 48s
CI / secret-scan (pull_request) Successful in 29s
CI / php-lint (pull_request) Successful in 44s
CI / static-tests (pull_request) Successful in 1m22s
CI / js-tests (pull_request) Successful in 47s
2026-06-25 12:27:48 +00:00
be4585aeb2 feat(catalogue): CRUD variantes (taille/Maxi) + menus base-only + garde serveur (#112)
All checks were successful
CI / secret-scan (push) Successful in 26s
CI / php-lint (push) Successful in 36s
CI / static-tests (push) Successful in 1m23s
CI / js-tests (push) Successful in 48s
2026-06-25 14:01:47 +02:00
ba2abbfae9 fix(db): suivi seeds unifie + idempotence DDL + doc trou 0004 (#111)
All checks were successful
CI / secret-scan (push) Successful in 20s
CI / php-lint (push) Successful in 31s
CI / static-tests (push) Successful in 1m30s
CI / js-tests (push) Successful in 33s
2026-06-25 10:58:39 +02:00
a6dcb31c16 feat(security): garde DELIVER_ORDER (PRE-3) + audit/re-verif PIN (#110)
All checks were successful
CI / secret-scan (push) Successful in 24s
CI / php-lint (push) Successful in 38s
CI / static-tests (push) Successful in 1m13s
CI / js-tests (push) Successful in 44s
2026-06-25 10:49:06 +02:00
6cc762a964 test(stock): tests directs OpenFoodFacts parse() + dashboard alertes (#109)
All checks were successful
CI / secret-scan (push) Successful in 24s
CI / php-lint (push) Successful in 43s
CI / static-tests (push) Successful in 1m17s
CI / js-tests (push) Successful in 49s
2026-06-25 10:37:06 +02:00
89488b20b2 feat(back-office): KDS cuisine - detail des commandes + bande SLA (#108)
All checks were successful
CI / secret-scan (push) Successful in 16s
CI / php-lint (push) Successful in 30s
CI / static-tests (push) Successful in 1m15s
CI / js-tests (push) Successful in 37s
2026-06-25 10:28:36 +02:00
1e5f930185 feat(back-office): POS rupture RG-T21 non commandable (parite borne) (#107)
All checks were successful
CI / secret-scan (push) Successful in 22s
CI / php-lint (push) Successful in 30s
CI / static-tests (push) Successful in 59s
CI / js-tests (push) Successful in 31s
2026-06-25 10:16:56 +02:00
2e0d535b58 docs: remediation audit vague 1 - sync Merise + journal/ADR + claims faux (#106)
All checks were successful
CI / secret-scan (push) Successful in 20s
CI / php-lint (push) Successful in 45s
CI / static-tests (push) Successful in 1m23s
CI / js-tests (push) Successful in 1m5s
2026-06-25 10:02:13 +02:00
2fe192452d feat(back-office): page Stock en tableau de bord (alertes + reappro en avant) (#105)
All checks were successful
CI / secret-scan (push) Successful in 19s
CI / php-lint (push) Successful in 51s
CI / static-tests (push) Successful in 1m35s
CI / js-tests (push) Successful in 41s
2026-06-24 14:44:25 +02:00
9bdd53120c feat(back-office): saisie commande comptoir/drive en POS tactile a tuiles (#104)
All checks were successful
CI / secret-scan (push) Successful in 24s
CI / php-lint (push) Successful in 50s
CI / static-tests (push) Successful in 1m44s
CI / js-tests (push) Successful in 52s
2026-06-24 14:32:11 +02:00
6f2aedc699 chore(borne): bascule allergenes sur /api/allergens + menage donnees/docs (#103)
All checks were successful
CI / secret-scan (push) Successful in 18s
CI / php-lint (push) Successful in 36s
CI / static-tests (push) Successful in 1m25s
CI / js-tests (push) Successful in 37s
2026-06-24 12:37:54 +02:00
3c53908952 fix(borne): confirmation avant Abandon de la commande (#102)
All checks were successful
CI / secret-scan (push) Successful in 16s
CI / php-lint (push) Successful in 47s
CI / static-tests (push) Successful in 1m8s
CI / js-tests (push) Successful in 34s
2026-06-24 12:29:32 +02:00
6bf3597b5e fix(borne): panier unique = panneau persistant (retrait cart.html + product.html) (#101)
All checks were successful
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 28s
CI / static-tests (push) Successful in 1m12s
CI / js-tests (push) Successful in 53s
2026-06-24 12:18:30 +02:00
352355f5a5 feat(back-office): refonte saisie commande comptoir/drive (prix, verrou, nav, file) (#100)
All checks were successful
CI / secret-scan (push) Successful in 15s
CI / php-lint (push) Successful in 35s
CI / static-tests (push) Successful in 1m0s
CI / js-tests (push) Successful in 32s
2026-06-24 12:05:25 +02:00
0968a98668 feat(borne): produit/menu en rupture stock non commandable (RG-T21) (#99)
All checks were successful
CI / secret-scan (push) Successful in 11s
CI / php-lint (push) Successful in 25s
CI / static-tests (push) Successful in 1m6s
CI / js-tests (push) Successful in 31s
2026-06-24 11:25:14 +02:00
411b04d548 feat(borne): menu Maxi agrandit la boisson en 50cl + transport du format (#98)
All checks were successful
CI / secret-scan (push) Successful in 13s
CI / php-lint (push) Successful in 33s
CI / static-tests (push) Successful in 1m5s
CI / js-tests (push) Successful in 35s
2026-06-24 11:04:20 +02:00
8e2e0382ba fix(devops): passer les variables SMTP/MAIL au conteneur wakdo-app (#97)
All checks were successful
CI / secret-scan (push) Successful in 11s
CI / php-lint (push) Successful in 27s
CI / static-tests (push) Successful in 58s
CI / js-tests (push) Successful in 28s
2026-06-23 16:11:31 +02:00
ef71101453 feat(auth): envoi reel de l'email de reset via relais SMTP (Brevo) (#96)
All checks were successful
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 28s
CI / static-tests (push) Successful in 1m3s
CI / js-tests (push) Successful in 1m29s
2026-06-23 15:34:27 +02:00
80b8272291 chore(devops): modeles versionnes docker-compose.prod.yml + .env de prod (#95)
All checks were successful
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 25s
CI / static-tests (push) Successful in 58s
CI / js-tests (push) Successful in 36s
2026-06-23 15:01:02 +02:00
8c5d942de8 feat(devops): CD push-based vers Vision (prod) + preuve de version (#94)
All checks were successful
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 24s
CI / static-tests (push) Successful in 1m4s
CI / js-tests (push) Successful in 35s
2026-06-23 11:32:57 +02:00
129 changed files with 7820 additions and 1818 deletions

View file

@ -131,3 +131,16 @@ CRON_TIMEZONE=Europe/Paris
# Nom du reseau Docker externe partage avec le Traefik de l'hote (doit exister
# AVANT le up : cree par la stack Traefik, ou `docker network create <nom>`).
REVERSE_PROXY_NETWORK=admin_proxy
# ===================================================================
# Envoi d'email (reinitialisation mot de passe) - OPTIONNEL
# ===================================================================
# Absentes en local : l'app journalise le lien de reset (LogMailer), aucun envoi.
# Renseigner SMTP_HOST + SMTP_USER + SMTP_PASSWORD active l'envoi via relais SMTP.
# Mettre les vraies valeurs uniquement dans le .env de l'hote (jamais versionnees).
# SMTP_HOST=smtp-relay.brevo.com
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASSWORD=
# MAIL_FROM_EMAIL=noreply@example.com
# MAIL_FROM_NAME=Wakdo

75
.env.prod.example Normal file
View file

@ -0,0 +1,75 @@
# Modele de configuration de PRODUCTION (derriere Traefik).
#
# cp .env.prod.example .env
# puis renseigner les lignes <REMPLIR> (domaines, mots de passe, reseau Traefik).
#
# Difference avec .env.example (dev) : APP_ENV=prod, APP_DEBUG=false, URLs en HTTPS,
# mots de passe forts, REVERSE_PROXY_NETWORK renseigne.
APP_ENV=prod
APP_DEBUG=false
APP_TIMEZONE=Europe/Paris
# Domaines publics (doivent resoudre en DNS vers l'hote de prod).
APP_HOST_KIOSK=<REMPLIR-domaine-borne>
APP_HOST_ADMIN=<REMPLIR-domaine-admin>
APP_URL_KIOSK=https://<REMPLIR-domaine-borne>
APP_URL_ADMIN=https://<REMPLIR-domaine-admin>
# Base de donnees : mots de passe FORTS en prod (openssl rand -base64 24).
DB_HOST=wakdo-db
DB_PORT=3306
DB_NAME=wakdo
DB_USER=wakdo
DB_PASSWORD=<REMPLIR-mot-de-passe-fort>
DB_ROOT_PASSWORD=<REMPLIR-autre-mot-de-passe-fort>
SESSION_LIFETIME_IDLE=14400
SESSION_LIFETIME_ABSOLUTE=36000
SESSION_NAME=WAKDO_SID
# Doit correspondre EXACTEMENT a APP_URL_KIOSK (pas de wildcard).
CORS_ALLOWED_ORIGIN=https://<REMPLIR-domaine-borne>
ARGON2_MEMORY_COST=65536
ARGON2_TIME_COST=4
ARGON2_THREADS=1
ACCOUNT_LOCKOUT_THRESHOLD=5
ACCOUNT_LOCKOUT_BASE_SECONDS=60
ACCOUNT_LOCKOUT_MAX_SECONDS=900
IP_THROTTLE_WINDOW_SECONDS=900
IP_THROTTLE_MAX_ATTEMPTS=20
STAFF_PIN_MIN_LENGTH=4
STAFF_PIN_MAX_LENGTH=12
PIN_THROTTLE_THRESHOLD=5
PIN_THROTTLE_BASE_SECONDS=30
PIN_THROTTLE_MAX_SECONDS=300
PIN_THROTTLE_WINDOW_SECONDS=900
PASSWORD_RESET_TTL=3600
AUDIT_LOG_RETENTION_DAYS=365
THROTTLE_PURGE_AFTER_HOURS=24
ORDER_RETENTION_DAYS=1095
UPLOAD_MAX_SIZE_MB=5
UPLOAD_ALLOWED_MIME=image/jpeg,image/png,image/webp
CRON_TIMEZONE=Europe/Paris
# Nom du reseau Docker externe du Traefik de l'hote (doit exister avant le up).
REVERSE_PROXY_NETWORK=<REMPLIR-reseau-traefik>
# ===================================================================
# Envoi d'email (reinitialisation mot de passe) - relais SMTP
# ===================================================================
# Si SMTP_HOST + SMTP_USER + SMTP_PASSWORD sont presents, l'app envoie via le
# relais ; sinon elle se rabat sur le journal (LogMailer). Renseigner ces 3
# valeurs UNIQUEMENT ici (jamais dans le depot). Exemple : relais Brevo.
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_USER=<REMPLIR-login-smtp>
SMTP_PASSWORD=<REMPLIR-cle-smtp-secrete>
MAIL_FROM_EMAIL=noreply@a3n.fr
MAIL_FROM_NAME=Wakdo

View file

@ -0,0 +1,45 @@
name: Deploy
# Deploiement continu (CD) vers Vision (prod) a chaque release sur main.
#
# Topologie : le runner tourne sur Stark (dev) et n'a pas le socket Docker. Il ne
# pilote donc PAS Docker lui-meme : il OUVRE une session SSH vers Vision (prod, hote
# distinct) ou une forced command (cote Vision) lance scripts/deploy.sh. La cle CI ne
# peut ainsi declencher QUE le deploiement, rien d'autre.
#
# main n'est alimentee que par des PR dev->main deja passees par la CI : le code
# deploye a donc deja ete teste. Voir docs/architecture/deployment.md pour la mise en
# place cote Vision (utilisateur deploy, forced command) et les secrets a creer.
on:
push:
branches: [main]
jobs:
deploy:
runs-on: docker
steps:
- name: Install SSH client
run: |
apt-get update -qq
apt-get install -y -qq openssh-client >/dev/null
- name: Deploy to Vision over SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
run: |
set -eu
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
# Cle d'hote epinglee (pas de TOFU) : la connexion echoue si Vision ne
# presente pas la cle attendue.
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
# Aucune commande passee : la forced command cote Vision lance deploy.sh.
# BatchMode : pas de prompt interactif (un echec d'auth echoue vite au lieu
# de pendre le job) ; ConnectTimeout borne l'attente si Vision est injoignable.
ssh -i ~/.ssh/id_deploy -o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o BatchMode=yes -o ConnectTimeout=15 \
"$DEPLOY_USER@$DEPLOY_HOST"

3
.gitignore vendored
View file

@ -56,6 +56,9 @@ Thumbs.db
*.log
/logs/
# === Marqueur de version (ecrit par scripts/deploy.sh sur l'hote, propre au deploiement) ===
/src/VERSION
# === Data / Uploads / Backups ===
# /var/ : contient /var/backups/ (bind-mount des dumps BDD du conteneur cron)
# et tout futur artefact run-time (caches persistes, logs).

View file

@ -19,7 +19,7 @@ d'authentification durci dans `docs/uml/security-sequence.md`.
| Brute-force | double throttle : compteur par compte (`user`) + par IP (`login_throttle`), backoff degressif |
| Sessions | cookies `HttpOnly` + `Secure` + `SameSite=Strict`, regeneration d'ID a la connexion (anti-fixation), idle 4h / absolu 10h |
| Injection | PDO prepared statements exclusivement |
| Upload | validation MIME + taille, stockage hors webroot |
| Upload | non implemente (aucun flux d'upload livre) ; prevu : validation MIME + taille, stockage hors webroot |
| En-tetes / PHP | `expose_php=Off`, `allow_url_fopen/include=Off`, `cgi.fix_pathinfo=0`, fonctions d'execution systeme desactivees |
| RGPD | retention limitee (audit ~12 mois, throttle 24h, commandes ~3 ans), droit consultation/modif/suppression |
| Secrets | `.env` gitignore, tenu hors de `.git/config` (credential helper lisant `.env`), secret-scan gitleaks en CI |

View file

@ -6,23 +6,53 @@ Transcription executable du MLD (`docs/merise/mld.md`, 21 tables) vers MariaDB 1
```
db/
migrations/ migrations SQL versionnees, appliquees dans l'ordre lexicographique
migrations/ migrations SQL versionnees, appliquees dans l'ordre lexicographique
0001_init_schema.sql schema initial : 21 tables, FK, CHECK, index (InnoDB, utf8mb4)
seeds/ donnees de demonstration (a venir : roles/permissions, allergenes, catalogue)
migrate.sh runner de migrations (idempotent)
seeds/ donnees de reference (RBAC, allergenes, catalogue, variantes)
migrate-container.sh runner de boot IN-CONTAINER (canonique, service wakdo-migrate)
migrate.sh runner de migrations cote HOTE (manuel, via docker exec)
seed.sh runner de seeds cote HOTE (manuel, via docker exec)
```
## Appliquer les migrations
## Numerotation des migrations (trou 0004 assume)
Les migrations sautent `0004` : la sequence est `0001, 0002, 0003, 0005, 0006,
0007`. Ce n'est PAS un fichier manquant mais un desalignement historique assume :
le numero `0004` a ete consomme cote `seeds/` (`0004_menu_side_maxi.sql`) lors
d'un meme increment de travail, sans contrepartie cote `migrations/`. Le suivi se
fait par NOM DE FICHIER (`schema_migrations`), pas par numero contigu : le trou
est donc inoffensif (rien ne presuppose une sequence sans lacune). Convention
conservee : ne pas reattribuer `0004` cote migrations pour eviter toute confusion
avec le seed homonyme ; la prochaine migration prend le numero suivant disponible.
## Appliquer les migrations + seeds
Chemin canonique (boot de la stack) : le service one-shot `wakdo-migrate`
(`docker compose up`) execute `db/migrate-container.sh`, qui applique
`db/migrations/*.sql` (suivi : table `schema_migrations`) PUIS `db/seeds/*.sql`
(suivi : table `seeds_applied`), de maniere idempotente. Il se connecte a
`wakdo-db` par le reseau compose.
Chemin manuel (hote, via `docker exec`) :
```bash
bash db/migrate.sh # applique les migrations en attente
bash db/migrate.sh --status # liste l'etat sans rien appliquer
bash db/migrate.sh --status # liste l'etat des migrations sans rien appliquer
bash db/seed.sh # charge les seeds en attente
bash db/seed.sh --status # liste l'etat des seeds sans rien charger
```
Le runner cible le conteneur `wakdo-db` et lit les identifiants dans `.env`
(`DB_NAME`, `DB_ROOT_PASSWORD`). Il maintient une table `schema_migrations`
(une ligne par fichier applique) : relancer ne rejoue que les nouvelles
migrations. La cible `bash db/migrate.sh` est destinee a appeler ce script.
Les runners hote ciblent le conteneur `wakdo-db` et lisent les identifiants dans
`.env` (`DB_NAME`, `DB_ROOT_PASSWORD`).
### Suivi partage entre les deux chemins
Les runners hote et conteneur partagent les MEMES tables de suivi (memes noms,
memes colonnes `filename` / `applied_at`) : `schema_migrations` pour les
migrations, `seeds_applied` pour les seeds. Consequence : rejouer un chemin apres
l'autre ne replaye RIEN. (Auparavant `db/seed.sh` suivait une table distincte
`seed_history`, ce qui pouvait lui faire re-jouer des seeds deja charges par
`wakdo-migrate` et echouer sur une contrainte UNIQUE — corrige.)
## Conventions

View file

@ -18,6 +18,12 @@
-- the application layer.
-- - No CREATE DATABASE / USE here: the target DB is chosen by the runner.
-- - No seed / INSERT data here (see db/seeds/0001_demo_data.sql).
--
-- Idempotence (defense en profondeur, suivi par schema_migrations) : chaque table
-- est creee en CREATE TABLE IF NOT EXISTS. Index, cles uniques et contraintes
-- (FK / CHECK) sont declares INLINE dans le CREATE TABLE : ils ne sont donc pas
-- re-joues quand la table preexiste. Re-jouer ce fichier sur une base deja
-- migree ne modifie pas le schema (aucun ALTER autonome).
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
@ -29,7 +35,7 @@ SET FOREIGN_KEY_CHECKS = 0;
-- -----------------------------------------------------------------------------
-- 4.1 category — root table for the Catalogue sub-domain (no FK)
-- -----------------------------------------------------------------------------
CREATE TABLE category (
CREATE TABLE IF NOT EXISTS category (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(60) NOT NULL,
slug VARCHAR(60) NOT NULL,
@ -46,7 +52,7 @@ CREATE TABLE category (
-- -----------------------------------------------------------------------------
-- 4.6 ingredient — root table for Ingredients & Stock (no FK)
-- -----------------------------------------------------------------------------
CREATE TABLE ingredient (
CREATE TABLE IF NOT EXISTS ingredient (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(120) NOT NULL,
unit VARCHAR(40) NOT NULL,
@ -71,7 +77,7 @@ CREATE TABLE ingredient (
-- -----------------------------------------------------------------------------
-- 4.8 allergen — reference table (INCO EU 1169/2011), no FK
-- -----------------------------------------------------------------------------
CREATE TABLE allergen (
CREATE TABLE IF NOT EXISTS allergen (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(30) NOT NULL,
name VARCHAR(80) NOT NULL,
@ -83,7 +89,7 @@ CREATE TABLE allergen (
-- -----------------------------------------------------------------------------
-- 4.10 role — root table for RBAC (no FK)
-- -----------------------------------------------------------------------------
CREATE TABLE role (
CREATE TABLE IF NOT EXISTS role (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(40) NOT NULL,
label VARCHAR(80) NOT NULL,
@ -100,7 +106,7 @@ CREATE TABLE role (
-- -----------------------------------------------------------------------------
-- 4.13 permission — reference table, catalogue frozen at 23 codes (no FK)
-- -----------------------------------------------------------------------------
CREATE TABLE permission (
CREATE TABLE IF NOT EXISTS permission (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(60) NOT NULL,
label VARCHAR(120) NOT NULL,
@ -113,7 +119,7 @@ CREATE TABLE permission (
-- -----------------------------------------------------------------------------
-- 4.21 login_throttle — per-source-IP brute-force throttle (no FK)
-- -----------------------------------------------------------------------------
CREATE TABLE login_throttle (
CREATE TABLE IF NOT EXISTS login_throttle (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
ip_address VARCHAR(45) NOT NULL,
failed_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
@ -128,7 +134,7 @@ CREATE TABLE login_throttle (
-- -----------------------------------------------------------------------------
-- 4.2 product — depends on category
-- -----------------------------------------------------------------------------
CREATE TABLE product (
CREATE TABLE IF NOT EXISTS product (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
category_id INT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
@ -151,7 +157,7 @@ CREATE TABLE product (
-- -----------------------------------------------------------------------------
-- 4.3 menu — depends on category, product
-- -----------------------------------------------------------------------------
CREATE TABLE menu (
CREATE TABLE IF NOT EXISTS menu (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
category_id INT UNSIGNED NOT NULL,
burger_product_id INT UNSIGNED NOT NULL,
@ -177,7 +183,7 @@ CREATE TABLE menu (
-- -----------------------------------------------------------------------------
-- 4.4 menu_slot — depends on menu (no audit fields)
-- -----------------------------------------------------------------------------
CREATE TABLE menu_slot (
CREATE TABLE IF NOT EXISTS menu_slot (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
menu_id INT UNSIGNED NOT NULL,
name VARCHAR(80) NOT NULL,
@ -194,7 +200,7 @@ CREATE TABLE menu_slot (
-- 4.5 menu_slot_option — pure join table, composite PK
-- depends on menu_slot, product
-- -----------------------------------------------------------------------------
CREATE TABLE menu_slot_option (
CREATE TABLE IF NOT EXISTS menu_slot_option (
menu_slot_id INT UNSIGNED NOT NULL,
product_id INT UNSIGNED NOT NULL,
PRIMARY KEY (menu_slot_id, product_id),
@ -209,7 +215,7 @@ CREATE TABLE menu_slot_option (
-- 4.7 product_ingredient — join table with attributes, composite PK
-- depends on product, ingredient
-- -----------------------------------------------------------------------------
CREATE TABLE product_ingredient (
CREATE TABLE IF NOT EXISTS product_ingredient (
product_id INT UNSIGNED NOT NULL,
ingredient_id INT UNSIGNED NOT NULL,
quantity_normal SMALLINT UNSIGNED NOT NULL DEFAULT 1,
@ -232,7 +238,7 @@ CREATE TABLE product_ingredient (
-- 4.9 ingredient_allergen — pure join table, composite PK
-- depends on ingredient, allergen
-- -----------------------------------------------------------------------------
CREATE TABLE ingredient_allergen (
CREATE TABLE IF NOT EXISTS ingredient_allergen (
ingredient_id INT UNSIGNED NOT NULL,
allergen_id INT UNSIGNED NOT NULL,
PRIMARY KEY (ingredient_id, allergen_id),
@ -246,7 +252,7 @@ CREATE TABLE ingredient_allergen (
-- -----------------------------------------------------------------------------
-- 4.11 user — depends on role
-- -----------------------------------------------------------------------------
CREATE TABLE user (
CREATE TABLE IF NOT EXISTS user (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(254) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
@ -275,7 +281,7 @@ CREATE TABLE user (
-- 4.12 role_visible_source — pure join table, composite PK
-- depends on role
-- -----------------------------------------------------------------------------
CREATE TABLE role_visible_source (
CREATE TABLE IF NOT EXISTS role_visible_source (
role_id INT UNSIGNED NOT NULL,
source ENUM('kiosk','counter','drive') NOT NULL,
PRIMARY KEY (role_id, source),
@ -287,7 +293,7 @@ CREATE TABLE role_visible_source (
-- 4.14 role_permission — pure join table, composite PK
-- depends on role, permission
-- -----------------------------------------------------------------------------
CREATE TABLE role_permission (
CREATE TABLE IF NOT EXISTS role_permission (
role_id INT UNSIGNED NOT NULL,
permission_id INT UNSIGNED NOT NULL,
PRIMARY KEY (role_id, permission_id),
@ -301,7 +307,7 @@ CREATE TABLE role_permission (
-- -----------------------------------------------------------------------------
-- 4.15 customer_order — depends on user (acting_user_id)
-- -----------------------------------------------------------------------------
CREATE TABLE customer_order (
CREATE TABLE IF NOT EXISTS customer_order (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
order_number VARCHAR(20) NOT NULL,
idempotency_key VARCHAR(36) NULL,
@ -336,7 +342,7 @@ CREATE TABLE customer_order (
-- 4.16 order_item — depends on customer_order, product, menu
-- polymorphic line (product XOR menu)
-- -----------------------------------------------------------------------------
CREATE TABLE order_item (
CREATE TABLE IF NOT EXISTS order_item (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id INT UNSIGNED NOT NULL,
item_type ENUM('product','menu') NOT NULL,
@ -370,7 +376,7 @@ CREATE TABLE order_item (
-- -----------------------------------------------------------------------------
-- 4.17 order_item_selection — depends on order_item, menu_slot, product
-- -----------------------------------------------------------------------------
CREATE TABLE order_item_selection (
CREATE TABLE IF NOT EXISTS order_item_selection (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
order_item_id INT UNSIGNED NOT NULL,
menu_slot_id INT UNSIGNED NOT NULL,
@ -391,7 +397,7 @@ CREATE TABLE order_item_selection (
-- -----------------------------------------------------------------------------
-- 4.18 order_item_modifier — depends on order_item, ingredient
-- -----------------------------------------------------------------------------
CREATE TABLE order_item_modifier (
CREATE TABLE IF NOT EXISTS order_item_modifier (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
order_item_id INT UNSIGNED NOT NULL,
ingredient_id INT UNSIGNED NOT NULL,
@ -411,7 +417,7 @@ CREATE TABLE order_item_modifier (
-- 4.19 stock_movement — append-only audit log
-- depends on ingredient, customer_order, user
-- -----------------------------------------------------------------------------
CREATE TABLE stock_movement (
CREATE TABLE IF NOT EXISTS stock_movement (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
ingredient_id INT UNSIGNED NOT NULL,
movement_type ENUM('sale','cancellation','restock','inventory_correction') NOT NULL,
@ -437,7 +443,7 @@ CREATE TABLE stock_movement (
-- 4.20 audit_log — append-only sensitive-action log
-- depends on user, role
-- -----------------------------------------------------------------------------
CREATE TABLE audit_log (
CREATE TABLE IF NOT EXISTS audit_log (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
actor_user_id INT UNSIGNED NULL,
actor_role_id INT UNSIGNED NULL,

View file

@ -16,7 +16,10 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE pin_throttle (
-- Idempotence (defense en profondeur) : CREATE TABLE IF NOT EXISTS. La cle
-- unique, l'index et la FK sont inline dans le CREATE TABLE, donc non re-joues
-- quand la table preexiste. Re-jouer ce fichier ne modifie pas le schema.
CREATE TABLE IF NOT EXISTS pin_throttle (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
actor_user_id INT UNSIGNED NOT NULL,
failed_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,

View file

@ -13,5 +13,21 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE customer_order
ADD COLUMN service_tag VARCHAR(20) NULL AFTER service_mode;
-- Idempotence : meme garde information_schema que 0006/0007 (re-jouable sans
-- erreur). On verifie l'absence de la colonne `service_tag` avant l'ALTER ;
-- si elle existe deja, on execute un no-op (DO 0). Le schema resultant est
-- inchange : seul l'ajout de la colonne (si absente) est joue.
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'customer_order' AND column_name = 'service_tag'
);
SET @ddl := IF(
@col_exists = 0,
'ALTER TABLE customer_order
ADD COLUMN service_tag VARCHAR(20) NULL AFTER service_mode',
'DO 0'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View file

@ -14,10 +14,26 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE ingredient
ADD COLUMN energy_kcal_100g SMALLINT UNSIGNED NULL AFTER pack_label,
ADD COLUMN nutrition_source VARCHAR(120) NULL AFTER energy_kcal_100g,
ADD COLUMN nutrition_fetched_at DATETIME NULL AFTER nutrition_source;
-- Idempotence : meme garde information_schema que 0007 (re-jouable sans erreur).
-- Les trois colonnes sont ajoutees ensemble ; l'existence de la premiere
-- (`energy_kcal_100g`) suffit donc a court-circuiter le groupe. Si elle existe
-- deja, on execute un no-op (DO 0). Le schema resultant est inchange.
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'ingredient' AND column_name = 'energy_kcal_100g'
);
SET @ddl := IF(
@col_exists = 0,
'ALTER TABLE ingredient
ADD COLUMN energy_kcal_100g SMALLINT UNSIGNED NULL AFTER pack_label,
ADD COLUMN nutrition_source VARCHAR(120) NULL AFTER energy_kcal_100g,
ADD COLUMN nutrition_fetched_at DATETIME NULL AFTER nutrition_source',
'DO 0'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- energy_kcal_100g : apport energetique pour 100 g (SMALLINT UNSIGNED suffit ; les
-- valeurs reelles restent < 1000). nutrition_source : provenance ("OpenFoodFacts").

View file

@ -8,10 +8,15 @@
-- domaine commande facture deja par product_id : le flux de commande
-- reste inchange, la borne resout juste la taille choisie en product_id.
--
-- Grouping DEDIE, distinct de maxi_variant_product_id (migration 0006) :
-- ce dernier pilote la substitution Maxi de l'accompagnement de menu
-- (resolveSelections) ; le reutiliser ferait basculer en 50 cl une
-- boisson 30 cl glissee dans un menu Maxi (effet de bord non voulu).
-- Grouping DEDIE (base_product_id), distinct de maxi_variant_product_id
-- (migration 0006) : base_product_id pilote la selection de taille A LA
-- CARTE (picker 30/50 cl) ; maxi_variant_product_id pilote la substitution
-- Maxi en MENU (resolveSelections). Les deux coexistent sur une boisson :
-- le seed 0006 pointe desormais chaque soda 30 cl vers sa variante 50 cl
-- pour qu'un menu Maxi serve la grande boisson (decision metier). Cet
-- "effet" est VOULU et ne s'applique qu'aux selections de menu au format
-- maxi ; une boisson 30 cl commandee a la carte (resolveLine type product)
-- ne consulte jamais maxi_variant_product_id et reste en 30 cl.
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
-- =============================================================================

View file

@ -1,11 +1,19 @@
#!/usr/bin/env bash
#
# Wakdo - seed runner.
# Wakdo - seed runner (hote, via `docker exec`).
#
# Applique les fichiers db/seeds/*.sql dans l'ordre lexicographique, de maniere
# idempotente : une table seed_history enregistre les fichiers deja charges.
# idempotente : la table seeds_applied enregistre les fichiers deja charges.
# Les seeds doivent etre joues APRES les migrations (les tables doivent exister).
#
# Contrepartie hote de db/migrate.sh : meme role que la phase seed du service de
# boot wakdo-migrate (db/migrate-container.sh), mais lance manuellement depuis
# l'hote. Le suivi DOIT utiliser la MEME table que le runner conteneur
# (seeds_applied) pour que les deux interoperent : rejouer l'un apres l'autre ne
# replaye RIEN. Auparavant ce script suivait une table distincte (seed_history),
# ce qui lui faisait re-jouer des seeds deja charges par wakdo-migrate (INSERT
# bruts -> echec sur contrainte UNIQUE).
#
# Cible : le service docker-compose `wakdo-db`. Identifiants lus dans .env.
#
# Usage :
@ -34,7 +42,9 @@ if [ ! -d "$SEEDS_DIR" ]; then
exit 0
fi
db "$DB_NAME" -e "CREATE TABLE IF NOT EXISTS seed_history (
# Meme schema de suivi que db/migrate-container.sh (seeds_applied) : nom de table
# et colonnes identiques, pour que les deux runners partagent le meme journal.
db "$DB_NAME" -e "CREATE TABLE IF NOT EXISTS seeds_applied (
filename VARCHAR(255) NOT NULL PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"
@ -47,7 +57,7 @@ if [ "${1:-}" = "--status" ]; then
echo "[seed] etat des seeds (base $DB_NAME) :"
for f in "${files[@]}"; do
base="$(basename "$f")"
n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM seed_history WHERE filename='$base';")"
n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM seeds_applied WHERE filename='$base';")"
[ "$n" = "0" ] && echo " PENDING $base" || echo " loaded $base"
done
exit 0
@ -56,11 +66,11 @@ fi
loaded=0
for f in "${files[@]}"; do
base="$(basename "$f")"
n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM seed_history WHERE filename='$base';")"
n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM seeds_applied WHERE filename='$base';")"
if [ "$n" = "0" ]; then
echo "[seed] chargement de $base ..."
db "$DB_NAME" < "$f"
db "$DB_NAME" -e "INSERT INTO seed_history (filename) VALUES ('$base');"
db "$DB_NAME" -e "INSERT INTO seeds_applied (filename) VALUES ('$base');"
loaded=$((loaded + 1))
else
echo "[seed] $base deja charge, ignore"

View file

@ -0,0 +1,50 @@
-- =============================================================================
-- Wakdo — Seed 0006 : boisson de menu = variante 50 cl automatique en Maxi
-- =============================================================================
-- Purpose : cabler la regle metier "boisson Maxi" sur les donnees seedees, sans
-- toucher au code. En menu Maxi, la boisson fontaine doit passer en
-- grande (50 cl), comme l'accompagnement passe en Grande Frite.
--
-- Mecanique reutilisee : product.maxi_variant_product_id (schema 0006),
-- deja exploite par OrderRepository::resolveSelections (substitution de
-- toute selection de menu au format 'maxi', sans garde sur le slot_type).
-- Il suffit donc de POINTER chaque soda fontaine 30 cl vers sa variante
-- 50 cl (creee par le seed 0005) : aucune ligne de code serveur a ecrire.
-- Le decrement de stock (consumption) frappera la 50 cl, et le snapshot
-- de libelle reflechira "<soda> 50cl".
--
-- Perimetre : seules les boissons fontaine ont une variante 50 cl (Coca Cola, Coca
-- Sans Sucres, Fanta Orange, Ice Tea Peche, Ice Tea Citron). Les boissons en
-- bouteille (Eau, Jus d'Orange, Jus de Pommes Bio) n'ont pas de variante : elles
-- restent en taille standard meme en Maxi (degradation gracieuse, modele fast-food
-- usuel). Le surcout Maxi est porte par le menu (price_maxi_cents), pas par la
-- boisson : aucune incidence de prix sur ces bouteilles.
--
-- Phase : depend du schema 0006 (maxi_variant_product_id) ET du seed 0005 (les
-- variantes 50 cl doivent exister). Joue donc APRES 0005 (ordre
-- lexicographique du runner db/seed.sh).
--
-- Conventions:
-- - Aucun id en dur : la cible est resolue structurellement (la variante 50 cl
-- est la ligne dont base_product_id pointe la base et size_cl = 50).
-- - IDEMPOTENT : UPDATE ... JOIN convergent (repositionne la meme valeur a chaque
-- execution). MariaDB autorise le self-join en UPDATE multi-tables (l'erreur
-- 1093 ne vise que les sous-requetes sur la table cible, pas les JOIN).
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- -----------------------------------------------------------------------------
-- Lier chaque boisson de base (30 cl, base_product_id NULL) a sa variante 50 cl.
-- La jointure ne matche que les produits ayant une variante de taille 50 cl :
-- structurellement, les seules boissons fontaine. Les accompagnements (frites,
-- deja relies par 0004) ne sont pas des variantes de taille -> non touches. Les
-- bouteilles sans variante 50 cl ne matchent pas -> maxi_variant_product_id reste
-- NULL.
-- -----------------------------------------------------------------------------
UPDATE product AS base
JOIN product AS variant
ON variant.base_product_id = base.id
AND variant.size_cl = 50
SET base.maxi_variant_product_id = variant.id
WHERE base.base_product_id IS NULL;

View file

@ -0,0 +1,184 @@
# Modele de compose de production (derriere un reverse proxy Traefik).
#
# Entierement pilote par le .env : le meme fichier marche sur n'importe quel hote,
# seules les valeurs du .env changent. Sur l'hote de prod :
# cp docker-compose.prod.yml.example docker-compose.prod.yml
# cp .env.prod.example .env # puis renseigner domaines + mots de passe
# docker compose -f docker-compose.prod.yml up -d --build
#
# Prerequis : le reseau externe ${REVERSE_PROXY_NETWORK} existe (cree par la stack
# Traefik de l'hote). Les entrypoints (websecure) et le certresolver (letsencrypt)
# doivent correspondre a la config Traefik de l'hote.
name: wakdo
networks:
wakdo_internal:
driver: bridge
reverse_proxy:
name: ${REVERSE_PROXY_NETWORK}
external: true
volumes:
wakdo_db_data:
wakdo_uploads:
services:
wakdo-db:
image: mariadb:11.4
container_name: wakdo-db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
TZ: ${APP_TIMEZONE:-Europe/Paris}
volumes:
- wakdo_db_data:/var/lib/mysql
- ./db/init:/docker-entrypoint-initdb.d:ro
networks:
- wakdo_internal
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 6
start_period: 30s
wakdo-migrate:
image: mariadb:11.4
container_name: wakdo-migrate
restart: "no"
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- ./db:/db:ro
networks:
- wakdo_internal
depends_on:
wakdo-db:
condition: service_healthy
entrypoint: ["bash", "/db/migrate-container.sh"]
wakdo-app:
build:
context: ./docker/php-fpm
dockerfile: Dockerfile
container_name: wakdo-app
restart: unless-stopped
environment:
APP_ENV: ${APP_ENV}
APP_DEBUG: ${APP_DEBUG}
APP_TIMEZONE: ${APP_TIMEZONE}
APP_URL_KIOSK: ${APP_URL_KIOSK}
APP_URL_ADMIN: ${APP_URL_ADMIN}
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
SESSION_LIFETIME_IDLE: ${SESSION_LIFETIME_IDLE}
SESSION_LIFETIME_ABSOLUTE: ${SESSION_LIFETIME_ABSOLUTE}
SESSION_NAME: ${SESSION_NAME}
CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN}
ARGON2_MEMORY_COST: ${ARGON2_MEMORY_COST}
ARGON2_TIME_COST: ${ARGON2_TIME_COST}
ARGON2_THREADS: ${ARGON2_THREADS}
ACCOUNT_LOCKOUT_THRESHOLD: ${ACCOUNT_LOCKOUT_THRESHOLD}
ACCOUNT_LOCKOUT_BASE_SECONDS: ${ACCOUNT_LOCKOUT_BASE_SECONDS}
ACCOUNT_LOCKOUT_MAX_SECONDS: ${ACCOUNT_LOCKOUT_MAX_SECONDS}
IP_THROTTLE_WINDOW_SECONDS: ${IP_THROTTLE_WINDOW_SECONDS}
IP_THROTTLE_MAX_ATTEMPTS: ${IP_THROTTLE_MAX_ATTEMPTS}
STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH}
STAFF_PIN_MAX_LENGTH: ${STAFF_PIN_MAX_LENGTH}
PIN_THROTTLE_THRESHOLD: ${PIN_THROTTLE_THRESHOLD}
PIN_THROTTLE_BASE_SECONDS: ${PIN_THROTTLE_BASE_SECONDS}
PIN_THROTTLE_MAX_SECONDS: ${PIN_THROTTLE_MAX_SECONDS}
PIN_THROTTLE_WINDOW_SECONDS: ${PIN_THROTTLE_WINDOW_SECONDS}
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL:-}
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Wakdo}
volumes:
- ./src:/var/www/html
- wakdo_uploads:/var/www/html/public/uploads
networks:
- wakdo_internal
depends_on:
wakdo-migrate:
condition: service_completed_successfully
wakdo-db:
condition: service_healthy
wakdo-web:
build:
context: ./docker/apache
dockerfile: Dockerfile
container_name: wakdo-web
restart: unless-stopped
environment:
APP_HOST_KIOSK: ${APP_HOST_KIOSK}
APP_HOST_ADMIN: ${APP_HOST_ADMIN}
volumes:
- ./src:/var/www/html
- wakdo_uploads:/var/www/html/public/uploads
networks:
- wakdo_internal
- reverse_proxy
depends_on:
wakdo-migrate:
condition: service_completed_successfully
wakdo-app:
condition: service_started
wakdo-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.docker.network=${REVERSE_PROXY_NETWORK}"
- "traefik.http.routers.wakdo-kiosk.rule=Host(`${APP_HOST_KIOSK}`)"
- "traefik.http.routers.wakdo-kiosk.entrypoints=websecure"
- "traefik.http.routers.wakdo-kiosk.tls=true"
- "traefik.http.routers.wakdo-kiosk.tls.certresolver=letsencrypt"
- "traefik.http.routers.wakdo-kiosk.service=wakdo-kiosk-svc"
- "traefik.http.services.wakdo-kiosk-svc.loadbalancer.server.port=80"
- "traefik.http.routers.wakdo-admin.rule=Host(`${APP_HOST_ADMIN}`)"
- "traefik.http.routers.wakdo-admin.entrypoints=websecure"
- "traefik.http.routers.wakdo-admin.tls=true"
- "traefik.http.routers.wakdo-admin.tls.certresolver=letsencrypt"
- "traefik.http.routers.wakdo-admin.service=wakdo-admin-svc"
- "traefik.http.services.wakdo-admin-svc.loadbalancer.server.port=80"
wakdo-cron:
build:
context: ./docker/cron
dockerfile: Dockerfile
container_name: wakdo-cron
restart: unless-stopped
init: true
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
AUDIT_LOG_RETENTION_DAYS: ${AUDIT_LOG_RETENTION_DAYS:-365}
THROTTLE_PURGE_AFTER_HOURS: ${THROTTLE_PURGE_AFTER_HOURS:-24}
TZ: ${CRON_TIMEZONE:-Europe/Paris}
volumes:
- ./var/backups:/backups
networks:
- wakdo_internal
depends_on:
wakdo-db:
condition: service_healthy

View file

@ -89,6 +89,12 @@ services:
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL:-}
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Wakdo}
volumes:
- ./src:/var/www/html
- wakdo_uploads:/var/www/html/public/uploads

View file

@ -17,13 +17,17 @@ Wakdo simule une borne de commande tactile de restauration rapide, avec back-off
d'administration, workflow cuisine et API REST interne. Deux surfaces applicatives :
- **Borne (kiosk)** — front statique (HTML/CSS/JS vanilla ES6) servi par Apache,
consommant des donnees (JSON statique en P5, API DB-backed au swap P4).
consommant l'API REST DB-backed (`/api/*`). Le repli JSON statique initial a ete
retire au profit d'un branchement direct sur l'API.
- **Back-office + API** — application PHP rendue serveur (MVC maison) + endpoints
`/api/*`, derriere authentification et RBAC.
Trois canaux de commande (`source`) : `kiosk`, `counter`, `drive`. Le cycle de vie
d'une commande et la machine a etats sont decrits dans `docs/merise/` (domaine
commande = phase **P4**, schema en base mais workflow applicatif a venir).
d'une commande et la machine a etats sont decrits dans `docs/merise/`. Le domaine
commande est livre de bout en bout : creation et encaissement via l'API
(`POST /api/orders`, `POST /api/orders/{number}/pay` avec decrement de stock),
file cuisine (KDS), annulation et livraison cote back-office, saisie comptoir et
drive (POS tactile).
---
@ -132,8 +136,8 @@ src/app/
Views/ admin/* (pages back-office rendues serveur), auth/* (login/reset)
src/public/
admin/ front controller + assets (CSS/JS) du back-office
borne/ front kiosk statique (index, categories, products, product, cart,
payment, confirmation) + assets JS modules + data JSON
borne/ front kiosk statique (index, categories, products, payment,
confirmation ; panier en panneau persistant) + assets JS modules
```
Conventions transverses : controleurs non-`final` (seam de test : sous-classe injectant
@ -165,7 +169,7 @@ Vue rendue dans admin/layout (sorties echappees, RG-T15) | ou JSON pour /api/*
```
La borne (kiosk) est servie en statique par Apache ; ses pages consomment les donnees
via `fetch` (JSON statique en P5 ; bascule sur `/api/*` DB-backed au swap P4).
via `fetch` sur l'API DB-backed (`/api/*`).
---
@ -214,7 +218,7 @@ Threat model STRIDE + classification des donnees : `docs/PROJECT_CONTEXT.md` sec
`ingredient`, `product_ingredient`, `allergen`, `ingredient_allergen`, `stock_movement`.
- **RBAC / comptes** : `user`, `role`, `permission`, `role_permission`,
`role_visible_source`.
- **Commande (P4, schema pret)** : `customer_order`, `order_item`,
- **Commande (livre)** : `customer_order`, `order_item`,
`order_item_selection`, `order_item_modifier`.
- **Transverses** : `audit_log` (journal immuable), `login_throttle`, `pin_throttle`.

View file

@ -117,7 +117,7 @@ Client Borne (Bloc 1) API (Bloc 2) BDD
### Compatibilite evaluation par bloc
- **Jury Bloc 1** : voit le front seul ; le front peut tomber en fallback sur JSON statiques fournis (`src/public/borne/data/*.json`) si l'API est indisponible.
- **Jury Bloc 1** : voit le front seul ; le front consomme les donnees via `fetch` sur l'API (`/api/*`). Le fallback JSON statique initialement envisage a ete retire (la borne est branchee directement sur l'API DB-backed).
- **Jury Bloc 2** : voit le back-office + teste l'API via curl/Postman de maniere autonome, sans dependre du front.
- **Jury Bloc 5** : lance `docker compose up` ou `docker compose up`, verifie la CI/CD, les crons, l'archi, les scripts.
@ -205,7 +205,7 @@ Reseaux :
### Bloc 1 — Borne client (Front)
**IN scope :**
- Affichage dynamique menus + produits (charges par Ajax depuis API ou JSON fallback)
- Affichage dynamique menus + produits (charges par `fetch` depuis l'API `/api/*`)
- Composition panier : produits unitaires OU menus (burger + accompagnement + boisson + sauce)
- Options taille (normale / grande, +0,50 € sur grande) pour accompagnements et boissons
- Options de personnalisation simples (ex : sans oignon, avec fromage)
@ -232,7 +232,7 @@ Reseaux :
- **Manager** : catalogue (create/update), stock (reappro + inventaire), statistiques ; utilisateurs en **lecture seule** (`user.read`, pas de creation/modification/desactivation), pas d'acces RBAC
- **Kitchen** : file des commandes `paid` triee par `paid_at` croissant, en **lecture seule** (KDS visuel) ; inventaire
- **Counter** / **Drive** : saisir une commande (comptoir / drive-thru via casque/intercom), bouton "declarer livree" (geste unique `paid -> delivered`), annuler ; `source` auto-tague depuis `role.order_source` ; inventaire
- Upload images produits (validation type MIME + taille + stockage dans volume `wakdo_uploads`)
- Upload images produits : **non implemente** ; prevu (validation type MIME + taille + stockage dans volume `wakdo_uploads`)
- Historique commandes par statut
- Stats de base (commandes du jour, CA jour, produits top)

View file

@ -0,0 +1,43 @@
# ADR-0011 — POS tactile a tuiles pour la saisie comptoir/drive
- Statut : Accepte
- Date : 2026-06-25
## Contexte
La saisie de commande comptoir/drive (mlt 4.1, `CREATE_COUNTER_ORDER`) avait d'abord ete
livree comme formulaire-liste enrichi (#100) : une liste de produits avec champs de
quantite, prix, verrou du mode de service au canal drive, file des commandes recentes.
Cet ecran est destine a des equipiers non-techniques, sur tablette, dans un contexte de
caisse ou la rapidite compte. Le formulaire-liste se prete mal au tactile (petites cibles,
defilement) et ne ressemble pas au geste deja appris cote borne client. Options :
conserver et raffiner le formulaire-liste ; adopter un paradigme de caisse (POS) a tuiles
reutilisant l'UX borne ; integrer une bibliotheque de POS tierce.
## Decision
La saisie comptoir/drive devient un **POS tactile a tuiles** (#104) calque sur la borne
client : onglets categories en haut, grille de tuiles produits/menus, panneau commande
persistant a droite ; un tap ajoute le produit, un produit a modificateurs ou un menu
ouvre une modale de composition. Le panier est construit cote client en vanilla JS
CSP-safe (`'self'`, zero handler inline) a partir d'un script JSON inerte, puis serialise
dans un champ cache `items_json`. Le **serveur reste seul juge** : il revalide la forme
(RG-T18), recalcule les prix et resout les modificateurs (RG-T16) ; les prix affiches cote
client sont indicatifs. Un **seul controleur** sert les deux canaux, la `source`
(counter/drive) etant derivee du chemin de la requete.
## Consequences
- (+) Geste unifie borne/comptoir : moins d'apprentissage, reutilisation des patterns
composeur deja eprouves cote borne.
- (+) Cibles tactiles larges adaptees a la tablette ; zero jargon dev a l'ecran
(utilisateurs non-techniques).
- (+) Decoupage par chemin (`/drive...` vs `/counter...`) : les canaux restent etanches,
un equipier ne peut pas requalifier sa commande via un champ falsifie.
- (+) Sans framework front (coherent ADR-0002) ; coherent avec ADR-0001 (pas de
dependance tierce, donc pas de POS externe).
- (-) Le POS est interactif par nature : sans JS, la grille ne s'affiche pas (un message
invite a activer JS). Un repli legacy `qty_<id>` reste accepte quand `items_json` est
absent, mais l'experience cible suppose JS.
- (-) Cet ecran a ete refondu deux fois a court intervalle (#100 puis #104) ; le palier
#100 est assume comme etape, pas comme dette cachee.
- Fichiers : `src/app/Controllers/CounterOrderController.php`,
`src/app/Views/admin/counter/new.php`, `src/public/admin/assets/js/counter-order.js`.
Detail : journal `docs/journal/2026-06-25--audit-remediation-et-features-94-105.md`.

View file

@ -0,0 +1,41 @@
# ADR-0012 — Page Stock en tableau de bord (alertes + reapprovisionnement en avant)
- Statut : Accepte
- Date : 2026-06-25
## Contexte
La page d'accueil Ingredients/Stock (domaine 8.8 + 9) etait une liste-CRUD exhaustive :
tous les ingredients, avec les actions creer/modifier/supprimer en premier plan. Elle
etait jugee trop chargee et opaque pour un equipier non-technique. Le besoin metier
quotidien est de voir vite ce qui manque et de reapprovisionner ; l'edition de fiches est
rare. De plus, le lien entre stock et disponibilite borne (un ingredient requis sous le
seuil critique rend indisponibles les produits qui l'utilisent, RG-T21, cf. ADR-0003)
n'etait pas visible a l'ecran. Options : garder la liste exhaustive triable ; basculer
vers un tableau de bord oriente action ; deux pages distinctes (dashboard + gestion).
## Decision
La page Stock devient un **tableau de bord oriente action** (#105) : un bandeau explicite
le lien stock -> disponibilite borne (RG-T21) ; un resume compte les ingredients
critiques / en alerte / au-dessus du seuil ; une section "A reapprovisionner" met en avant
les ingredients bas (critiques d'abord) avec barre de niveau et bouton de
reapprovisionnement direct. La liste complete passe au second plan et le CRUD est relegue.
Les compteurs par etat sont **calcules cote serveur** dans `IngredientController::index()`
a partir du `stock_band` deja resolu par le depot, pour garder la vue declarative et la
valeur directement testable. Les sous-pages (reappro, inventaire, mouvements, creation)
restent inchangees.
## Consequences
- (+) L'ecran s'aligne sur le geste quotidien (reperer le manque, reapprovisionner) plutot
que sur l'edition de fiches.
- (+) Le lien stock -> disponibilite borne (RG-T21) devient explicite a l'ecran, pas
seulement dans le code.
- (+) Compteurs testables (la logique de comptage est cote serveur, pas dans la vue) :
+4 cas de test `IngredientController` (bandeau, promotion d'un critique en section
reappro, etat vide positif, compteurs par etat).
- (-) La liste exhaustive et le CRUD sont moins immediats (un cran plus loin) : choix
assume, ces operations etant moins frequentes que la lecture d'alerte.
- (-) Le tri/filtre avance de l'ancienne liste n'est pas reconduit en premier plan.
- Coherent avec ADR-0002 (MVC rendu serveur) et ADR-0003 (disponibilite calculee depuis
le stock). Fichiers : `src/app/Controllers/IngredientController.php`,
`src/app/Views/admin/ingredients/index.php`, `src/public/admin/assets/css/admin.css`.
Detail : journal `docs/journal/2026-06-25--audit-remediation-et-features-94-105.md`.

View file

@ -18,6 +18,8 @@ une decision revisee donne une nouvelle fiche qui *supersede* l'ancienne (statut
| [0008](0008-makefile-vers-compose-migrate.md) | Du Makefile a `docker compose up` (service wakdo-migrate) | Accepte |
| [0009](0009-compose-standalone-et-prod-gitignore.md) | docker-compose.yml standalone + docker-compose.prod.yml gitignore | Accepte |
| [0010](0010-cookie-secure-conditionnel-https.md) | Cookie de session Secure conditionnel au HTTPS | Accepte |
| [0011](0011-pos-tactile-tuiles-comptoir-drive.md) | POS tactile a tuiles pour la saisie comptoir/drive | Accepte |
| [0012](0012-page-stock-tableau-de-bord.md) | Page Stock en tableau de bord (alertes + reappro en avant) | Accepte |
## Modele de fiche

View file

@ -51,10 +51,9 @@ Code de reference : routes dans `src/public/admin/index.php`, controleurs dans
| Famille | Prefixe | Rendu | Authentification | Exemple |
|---|---|---|---|---|
| Pages back-office | aucun | HTML (vue serveur + `layout.php`) | session admin | `/login`, `/forgot_password` |
| API REST | `/api/` | JSON (enveloppe section 7) | selon la ressource (section 10) | `/api/health`, `/api/categories` (prevu) |
| API REST | `/api/` | JSON (enveloppe section 7) | selon la ressource (section 10) | `/api/health`, `/api/categories` (livre) |
La borne (kiosk) consommera l'API REST `/api/*` (P4). En attendant, elle lit un repli JSON
statique sous `src/public/borne/data/` (voir section 8.3).
La borne (kiosk) consomme l'API REST `/api/*` en lecture pour le catalogue (voir section 8.3).
---
@ -107,7 +106,7 @@ is_active) et d'`Authorizer` (RG-T03, permissions rechargees depuis la base). Re
si la session est absente, expiree ou le compte desactive. Les autorisations par operation
(et le PIN des actions sensibles, RG-T13) se cablent quand les operations existent (P3).
### 5.2 API kiosk - lecture catalogue + commande (prevu P4, public)
### 5.2 API kiosk - lecture catalogue + commande (livre, public)
La borne est publique (aucune session) ; cf. `mlt.md` CREATE_ORDER, declencheur kiosk.
@ -270,18 +269,16 @@ Codes specifiques nommes par le MLT, en surcharge du socle : `CANNOT_CANCEL_IN_S
`INVALID_TRANSITION` (409) pour l'annulation (`mlt.md` 7.1, `security-sequence.md`). Meme format
d'enveloppe.
### 8.3 Divergence connue : repli JSON de la borne
### 8.3 Nommage borne vs canonique : le rapprochement dans data.js
Le repli statique de la borne (`src/public/borne/data/categories.json`, `produits.json`) provient
des sources de l'ecole et porte un nommage different et heterogene (`title`/`nom`, `prix`, `image`,
`type`). Ce contrat est fige par le brief ecole et consomme tel quel par le JS de la borne via
`data.js`.
Le front de la borne attend un nommage historique heterogene issu des sources de l'ecole
(`title`/`nom`, `prix`, `image`, `type`). L'API sert la forme canonique de 8.1
(`/api/categories`, `/api/products`, `/api/menus`, `/api/allergens`). Le rapprochement se fait
en un point unique : la couche `data.js`, qui deballe l'enveloppe `{ data }` et mappe la forme
canonique vers ce que la borne attend. Les anciens fichiers JSON statiques sous
`src/public/borne/data/` ont ete retires.
La convention canonique reste celle de 8.1. Le rapprochement se fait en un point unique : la couche
`data.js` (bascule prevue en P4). Quand l'API exposera `/api/categories` et `/api/products`, elle
servira la forme canonique ; `data.js` mappera vers ce que la borne attend.
| Repli borne | Canonique API / dictionnaire |
| Forme borne | Canonique API / dictionnaire |
|---|---|
| `title` (categorie) | `name` |
| `nom` (produit) | `name` |

View file

@ -0,0 +1,115 @@
# Deploiement continu (CD) — Wakdo
Ce document decrit le deploiement automatique vers la production et la mise en place
a faire une seule fois cote serveur. Il complete `scripts/deploy.sh` et
`.forgejo/workflows/deploy.yml`.
## Topologie
| Hote | Role |
|---|---|
| **Thanos** (`git.acadenice.com`) | Forge : depot Git + Forgejo Actions |
| **Stark** | Environnement de dev ; heberge le runner Forgejo |
| **Vision** | Production : la stack Wakdo y tourne, cible du deploiement |
Le runner (sur Stark) n'a pas acces au socket Docker, par choix de securite : un job
CI ne peut pas piloter Docker sur son hote. Le deploiement vers Vision se fait donc
par SSH — ce qui correspond au schema normal d'un deploiement vers un hote distant.
## Flux
```
merge dev -> main (release, deja passee par la CI sur la PR)
Forgejo Actions: workflow Deploy (.forgejo/workflows/deploy.yml)
│ ssh deploy@vision (sans commande : forced command cote Vision)
Vision: scripts/deploy.sh (git ff-only -> VERSION + deploy.log -> compose build/up)
GET /api/health renvoie le nouveau SHA ← preuve du deploiement
```
## Ce qui est automatise (dans le depot)
- `.forgejo/workflows/deploy.yml` : sur push `main`, ouvre la session SSH vers Vision.
- `scripts/deploy.sh` : recupere `main` (fast-forward), ecrit le marqueur de version
(`src/VERSION`) et une ligne dans `deploy.log`, reconstruit et recree la stack.
Mode non-interactif via `DEPLOY_YES=1`.
- `GET /api/health` expose `version` (SHA) et `deployed_at` (date), lus depuis
`src/VERSION`.
## Mise en place cote Vision (une fois)
Prerequis : Docker + docker compose, le depot clone (ex. `/srv/wakdo`).
Le compose et le `.env` de prod ne sont pas versionnes (propres a l'hote) ; ils se
derivent des modeles fournis dans le depot :
```bash
cp docker-compose.prod.yml.example docker-compose.prod.yml
cp .env.prod.example .env # puis renseigner domaines + mots de passe + reseau Traefik
docker compose -f docker-compose.prod.yml up -d --build
```
Le compose est entierement pilote par le `.env` : le meme fichier marche sur tout hote.
1. Creer un utilisateur dedie au deploiement, membre du groupe `docker` :
```bash
sudo useradd -m -G docker deploy
```
2. Lui donner le depot (ou ajuster les droits du clone existant) :
```bash
sudo chown -R deploy:deploy /srv/wakdo
```
3. Autoriser la cle CI avec une **forced command** : la cle ne peut lancer que le
deploiement, aucune autre commande. Dans `~deploy/.ssh/authorized_keys` :
```
command="cd /srv/wakdo && DEPLOY_YES=1 scripts/deploy.sh main",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA...CLE_PUBLIQUE... deploy@wakdo-ci
```
`deploy.sh` ne lit pas `$SSH_ORIGINAL_COMMAND` : meme si un appel SSH tentait de
passer une autre commande, elle serait ignoree.
## Generer la cle et la connaitre cote forge
Sur un poste de confiance :
```bash
ssh-keygen -t ed25519 -f wakdo-deploy -C "deploy@wakdo-ci" -N ""
# wakdo-deploy -> cle PRIVEE (secret de la forge, ci-dessous)
# wakdo-deploy.pub -> cle PUBLIQUE (authorized_keys de Vision, etape 3)
ssh-keyscan -t ed25519 <hote-vision> # -> contenu du secret DEPLOY_KNOWN_HOSTS
```
## Secrets et variables a creer sur la forge
Depot -> Settings -> Actions -> Secrets / Variables :
| Type | Nom | Valeur |
|---|---|---|
| Secret | `DEPLOY_SSH_KEY` | contenu de la cle privee `wakdo-deploy` |
| Secret | `DEPLOY_KNOWN_HOSTS` | sortie de `ssh-keyscan` (cle d'hote de Vision) |
| Secret | `DEPLOY_HOST` | nom/IP de Vision |
| Variable | `DEPLOY_USER` | `deploy` |
## Verification
1. Faire une release (`dev -> main`).
2. Suivre le workflow **Deploy** dans l'interface de la forge (il se declenche au push
sur `main`).
3. Interroger la sonde et lire la version deployee :
```bash
curl -s https://<fqdn-admin-prod>/api/health
# { ... "version": "<sha>", "deployed_at": "<date>" }
```
Le `version` correspond au HEAD de `main` apres la release — preuve que Vision a ete
mise a jour sans intervention manuelle.
## Notes de securite
- Cle SSH dediee au seul deploiement, **forced command** + options `no-*` qui retirent
shell, tunnels et forwarding.
- Cle d'hote **epinglee** (`DEPLOY_KNOWN_HOSTS`, `StrictHostKeyChecking=yes`) : pas de
confiance a la premiere connexion.
- Secrets stockes cote forge, hors du depot. `.env` et `docker-compose.prod.yml`
restent gitignores.
- Le runner n'a pas le socket Docker : un job ne peut pas agir sur Docker localement.

View file

@ -23,9 +23,12 @@ Accueil
-> Remerciement
```
Le kiosk construit, lui, eclate cet ecran unique en **pages distinctes** et n'a
pas de panneau de commande persistant. C'est l'origine du sentiment "ca ne
correspond pas".
Le kiosk construit a desormais rejoint ce paradigme : l'ecran de commande
(`products.html`) porte un **panneau de commande persistant** a droite, les options
produit et le composeur de menu s'ouvrent **en modale** par-dessus la grille, et le
**chevalet** (saisie du numero de table) s'ouvre en modale au paiement sur place. Les
pages intermediaires `product.html` et `cart.html` du premier jet ont ete retirees.
Cette note garde la trace de la decomposition maquette -> code et des ecarts resorbes.
## 2. Decomposition ecran par ecran
@ -87,25 +90,29 @@ correspond pas".
| Maquette | Kiosk construit | Verdict |
|----------|-----------------|---------|
| 1. Accueil sur place / a emporter | `index.html` | conforme |
| 2 + 6. Ecran de commande unique (bandeau + grille + **panneau persistant**) | eclate en `categories.html` -> `products.html` -> `cart.html` | divergence structurante : multi-pages, et **pas de panneau de commande persistant** |
| 2 + 6. Ecran de commande unique (bandeau + grille + **panneau persistant**) | `products.html` : bandeau categories (`category-strip.js`) + grille + **panneau de commande persistant** a droite (`order-panel.js`) | conforme |
| (pas de page categories separee) | `categories.html` plein ecran "Que souhaitez-vous commander ?" | ecran **ajoute** (la maquette met les categories en bandeau) |
| 3-5. Composeur menu = **assistant modal en etapes** | `page-product-menu.js` = composition **libre** | divergence (le refactor "consommer les slots /api/menus" est deja en file P4) |
| 8. Modale d'option produit (taille + quantite) | `product.html` (page) | divergence : page au lieu de modale |
| 9. Ecran **chevalet** dedie (saisie numero) | numero gere par l'API (chunk 1a), affiche en confirmation | manquant cote ecran |
| 3-5. Composeur menu = **assistant modal en etapes** | `page-product-menu.js` : composeur **modal pilote par les slots** de `/api/menus/{id}` (format Maxi puis 1 etape par slot) | conforme |
| 8. Modale d'option produit (taille + quantite) | `product-options.js` : **modale** d'options (taille R4 + stepper de quantite) au-dessus de la grille | conforme |
| 9. Ecran **chevalet** dedie (saisie numero) | **modale chevalet** au paiement sur place (`page-payment.js`), numero pose via l'API ; rappele en confirmation | conforme |
| (aucun ecran de paiement) | `payment.html` "Carte bancaire / Especes" | ecran **ajoute** par le build |
| 10. Remerciement | `confirmation.html` | conforme |
## 4. Ecarts structurants (le fond du sujet)
## 4. Ecarts structurants (resorbes)
1. **Paradigme inverse.** Maquette = **mono-ecran** (un plan de commande avec
categories en bandeau et un panneau recapitulatif persistant a droite, modales
par-dessus). Build = **multi-pages** classiques (categories -> produits ->
produit -> panier). C'est l'ecart structurant principal.
2. **Panneau de commande lateral absent.** La piece centrale de la maquette
(numero de commande, lignes editables avec corbeille, TOTAL ttc, Abandon /
Payer, visible en permanence) n'est pas presente dans le build.
3. **Composition de menu.** Maquette = assistant modal en etapes ; build =
composition libre cote client (`page-product-menu.js`).
Les ecarts structurants du premier jet ont ete realignes sur la maquette :
1. **Paradigme.** L'ecran de commande (`products.html`) suit le plan mono-ecran de
la maquette : categories en bandeau (`category-strip.js`), grille produits, et
panneau recapitulatif persistant a droite ; les options et le composeur de menu
s'ouvrent en modale par-dessus. Les pages `product.html` et `cart.html` du
premier jet ont ete retirees.
2. **Panneau de commande lateral.** La piece centrale de la maquette (numero de
commande, lignes editables avec quantite et retrait, TOTAL ttc, Abandon / Payer)
est rendue par `order-panel.js`, visible en permanence sur l'ecran de commande.
3. **Composition de menu.** Le composeur (`page-product-menu.js`) est un assistant
modal en etapes pilote par les slots de `/api/menus/{id}` (format Maxi puis une
etape par slot), conforme a l'enchainement de la maquette.
## 5. Rebrand McDonald's -> Wakdo
@ -116,6 +123,8 @@ note n'est donc pas le rebrand mais la **structure** des ecrans.
## 6. Suite
Re-alignement du kiosk sur la maquette (panneau persistant + bandeau categories +
composeur en modale) = chantier UI conduit via un cycle FD dedie. Backlog des
divergences = section 3 ci-dessus.
Le re-alignement du kiosk sur la maquette (panneau persistant + bandeau categories
+ composeur en modale + chevalet en modale) est livre. La borne lit le catalogue
via l'API REST (`/api/categories|products|menus|allergens`). Reste a faire : la
generation dynamique de l'ecran categories depuis `GET /api/categories` (section 3,
ecran categories) et le polissage visuel du rebrand Wakdo.

View file

@ -0,0 +1,210 @@
# 2026-06-25 — Synthese : CD prod, SMTP reel, durcissement borne, POS comptoir/drive, dashboard stock (#94-#105)
**Auteur : BYAN.** Retrospective de synthese couvrant douze PR mergees apres la session
du 2026-06-18 (front login + amorce P4 commande). Elles se regroupent en quatre fils :
mise en production reelle (#94-#97), finition du parcours borne client (#98, #99, #101,
#102, #103), et refonte de la saisie comptoir/drive et de la page stock cote back-office
(#100, #104, #105). Entree descriptive : on decrit ce qui est livre, pas ce qui est
promis.
---
## Ce qui a ete livre (PR mergees)
| PR | Bloc | Objet |
|----|------|-------|
| #94 | CD | Deploiement push-based vers Vision (prod) + preuve de version dans `GET /api/health` |
| #95 | CD | Modeles versionnes `docker-compose.prod.yml.example` + `.env.prod.example` |
| #96 | Auth | Envoi reel de l'email de reset via relais SMTP (Brevo) — client SMTP maison |
| #97 | CD | Passage des variables SMTP/MAIL au conteneur `wakdo-app` (correctif #96) |
| #98 | Borne | Menu Maxi agrandit la boisson en 50cl + transport du format choisi |
| #99 | Borne | Produit/menu en rupture de stock rendu non commandable (RG-T21) |
| #100 | Back-office | Refonte saisie comptoir/drive : prix, verrou du mode, navigation, file |
| #101 | Borne | Panier unique = panneau persistant (retrait de `cart.html` et `product.html`) |
| #102 | Borne | Confirmation avant l'abandon de la commande |
| #103 | Borne | Bascule des allergenes sur `/api/allergens` + menage des donnees/docs statiques |
| #104 | Back-office | Saisie comptoir/drive en POS tactile a tuiles (refonte de #100) |
| #105 | Back-office | Page Stock en tableau de bord (alertes + reapprovisionnement en avant) |
---
## Bloc 1 — Mise en production reelle (#94, #95, #96, #97)
### Ce qui a ete fait
- **CD push-based (#94)** : `.forgejo/workflows/deploy.yml` ouvre, sur push `main`, une
session SSH vers Vision (l'hote de prod). `scripts/deploy.sh` y recupere `main` en
fast-forward, ecrit un marqueur de version (`src/VERSION` : SHA + date), journalise une
ligne dans `deploy.log`, puis reconstruit et recree la stack. `GET /api/health` expose
desormais `version` et `deployed_at`, lus depuis ce marqueur : c'est la preuve cote app
qu'un deploiement a bien repris le dernier commit. Doc : `docs/architecture/deployment.md`.
- **Modeles de prod versionnes (#95)** : `docker-compose.prod.yml.example` et
`.env.prod.example` entrent au depot comme gabarits. Le fichier reel reste gitignore
(specifique a l'hote : Traefik, reseau externe), conformement a ADR-0009 ; le `.example`
documente la forme attendue.
- **SMTP reel (#96, #97)** : la reinitialisation de mot de passe envoyait jusque-la un mail
inerte. Un client SMTP maison (`SmtpClient`, `SmtpMailer`, transport via flux PHP
`StreamSmtpTransport` derriere l'interface `SmtpTransport`) parle a un relais reel
(Brevo en l'occurrence). `PasswordResetController` s'y branche. #97 corrige un oubli :
les variables SMTP/MAIL n'etaient pas transmises au conteneur `wakdo-app` (declarees
dans les deux fichiers compose).
### Pourquoi — decisions et alternatives
- **Decision : CD par SSH, pas par Docker-in-CI.** Le runner Forgejo (sur Stark) n'a pas
acces au socket Docker, par choix de securite : un job CI ne pilote pas Docker sur son
hote. Le deploiement vers Vision se fait donc par SSH avec une *forced command* cote
serveur. *Alternative ecartee* : donner le socket Docker au runner — rejetee pour la
surface d'attaque. C'est le prolongement de la decision E2E-CI du 2026-06-18 (meme
contrainte de socket).
- **Decision : client SMTP maison plutot qu'une bibliotheque.** ADR-0001 fige le projet
sans Composer ni dependance tierce ; un client SMTP minimal (EHLO/AUTH/MAIL/RCPT/DATA
sur flux) reste coherent avec cette contrainte et reste testable via un faux transport
(`FakeSmtpTransport`). *Alternative ecartee* : `mail()` de PHP — sans relais
authentifie, la delivrabilite est aleatoire et la configuration sort du depot.
- **Decision : marqueur de version dans `src/VERSION` lu a chaud.** Le marqueur est sous
le mount du code (`./src` -> `/var/www/html`), donc relu sans rebuild. Cela donne une
preuve de deploiement observable de l'exterieur sans instrumentation supplementaire.
### Criteres RNCP couverts
- **Bloc 3 - deploiement** : chaine de livraison continue tracable (`deploy.yml`,
`deploy.sh`, `deployment.md`), preuve de version exposee.
- **Bloc 1 - securite** : separation runner/prod sans socket Docker ; secrets de prod
hors du depot (gabarits `.example` seulement).
---
## Bloc 2 — Finition du parcours borne client (#98, #99, #101, #102, #103)
### Ce qui a ete fait
- **Menu Maxi -> boisson 50cl (#98)** : choisir le format Maxi d'un menu fait passer la
boisson de 33cl a 50cl. La migration `0007_product_size_variant` et le seed
`0006_drink_maxi_variant` portent la variante en base ; le format choisi est transporte
jusqu'au paiement (`checkout.js`, `page-product-menu.js`). Tests `OrderRepository` +
tests JS du composeur.
- **Rupture non commandable (#99, RG-T21)** : un produit (ou un menu dont un composant
requis) sous le seuil critique de stock est marque indisponible et non ajoutable a la
borne ; le serveur refuse aussi la creation cote `OrderRepository` (defense en
profondeur, le client n'est pas seul juge). Visuel d'indisponibilite cote `style.css`.
- **Panier unique = panneau persistant (#101)** : suppression des pages `cart.html` et
`product.html` ; le panier devient un panneau lateral persistant (`order-panel.js`)
present sur les pages au lieu d'une page dediee. Le net du diff est negatif
(-784 lignes) : c'est une simplification de l'architecture front borne.
- **Confirmation d'abandon (#102)** : abandonner la commande ouvre une modale de
confirmation (`confirm-modal.js`) au lieu de vider le panier au premier clic.
- **Allergenes via API (#103)** : la borne lisait des fichiers JSON statiques
(`allergens.json`, `categories.json`, `produits.json`) ; elle consomme desormais
`/api/allergens` (et le catalogue par API). Les JSON statiques et la doc afferente sont
retires (menage). `docs/api/conventions.md` et `docs/design/maquette-vs-build.md` mis a
jour.
### Pourquoi — decisions et alternatives
- **Decision : rupture controlee cote serveur ET cote client (#99).** L'indisponibilite
est calculee au plus pres de la verite (RG-T21, ADR-0003) ; le client la reflete pour
l'UX, mais `OrderRepository` revalide a la creation. *Alternative ecartee* : masquer
cote client seulement — laisse une fenetre ou une commande forgee passerait.
- **Decision : panier persistant plutot que page panier (#101).** Sur une borne, l'aller-
retour vers une page panier ajoute une etape ; un panneau visible en permanence reduit
la navigation. Le retrait de deux pages reduit aussi la surface a maintenir.
- **Decision : source de verite unique pour les donnees borne (#103).** Les JSON statiques
dupliquaient le catalogue de la base ; ils pouvaient diverger silencieusement. Passer par
l'API supprime la duplication et fait converger borne et back-office sur le meme modele.
### Criteres RNCP couverts
- **Bloc 2 - front client** : parcours borne complet (composeur, panier, abandon,
indisponibilite) sur donnees reelles par API.
- **Bloc 1 - regles metier** : RG-T21 (disponibilite calculee) appliquee de bout en bout.
---
## Bloc 3 — Saisie comptoir/drive en POS tactile (#100 puis #104)
### Ce qui a ete fait
- **#100** a d'abord refondu la saisie comptoir/drive en tant que formulaire enrichi :
prix affiches, verrou du mode de service au canal drive, navigation, file des commandes
recentes. `CounterOrderController` derive la `source` (counter/drive) du chemin de la
requete ; ajout de la liste des commandes recentes par canal.
- **#104** a ensuite remplace ce formulaire-liste par un **POS tactile a tuiles** facon
borne : onglets categories en haut, grille de tuiles produits/menus, panneau commande
persistant a droite. Pense pour la tablette (grandes cibles, un tap = ajout). Le panier
est construit cote client (`counter-order.js`, CSP `'self'`, vanilla, zero handler
inline) a partir d'un script JSON inerte, puis serialise dans un champ cache
`items_json`. Le serveur revalide la forme (RG-T18), recalcule les prix (RG-T16) et
resout les modificateurs : les prix cote client sont indicatifs.
### Pourquoi — decisions et alternatives
- **Decision : POS a tuiles, reutilisant l'UX borne (#104).** Cf. ADR-0011. L'equipier
comptoir et le client borne font le meme geste (choisir des produits, composer) ; un
meme paradigme tuiles+panneau reduit l'apprentissage et reutilise les patterns deja
eprouves. *Alternative ecartee* : garder le formulaire-liste (#100) — moins adapte au
tactile et a la rapidite attendue d'une caisse.
- **Decision : serveur seul juge des prix et de la composition.** Le client propose, le
serveur fige (RG-T16). Coherent avec le reste du domaine commande.
- **Decision : un controleur pour deux canaux, source derivee du chemin.** Le decoupage
par chemin (`/drive...` vs `/counter...`) plutot que par parametre rend les deux canaux
etanches : un equipier ne peut pas requalifier sa commande en falsifiant un champ.
### Comment — points techniques cles
- `CounterOrderController` : `source()` lue depuis le chemin ; `store()` decode
`items_json`, revalide, delegue a `createStaffOrder` (commande creee directement `paid`,
encaissement immediat, sans PIN — la permission `order.create` suffit). Repli legacy
`qty_<id>` accepte quand `items_json` est absent (degradation sans JS).
- `counter-order.js` : construction des onglets/grille/panneau, modale de composition pour
produits a modificateurs et menus, serialisation a la soumission.
### Criteres RNCP couverts
- **Bloc 2 - back-office** : ecran de caisse pour equipiers non-techniques (zero jargon),
CSP-safe sans framework front.
- **Bloc 1 - regles metier** : recalcul/revalidation serveur (RG-T16, RG-T18), etancheite
des canaux.
---
## Bloc 4 — Page Stock en tableau de bord (#105)
### Ce qui a ete fait
La page d'accueil Ingredients/Stock, jugee trop chargee et opaque, est refondue en
tableau de bord. Elle porte desormais : un bandeau expliquant le lien stock ->
disponibilite borne (un ingredient requis sous le seuil critique rend indisponibles les
produits qui l'utilisent, RG-T21) ; un resume comptant les ingredients critiques / en
alerte / au-dessus du seuil ; une section "A reapprovisionner" mettant en avant les
ingredients bas (critiques d'abord) avec barre de niveau et bouton de reapprovisionnement
direct. La liste complete passe au second plan et le CRUD est relegue. Les sous-pages
(reappro, inventaire, mouvements, creation) restent inchangees. `index()` expose des
compteurs par etat, calcules cote serveur a partir de `stock_band` deja resolu par le
depot, pour garder la vue declarative et la valeur testable.
### Pourquoi — decisions et alternatives
Cf. ADR-0012. **Decision : dashboard oriente action plutot que liste-CRUD.** Le metier
quotidien d'un equipier stock est de voir vite ce qui manque et de reapprovisionner, pas
d'editer des fiches. Mettre les alertes et le bouton de reappro en avant aligne l'ecran
sur ce geste. *Alternative ecartee* : garder une liste exhaustive triable — exhaustive
mais muette sur l'urgence.
### Criteres RNCP couverts
- **Bloc 2 - back-office** : ergonomie orientee tache pour utilisateur non-technique.
- **Bloc 1 - regles metier** : lien stock -> disponibilite (RG-T21) rendu explicite a
l'ecran.
---
## Verifications
A la cloture du lot : suite JS 135 tests verte (verifiee en local), suites PHPUnit et PHPStan niveau 6 vertes en CI Forgejo,
`php -l` propre. Chaque PR est passee par la CI Forgejo (checks requis) avant merge natif.
## Points d'amelioration conscients
- **#100 puis #104** : la refonte POS (#104) remplace une premiere refonte (#100) du meme
ecran a quelques jours d'intervalle. Le formulaire enrichi de #100 a servi de palier
avant le pivot tactile ; l'iteration est assumee plutot que masquee.
- **SMTP** : le client maison couvre le cas d'usage reset (un destinataire, texte). Il
n'est pas un agent mail generaliste ; tout besoin plus large (pieces jointes, files)
serait a reevaluer.
## Liens vers artefacts
- Commits : `8c5d942` (#94) -> `03ef99d` (#105).
- ADR associes : `docs/adr/0011-pos-tactile-tuiles-comptoir-drive.md`,
`docs/adr/0012-page-stock-tableau-de-bord.md`.
- Fichiers principaux : `.forgejo/workflows/deploy.yml`, `scripts/deploy.sh`,
`src/app/Controllers/HealthController.php`, `src/app/Auth/SmtpClient.php`,
`src/app/Controllers/CounterOrderController.php`,
`src/public/admin/assets/js/counter-order.js`,
`src/app/Controllers/IngredientController.php`,
`src/app/Views/admin/ingredients/index.php`.

View file

@ -33,8 +33,13 @@ Les fichiers sont ordonnes chronologiquement par leur nom.
| 2026-06-04 | [conception-prodlike-revision](2026-06-04--conception-prodlike-revision.md) | Revue d'alignement P1 + decisions prod-like du modele de donnees (drop commande_event, nommage EN, TVA par produit apres fact-check BOFiP, perso menus/ingredients, allergenes, ~16 entites) | `feat/p1-conception` |
| 2026-06-15 | [p3-throttle-pin-rg-t22](2026-06-15--p3-throttle-pin-rg-t22.md) | P3 securite : throttle du PIN d'action sensible (RG-T22) — design multi-agents + verification adversariale, dimension "utilisateur agissant", entite 22 `pin_throttle` | `feat/p3-pin-throttle` -> `dev` |
| 2026-06-16 | [audit-reel-livrables-p2-p3](2026-06-16--audit-reel-livrables-p2-p3.md) | Verification sur pieces des livrables du 2026-06-15 (sweep 10 dimensions + adversarial) : socle SbD confirme, miss confirmes par gravite (php.ini non deploye, CI sans tests DB, XSS kiosk, liens morts...) et remediations | `docs/journal-audit-2026-06-16` -> `dev` (PR #19/#20/#21) |
| 2026-06-04 | [p1-merise-v0.2-rewrite-and-forgejo-migration](2026-06-04--p1-merise-v0.2-rewrite-and-forgejo-migration.md) | P1 Merise v0.2 (prod-like) reecrit + migration vers Forgejo auto-heberge | `feat/p1-conception` |
| 2026-06-17 | [makefile-to-compose-migrate](2026-06-17--makefile-to-compose-migrate.md) | Du Makefile a `docker compose up` : service one-shot `wakdo-migrate` (migrate + seed idempotents) | `feat/compose-migrate` -> `dev` |
| 2026-06-17 | [session-infra-doc-e2e](2026-06-17--session-infra-doc-e2e.md) | Session infra compose, documentation Forge, amorce des tests E2E Playwright | `dev` (PR #37/#38/#39 et suivantes) |
| 2026-06-18 | [front-login-ui-admin-p4-commande](2026-06-18--front-login-ui-admin-p4-commande.md) | Page login, refonte UI admin (equipiers non-techniques), humanisation des libelles, amorce P4 commande (creation + encaissement) | `dev` (PR #48 a #58) |
| 2026-06-25 | [audit-remediation-et-features-94-105](2026-06-25--audit-remediation-et-features-94-105.md) | Synthese #94-#105 : CD push-based vers Vision + preuve de version, modeles compose prod, SMTP reel reset (Brevo), durcissement borne (Maxi 50cl, rupture non commandable, panier persistant, confirm abandon, allergenes /api), POS tactile comptoir/drive, page Stock en tableau de bord | `dev`/`main` (PR #94 a #105) |
*Mis a jour a chaque nouvelle entree.*
*Mis a jour a chaque nouvelle entree. Les entrees sont ordonnees par leur nom de fichier (date) ; cet index les liste dans l'ordre de redaction.*
---

View file

@ -13,6 +13,9 @@ erDiagram
varchar name
text description
int price_cents
int maxi_variant_product_id FK
smallint size_cl
int base_product_id FK
smallint vat_rate
varchar image_path
tinyint is_available
@ -46,6 +49,8 @@ erDiagram
category ||--o{ product : "groups"
category ||--o{ menu : "groups"
menu ||--|| product : "anchors (burger_product_id)"
product ||--o{ product : "maxi_variant (maxi_variant_product_id)"
product ||--o{ product : "size_variant_of (base_product_id)"
menu ||--o{ menu_slot : "defines_slot"
menu_slot ||--o{ menu_slot_option : "lists"
product ||--o{ menu_slot_option : "is_eligible_for"

View file

@ -11,6 +11,9 @@ erDiagram
int stock_capacity
smallint pack_size
varchar pack_label
smallint energy_kcal_100g
varchar nutrition_source
datetime nutrition_fetched_at
smallint low_stock_pct
smallint critical_stock_pct
tinyint is_active

View file

@ -6,6 +6,7 @@ erDiagram
enum source
int acting_user_id FK
enum service_mode
varchar service_tag
enum status
int total_ht_cents
int total_vat_cents

View file

@ -11,6 +11,9 @@ erDiagram
int category_id FK
varchar name
int price_cents
int maxi_variant_product_id FK
smallint size_cl
int base_product_id FK
smallint vat_rate
tinyint is_available
smallint display_order
@ -41,6 +44,8 @@ erDiagram
category ||--o{ product : "category_id (RESTRICT)"
category ||--o{ menu : "category_id (RESTRICT)"
product ||--o{ menu : "burger_product_id (RESTRICT)"
product ||--o{ product : "maxi_variant_product_id (SET NULL)"
product ||--o{ product : "base_product_id (CASCADE)"
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)"

View file

@ -6,6 +6,9 @@ erDiagram
int stock_quantity
int stock_capacity
smallint pack_size
smallint energy_kcal_100g
varchar nutrition_source
datetime nutrition_fetched_at
smallint low_stock_pct
smallint critical_stock_pct
tinyint is_active

View file

@ -6,6 +6,7 @@ erDiagram
enum source
int acting_user_id FK
enum service_mode
varchar service_tag
enum status
int total_ht_cents
int total_vat_cents

View file

@ -4,7 +4,7 @@
**Version** : v0.3 — prod-like, 22 entites (19 prod-like + couche security-by-design, incl. les entites `login_throttle` et `pin_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)
**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) ; colonnes additives post-v0.3 des migrations 0003/0005/0006/0007 alignees sur le deploye (voir note 14)
**Auteur** : BYAN (couche methodologie)
---
@ -114,6 +114,9 @@ Un article vendable unique, disponible a la carte ou comme composant dans un slo
| `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) |
| `maxi_variant_product_id` | INT UNSIGNED | YES | NULL | FK -> `product(id)`, ON DELETE SET NULL | (migration 0006) | auto-reference : variante servie quand un menu est commande au format Maxi (ex. Moyenne Frite -> Grande Frite). Data-driven (la regle vit dans la donnee). SET NULL = degradation gracieuse : si la variante Grande est retiree du catalogue, le produit de base reste vendable, il perd seulement sa substitution Maxi. Voir note 14 |
| `size_cl` | SMALLINT UNSIGNED | YES | NULL | — | (migration 0007) | variante de TAILLE a la carte : volume en centilitres d'une boisson fontaine (ex. 30 / 50 cl). NULL = produit sans dimension taille (bouteille, non-boisson). La ligne de base ET la variante portent leur volume pour l'affichage du picker. Voir note 14 |
| `base_product_id` | INT UNSIGNED | YES | NULL | FK -> `product(id)`, ON DELETE CASCADE | (migration 0007) | auto-reference vers la ligne de base d'une variante de taille. NULL = produit de base ou autonome (visible dans la grille catalogue) ; NON NULL = variante de taille (masquee de la grille, atteinte via le picker). CASCADE : une variante de taille n'a pas de sens sans sa base (suppression de la base -> suppression de ses variantes). Voir note 14 |
| `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 |
@ -197,6 +200,9 @@ Ingredient elementaire utilise dans la composition des produits. Porte les donne
| `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") |
| `energy_kcal_100g` | SMALLINT UNSIGNED | YES | NULL | — | enrichissement nutritionnel (migration 0005) : apport energetique pour 100 g, importe depuis l'API externe OpenFoodFacts (Cr 3.a.3). Nullable : un ingredient non enrichi reste valide. Voir note 14 |
| `nutrition_source` | VARCHAR(120) | YES | NULL | — | enrichissement nutritionnel (migration 0005) : provenance de la donnee (ex. "OpenFoodFacts"). Voir note 14 |
| `nutrition_fetched_at` | DATETIME | YES | NULL | — | enrichissement nutritionnel (migration 0005) : horodatage de l'import, pour tracer la fraicheur. Voir note 14 |
| `low_stock_pct` | SMALLINT UNSIGNED | NO | 10 | CHECK BETWEEN 0 AND 100 | bande dalerte, 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 |
@ -281,11 +287,12 @@ Transaction client : 1 commande = 1 panier valide a un instant donne.
| Attribut | Type | NULL | Default | Contrainte | Notes |
|---|---|---|---|---|---|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
| `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. |
| `order_number` | VARCHAR(20) | NO | — | UNIQUE | numero lisible : prefixe canal + id sequentiel, soit `K<id>` / `C<id>` / `D<id>` (K=kiosk, C=counter, D=drive). Ecrit en deux temps (INSERT puis UPDATE avec `LAST_INSERT_ID()`). 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). |
| `service_tag` | VARCHAR(20) | YES | NULL | — | numero de chevalet pour le service EN SALLE (migration 0003), saisi a la borne quand le client choisit `dine_in` ; permet d'apporter la commande a la bonne table (B4). NULL pour `takeaway` / `drive`. Voir note 14 |
| `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 |
@ -687,15 +694,28 @@ et evite tous les conflits.
`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 — Prefixe de numero de commande par canal
### Note 4 — Prefixe de numero de commande par canal (existant : prefixe + id)
Format : `K`/`C`/`D`-YYYY-MM-DD-NNN (kiosk / counter / drive).
Format reel (decision utilisateur) : prefixe canal + id de la commande, soit `K<id>` / `C<id>` /
`D<id>` (kiosk / counter / drive). Implemente dans `src/app/Order/OrderRepository.php` : la commande
est inseree avec un `order_number` provisoire vide, puis l'`order_number` est ecrit en `prefix .
LAST_INSERT_ID()` (ex. `K42`, `C7`, `D13`).
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.
Rationale : le prefixe encode le canal, utile pour une identification visuelle rapide par le personnel
cuisine et comptoir sans interroger la colonne `source`. Le suffixe est l'id sequentiel auto-incremente :
pas de compteur quotidien a tenir ni de `service_day` a gerer cote numerotation (rasoir d'Ockham,
mantra #37).
Alternative rejetee : prefixe neutre `W-` pour tous les canaux (plus simple, mais perd la lisibilite
Ecart assume avec la cible v0.x initiale `K-AAAA-MM-JJ-NNN` (compteur journalier par canal) :
cette derniere n'a pas ete retenue a l'implementation, jugee plus lourde sans valeur metier
proportionnelle pour le volume attendu. La forme acte ici est celle qui tourne.
Compromis connu : un numero `prefixe + id` est sequentiel, donc devinable (un client peut incrementer
l'id). Couple a l'endpoint de paiement anonyme cote borne (lecture/encaissement par `order_number`
sans authentification), c'est une surface a surveiller. Piste d'amelioration : numero non sequentiel
(ex. suffixe aleatoire court) si le suivi anonyme par numero devait s'ouvrir davantage.
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` (canal vs mode de consommation)
@ -910,6 +930,49 @@ est un backoff degressif aux bornes propres (PIN_THROTTLE_*). Meme purge cron qu
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).
### Note 14 — Colonnes additives post-v0.3 (migrations 0003 / 0005 / 0006 / 0007)
Ces colonnes etendent le schema apres v0.3 par des migrations purement additives (ajout de colonnes
nullables et de FK auto-referentes ; aucune donnee existante a retro-remplir, aucune table nouvelle).
Le runner applique les `*.sql` dans l'ordre lexicographique via `schema_migrations`. Elles sont alignees
ici sur le schema reellement deploye.
**Migration 0003 — `customer_order.service_tag` VARCHAR(20) NULL (AFTER service_mode).** Numero de
chevalet pour le service en salle (mode `dine_in`), saisi a la borne ; NULL pour `takeaway` / `drive`.
Permet d'apporter la commande a la bonne table (B4). Entite 3.10.
**Migration 0005 — enrichissement nutritionnel de `ingredient` (AFTER pack_label).**
`energy_kcal_100g` SMALLINT UNSIGNED NULL, `nutrition_source` VARCHAR(120) NULL,
`nutrition_fetched_at` DATETIME NULL. Donnees importees depuis l'API externe OpenFoodFacts (Cr 3.a.3 :
exploitation d'informations externes dans le modele de donnees). Opt-in et egress maitrise : aucun appel
automatique au runtime borne ; la passerelle (`App\Catalogue\OpenFoodFactsGateway`) est invoquee seulement
par `IngredientController::enrich` (action explicite manager/admin). Toutes nullables : un ingredient non
enrichi reste valide. Entite 3.6.
**Migration 0006 — `product.maxi_variant_product_id` INT UNSIGNED NULL, FK -> `product(id)` ON DELETE
SET NULL (AFTER price_cents).** Auto-reference : variante servie quand un menu est commande au format
Maxi (ex. Moyenne Frite -> Grande Frite), substituee cote serveur dans `OrderRepository::resolveSelections`
sans choix supplementaire. Approche data-driven (la regle vit dans la donnee, pas dans le code), et le
decrement de stock frappe alors le bon produit. SET NULL plutot que RESTRICT : si la variante Grande est
supprimee du catalogue, le produit de base reste vendable et perd seulement sa substitution Maxi
(degradation gracieuse) ; la reference est un confort metier, pas une integrite forte de commande (les
commandes figent deja leurs snapshots). Entite 3.2.
**Migration 0007 — variante de TAILLE de `product` (AFTER price_cents).** `size_cl` SMALLINT UNSIGNED
NULL, `base_product_id` INT UNSIGNED NULL avec FK -> `product(id)` ON DELETE CASCADE. La dimension taille
des boissons fontaine (la maquette borne propose 30 / 50 cl) est modelisee en lignes produit distinctes
(meme approche que Moyenne/Grande Frite) : le domaine commande facture deja par `product_id`, le flux reste
inchange, la borne resout la taille choisie en `product_id`. `base_product_id` NULL = produit de base ou
autonome (visible dans la grille catalogue) ; NON NULL = variante de taille (masquee de la grille, atteinte
via le picker). CASCADE plutot que SET NULL (a la difference de 0006) : une variante de taille n'a aucun
sens sans sa base (une "Coca Cola 50 cl" orpheline n'est pas commercialisable), donc supprimer la base
emporte ses variantes de taille. Les deux groupings coexistent sur une boisson sans se confondre :
`base_product_id` pilote la selection de taille a la carte (picker 30/50 cl) ; `maxi_variant_product_id`
(0006) pilote la substitution Maxi en menu. Entite 3.2.
References : `db/migrations/0003_order_service_tag.sql`, `0005_ingredient_nutrition.sql`,
`0006_product_maxi_variant.sql`, `0007_product_size_variant.sql`.
---
## 5. Synthese du decompte des entites

View file

@ -111,6 +111,9 @@ erDiagram
varchar name
text description
int price_cents
int maxi_variant_product_id FK
smallint size_cl
int base_product_id FK
smallint vat_rate
varchar image_path
tinyint is_available
@ -144,6 +147,8 @@ erDiagram
category ||--o{ product : "groups"
category ||--o{ menu : "groups"
menu ||--|| product : "anchors (burger_product_id)"
product ||--o{ product : "maxi_variant (maxi_variant_product_id)"
product ||--o{ product : "size_variant_of (base_product_id)"
menu ||--o{ menu_slot : "defines_slot"
menu_slot ||--o{ menu_slot_option : "lists"
product ||--o{ menu_slot_option : "is_eligible_for"
@ -159,6 +164,8 @@ erDiagram
| 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. |
| C7 | maxi_variant (migration 0006) | product (base) | (0,1) | product (variante Maxi) | (0,N) | Auto-reference : un produit pointe vers 0 ou 1 variante servie en menu Maxi (`maxi_variant_product_id`, nullable) ; un produit peut etre la variante Maxi de plusieurs autres. ON DELETE SET NULL (degradation gracieuse). |
| C8 | size_variant_of (migration 0007) | product (base) | (0,N) | product (variante de taille) | (0,1) | Auto-reference : une variante de taille pointe vers sa ligne de base (`base_product_id`, nullable ; NULL = base/autonome) ; un produit de base peut avoir plusieurs variantes de taille (30/50 cl). ON DELETE CASCADE (une variante de taille n'a pas de sens sans sa base). |
### 4.3 Notes sur le sous-domaine Catalogue
@ -168,6 +175,8 @@ erDiagram
**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).
**Variantes de produit (migrations 0006 / 0007, voir note 14 du dictionnaire)** : deux auto-references sur `product`, distinctes par leur role et leur comportement `ON DELETE`. `maxi_variant_product_id` (SET NULL) designe la variante servie quand un menu est commande au format Maxi (ex. Moyenne Frite -> Grande Frite), substituee cote serveur. `size_cl` + `base_product_id` (CASCADE) modelisent une variante de TAILLE a la carte (boisson 30/50 cl) en lignes produit : `base_product_id` NULL = produit de base/autonome visible au catalogue, NON NULL = variante masquee de la grille et atteinte via le picker. Les deux groupings coexistent sur une boisson sans se confondre.
---
## 5. Sous-domaine : Ingredients & Stock
@ -188,6 +197,9 @@ erDiagram
int stock_capacity
smallint pack_size
varchar pack_label
smallint energy_kcal_100g
varchar nutrition_source
datetime nutrition_fetched_at
smallint low_stock_pct
smallint critical_stock_pct
tinyint is_active
@ -262,6 +274,8 @@ erDiagram
**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.
**Enrichissement nutritionnel (migration 0005, voir note 14 du dictionnaire)** : `energy_kcal_100g`, `nutrition_source` et `nutrition_fetched_at` (toutes nullables) stockent une donnee importee depuis l'API externe OpenFoodFacts (Cr 3.a.3 : exploitation d'informations externes dans le modele de donnees). Opt-in : l'import est declenche par `IngredientController::enrich` (action manager/admin), pas au runtime borne ; un ingredient non enrichi reste valide.
---
## 6. Sous-domaine : Order
@ -277,6 +291,7 @@ erDiagram
enum source
int acting_user_id FK
enum service_mode
varchar service_tag
enum status
int total_ht_cents
int total_vat_cents
@ -382,6 +397,11 @@ enregistre l'employe de comptoir/drive qui a pris la commande sous PIN ; NULL po
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.
**Numero de commande (existant) et service en salle (migration 0003)** : `order_number` est un attribut
non cle (UNIQUE) de forme `prefixe canal + id` (`K<id>` / `C<id>` / `D<id>`) ; pas une association. Voir
note 4 du dictionnaire. `service_tag` (VARCHAR(20), nullable) porte le numero de chevalet du service en
salle (mode `dine_in`), saisi a la borne ; NULL pour `takeaway` / `drive`. Voir note 14 du dictionnaire.
---
## 7. Sous-domaine : RBAC
@ -580,7 +600,7 @@ Le MCD reste au niveau conceptuel. Les decisions suivantes sont reportees au MLD
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).
format prefixe canal + id : `K<id>` / `C<id>` / `D<id>`).
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.

View file

@ -143,7 +143,7 @@ Pour chaque operation, le document fournit :
| **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** | 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`. |
| **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 prefixe canal + id (`K<id>`, voir dictionnaire note 4). |
| **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 |
@ -175,7 +175,7 @@ Pour chaque operation, le document fournit :
| **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** | 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). |
| **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 : prefixe canal + id (`C<id>` comptoir, `D<id>` 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 |

View file

@ -125,6 +125,9 @@ erDiagram
int category_id FK
varchar name
int price_cents
int maxi_variant_product_id FK
smallint size_cl
int base_product_id FK
smallint vat_rate
tinyint is_available
smallint display_order
@ -155,6 +158,8 @@ erDiagram
category ||--o{ product : "category_id (RESTRICT)"
category ||--o{ menu : "category_id (RESTRICT)"
product ||--o{ menu : "burger_product_id (RESTRICT)"
product ||--o{ product : "maxi_variant_product_id (SET NULL)"
product ||--o{ product : "base_product_id (CASCADE)"
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)"
@ -171,6 +176,9 @@ erDiagram
int stock_quantity
int stock_capacity
smallint pack_size
smallint energy_kcal_100g
varchar nutrition_source
datetime nutrition_fetched_at
smallint low_stock_pct
smallint critical_stock_pct
tinyint is_active
@ -235,6 +243,7 @@ erDiagram
enum source
int acting_user_id FK
enum service_mode
varchar service_tag
enum status
int total_ht_cents
int total_vat_cents
@ -410,11 +419,14 @@ Pas de FK. Table racine du sous-domaine Catalogue.
### 4.2 `product`
```
product (id, #category_id, name, [description], price_cents, vat_rate,
product (id, #category_id, name, [description], price_cents,
[#maxi_variant_product_id], [size_cl], [#base_product_id], vat_rate,
[image_path], is_available, display_order, created_at, updated_at)
PK : id
FK : category_id -> category(id) ON DELETE RESTRICT
FK : category_id -> category(id) ON DELETE RESTRICT
FK : maxi_variant_product_id -> product(id) ON DELETE SET NULL
FK : base_product_id -> product(id) ON DELETE CASCADE
IDX : (category_id, is_available, display_order)
CHK : price_cents > 0
CHK : vat_rate IN (55, 100)
@ -427,6 +439,9 @@ product (id, #category_id, name, [description], price_cents, vat_rate,
| `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 |
| `maxi_variant_product_id` | INT UNSIGNED | YES | FK -> product (auto-reference), ON DELETE SET NULL ; variante servie en menu Maxi (migration 0006, voir note de table) |
| `size_cl` | SMALLINT UNSIGNED | YES | Volume en cl d'une variante de taille de boisson ; NULL si pas de dimension taille (migration 0007) |
| `base_product_id` | INT UNSIGNED | YES | FK -> product (auto-reference), ON DELETE CASCADE ; ligne de base d'une variante de taille, NULL = base/autonome (migration 0007) |
| `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 |
@ -437,6 +452,19 @@ product (id, #category_id, name, [description], price_cents, vat_rate,
**ON DELETE RESTRICT** sur `category_id` : une categorie avec des produits ne peut pas etre supprimee. Empeche les
produits orphelins.
**Auto-references de variante (migrations 0006 / 0007, voir note 14 du dictionnaire)** : deux groupings
distincts, tous deux pointant vers `product(id)`.
- `maxi_variant_product_id` (ON DELETE SET NULL) : variante servie quand un menu est commande au format
Maxi (ex. Moyenne Frite -> Grande Frite). SET NULL = degradation gracieuse, le produit de base reste
vendable si la variante Grande est supprimee.
- `size_cl` + `base_product_id` (ON DELETE CASCADE) : variante de TAILLE a la carte (boisson 30/50 cl)
modelisee en lignes produit. `base_product_id` NULL = produit de base/autonome (visible catalogue) ;
NON NULL = variante de taille (masquee de la grille, atteinte via le picker). CASCADE car une variante
de taille n'a pas de sens sans sa base.
Les deux coexistent sur une boisson sans se confondre : `base_product_id` pilote la selection de taille a
la carte ; `maxi_variant_product_id` pilote la substitution Maxi en menu.
---
### 4.3 `menu`
@ -529,6 +557,7 @@ Pas d'horodatages. Table de jointure pure.
```
ingredient (id, name, unit, stock_quantity, stock_capacity, pack_size, [pack_label],
[energy_kcal_100g], [nutrition_source], [nutrition_fetched_at],
low_stock_pct, critical_stock_pct, is_active, created_at, updated_at)
PK : id
@ -549,6 +578,9 @@ ingredient (id, name, unit, stock_quantity, stock_capacity, pack_size, [pack_lab
| `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 |
| `energy_kcal_100g` | SMALLINT UNSIGNED | YES | Apport energetique pour 100 g, importe depuis l'API externe OpenFoodFacts (migration 0005) |
| `nutrition_source` | VARCHAR(120) | YES | Provenance de la donnee nutritionnelle, ex. "OpenFoodFacts" (migration 0005) |
| `nutrition_fetched_at` | DATETIME | YES | Horodatage de l'import nutritionnel, trace la fraicheur (migration 0005) |
| `low_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 10 | NO | Bande dalerte, 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 |
@ -577,6 +609,11 @@ bloque pas le produit (seul son supplement devient indisponible). Le tableau de
retrait manuel (`is_available=0`) d'une rupture pilotee par le stock (`is_available=1` mais un ingredient
requis est critique).
**Enrichissement nutritionnel (migration 0005, voir note 14 du dictionnaire)** : `energy_kcal_100g`,
`nutrition_source` et `nutrition_fetched_at` (toutes nullables) stockent une donnee importee depuis l'API
externe OpenFoodFacts (Cr 3.a.3). Opt-in : l'import est declenche par `IngredientController::enrich`
(action manager/admin), pas au runtime borne ; un ingredient non enrichi reste valide.
---
### 4.7 `product_ingredient`
@ -811,7 +848,7 @@ Pas d'horodatages. Table de jointure pure.
```
customer_order (id, order_number, [idempotency_key], source, [#acting_user_id],
service_mode, status,
service_mode, [service_tag], status,
total_ht_cents, total_vat_cents, total_ttc_cents,
[paid_at], [delivered_at], [cancelled_at],
created_at, updated_at)
@ -833,11 +870,12 @@ customer_order (id, order_number, [idempotency_key], source, [#acting_user_id],
| 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 |
| `order_number` | VARCHAR(20) | NO | Prefixe canal + id sequentiel : `K<id>`/`C<id>`/`D<id>` (existant, voir note de table) |
| `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) |
| `service_tag` | VARCHAR(20) | YES | Numero de chevalet du service en salle (`dine_in`), saisi a la borne ; NULL pour takeaway/drive (migration 0003) |
| `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 |
@ -848,6 +886,17 @@ customer_order (id, order_number, [idempotency_key], source, [#acting_user_id],
| `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 |
**Numero de commande (existant, voir note 4 du dictionnaire)** : `order_number` = prefixe canal + id
sequentiel (`K<id>` / `C<id>` / `D<id>`), ecrit en deux temps (INSERT avec numero provisoire vide, puis
UPDATE en `prefix . LAST_INSERT_ID()`) dans `OrderRepository`. La cible initiale `K-AAAA-MM-JJ-NNN`
(compteur journalier) n'a pas ete retenue : la forme `prefixe + id` evite un compteur quotidien. Compromis
connu : numero sequentiel donc devinable, couple a l'endpoint de paiement anonyme cote borne (piste
d'amelioration : numero non sequentiel).
**Service en salle (migration 0003)** : `service_tag` (VARCHAR(20), nullable) porte le numero de chevalet
saisi a la borne pour le mode `dine_in` ; NULL pour `takeaway` / `drive`. Colonne additive, sans contrainte
de BD (la coherence avec `service_mode` est appliquee au niveau applicatif).
**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
@ -1235,11 +1284,11 @@ 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 | |
| `product` (C2) | `product` (4.2) | entite 1:1 | Additif post-v0.3 : `maxi_variant_product_id` (0006), `size_cl` + `base_product_id` (0007) |
| `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) |
| `ingredient` (C6) | `ingredient` (4.6) | entite 1:1 | Nouvelle entite (v0.2) ; additif post-v0.3 : `energy_kcal_100g`, `nutrition_source`, `nutrition_fetched_at` (0005) |
| `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) |
@ -1248,7 +1297,7 @@ et que toutes les tables se rattachent au MCD.
| `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 |
| `customer_order` (C15) | `customer_order` (4.15) | entite 1:1 | Renommee depuis `commande` ; machine a 4 etats ; horodatages de phase ; additif post-v0.3 : `service_tag` (0003) |
| `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) |

View file

@ -115,7 +115,7 @@ Ces regles s'appliquent a plusieurs operations et sont centralisees ici pour evi
| **[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-3 — order number]** | Format du numero de commande : prefixe canal + id auto-incremente, soit `K<id>` pour la source `kiosk` (ex. `K42`). Genere en deux temps dans la transaction : INSERT avec `order_number` provisoire vide, puis UPDATE `prefix . LAST_INSERT_ID()`. Pas de compteur par service_day (voir dictionnaire note 4). La source est `kiosk` (derivee de l'endpoint public). Le numero provisoire vide partage avant l'UPDATE reste une surface de robustesse a durcir sous forte concurrence (suivi au backlog). |
| **[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. |
@ -163,7 +163,7 @@ Ces regles s'appliquent a plusieurs operations et sont centralisees ici pour evi
| **[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-3 — order number]** | Format : prefixe canal + id auto-incremente, soit `C<id>` (comptoir) ou `D<id>` (drive). Meme generation en deux temps que CREATE_ORDER RG-3. Pas de compteur par service_day (voir dictionnaire note 4). |
| **[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. |

View file

@ -211,7 +211,7 @@ flowchart LR
| Cas | Operation MCT | Permission | Description | Entites |
|---|---|---|---|---|
| Saisir une commande comptoir/drive | 4.1 CREATE_COUNTER_ORDER | `order.create` | Composer une commande pour un client au comptoir (`counter`) ou au drive (`drive`). Logique identique a CREATE_ORDER ; `source` auto-tague depuis `role.order_source`. Numero `C-`/`D-YYYY-MM-DD-NNN`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient`, `stock_movement` |
| Saisir une commande comptoir/drive | 4.1 CREATE_COUNTER_ORDER | `order.create` | Composer une commande pour un client au comptoir (`counter`) ou au drive (`drive`). Logique identique a CREATE_ORDER ; `source` auto-tague depuis `role.order_source`. Numero prefixe canal + id (`C<id>`/`D<id>`, voir dictionnaire note 4). | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient`, `stock_movement` |
| Consulter la file de preparation | 5.1 LIST_ORDERS_DISPLAY | `order.read` | Voir les commandes `paid` triees par `paid_at` croissant, filtrees par `role_visible_source` (counter voit kiosk+counter ; drive voit drive). Couleur KDS = `now - paid_at`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` |
| Remettre la commande | 6.1 DELIVER_ORDER | `order.deliver` | Geste unique `paid -> delivered`, `delivered_at = NOW()`. | `customer_order` |
| Annuler une commande | 7.1 CANCEL_ORDER | `order.cancel` | Transition vers `cancelled` depuis `pending_payment`/`paid`, `cancelled_at = NOW()`. Re-credit du stock si `paid`. | `customer_order`, `ingredient`, `stock_movement` |

View file

@ -52,25 +52,40 @@ if ! command -v docker >/dev/null 2>&1; then
fi
echo "Deploiement Wakdo : branche '$BRANCH' depuis '$REMOTE' via $COMPOSE_FILE"
printf 'Confirmer le deploiement en production ? [oui/NON] '
read -r answer
if [ "$answer" != "oui" ]; then
echo "deploy: annule."
exit 1
# Mode non-interactif pour le CD : DEPLOY_YES=1 saute la confirmation (la forced
# command SSH le pose). On NE lit PAS $SSH_ORIGINAL_COMMAND : la cle CI ne peut
# influencer ni la branche ni le compose, seulement declencher CE script.
if [ "${DEPLOY_YES:-}" = "1" ] || [ "${DEPLOY_YES:-}" = "oui" ]; then
echo "deploy: confirmation automatique (DEPLOY_YES)."
else
printf 'Confirmer le deploiement en production ? [oui/NON] '
read -r answer
if [ "$answer" != "oui" ]; then
echo "deploy: annule."
exit 1
fi
fi
echo "[1/4] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
echo "[1/5] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
git fetch --prune "$REMOTE" "$BRANCH"
git checkout "$BRANCH"
git merge --ff-only "$REMOTE/$BRANCH"
echo "[2/4] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
echo "[2/5] marqueur de version (preuve CD cote app)"
SHA="$(git rev-parse --short HEAD)"
NOW="$(date --iso-8601=seconds)"
# Sous src/ pour etre visible dans le conteneur (mount ./src -> /var/www/html),
# lu a chaud par GET /api/health. Journal d'historique a la racine du depot.
printf '%s %s\n' "$SHA" "$NOW" > src/VERSION
printf '[%s] deploy %s (branche %s)\n' "$NOW" "$SHA" "$BRANCH" >> deploy.log
echo "[3/5] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
docker compose -f "$COMPOSE_FILE" build --pull
echo "[3/4] demarrage de la stack (migrate + seed idempotents puis app)"
echo "[4/5] demarrage de la stack (migrate + seed idempotents puis app)"
docker compose -f "$COMPOSE_FILE" up -d
echo "[4/4] etat des services"
echo "[5/5] etat des services"
docker compose -f "$COMPOSE_FILE" ps
echo "Deploiement termine."
echo "Deploiement termine ($SHA)."

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use RuntimeException;
/**
* Client SMTP minimal (sans dependance) : ESMTP + STARTTLS + AUTH LOGIN, suffisant
* pour un relais authentifie type Brevo. Conduit la conversation contre un
* SmtpTransport injecte ; chaque etape verifie le code de reponse attendu et leve
* en cas d'ecart. La construction du message est laissee a l'appelant (SmtpMailer).
*/
final class SmtpClient
{
public function __construct(
private readonly SmtpTransport $transport,
private readonly string $heloName = 'wakdo',
) {
}
/**
* Ouvre la session, s'authentifie, transmet un message deja assemble
* (en-tetes + corps, lignes en CRLF, dot-stuffing applique) puis ferme.
*/
public function send(
string $host,
int $port,
string $user,
string $password,
string $from,
string $to,
string $message,
): void {
// Defense en profondeur : un CRLF dans une adresse injecterait une commande
// SMTP (RCPT supplementaire) ou un en-tete. On refuse avant toute connexion.
$this->assertNoInjection($from, 'expediteur');
$this->assertNoInjection($to, 'destinataire');
$t = $this->transport;
try {
$t->open($host, $port, 15);
$this->expect($t->readReply(), 220, 'greeting');
$this->command('EHLO ' . $this->heloName, 250, 'EHLO');
$this->command('STARTTLS', 220, 'STARTTLS');
$t->enableCrypto();
// Re-EHLO obligatoire apres bascule TLS (la session repart de zero).
$this->command('EHLO ' . $this->heloName, 250, 'EHLO TLS');
$this->command('AUTH LOGIN', 334, 'AUTH LOGIN');
$this->command(base64_encode($user), 334, 'AUTH user');
$this->command(base64_encode($password), 235, 'AUTH password');
$this->command('MAIL FROM:<' . $from . '>', 250, 'MAIL FROM');
$this->command('RCPT TO:<' . $to . '>', 250, 'RCPT TO');
$this->command('DATA', 354, 'DATA');
// Corps + terminateur "<CRLF>.<CRLF>".
$t->write($message . "\r\n.\r\n");
$this->expect($t->readReply(), 250, 'corps du message');
$t->write("QUIT\r\n");
// La fermeture (221) n'est pas bloquante : le message est deja accepte.
$t->readReply();
} finally {
$t->close();
}
}
private function command(string $line, int $expected, string $stage): void
{
$this->transport->write($line . "\r\n");
$this->expect($this->transport->readReply(), $expected, $stage);
}
private function assertNoInjection(string $address, string $label): void
{
if (preg_match('/[\r\n]/', $address) === 1) {
throw new RuntimeException(
sprintf('SMTP : adresse %s invalide (saut de ligne interdit)', $label),
);
}
}
private function expect(string $reply, int $code, string $stage): void
{
$got = (int) substr(ltrim($reply), 0, 3);
if ($got !== $code) {
// On ne journalise pas le corps : il peut contenir le lien de reset.
throw new RuntimeException(
sprintf('SMTP %s : attendu %d, recu "%s"', $stage, $code, trim($reply)),
);
}
}
}

101
src/app/Auth/SmtpMailer.php Normal file
View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use RuntimeException;
/**
* Mailer SMTP reel (relais authentifie type Brevo). Implemente l'interface Mailer
* a la place de LogMailer quand le SMTP est configure (voir PasswordResetController).
* Assemble un message texte/plain UTF-8 conforme puis delegue l'envoi a SmtpClient.
*/
final class SmtpMailer implements Mailer
{
public function __construct(
private readonly SmtpClient $client,
private readonly string $host,
private readonly int $port,
private readonly string $user,
private readonly string $password,
private readonly string $fromEmail,
private readonly string $fromName,
) {
}
public function sendPasswordReset(string $email, string $resetUrl): void
{
// Garde destinataire : une adresse valide ne contient ni CRLF ni structure
// d'injection (verrou en plus de la garde transport de SmtpClient).
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new RuntimeException('SmtpMailer : adresse destinataire invalide');
}
$subject = 'Reinitialisation de votre mot de passe Wakdo';
$body = "Bonjour,\r\n\r\n"
. "Une reinitialisation de mot de passe a ete demandee pour ce compte.\r\n"
. "Pour definir un nouveau mot de passe, ouvrez ce lien :\r\n\r\n"
. $resetUrl . "\r\n\r\n"
. "Ce lien expire rapidement. Si vous n'etes pas a l'origine de la demande, "
. "ignorez cet email.\r\n";
$message = $this->buildMessage($email, $subject, $body);
$this->client->send(
$this->host,
$this->port,
$this->user,
$this->password,
$this->fromEmail,
$email,
$message,
);
}
/** Assemble en-tetes + corps en CRLF, avec dot-stuffing pour la phase DATA. */
private function buildMessage(string $to, string $subject, string $body): string
{
$headers = [
'From: ' . $this->encodeHeader($this->fromName) . ' <' . $this->fromEmail . '>',
'To: <' . $to . '>',
'Subject: ' . $this->encodeHeader($subject),
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: 8bit',
];
$raw = implode("\r\n", $headers) . "\r\n\r\n" . $this->normalizeEol($body);
return $this->dotStuff($raw);
}
/** RFC 2047 (encoded-word base64) si la valeur sort de l'ASCII imprimable. */
private function encodeHeader(string $value): string
{
if (preg_match('/^[\x20-\x7E]*$/', $value) === 1) {
return $value;
}
return '=?UTF-8?B?' . base64_encode($value) . '?=';
}
/** Normalise toutes les fins de ligne en CRLF (LF ou CR isoles -> CRLF). */
private function normalizeEol(string $text): string
{
return (string) preg_replace('/\r\n|\r|\n/', "\r\n", $text);
}
/** Double un point en debut de ligne (RFC 5321 transparency). */
private function dotStuff(string $message): string
{
$lines = explode("\r\n", $message);
foreach ($lines as $i => $line) {
if (isset($line[0]) && $line[0] === '.') {
$lines[$i] = '.' . $line;
}
}
return implode("\r\n", $lines);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Auth;
/**
* Couche transport d'une session SMTP : abstrait le socket reel pour que la
* logique du protocole (SmtpClient) soit testable sans reseau (double en test).
*/
interface SmtpTransport
{
public function open(string $host, int $port, int $timeoutSeconds): void;
/** Ecrit exactement $raw sur la connexion (CRLF inclus par l'appelant). */
public function write(string $raw): void;
/**
* Lit une reponse SMTP complete. Gere le multiligne (RFC 5321 : les lignes
* de continuation ont un '-' en 4e position, la derniere un espace).
*/
public function readReply(): string;
/** Bascule la connexion en TLS (apres STARTTLS). */
public function enableCrypto(): void;
public function close(): void;
}

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use RuntimeException;
/**
* Transport SMTP reel sur socket TCP (stream_socket_client + STARTTLS). Aucune
* dependance externe. Non teste unitairement (effet de bord reseau) : la logique
* du protocole est couverte via SmtpClient + un transport double.
*/
final class StreamSmtpTransport implements SmtpTransport
{
/** @var resource|null */
private $stream = null;
public function open(string $host, int $port, int $timeoutSeconds): void
{
$errno = 0;
$errstr = '';
$stream = @stream_socket_client(
sprintf('tcp://%s:%d', $host, $port),
$errno,
$errstr,
$timeoutSeconds,
);
if ($stream === false) {
throw new RuntimeException(sprintf('SMTP : connexion echouee (%s)', $errstr));
}
stream_set_timeout($stream, $timeoutSeconds);
$this->stream = $stream;
}
public function write(string $raw): void
{
fwrite($this->requireStream(), $raw);
}
public function readReply(): string
{
$stream = $this->requireStream();
$data = '';
$lines = 0;
while (($line = fgets($stream, 515)) !== false) {
$data .= $line;
// Bornes anti-boucle sur reponse malformee (ni ligne finale, ni EOF).
if (++$lines > 100 || strlen($data) > 65536) {
break;
}
// Continuation UNIQUEMENT si '-' en 4e position ; toute autre ligne
// (y compris trop courte) termine la reponse.
if (!(strlen($line) >= 4 && $line[3] === '-')) {
break;
}
}
if ($data === '') {
throw new RuntimeException('SMTP : aucune reponse du serveur');
}
return $data;
}
public function enableCrypto(): void
{
if (!stream_socket_enable_crypto($this->requireStream(), true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new RuntimeException('SMTP : echec de la negociation TLS (STARTTLS)');
}
}
public function close(): void
{
if (is_resource($this->stream)) {
fclose($this->stream);
}
$this->stream = null;
}
/** @return resource */
private function requireStream()
{
if (!is_resource($this->stream)) {
throw new RuntimeException('SMTP : transport non ouvert');
}
return $this->stream;
}
}

View file

@ -18,12 +18,16 @@ final class UserDirectory
}
/**
* @return array{name: string, role_label: string, email: string}
* order_source : canal de saisie du role ('counter' | 'drive' | '' pour les
* roles globaux admin/manager/kitchen). Sert au layout a router le lien
* "Saisie commande" vers la landing du bon canal sans une requete dediee.
*
* @return array{name: string, role_label: string, email: string, order_source: string}
*/
public function displayInfo(int $userId): array
{
$row = $this->db->fetch(
'SELECT u.first_name, u.last_name, u.email, r.label AS role_label '
'SELECT u.first_name, u.last_name, u.email, r.label AS role_label, r.order_source '
. 'FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id',
['id' => $userId],
);
@ -33,9 +37,10 @@ final class UserDirectory
$name = trim($first . ' ' . $last);
return [
'name' => $name !== '' ? $name : 'Utilisateur',
'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '',
'email' => is_string($row['email'] ?? null) ? $row['email'] : '',
'name' => $name !== '' ? $name : 'Utilisateur',
'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '',
'email' => is_string($row['email'] ?? null) ? $row['email'] : '',
'order_source' => is_string($row['order_source'] ?? null) ? $row['order_source'] : '',
];
}
}

View file

@ -9,8 +9,9 @@ use App\Core\DatabaseInterface;
/**
* Lecture des allergenes a declaration obligatoire (INCO) : info GENERALE (les 14
* categories), pas un calcul par produit (le mapping ingredient_allergen reste
* differe). Sert l'endpoint public anonyme /api/allergens. Le schema ne porte que
* code + name ; les descriptions riches restent cote borne (data/allergens.json).
* differe). Sert l'endpoint public anonyme /api/allergens. Le schema porte
* code + name + description ; la description (texte INCO seede) est exposee par
* l'API et consommee par la borne via /api/allergens.
*
* Non `final` : seam de test (sous-classe -> double sans base).
*/
@ -27,6 +28,6 @@ class AllergenRepository
*/
public function all(): array
{
return $this->db->fetchAll('SELECT id, code, name FROM allergen ORDER BY id');
return $this->db->fetchAll('SELECT id, code, name, description FROM allergen ORDER BY id');
}
}

View file

@ -135,6 +135,23 @@ final class IngredientRepository
);
}
/**
* Reglage rapide des seuils depuis le tableau de bord stock (F13). Cible UNIQUEMENT
* les trois colonnes de calibrage (capacite = reference 100 %, seuils alerte/critique
* en %), distinctes de update() qui exige aussi name/unit/pack. stock_quantity n'est
* jamais touche : le niveau ne bouge que via restock/inventoryCount (ledger). Les
* bornes (capacite >= 1, % 0-100, critique < alerte strict) sont validees par
* l'appelant (controleur, RG-T18), pas ici.
*/
public function updateThresholds(int $id, int $capacity, int $low, int $critical): void
{
$this->db->execute(
'UPDATE ingredient SET stock_capacity = :cap, low_stock_pct = :low, '
. 'critical_stock_pct = :crit WHERE id = :id',
['cap' => $capacity, 'low' => $low, 'crit' => $critical, 'id' => $id],
);
}
/**
* Suppression dure. Bloquee par FK RESTRICT (product_ingredient / stock_movement)
* des qu'une recette ou un mouvement reference l'ingredient ; le controleur

View file

@ -63,8 +63,9 @@ final class MenuRepository
* disponibles (is_available = 1) ET en categorie active (c.is_active = 1).
* Projection enrichie (description, image_path) absente de all() back-office.
* Liste LEGERE : sans les slots (le detail /api/menus/{id} les porte). La
* disponibilite du burger impose (B1) reste un raffinement de la dispo calculee
* RG-T21, differe au seed des recettes.
* disponibilite du burger impose (B1, RG-T21) est calculee par CatalogueController
* (croisement avec ProductRepository::autoUnavailableIds) et exposee en is_orderable :
* un menu dont le burger est en rupture est grise par la borne (granularite burger seul).
*
* @return array<int, array<string, mixed>>
*/
@ -146,6 +147,23 @@ final class MenuRepository
return $this->db->fetch('SELECT id FROM product WHERE id = :id', ['id' => $id]) !== null;
}
/**
* Le produit existe-t-il ET est-il un produit de BASE (base_product_id IS NULL,
* R4) ? Garde serveur de l'eligibilite au menu (F9-2) : un menu ne peut prendre
* comme burger principal NI comme option de slot une VARIANTE de taille (ex.
* "Coca Cola 50cl"), qui n'est pas un produit autonome. Predicat plus strict que
* productExists() : il rejette une variante meme si l'UI est contournee. Le
* formulaire menu n'expose deja que des bases (ProductRepository::basesOnly),
* cette garde verrouille le chemin serveur en plus.
*/
public function productIsBase(int $id): bool
{
return $this->db->fetch(
'SELECT id FROM product WHERE id = :id AND base_product_id IS NULL',
['id' => $id],
) !== null;
}
/**
* Pre-verification FK-safe (mlt 8.6 RG-1) : le menu est-il reference par une
* ligne de commande historique ? La FK order_item.menu_id est RESTRICT.

View file

@ -28,7 +28,16 @@ final class ProductRepository
}
/**
* Liste pour le back-office, avec le libelle de categorie.
* Liste pour le back-office, avec le libelle de categorie et, pour une VARIANTE
* de taille (base_product_id non nul, R4), le nom de sa base. La liste admin
* affiche AINSI toutes les lignes produit -- bases ET variantes -- mais marque
* chaque variante "Variante de X" : l'admin la voit, comprend qu'elle n'est pas
* un produit autonome, et peut la delier/relier via le formulaire. La projection
* remonte base_product_id pour que la vue distingue les deux.
*
* Cette methode N'ALIMENTE PLUS les selects du formulaire menu (qui doivent etre
* base-only, R4/F9-1) : ceux-ci passent par basesOnly(). all() peut donc porter
* le LEFT JOIN d'enrichissement sans fausser une liste deroulante.
*
* @return array<int, array<string, mixed>>
*/
@ -36,12 +45,34 @@ final class ProductRepository
{
return $this->db->fetchAll(
'SELECT p.id, p.category_id, p.name, p.price_cents, p.vat_rate, p.is_available, '
. 'p.display_order, c.name AS category_name '
. 'p.display_order, p.size_cl, p.base_product_id, c.name AS category_name, '
. 'b.name AS base_name '
. 'FROM product p JOIN category c ON c.id = p.category_id '
. 'LEFT JOIN product b ON b.id = p.base_product_id '
. 'ORDER BY p.display_order, p.name',
);
}
/**
* Produits de BASE uniquement (base_product_id IS NULL, R4), pour alimenter les
* listes deroulantes du formulaire menu (burger principal + options de slot,
* F9-1) et le select base_product_id du formulaire produit. Une VARIANTE de
* taille (ex. "Coca Cola 50cl") n'est jamais un produit autonome : la proposer
* comme burger/option/base ferait apparaitre la variante comme un produit a part
* entiere. Le predicat anti-variante vit ici (cote requete), miroir de la garde
* serveur MenuRepository::productIsBase(). Projection minimale {id, name} : seules
* colonnes utiles a un <option>.
*
* @return array<int, array<string, mixed>>
*/
public function basesOnly(): array
{
return $this->db->fetchAll(
'SELECT id, name FROM product WHERE base_product_id IS NULL '
. 'ORDER BY display_order, name',
);
}
/**
* @return array<string, mixed>|null
*/
@ -50,9 +81,12 @@ final class ProductRepository
// maxi_variant_product_id : expose la variante Grande de l'accompagnement
// pour que OrderRepository::resolveSelections puisse substituer au format
// Maxi (cote serveur uniquement ; la borne n'en a pas besoin).
// size_cl + base_product_id (R4) : remontes pour que le formulaire produit
// pre-remplisse les champs de variante a l'edition (F9-3).
return $this->db->fetch(
'SELECT id, category_id, name, description, price_cents, maxi_variant_product_id, '
. 'vat_rate, image_path, is_available, display_order FROM product WHERE id = :id',
'SELECT id, category_id, name, description, price_cents, size_cl, base_product_id, '
. 'maxi_variant_product_id, vat_rate, image_path, is_available, display_order '
. 'FROM product WHERE id = :id',
['id' => $id],
);
}
@ -63,8 +97,10 @@ final class ProductRepository
* (c.is_active = 1), pour ne jamais proposer un produit dont l'onglet de
* categorie n'apparait pas. vat_rate n'est pas selectionne : le calcul fiscal
* vit cote serveur a la commande, la borne ne l'affiche pas. Filtre de
* disponibilite = flag is_available ; la dispo CALCULEE RG-T21 (exclusion des
* ruptures auto via autoUnavailableIds) se branchera au seed des recettes.
* disponibilite = flag is_available (retrait manuel) ; la dispo CALCULEE RG-T21
* (rupture par stock) n'exclut PAS la ligne ici : CatalogueController la croise
* avec autoUnavailableIds() pour exposer is_orderable, et la borne grise la tuile
* (visible mais non commandable) au lieu de la masquer.
*
* base_product_id IS NULL (R4) : les VARIANTES de taille (ex. "Coca Cola 50cl")
* ne sont jamais des tuiles catalogue autonomes ; elles sont atteintes via le
@ -82,11 +118,11 @@ final class ProductRepository
// un libelle d'affichage seulement.
return $this->db->fetchAll(
'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, p.size_cl, '
. 'p.image_path, p.display_order, mv.name AS maxi_variant_name '
. 'p.image_path, p.display_order, c.name AS category_name, mv.name AS maxi_variant_name '
. 'FROM product p JOIN category c ON c.id = p.category_id '
. 'LEFT JOIN product mv ON mv.id = p.maxi_variant_product_id '
. 'WHERE p.is_available = 1 AND c.is_active = 1 AND p.base_product_id IS NULL '
. 'ORDER BY p.display_order, p.name',
. 'ORDER BY c.display_order, c.name, p.display_order, p.name',
);
}
@ -181,27 +217,49 @@ final class ProductRepository
return $this->db->fetch('SELECT id FROM category WHERE id = :id', ['id' => $categoryId]) !== null;
}
public function productExists(int $id): bool
{
return $this->db->fetch('SELECT id FROM product WHERE id = :id', ['id' => $id]) !== null;
}
/**
* @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
* Le produit existe-t-il ET est-il une BASE (base_product_id IS NULL, R4) ?
* Sert a valider les FK de variante du formulaire produit (F9-3) : une base ne
* peut pointer vers une AUTRE variante (pas de chaine de variantes), et la cible
* d'une variante de taille doit elle-meme etre une base. Retourne false si l'id
* est inconnu OU si la ligne est deja une variante.
*/
public function productIsBase(int $id): bool
{
return $this->db->fetch(
'SELECT id FROM product WHERE id = :id AND base_product_id IS NULL',
['id' => $id],
) !== null;
}
/**
* @param array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
*/
public function create(array $data): void
{
$this->db->execute(
'INSERT INTO product (category_id, name, description, price_cents, vat_rate, image_path, is_available, display_order) '
. 'VALUES (:category, :name, :description, :price, :vat, :image, :available, :ord)',
'INSERT INTO product (category_id, name, description, price_cents, size_cl, base_product_id, '
. 'maxi_variant_product_id, vat_rate, image_path, is_available, display_order) '
. 'VALUES (:category, :name, :description, :price, :size, :base, :maxi, :vat, :image, :available, :ord)',
$this->bind($data),
);
}
/**
* @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
* @param array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
*/
public function update(int $id, array $data): void
{
$this->db->execute(
'UPDATE product SET category_id = :category, name = :name, description = :description, '
. 'price_cents = :price, vat_rate = :vat, image_path = :image, is_available = :available, '
. 'display_order = :ord WHERE id = :id',
. 'price_cents = :price, size_cl = :size, base_product_id = :base, '
. 'maxi_variant_product_id = :maxi, vat_rate = :vat, image_path = :image, '
. 'is_available = :available, display_order = :ord WHERE id = :id',
$this->bind($data) + ['id' => $id],
);
}
@ -341,7 +399,7 @@ final class ProductRepository
/**
* Allowlist d'affectation de masse (RG-T16) : seules ces colonnes sont liees.
*
* @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
* @param array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
* @return array<string, mixed>
*/
private function bind(array $data): array
@ -351,6 +409,11 @@ final class ProductRepository
'name' => $data['name'],
'description' => $data['description'],
'price' => $data['price_cents'],
// Champs de variante (R4/0006-0007), tous nullables : null = produit de
// base/autonome sans dimension taille ni substitution Maxi.
'size' => $data['size_cl'],
'base' => $data['base_product_id'],
'maxi' => $data['maxi_variant_product_id'],
'vat' => $data['vat_rate'],
'image' => $data['image_path'],
'available' => $data['is_available'],

View file

@ -68,6 +68,11 @@ abstract class AdminController extends AuthenticatedController
'permissions' => $this->authorizer()->permissionsFor($roleId),
'csrfToken' => Csrf::token($this->sessionManager()),
'activeNav' => '',
// Canal de saisie du role courant ('counter' | 'drive') pour que le lien
// "Saisie commande" du layout envoie un equipier drive vers /drive/orders
// et un equipier comptoir vers /counter/orders. Derive de role.order_source
// (remonte par displayInfo, qui joint deja la table role).
'orderChannel' => $info['order_source'] === 'drive' ? 'drive' : 'counter',
'flash' => $this->takeFlash(),
];

View file

@ -56,8 +56,16 @@ class CatalogueController extends Controller
// requete (sizesByBase), pas une par produit -> /api/products reste un seul
// aller-retour cache-friendly cote borne (data.js memoise la liste).
$sizesByBase = $repo->sizesByBase();
// RG-T21 : rupture calculee par le stock, en UNE requete (set d'ids). Un
// produit liste (is_available=1) mais en rupture devient non commandable ->
// la borne le grise au lieu de laisser composer une commande vouee a echouer.
$unavailable = array_fill_keys($repo->autoUnavailableIds(), true);
$rows = array_map(
fn (array $row): array => $this->presentProduct($row, $sizesByBase[(int) ($row['id'] ?? 0)] ?? []),
fn (array $row): array => $this->presentProduct(
$row,
$sizesByBase[(int) ($row['id'] ?? 0)] ?? [],
!isset($unavailable[(int) ($row['id'] ?? 0)]),
),
$repo->availableForCatalogue(),
);
@ -84,8 +92,10 @@ class CatalogueController extends Controller
// au moins une VARIANTE (sinon sizesForProduct ne remonte que la base, et la
// base seule n'est pas une dimension de taille -> sizes vide cote presentation).
$sizes = $repo->sizesForProduct($id);
// RG-T21 : meme dispo calculee qu'en liste, pour ce produit (membership du set).
$orderable = !in_array($id, $repo->autoUnavailableIds(), true);
return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [])]);
return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [], $orderable)]);
}
/**
@ -93,8 +103,15 @@ class CatalogueController extends Controller
*/
public function menus(array $params = []): Response
{
// RG-T21 (granularite : burger impose seul) : un menu dont le burger principal
// est en rupture calculee n'est plus commandable. Set d'ids produits en rupture
// reutilise pour tous les menus (pas de N+1).
$unavailable = array_fill_keys($this->productsRepo()->autoUnavailableIds(), true);
$rows = array_map(
fn (array $row): array => $this->presentMenu($row),
fn (array $row): array => $this->presentMenu(
$row,
!isset($unavailable[(int) ($row['burger_product_id'] ?? 0)]),
),
$this->menusRepo()->availableForCatalogue(),
);
@ -117,8 +134,10 @@ class CatalogueController extends Controller
);
}
// RG-T21 (burger impose seul) : dispo calculee du menu = burger non en rupture.
$orderable = !in_array((int) ($row['burger_product_id'] ?? 0), $this->productsRepo()->autoUnavailableIds(), true);
// Detail = menu + ses slots de composition (B1 burger impose, B2 Normal/Maxi).
$menu = $this->presentMenu($row) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
$menu = $this->presentMenu($row, $orderable) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
return $this->json(['data' => $menu]);
}
@ -168,14 +187,15 @@ class CatalogueController extends Controller
/**
* @param array<string, mixed> $row
* @return array{id: int, code: string, name: string}
* @return array{id: int, code: string, name: string, description: ?string}
*/
private function presentAllergen(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'code' => (string) ($row['code'] ?? ''),
'name' => (string) ($row['name'] ?? ''),
'id' => (int) ($row['id'] ?? 0),
'code' => (string) ($row['code'] ?? ''),
'name' => (string) ($row['name'] ?? ''),
'description' => $this->nullableString($row['description'] ?? null),
];
}
@ -200,9 +220,9 @@ class CatalogueController extends Controller
* variantes ; vide si le produit n'a pas de dimension taille. Chaque entree
* devient {product_id, size_cl, price_cents, label} ; le label humain est
* derive du volume ("30 cl") -- aucun slug/enum ne fuit a l'ecran.
* @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, maxi_variant_name: ?string, sizes: list<array{product_id: int, size_cl: int, price_cents: int, label: string}>}
* @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, maxi_variant_name: ?string, sizes: list<array{product_id: int, size_cl: int, price_cents: int, label: string}>, is_orderable: bool}
*/
private function presentProduct(array $row, array $sizes = []): array
private function presentProduct(array $row, array $sizes = [], bool $isOrderable = true): array
{
return [
'id' => (int) ($row['id'] ?? 0),
@ -229,14 +249,19 @@ class CatalogueController extends Controller
},
array_values($sizes),
),
// is_orderable : false si rupture calculee par le stock (RG-T21). La borne
// grise la tuile (echo UX) ; l'enforcement qui fait foi est cote serveur a la
// creation de commande (OrderRepository::resolveLine refuse un item en
// rupture). Le retrait manuel (is_available=0) est deja exclu en amont.
'is_orderable' => $isOrderable,
];
}
/**
* @param array<string, mixed> $row
* @return array{id: int, category_id: int, burger_product_id: int, name: string, description: ?string, price_normal_cents: int, price_maxi_cents: int, image_path: ?string, display_order: int}
* @return array{id: int, category_id: int, burger_product_id: int, name: string, description: ?string, price_normal_cents: int, price_maxi_cents: int, image_path: ?string, display_order: int, is_orderable: bool}
*/
private function presentMenu(array $row): array
private function presentMenu(array $row, bool $isOrderable = true): array
{
return [
'id' => (int) ($row['id'] ?? 0),
@ -248,6 +273,9 @@ class CatalogueController extends Controller
'price_maxi_cents' => (int) ($row['price_maxi_cents'] ?? 0),
'image_path' => $this->nullableString($row['image_path'] ?? null),
'display_order' => (int) ($row['display_order'] ?? 0),
// is_orderable : false si le burger impose est en rupture calculee (RG-T21,
// granularite burger seul). La borne grise le menu.
'is_orderable' => $isOrderable,
];
}

View file

@ -56,18 +56,25 @@ class CounterOrderController extends AdminController
}
$source = $this->source();
$orderQuery = $this->orderQuery();
// RG-1 (5.1, source filter) : ne lister que les commandes du canal. recent()
// ramene les plus recentes tous canaux ; on filtre sur la source derivee du
// chemin pour que le comptoir ne voie pas le drive et inversement.
$orders = array_values(array_filter(
$this->orderQuery()->recent(50),
$orderQuery->recent(50),
static fn (array $o): bool => (string) ($o['source'] ?? '') === $source,
));
// File "En cours" (RG-T12) : commandes du canal au statut paid non livrees,
// la plus ancienne d'abord (tri paid_at croissant fait par paidQueue). Filtree
// a la SEULE source du canal pour que l'equipier ne voie que ce qu'il sert.
$inProgress = $orderQuery->paidQueue([$source]);
return $this->channelView('admin/counter/index', $source, [
'title' => $this->channelTitle($source) . ' - Wakdo Admin',
'orders' => $orders,
'title' => $this->channelTitle($source) . ' - Wakdo Admin',
'orders' => $orders,
'inProgress' => $inProgress,
], $guard);
}
@ -115,6 +122,11 @@ class CounterOrderController extends AdminController
$source = $this->source();
$serviceMode = (string) ($form['service_mode'] ?? '');
// Numero de table (confort comptoir) : ne porte de sens qu'en sur place. On ne
// le transmet qu'en dine_in ; persist() le rejette de toute facon hors dine_in,
// mais ne pas le passer evite un INVALID_SERVICE_TAG sur une saisie residuelle.
$serviceTag = $serviceMode === 'dine_in' ? trim((string) ($form['service_tag'] ?? '')) : '';
// Chemin unifie : le panier construit par counter-order.js arrive serialise
// dans items_json. Quand il est present, il fait foi ; les quantites legacy
// qty_<id> ne servent qu'au repli sans JS (degradation gracieuse).
@ -127,9 +139,14 @@ class CounterOrderController extends AdminController
return $this->renderForm($guard, $source, $form, 'Ajoutez au moins un produit ou un menu.', 422);
}
$req = ['service_mode' => $serviceMode, 'items' => $items];
if ($serviceTag !== '') {
$req['service_tag'] = $serviceTag;
}
try {
$order = $this->orders()->createStaffOrder(
['service_mode' => $serviceMode, 'items' => $items],
$req,
$guard->userId ?? 0,
$source,
);
@ -347,10 +364,18 @@ class CounterOrderController extends AdminController
$productRepository = $this->productRepository();
$products = $productRepository->availableForCatalogue();
// RG-T21 (parite borne) : rupture calculee par le stock, en UNE requete (set
// d'ids). availableForCatalogue() ne filtre que le retrait manuel (is_available)
// ; un produit liste mais en rupture devient non commandable -> le POS grise la
// tuile au lieu de laisser composer une commande vouee a echouer (meme echo UX
// que CatalogueController cote borne ; l'enforcement reste serveur a la commande).
$unavailable = array_fill_keys($productRepository->autoUnavailableIds(), true);
// Modificateurs proposables par produit a la carte : seuls les produits dont la
// recette offre au moins un ingredient retirable/ajoutable portent une compo.
$products = array_map(function (array $product) use ($productRepository): array {
$products = array_map(function (array $product) use ($productRepository, $unavailable): array {
$product['modifiers'] = $this->proposableModifiers($productRepository, (int) ($product['id'] ?? 0));
$product['is_orderable'] = !isset($unavailable[(int) ($product['id'] ?? 0)]);
return $product;
}, $products);
@ -358,8 +383,9 @@ class CounterOrderController extends AdminController
return $this->channelView('admin/counter/new', $source, [
'title' => 'Nouvelle commande ' . ($source === 'drive' ? 'drive' : 'comptoir') . ' - Wakdo Admin',
'products' => $products,
'menus' => $this->menusWithSlots($productRepository),
'menus' => $this->menusWithSlots($productRepository, $unavailable),
'serviceMode' => (string) ($values['service_mode'] ?? ($source === 'drive' ? 'drive' : 'dine_in')),
'serviceTag' => (string) ($values['service_tag'] ?? ''),
'error' => $error,
], $guard, $status);
}
@ -374,16 +400,21 @@ class CounterOrderController extends AdminController
* cote borne ; `burger_modifiers` calque proposableModifiers() (la selection de
* modificateurs d'un menu cible le burger, comme resolveModifiers cote serveur).
*
* @param array<int, true> $unavailable set d'ids produits en rupture calculee (RG-T21),
* partage avec renderForm pour ne pas refaire la requete autoUnavailableIds.
* @return list<array<string, mixed>>
*/
private function menusWithSlots(ProductRepository $productRepository): array
private function menusWithSlots(ProductRepository $productRepository, array $unavailable): array
{
$menuRepository = $this->menuRepository();
$menus = $menuRepository->availableForCatalogue();
return array_map(function (array $menu) use ($menuRepository, $productRepository): array {
return array_map(function (array $menu) use ($menuRepository, $productRepository, $unavailable): array {
$menu['slots'] = $menuRepository->slotsWithOptions((int) ($menu['id'] ?? 0));
$menu['burger_modifiers'] = $this->proposableModifiers($productRepository, (int) ($menu['burger_product_id'] ?? 0));
// RG-T21 (granularite burger impose seul, parite borne) : un menu dont le
// burger principal est en rupture calculee n'est plus commandable -> grise.
$menu['is_orderable'] = !isset($unavailable[(int) ($menu['burger_product_id'] ?? 0)]);
return $menu;
}, $menus);

View file

@ -12,9 +12,13 @@ use App\Core\Response;
* Sonde de sante. GET /api/health.
*
* Le comptage des categories prouve la chaine complete (autoloader -> routeur
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond.
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond. Expose aussi
* la version deployee (SHA + date), ecrite par scripts/deploy.sh : c'est la preuve
* cote app du CD (apres un deploiement, ce champ reflete le nouveau commit).
*
* Non-final : seam de test (la sous-classe redirige versionFilePath sur une fixture).
*/
final class HealthController extends Controller
class HealthController extends Controller
{
/**
* @param array<string, string> $params
@ -35,6 +39,8 @@ final class HealthController extends Controller
$httpStatus = 503;
}
$version = $this->readVersion();
return $this->json(
[
'status' => $dbStatus === 'ok' ? 'ok' : 'degraded',
@ -42,8 +48,45 @@ final class HealthController extends Controller
'php_version' => PHP_VERSION,
'db' => $dbStatus,
'categories' => $categories,
'version' => $version['version'],
'deployed_at' => $version['deployed_at'],
],
$httpStatus,
);
}
/**
* Chemin du marqueur de version. Sous le mount du code (./src -> /var/www/html),
* donc lisible a chaud par l'app sans rebuild.
*/
protected function versionFilePath(): string
{
return dirname(__DIR__, 2) . '/VERSION';
}
/**
* Lit "SHA<espace>date" ecrit par deploy.sh. Absence toleree (dev / avant 1er
* deploiement) : les deux champs retombent a null.
*
* @return array{version: ?string, deployed_at: ?string}
*/
private function readVersion(): array
{
$path = $this->versionFilePath();
if (!is_file($path) || !is_readable($path)) {
return ['version' => null, 'deployed_at' => null];
}
$line = trim((string) @file_get_contents($path));
if ($line === '') {
return ['version' => null, 'deployed_at' => null];
}
$parts = explode(' ', $line, 2);
return [
'version' => $parts[0] !== '' ? $parts[0] : null,
'deployed_at' => isset($parts[1]) && $parts[1] !== '' ? $parts[1] : null,
];
}
}

View file

@ -47,14 +47,38 @@ class IngredientController extends AdminController
return $guard;
}
return $this->renderIndex($guard);
}
/**
* Rend le tableau de bord stock. Factorise pour servir la lecture (index, 200) et le
* re-rendu en erreur du reglage rapide de seuils (updateThresholds, 422). $error est
* affiche en bandeau si present. Les drapeaux de permission pilotent l'affichage des
* actions (la garde reelle reste par-route).
*/
private function renderIndex(GuardResult $guard, ?string $error = null, int $status = 200): Response
{
$ingredients = $this->ingredientRepository()->all();
// Compteurs par bande pour le resume du tableau de bord (3 pastilles).
// Calcules cote serveur a partir de stock_band deja resolu par le depot,
// pour que la vue reste declarative et la valeur testable directement.
$counts = ['critical' => 0, 'low' => 0, 'normal' => 0];
foreach ($ingredients as $row) {
$band = (string) ($row['stock_band'] ?? 'normal');
$counts[$band] = ($counts[$band] ?? 0) + 1;
}
return $this->adminView('admin/ingredients/index', [
'title' => 'Stock - Wakdo Admin',
'activeNav' => 'stock',
'ingredients' => $this->ingredientRepository()->all(),
'canManage' => $this->may($guard, 'ingredient.manage'),
'canRestock' => $this->may($guard, 'stock.manage'),
'canCount' => $this->may($guard, 'stock.count'),
], $guard);
'title' => 'Stock - Wakdo Admin',
'activeNav' => 'stock',
'ingredients' => $ingredients,
'bandCounts' => $counts,
'canManage' => $this->may($guard, 'ingredient.manage'),
'canRestock' => $this->may($guard, 'stock.manage'),
'canCount' => $this->may($guard, 'stock.count'),
'thresholdError' => $error,
], $guard, $status);
}
/**
@ -291,6 +315,49 @@ class IngredientController extends AdminController
return $this->redirect('/admin/ingredients');
}
/**
* Reglage rapide des seuils depuis le tableau de bord stock (F13). Endpoint LEGER :
* ne reutilise PAS update() (qui exige name/unit/pack), il ne valide et n'ecrit que
* capacite + seuils alerte/critique. Permission stock.manage (calibrage du stock, pas
* du catalogue), CSRF, SANS PIN (config, pas un comptage d'inventaire RG-T13). Succes
* -> redirect liste + flash ; erreur de validation -> 422 re-rendu de la liste avec un
* bandeau d'erreur (convention restock/inventory : 422 + message, pas de redirect muet).
*
* @param array<string, string> $params
*/
public function updateThresholds(array $params): Response
{
$guard = $this->guard('stock.manage');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$id = (int) ($params['id'] ?? 0);
$ingredient = $this->ingredientRepository()->find($id);
if ($ingredient === null) {
return $this->notFound($guard);
}
[$data, $errors] = $this->validateThresholds($form);
if ($errors !== []) {
// Premier message d'erreur en bandeau : la modale est rouverte cote client si
// besoin ; l'equipier voit la raison du rejet sans jargon de champ.
$messages = array_values($errors);
return $this->renderIndex($guard, $messages[0], 422);
}
$this->ingredientRepository()->updateThresholds($id, $data['stock_capacity'], $data['low_stock_pct'], $data['critical_stock_pct']);
$this->setFlash('Seuils mis a jour.');
return $this->redirect('/admin/ingredients');
}
/**
* @param array<string, string> $params
*/
@ -550,12 +617,6 @@ class IngredientController extends AdminController
$errors['unit'] = 'L unite est requise (40 caracteres max).';
}
$capRaw = trim($form['stock_capacity'] ?? '');
$capValid = ctype_digit($capRaw) && (int) $capRaw >= 1 && (int) $capRaw <= 2147483647;
if (!$capValid) {
$errors['stock_capacity'] = 'La capacite (reference 100%) doit etre un entier >= 1.';
}
$packRaw = trim($form['pack_size'] ?? '');
$packValid = ctype_digit($packRaw) && (int) $packRaw >= 1 && (int) $packRaw <= 65535;
if (!$packValid) {
@ -567,6 +628,45 @@ class IngredientController extends AdminController
$errors['pack_label'] = 'Libelle de pack trop long (80 caracteres max).';
}
// Capacite + seuils : meme regle (capacite >= 1, % 0-100, critique < alerte strict)
// que le reglage rapide F13 -> source unique validateThresholds(), pas de copie
// divergente. Les messages restent indexes par champ pour le formulaire complet.
[$thresholds, $thresholdErrors] = $this->validateThresholds($form);
$errors = array_merge($errors, $thresholdErrors);
$data = [
'name' => $name,
'unit' => $unit,
'stock_capacity' => $thresholds['stock_capacity'],
'pack_size' => $packValid ? (int) $packRaw : 0,
'pack_label' => $label !== '' ? $label : null,
'low_stock_pct' => $thresholds['low_stock_pct'],
'critical_stock_pct' => $thresholds['critical_stock_pct'],
];
return [$data, $errors];
}
/**
* Validation des trois reglages de calibrage du stock, partagee par le formulaire
* complet (validate) et l'endpoint leger F13 (updateThresholds) pour qu'une seule
* regle existe : capacite (reference 100 %) >= 1 ; seuils alerte/critique entiers
* 0-100 ; critique STRICTEMENT inferieur a alerte (RG-CREATE-ING, garanti aussi par
* un CHECK de table). Renvoie [valeurs normalisees, erreurs indexees par champ].
*
* @param array<string, string> $form
* @return array{0: array{stock_capacity: int, low_stock_pct: int, critical_stock_pct: int}, 1: array<string, string>}
*/
private function validateThresholds(array $form): array
{
$errors = [];
$capRaw = trim($form['stock_capacity'] ?? '');
$capValid = ctype_digit($capRaw) && (int) $capRaw >= 1 && (int) $capRaw <= 2147483647;
if (!$capValid) {
$errors['stock_capacity'] = 'La capacite (reference 100%) doit etre un entier >= 1.';
}
$lowRaw = trim($form['low_stock_pct'] ?? '');
$lowValid = ctype_digit($lowRaw) && (int) $lowRaw <= 100;
if (!$lowValid) {
@ -579,17 +679,12 @@ class IngredientController extends AdminController
$errors['critical_stock_pct'] = 'Le seuil critique doit etre un entier entre 0 et 100.';
}
// RG-CREATE-ING : critical_stock_pct < low_stock_pct (strict).
if ($lowValid && $critValid && (int) $critRaw >= (int) $lowRaw) {
$errors['critical_stock_pct'] = 'Le seuil critique doit etre strictement inferieur au seuil d alerte.';
}
$data = [
'name' => $name,
'unit' => $unit,
'stock_capacity' => $capValid ? (int) $capRaw : 0,
'pack_size' => $packValid ? (int) $packRaw : 0,
'pack_label' => $label !== '' ? $label : null,
'low_stock_pct' => $lowValid ? (int) $lowRaw : 0,
'critical_stock_pct' => $critValid ? (int) $critRaw : 0,
];

View file

@ -32,10 +32,13 @@ class KitchenController extends AdminController
$sources = $this->orderQuery()->visibleSources($guard->roleId ?? 0);
// paidQueueWithDetail : memes commandes que paidQueue, enrichies du detail des
// articles (selections + modificateurs) et d'une bande SLA derivee de paid_at,
// pour que le KDS soit exploitable pour PREPARER (et pas seulement lister).
return $this->adminView('admin/kitchen/display', [
'title' => 'Cuisine - Wakdo Admin',
'activeNav' => 'kitchen',
'orders' => $this->orderQuery()->paidQueue($sources),
'orders' => $this->orderQuery()->paidQueueWithDetail($sources),
'canDeliver' => $this->may($guard, 'order.deliver'),
], $guard);
}

View file

@ -309,10 +309,13 @@ class MenuController extends AdminController
$errors['category_id'] = 'Categorie requise et valide.';
}
// F9-2 : le burger principal doit etre un produit de BASE (R4). productIsBase
// rejette une variante de taille meme si l'UI (base-only) est contournee :
// une variante n'est pas un produit autonome commercialisable en menu.
$burgerRaw = trim($form['burger_product_id'] ?? '');
$burgerId = ctype_digit($burgerRaw) ? (int) $burgerRaw : 0;
if ($burgerId === 0 || !$this->menuRepository()->productExists($burgerId)) {
$errors['burger_product_id'] = 'Le produit burger de base est requis et doit exister.';
if ($burgerId === 0 || !$this->menuRepository()->productIsBase($burgerId)) {
$errors['burger_product_id'] = 'Le produit burger de base est requis et doit etre un produit de base (pas une variante de taille).';
}
$name = trim($form['name'] ?? '');
@ -385,12 +388,23 @@ class MenuController extends AdminController
$slotType = is_string($raw['slot_type'] ?? null) ? $raw['slot_type'] : '';
$required = !empty($raw['is_required']) ? 1 : 0;
// F9-2 : une option de slot doit etre un produit de BASE (R4). Un id de
// variante de taille (base_product_id non nul) est REJETE explicitement
// (422) plutot que filtre en silence : choisir une variante comme option
// serait un contournement de l'UI base-only, et un drop muet ferait perdre
// un choix sans message clair. Un id inconnu reste filtre (allowlist).
$optionIds = [];
$hasVariantOption = false;
foreach (is_array($raw['options'] ?? null) ? $raw['options'] : [] as $opt) {
$pid = is_numeric($opt) ? (int) $opt : 0;
if ($pid > 0 && $this->menuRepository()->productExists($pid)) {
$optionIds[] = $pid;
if ($pid <= 0 || !$this->menuRepository()->productExists($pid)) {
continue; // id inconnu : filtre (allowlist), pas une erreur
}
if (!$this->menuRepository()->productIsBase($pid)) {
$hasVariantOption = true;
continue; // variante de taille : non eligible comme option de menu
}
$optionIds[] = $pid;
}
$optionIds = array_values(array_unique($optionIds));
@ -402,6 +416,10 @@ class MenuController extends AdminController
$errors['slots'] = 'Type de slot invalide.';
continue;
}
if ($hasVariantOption) {
$errors['slots'] = 'Une variante de taille ne peut pas etre proposee comme option de menu (choisissez le produit de base).';
continue;
}
if ($optionIds === []) {
$errors['slots'] = 'Chaque slot doit proposer au moins une option valide.';
continue;
@ -459,7 +477,10 @@ class MenuController extends AdminController
'activeNav' => 'menus',
'menuId' => $id,
'categories' => $this->categoryRepository()->all(),
'products' => $this->productRepository()->all(),
// F9-1 : listes deroulantes base-only (burger principal + options de
// slot). basesOnly() exclut les variantes de taille (R4) ; all() les
// inclut (liste admin), il ne doit donc pas alimenter ces selects.
'products' => $this->productRepository()->basesOnly(),
'slotTypes' => self::SLOT_TYPES,
'values' => [
'category_id' => (string) ($values['category_id'] ?? ''),

View file

@ -53,6 +53,15 @@ class OrderAdminController extends AdminController
* Remise au client : paid -> delivered (mlt 6.1). POST + CSRF, garde order.deliver.
* Pas de PIN (geste routinier). Issue affichee en flash, retour a la liste.
*
* PRE-3 / ERR-2 (6.1) : au-dela de la permission, la SOURCE de la commande doit
* etre VISIBLE par le role de l'acteur (role_visible_source). Sans ce controle,
* tout role detenant order.deliver pourrait remettre une commande de n'importe
* quel canal (ex. un equipier drive remettant une commande comptoir). La file
* KDS (KitchenController) est deja filtree par visibleSources a l'affichage ;
* cette garde rejoue la regle cote ecriture (defense en profondeur, meme posture
* que cancel() qui ne se fie pas a l'affichage de la liste). Hors des sources
* visibles -> 403 FORBIDDEN (memes statut/vue que la garde de permission).
*
* @param array<string, string> $params
*/
public function deliver(array $params = []): Response
@ -67,8 +76,22 @@ class OrderAdminController extends AdminController
return $this->invalidCsrf();
}
$number = (string) ($params['number'] ?? '');
// PRE-3 (6.1) : refuse la remise d'une commande dont la source n'est pas dans
// les sources visibles du role agissant. visibleSources reutilise
// role_visible_source (memes donnees que KitchenController) : liste vide en base
// = vue globale (admin/manager voient les trois sources). Le numero inconnu
// (source null) retombe sur le chemin "non visible" -> 403 ; la branche
// ORDER_NOT_FOUND ci-dessous reste atteignable pour une course (commande
// supprimee entre la lecture et la transition).
$source = $this->orderSource($number);
if ($source === null || !in_array($source, $this->orderQuery()->visibleSources($guard->roleId ?? 0), true)) {
return $this->forbidden($guard);
}
try {
$this->orders()->deliver((string) ($params['number'] ?? ''));
$this->orders()->deliver($number);
$this->setFlash('Commande remise (livree).');
} catch (OrderValidationException $exception) {
$this->setFlash(
@ -178,6 +201,28 @@ class OrderAdminController extends AdminController
return new OrderQueryRepository($this->db());
}
/**
* Source (canal) d'une commande par son numero, pour la garde de visibilite
* PRE-3 (6.1). Lecture ciblee d'une seule colonne : OrderRepository::findByNumber
* renvoie une forme typee {id, order_number, total_ttc_cents, status} qui n'expose
* pas la source ; plutot que d'elargir ce contrat partage, on lit ici le seul champ
* requis (meme couture db() que logFailedPin). Renvoie null si le numero est
* inconnu (traite comme non visible par l'appelant).
*/
protected function orderSource(string $number): ?string
{
if ($number === '') {
return null;
}
$row = $this->db()->fetch(
'SELECT source FROM customer_order WHERE order_number = :n',
['n' => $number],
);
$source = is_string($row['source'] ?? null) ? (string) $row['source'] : '';
return $source === '' ? null : $source;
}
protected function orders(): OrderRepository
{
$db = $this->db();
@ -247,6 +292,16 @@ class OrderAdminController extends AdminController
return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'orders'], $guard, 404);
}
/**
* Refus de visibilite (ERR-2, 6.1) : la source de la commande n'est pas visible
* par le role agissant. Memes statut (403) et vue (admin/forbidden) que la garde
* de permission d'AdminController::guard, pour une convention de refus homogene.
*/
private function forbidden(GuardResult $guard): Response
{
return $this->adminView('admin/forbidden', ['title' => 'Acces refuse', 'activeNav' => 'orders'], $guard, 403);
}
private function redirect(string $location): Response
{
return Response::make('', 302, ['Location' => $location]);

View file

@ -7,9 +7,13 @@ namespace App\Controllers;
use Throwable;
use App\Auth\Csrf;
use App\Auth\LogMailer;
use App\Auth\Mailer;
use App\Auth\PasswordHasher;
use App\Auth\PasswordResetService;
use App\Auth\SessionManager;
use App\Auth\SmtpClient;
use App\Auth\SmtpMailer;
use App\Auth\StreamSmtpTransport;
use App\Core\Controller;
use App\Core\Response;
@ -124,7 +128,33 @@ class PasswordResetController extends Controller
$this->database,
$this->config,
new PasswordHasher($this->config),
new LogMailer(),
$this->mailer(),
);
}
/**
* SMTP reel si configure (SMTP_HOST + SMTP_USER + SMTP_PASSWORD presents),
* sinon repli sur LogMailer (le lien est journalise, pas d'envoi) : le dev
* reste sans infra mail, la prod envoie via le relais.
*/
protected function mailer(): Mailer
{
$host = $this->config->get('SMTP_HOST');
$user = $this->config->get('SMTP_USER');
$password = $this->config->get('SMTP_PASSWORD');
if ($host === null || $user === null || $password === null) {
return new LogMailer();
}
return new SmtpMailer(
new SmtpClient(new StreamSmtpTransport()),
$host,
(int) ($this->config->get('SMTP_PORT', '587') ?? '587'),
$user,
$password,
$this->config->get('MAIL_FROM_EMAIL', 'noreply@localhost') ?? 'noreply@localhost',
$this->config->get('MAIL_FROM_NAME', 'Wakdo') ?? 'Wakdo',
);
}

View file

@ -79,7 +79,9 @@ class ProductController extends AdminController
return $this->invalidCsrf();
}
[$data, $errors] = $this->validate($form);
// id = 0 a la creation : pas d'auto-reference possible (le produit n'existe
// pas encore), validate() le sait par le 2e argument.
[$data, $errors] = $this->validate($form, 0);
if ($errors !== []) {
return $this->renderForm($guard, 0, $form, $errors, 422);
}
@ -130,7 +132,7 @@ class ProductController extends AdminController
return $this->notFound($guard);
}
[$data, $errors] = $this->validate($form);
[$data, $errors] = $this->validate($form, $id);
if ($errors !== []) {
return $this->renderForm($guard, $id, $form, $errors, 422);
}
@ -383,11 +385,13 @@ class ProductController extends AdminController
/**
* Validation serveur (RG-T18) + allowlist (RG-T16). Renvoie [donnees, erreurs].
* $currentId = id du produit edite (0 a la creation), pour interdire l'auto-
* reference des FK de variante (F9-3).
*
* @param array<string, string> $form
* @return array{0: array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}, 1: array<string, string>}
* @return array{0: array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}, 1: array<string, string>}
*/
private function validate(array $form): array
private function validate(array $form, int $currentId): array
{
$errors = [];
@ -425,15 +429,72 @@ class ProductController extends AdminController
$description = trim($form['description'] ?? '');
// --- Champs de variante (F9-3, R4 / migrations 0006-0007) ---
// Tous nullables : un champ vide signifie "produit de base / autonome, sans
// dimension taille ni substitution Maxi". Bornes refletant les colonnes :
// size_cl SMALLINT UNSIGNED (0..65535), base/maxi FK INT UNSIGNED.
// size_cl : volume en cl, entier >= 0 si fourni (vide = NULL).
$sizeRaw = trim($form['size_cl'] ?? '');
$sizeCl = null;
if ($sizeRaw !== '') {
if (!ctype_digit($sizeRaw) || (int) $sizeRaw > 65535) {
$errors['size_cl'] = 'La taille (en cl) doit etre un entier entre 0 et 65535.';
} else {
$sizeCl = (int) $sizeRaw;
}
}
// base_product_id : ce produit devient une VARIANTE de taille de la base
// designee. La base doit exister, etre differente de soi (pas d'auto-
// reference), et etre elle-meme une BASE (productIsBase) : on interdit une
// chaine de variantes (une variante ne peut pointer vers une autre variante).
$baseRaw = trim($form['base_product_id'] ?? '');
$baseId = null;
if ($baseRaw !== '') {
if (!ctype_digit($baseRaw)) {
$errors['base_product_id'] = 'Le produit de base doit etre un produit existant.';
} elseif ((int) $baseRaw === $currentId) {
$errors['base_product_id'] = 'Un produit ne peut pas etre sa propre base.';
} elseif (!$this->productRepository()->productExists((int) $baseRaw)) {
$errors['base_product_id'] = 'Le produit de base doit etre un produit existant.';
} elseif (!$this->productRepository()->productIsBase((int) $baseRaw)) {
$errors['base_product_id'] = 'Le produit de base doit lui-meme etre un produit de base (pas une variante).';
} else {
$baseId = (int) $baseRaw;
}
}
// maxi_variant_product_id : la variante Grande servie quand un MENU est
// commande en Maxi. Doit exister et etre differente de soi (auto-reference
// directe interdite). Pas de contrainte de base ici : la cible Maxi est elle
// aussi un produit a part entiere (ex. "Grande Frite"), pas une base de taille.
$maxiRaw = trim($form['maxi_variant_product_id'] ?? '');
$maxiId = null;
if ($maxiRaw !== '') {
if (!ctype_digit($maxiRaw)) {
$errors['maxi_variant_product_id'] = 'La variante Maxi doit etre un produit existant.';
} elseif ((int) $maxiRaw === $currentId) {
$errors['maxi_variant_product_id'] = 'Un produit ne peut pas etre sa propre variante Maxi.';
} elseif (!$this->productRepository()->productExists((int) $maxiRaw)) {
$errors['maxi_variant_product_id'] = 'La variante Maxi doit etre un produit existant.';
} else {
$maxiId = (int) $maxiRaw;
}
}
$data = [
'category_id' => $categoryId,
'name' => $name,
'description' => $description !== '' ? $description : null,
'price_cents' => $priceValid ? (int) $priceRaw : 0,
'vat_rate' => ($vat === 55 || $vat === 100) ? $vat : 100,
'image_path' => $image !== '' ? $image : null,
'is_available' => ($form['is_available'] ?? '') !== '' ? 1 : 0,
'display_order' => (ctype_digit($orderRaw) && (int) $orderRaw <= 65535) ? (int) $orderRaw : 0,
'category_id' => $categoryId,
'name' => $name,
'description' => $description !== '' ? $description : null,
'price_cents' => $priceValid ? (int) $priceRaw : 0,
'size_cl' => $sizeCl,
'base_product_id' => $baseId,
'maxi_variant_product_id' => $maxiId,
'vat_rate' => ($vat === 55 || $vat === 100) ? $vat : 100,
'image_path' => $image !== '' ? $image : null,
'is_available' => ($form['is_available'] ?? '') !== '' ? 1 : 0,
'display_order' => (ctype_digit($orderRaw) && (int) $orderRaw <= 65535) ? (int) $orderRaw : 0,
];
return [$data, $errors];
@ -588,23 +649,38 @@ class ProductController extends AdminController
*/
private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response
{
// F9-3 : selects base_product_id (de quelle base ce produit est-il la
// variante de taille ?) et maxi_variant_product_id (quelle variante Grande
// servir en menu Maxi ?). On ne propose que des produits de BASE
// (basesOnly, R4) -- une variante ne peut etre ni une base ni, par
// simplicite, une cible Maxi -- et on exclut le produit lui-meme de la liste
// (pas d'auto-reference), garde miroir de validate().
$baseCandidates = array_values(array_filter(
$this->productRepository()->basesOnly(),
static fn (array $p): bool => (int) ($p['id'] ?? 0) !== $id,
));
return $this->adminView('admin/products/form', [
'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' produit - Wakdo Admin',
'activeNav' => 'products',
'productId' => $id,
'categories' => $this->categoryRepository()->all(),
'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' produit - Wakdo Admin',
'activeNav' => 'products',
'productId' => $id,
'categories' => $this->categoryRepository()->all(),
'baseCandidates' => $baseCandidates,
'values' => [
'category_id' => (string) ($values['category_id'] ?? ''),
'name' => (string) ($values['name'] ?? ''),
'description' => (string) ($values['description'] ?? ''),
'price_cents' => (string) ($values['price_cents'] ?? ''),
'vat_rate' => (string) ($values['vat_rate'] ?? '100'),
'image_path' => (string) ($values['image_path'] ?? ''),
'category_id' => (string) ($values['category_id'] ?? ''),
'name' => (string) ($values['name'] ?? ''),
'description' => (string) ($values['description'] ?? ''),
'price_cents' => (string) ($values['price_cents'] ?? ''),
'size_cl' => (string) ($values['size_cl'] ?? ''),
'base_product_id' => (string) ($values['base_product_id'] ?? ''),
'maxi_variant_product_id' => (string) ($values['maxi_variant_product_id'] ?? ''),
'vat_rate' => (string) ($values['vat_rate'] ?? '100'),
'image_path' => (string) ($values['image_path'] ?? ''),
// Defaut coche a la creation (errors vide + values vide) ; sur un
// re-rendu POST (erreurs), refleter la presence reelle du champ
// (case decochee = absente = non cochee), pas le defaut a 1.
'is_available' => $errors === [] ? ((int) ($values['is_available'] ?? 1) === 1) : array_key_exists('is_available', $values),
'display_order' => (string) ($values['display_order'] ?? '0'),
'is_available' => $errors === [] ? ((int) ($values['is_available'] ?? 1) === 1) : array_key_exists('is_available', $values),
'display_order' => (string) ($values['display_order'] ?? '0'),
],
'errors' => $errors,
], $guard, $status);

View file

@ -17,6 +17,14 @@ use App\Core\Response;
* actions sensibles, RG-T13). Accessible a tout utilisateur authentifie ; aucune
* permission specifique (on n'agit que sur son propre compte = session userId).
*
* Le PIN est un credential sensible : le (re)definir exige le mot de passe COURANT
* (re-verification d'identite sur poste a session partagee, meme posture que la
* verification PIN d'ADR-0004) ET ecrit une ligne `audit_log` (ADR-0004, RG-T14).
* Le SET du PIN n'est PAS throttle : la surface de brute-force est la VERIFICATION
* du PIN (couverte par pin_throttle / RG-T22, ADR-0005), pas sa definition par un
* utilisateur deja authentifie. L'audit ne porte que l'evenement (set vs change),
* jamais le PIN ni un hash.
*
* Non `final` : les tests sous-classent pour injecter des doubles.
*/
class ProfileController extends AdminController
@ -66,6 +74,7 @@ class ProfileController extends AdminController
$pin = $form['pin'] ?? '';
$confirm = $form['pin_confirm'] ?? '';
$currentPassword = $form['current_password'] ?? '';
$error = null;
if (!$this->pinVerifier()->meetsLengthPolicy($pin)) {
@ -78,12 +87,30 @@ class ProfileController extends AdminController
return $this->renderPinForm($guard, $userId, $error, 422);
}
// Re-verification d'identite : (re)definir un credential sensible exige le mot
// de passe courant. Message generique (ne distingue pas mot de passe vide /
// faux) ; verify paie le cout argon2id, sans leurre dedie ici car l'utilisateur
// est deja authentifie (l'enumeration de comptes ne s'applique pas a sa propre
// session). Echec -> 422 (requete bien formee, semantiquement refusee).
if (!$this->passwordHasher()->verify($currentPassword, $this->currentPasswordHash($userId))) {
return $this->renderPinForm($guard, $userId, 'Mot de passe actuel incorrect.', 422);
}
// `pinIsSet` AVANT l'ecriture : distingue une premiere definition d'un changement
// pour le libelle d'audit (aucune valeur sensible n'est tracee).
$wasSet = $this->userRepository()->pinIsSet($userId);
// Gate sur 1 ligne affectee : une cible inexistante (0 ligne) ne doit pas
// produire un faux "PIN enregistre" (defense en profondeur).
if ($this->userRepository()->setPinHash($userId, $this->passwordHasher()->hash($pin)) !== 1) {
return $this->renderPinForm($guard, $userId, 'Echec de l enregistrement du PIN.', 500);
}
// Trace d'audit (ADR-0004, RG-T14) : l'acteur est l'utilisateur de session
// (action self-service, pas de PIN equipier tiers). Le summary ne porte que
// l'evenement set/change, jamais le PIN ni un hash.
$this->writePinAudit($userId, $guard->roleId ?? 0, $wasSet);
$this->setFlash('PIN enregistre.');
return Response::make('', 302, ['Location' => '/admin/profile/pin']);
@ -113,4 +140,45 @@ class ProfileController extends AdminController
{
return new PasswordHasher($this->config);
}
/**
* Hash du mot de passe courant de l'utilisateur de session, pour la
* re-verification d'identite. Lecture ciblee d'une colonne (UserRepository
* n'expose pas le hash : son allowlist d'ecriture ne le lie jamais) ; un compte
* absent/inactif renvoie une chaine vide -> verify echoue (refus generique).
* is_active = 1 : un compte desactive ne peut pas (re)definir son PIN.
*/
protected function currentPasswordHash(int $userId): string
{
$row = $this->db()->fetch(
'SELECT password_hash FROM user WHERE id = :id AND is_active = 1',
['id' => $userId],
);
return is_string($row['password_hash'] ?? null) ? (string) $row['password_hash'] : '';
}
/**
* Ecrit la trace d'audit du set/change de PIN (ADR-0004, RG-T14). action_code
* `pin.set` pour les deux cas (definition ET changement) ; le summary distingue
* via $wasSet. entity = l'utilisateur agissant (self-service). Aucune valeur
* sensible (PIN, hash) n'est journalisee. Hors transaction : l'ecriture du PIN est
* un seul UPDATE deja committe ; l'audit suit immediatement (pas d'effet composite
* a rendre atomique, a la difference de l'annulation OrderRepository::cancel).
*/
protected function writePinAudit(int $userId, int $roleId, bool $wasSet): void
{
$this->db()->execute(
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) '
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
[
'uid' => $userId,
'rid' => $roleId,
'code' => 'pin.set',
'etype' => 'user',
'eid' => $userId,
'summary' => $wasSet ? 'PIN modifie (self-service)' : 'PIN defini (self-service)',
],
);
}
}

View file

@ -16,6 +16,14 @@ use App\Core\DatabaseInterface;
*/
class OrderQueryRepository
{
/**
* Seuils de la bande SLA du KDS (RG-4 de 5.1 ; seuil cible ~10 min, Note 6).
* Constantes plutot qu'env : un seul reglage, simple a relire et a tester ;
* a externaliser en configuration si le besoin de variation par site apparait.
*/
private const SLA_WARN_SECONDS = 300; // 5 min : passage vert -> ambre.
private const SLA_LATE_SECONDS = 600; // 10 min (seuil cible) : ambre -> rouge.
public function __construct(private readonly DatabaseInterface $db)
{
}
@ -90,6 +98,173 @@ class OrderQueryRepository
);
}
/**
* File de preparation enrichie pour le KDS (LIST_ORDERS_DISPLAY, mlt 5.1) :
* memes commandes `paid` que paidQueue (meme filtre de sources, meme tri
* paid_at croissant), mais chaque commande porte en plus :
* - `items` : ses lignes order_item (label_snapshot, quantity, format),
* chacune avec ses `selections` (choix de slot, label_snapshot)
* et ses `modifiers` (ingredient + action remove/add) ;
* - `sla_band`: la bande SLA derivee de (now - paid_at) -- fresh / warn / late.
*
* RG-3 (5.1) : l'affichage s'appuie sur les SNAPSHOTS persistes ; aucune
* re-jointure sur product/menu n'est faite. RG-4 : la couleur est calculee au
* rendu, sans etat stocke (Note 6 du dictionnaire).
*
* Anti N+1 : 4 requetes au total quel que soit le nombre de commandes (la file
* + un fetch groupe pour items / selections / modifiers via IN (...)), plutot
* qu'un fetch par commande. L'horloge est injectable ($now) pour des bandes SLA
* deterministes en test (meme couture ?int $now que SessionGuard / PinThrottle).
*
* @param list<string> $sources
* @param int|null $now epoch de reference pour la bande SLA ; null => time()
* @return list<array<string, mixed>>
*/
public function paidQueueWithDetail(array $sources, ?int $now = null): array
{
$now ??= time();
if ($sources === []) {
return [];
}
$placeholders = [];
$params = [];
foreach (array_values($sources) as $i => $source) {
$key = 's' . $i;
$placeholders[] = ':' . $key;
$params[$key] = $source;
}
// `id` est selectionne ici (a la difference de paidQueue) : il sert de cle de
// jointure pour le fetch groupe des lignes, sans etre expose tel quel a la vue.
$orders = $this->db->fetchAll(
'SELECT id, order_number, source, service_mode, service_tag, total_ttc_cents, paid_at '
. 'FROM customer_order WHERE status = \'paid\' AND source IN (' . implode(', ', $placeholders) . ') '
. 'ORDER BY paid_at ASC, id ASC',
$params,
);
if ($orders === []) {
return [];
}
$orderIds = array_map(static fn (array $o): int => (int) $o['id'], $orders);
$items = $this->itemsForOrders($orderIds);
$out = [];
foreach ($orders as $order) {
$order['items'] = $items[(int) $order['id']] ?? [];
$order['sla_band'] = $this->slaBand((string) ($order['paid_at'] ?? ''), $now);
unset($order['id']); // l'id technique ne sert qu'a la jointure, pas a la vue.
$out[] = $order;
}
return $out;
}
/**
* Bande SLA d'une commande a partir de l'ecart (now - paid_at), Note 6 / RG-4 (5.1).
* Bandes : `fresh` si < 5 min, `warn` si 5-10 min, `late` au-dela (seuil cible
* 10 min). Un paid_at vide ou non parsable retombe sur `fresh` (pas d'alerte sur
* une donnee absente). Calcul pur (pas d'I/O) : la vue ne fait que mapper la bande
* vers une classe CSS ; cf. kitchen/display.php.
*/
public function slaBand(string $paidAt, ?int $now = null): string
{
$now ??= time();
$paid = $paidAt !== '' ? strtotime($paidAt) : false;
if ($paid === false) {
return 'fresh';
}
$elapsed = $now - $paid;
if ($elapsed >= self::SLA_LATE_SECONDS) {
return 'late';
}
if ($elapsed >= self::SLA_WARN_SECONDS) {
return 'warn';
}
return 'fresh';
}
/**
* Charge en lot les lignes des commandes donnees (order_item + selections +
* modifiers), regroupees par order_id puis structurees par ligne. Trois requetes
* groupees (IN (...)) au lieu d'un fetch par commande : borne le cout a O(1)
* aller-retours quel que soit le volume de la file. Les ids viennent de la liste
* interne (entiers surs), interpoles comme entiers : LIMIT/IN ne lient pas avec
* ATTR_EMULATE_PREPARES=false, et un cast (int) ferme l'injection.
*
* @param list<int> $orderIds
* @return array<int, list<array<string, mixed>>> items par order_id
*/
private function itemsForOrders(array $orderIds): array
{
$ids = array_values(array_unique(array_map('intval', $orderIds)));
if ($ids === []) {
return [];
}
$inOrders = implode(', ', $ids);
$itemRows = $this->db->fetchAll(
'SELECT id, order_id, item_type, format, label_snapshot, quantity '
. 'FROM order_item WHERE order_id IN (' . $inOrders . ') ORDER BY id ASC',
);
if ($itemRows === []) {
return [];
}
$itemIds = array_map(static fn (array $r): int => (int) $r['id'], $itemRows);
$inItems = implode(', ', array_values(array_unique($itemIds)));
$selectionsByItem = $this->groupByItem(
$this->db->fetchAll(
'SELECT order_item_id, label_snapshot FROM order_item_selection '
. 'WHERE order_item_id IN (' . $inItems . ') ORDER BY id ASC',
),
);
// order_item_modifier ne stocke PAS de libelle (uniquement ingredient_id) :
// a la difference des selections (label_snapshot present), le nom lisible vient
// d'une jointure sur `ingredient`. Seule re-jointure necessaire (RG-3 ne
// l'exclut que pour product/menu). Le nom d'ingredient est relativement stable ;
// a defaut de snapshot c'est la source disponible.
$modifiersByItem = $this->groupByItem(
$this->db->fetchAll(
'SELECT oim.order_item_id, oim.action, i.name AS ingredient_name '
. 'FROM order_item_modifier oim JOIN ingredient i ON i.id = oim.ingredient_id '
. 'WHERE oim.order_item_id IN (' . $inItems . ') ORDER BY oim.id ASC',
),
);
$itemsByOrder = [];
foreach ($itemRows as $row) {
$itemId = (int) $row['id'];
$row['selections'] = $selectionsByItem[$itemId] ?? [];
$row['modifiers'] = $modifiersByItem[$itemId] ?? [];
$itemsByOrder[(int) $row['order_id']][] = $row;
}
return $itemsByOrder;
}
/**
* Regroupe des lignes filles par leur order_item_id (cle de jointure commune
* aux selections et aux modificateurs).
*
* @param list<array<string, mixed>> $rows
* @return array<int, list<array<string, mixed>>>
*/
private function groupByItem(array $rows): array
{
$grouped = [];
foreach ($rows as $row) {
$grouped[(int) ($row['order_item_id'] ?? 0)][] = $row;
}
return $grouped;
}
/**
* KPIs de vente : CA encaisse (statuts paid + delivered), nombre de commandes
* encaissees, panier moyen, CA et nombre du JOUR, total de commandes, et la

View file

@ -184,8 +184,14 @@ class OrderRepository
throw new OrderValidationException('EMPTY_ORDER');
}
// RG-T21 : garde a la creation de commande. Un produit (ou le burger d'un menu)
// en rupture calculee par le stock est REFUSE, quel que soit le canal et meme par
// acces direct (la borne ne fait que griser l'affichage, F2). C'est la couche qui
// fait foi. Set calcule UNE seule fois (pas de N+1 sur les lignes).
$unavailable = array_fill_keys($this->products->autoUnavailableIds(), true);
// Resolution + calcul (lecture seule) AVANT la transaction d'ecriture.
$lines = array_map(fn (array $item): array => $this->resolveLine($item), $items);
$lines = array_map(fn (array $item): array => $this->resolveLine($item, $unavailable), $items);
$totalTtc = 0;
$totalHt = 0;
@ -597,9 +603,10 @@ class OrderRepository
* Resout une ligne (produit ou menu) : lit le catalogue, valide, calcule le prix.
*
* @param array<string, mixed> $item
* @param array<int, bool> $unavailable set des product_id en rupture calculee (RG-T21)
* @return array{item_type:string, product_id:?int, menu_id:?int, format:string, label:string, unit_ttc:int, unit_ht:int, vat_rate:int, quantity:int, selections:list<array{menu_slot_id:int,product_id:int,label:string}>, modifiers:list<array{ingredient_id:int,action:string,extra_price_cents:int}>}
*/
private function resolveLine(array $item): array
private function resolveLine(array $item, array $unavailable = []): array
{
$type = (string) ($item['type'] ?? '');
$quantity = max(1, (int) ($item['quantity'] ?? 1));
@ -610,6 +617,10 @@ class OrderRepository
if ($product === null || (int) ($product['is_available'] ?? 0) !== 1) {
throw new OrderValidationException('PRODUCT_UNAVAILABLE');
}
// RG-T21 : rupture calculee (ingredient requis sous la bande critique).
if (isset($unavailable[(int) $product['id']])) {
throw new OrderValidationException('PRODUCT_UNAVAILABLE');
}
$unitBase = (int) $product['price_cents'];
$vat = (int) $product['vat_rate'];
$modifiers = $this->resolveModifiers($item, (int) $product['id']);
@ -623,6 +634,14 @@ class OrderRepository
if ($menu === null || (int) ($menu['is_available'] ?? 0) !== 1) {
throw new OrderValidationException('MENU_UNAVAILABLE');
}
// RG-T21, granularite "burger impose seul" (coherente avec l'affichage borne
// F2) : si le burger principal est en rupture calculee, le menu n'est pas
// commandable. La rupture d'un accompagnement/boisson requis n'est PAS
// verifiee ici (decision produit : granularite burger seul, a elargir au
// besoin via les produits des slots requis).
if (isset($unavailable[(int) ($menu['burger_product_id'] ?? 0)])) {
throw new OrderValidationException('MENU_UNAVAILABLE');
}
$burger = $this->products->find((int) $menu['burger_product_id']);
$vat = $burger !== null ? (int) $burger['vat_rate'] : 100;
$unitBase = $format === 'maxi' ? (int) $menu['price_maxi_cents'] : (int) $menu['price_normal_cents'];

View file

@ -4,11 +4,15 @@ declare(strict_types=1);
/**
* Liste des commandes du canal (comptoir ou drive), injectee dans admin/layout.php.
* Lecture seule : numero, mode, statut, total, date + bouton "Nouvelle commande".
* Partagee par les deux canaux ; le titre, le lien de creation et la source viennent
* du controleur (CounterOrderController::channelView). Toute valeur est echappee (RG-T15).
* Deux sections : "En cours" (commandes payees non livrees du canal, la plus ancienne
* d'abord, RG-T12) EN HAUT pour le service, puis l'historique recent (tous statuts)
* en dessous. Lecture seule : numero, mode, statut, total, date + bouton "Nouvelle
* commande". Partagee par les deux canaux ; le titre, le lien de creation et la source
* viennent du controleur (CounterOrderController::channelView). Echappement RG-T15.
* Aucun rafraichissement auto (polling hors scope) : la page se relit a la navigation.
*
* @var list<array<string, mixed>> $orders
* @var list<array<string, mixed>> $orders historique recent (tous statuts)
* @var list<array<string, mixed>> $inProgress file "En cours" (paid non livre, canal)
* @var string $channelTitle
* @var string $newPath
*/
@ -39,6 +43,8 @@ $statusPill = static fn (string $s): string => match ($s) {
/** @var list<array<string, mixed>> $rows */
$rows = isset($orders) && is_array($orders) ? $orders : [];
/** @var list<array<string, mixed>> $queue */
$queue = isset($inProgress) && is_array($inProgress) ? $inProgress : [];
$heading = isset($channelTitle) && is_string($channelTitle) ? $channelTitle : 'Commandes';
$createPath = isset($newPath) && is_string($newPath) ? $newPath : '/counter/orders/new';
?>
@ -48,8 +54,45 @@ $createPath = isset($newPath) && is_string($newPath) ? $newPath : '/counter/orde
<h1 id="counter-heading" class="admin-section__title"><?= $esc($heading) ?></h1>
<a class="btn btn-primary" href="<?= $esc($createPath) ?>">Nouvelle commande</a>
</div>
<p class="admin-section__sub"><?= count($rows) ?> commande(s) recente(s)</p>
<h2 class="admin-section__subtitle">En cours</h2>
<p class="admin-section__sub"><?= count($queue) ?> commande(s) a servir</p>
<?php if ($queue === []): ?>
<p class="admin-empty">Aucune commande en cours.</p>
<?php else: ?>
<table class="admin-table">
<thead>
<tr>
<th>Numero</th>
<th>Mode</th>
<th>Table</th>
<th>Total</th>
<th>Payee a</th>
</tr>
</thead>
<tbody>
<?php foreach ($queue as $o): ?>
<?php
// Numero de table : pertinent seulement en sur place (service_tag est
// NULL hors dine_in cote serveur). On affiche un tiret sinon, pour que
// l'equipier distingue "pas de table" d'une donnee manquante.
$queueMode = (string) ($o['service_mode'] ?? '');
$queueTag = $queueMode === 'dine_in' ? (string) ($o['service_tag'] ?? '') : '';
?>
<tr>
<td><strong><?= $esc($o['order_number'] ?? '') ?></strong></td>
<td><?= $esc($modeLabel($queueMode)) ?></td>
<td><?= $queueTag !== '' ? $esc($queueTag) : '-' ?></td>
<td><?= $esc($euros($o['total_ttc_cents'] ?? 0)) ?></td>
<td><?= $esc($o['paid_at'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h2 class="admin-section__subtitle">Historique recent</h2>
<p class="admin-section__sub"><?= count($rows) ?> commande(s) recente(s)</p>
<?php if ($rows === []): ?>
<p class="admin-empty">Aucune commande pour ce canal.</p>
<?php else: ?>

View file

@ -3,25 +3,32 @@
declare(strict_types=1);
/**
* Composeur de commande comptoir/drive COMPLET (sous-lot 3c), injecte dans
* admin/layout.php. Produits commandables ET menus composes (slots
* accompagnement/boisson/sauce + format Normal/Maxi + modificateurs d'ingredients).
* POS tactile a tuiles (comptoir / drive), injecte dans admin/layout.php. Refonte de
* la saisie : a la place du formulaire-liste, un ecran de caisse facon borne client
* (onglets categories en haut, grille de tuiles produits/menus a gauche, panneau
* commande persistant a droite). Pensee pour la tablette : grandes cibles tactiles,
* un tap sur une tuile ajoute le produit a la commande (qty 1), un produit a
* modificateurs ou un menu ouvre la modale de composition.
*
* Le panier est construit cote client par counter-order.js (CSP 'self', vanilla JS,
* zero handler inline) : il lit produits et menus depuis les data-* de
* #counter-order-form (dont la composition PROPOSABLE de chaque produit et du burger
* de chaque menu : ingredients retirables / ajoutables + surcout), et serialise les
* items en JSON dans le champ cache #items_json a la soumission. Le serveur revalide
* tout (RG-T18, resolveModifiers) et recalcule les prix (RG-T16). Le tableau de
* quantites produit `qty_<id>` reste present comme repli sans JS (3a).
* zero handler inline) : il lit produits et menus depuis un script JSON inerte
* (type="application/json"), construit les onglets, rend la grille, gere le panneau
* commande, et serialise les items en JSON dans le champ cache #items_json a la
* soumission. Le serveur revalide tout (RG-T18, resolveModifiers) et recalcule les
* prix (RG-T16) : les prix affiches cote client (par ligne + total + libelle du
* bouton) sont INDICATIFS, le serveur reste seul juge. Le contrat de soumission est
* inchange (items_json + service_mode + service_tag + _csrf). Sans JS, la grille ne
* s'affiche pas : un message invite a activer JS (le POS est interactif par nature).
*
* Partage par les deux canaux ; la source/landing viennent du controleur. Au canal
* drive, service_mode est verrouille a 'drive' (RG-T09). Echappement RG-T15.
* drive, service_mode est FIGE a 'drive' (affichage non editable + input cache,
* RG-T09 : un select readonly reste editable, on ne s'y fie pas). Echappement RG-T15.
*
* @var list<array<string, mixed>> $products
* @var list<array<string, mixed>> $menus menus + slots (option_product_ids)
* @var string $source 'counter' | 'drive'
* @var string $serviceMode valeur preselectionnee / reaffichee
* @var string $serviceTag numero de table reaffiche (re-rendu d'erreur)
* @var string $landing retour a la liste du canal
* @var string|null $error
* @var string $csrfToken
@ -30,19 +37,12 @@ declare(strict_types=1);
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR';
// Donnees pour counter-order.js, passees en attributs data-* (CSP 'self' : pas de
// script inline). htmlspecialchars rend le JSON sur-able comme valeur d'attribut.
$attr = static fn (mixed $data): string => htmlspecialchars(
(string) json_encode($data, JSON_UNESCAPED_UNICODE),
ENT_QUOTES,
'UTF-8',
);
$csrf = $esc($csrfToken ?? '');
$chan = isset($source) && $source === 'drive' ? 'drive' : 'counter';
$action = $chan === 'drive' ? '/drive/orders' : '/counter/orders';
$backTo = isset($landing) && is_string($landing) ? $landing : '/counter/orders';
$mode = isset($serviceMode) && is_string($serviceMode) ? $serviceMode : ($chan === 'drive' ? 'drive' : 'dine_in');
$tag = isset($serviceTag) && is_string($serviceTag) ? $serviceTag : '';
$errorMessage = isset($error) && is_string($error) ? $error : null;
/** @var list<array<string, mixed>> $productRows */
@ -50,10 +50,11 @@ $productRows = isset($products) && is_array($products) ? $products : [];
/** @var list<array<string, mixed>> $menuRows */
$menuRows = isset($menus) && is_array($menus) ? $menus : [];
// Projection compacte pour le JS : seules les cles utiles a la composition. Les
// prix sont passes pour l'affichage local (le serveur reste seul juge, RG-T16).
// modifiers : ingredients retirables / ajoutables proposables (le client les affiche
// en cases a cocher ; resolveModifiers revalide chacun cote serveur).
// Projection compacte pour le JS : seules les cles utiles a la composition, l'affichage
// (tuiles : nom, prix, image, categorie) et le calcul local. Les prix sont passes pour
// l'affichage local (le serveur reste seul juge, RG-T16). modifiers : ingredients
// retirables / ajoutables proposables (cases a cocher cote client ; resolveModifiers
// revalide chacun cote serveur).
$jsModifiers = static fn (mixed $rows): array => array_map(
static fn (array $r): array => [
'ingredient_id' => (int) ($r['ingredient_id'] ?? 0),
@ -64,17 +65,31 @@ $jsModifiers = static fn (mixed $rows): array => array_map(
],
is_array($rows) ? $rows : [],
);
// Nom de categorie d'une ligne : category_name si fourni, sinon repli "Autres" pour ne
// pas creer d'onglet a libelle vide.
$catNameOf = static fn (array $r): string => isset($r['category_name'])
&& is_string($r['category_name']) && $r['category_name'] !== ''
? $r['category_name']
: 'Autres';
$jsProducts = array_map(
static fn (array $p): array => [
'id' => (int) ($p['id'] ?? 0),
'name' => (string) ($p['name'] ?? ''),
'price' => (int) ($p['price_cents'] ?? 0),
'modifiers' => $jsModifiers($p['modifiers'] ?? null),
'id' => (int) ($p['id'] ?? 0),
'name' => (string) ($p['name'] ?? ''),
'price' => (int) ($p['price_cents'] ?? 0),
'image' => (string) ($p['image_path'] ?? ''),
'category_id' => (int) ($p['category_id'] ?? 0),
'category_name' => $catNameOf($p),
'modifiers' => $jsModifiers($p['modifiers'] ?? null),
// RG-T21 : false = rupture de stock calculee. La tuile reste visible (parite
// borne) mais grisee et non tappable cote JS. Absent => commandable par defaut.
'commandable' => ($p['is_orderable'] ?? true) !== false,
],
$productRows,
);
$jsMenus = array_map(
static function (array $m) use ($jsModifiers): array {
static function (array $m) use ($jsModifiers, $catNameOf): array {
/** @var list<array<string, mixed>> $slots */
$slots = isset($m['slots']) && is_array($m['slots']) ? $m['slots'] : [];
@ -83,6 +98,12 @@ $jsMenus = array_map(
'name' => (string) ($m['name'] ?? ''),
'price_normal' => (int) ($m['price_normal_cents'] ?? 0),
'price_maxi' => (int) ($m['price_maxi_cents'] ?? 0),
'image' => (string) ($m['image_path'] ?? ''),
'category_id' => (int) ($m['category_id'] ?? 0),
'category_name' => $catNameOf($m),
// RG-T21 (granularite burger impose seul) : false = burger en rupture
// calculee. La tuile menu reste visible mais grisee et non tappable.
'commandable' => ($m['is_orderable'] ?? true) !== false,
// Modificateurs du burger support : la selection d'un menu cible le burger
// (resolveModifiers cote serveur le resout sur burger_product_id).
'burger_modifiers' => $jsModifiers($m['burger_modifiers'] ?? null),
@ -102,109 +123,95 @@ $jsMenus = array_map(
$menuRows,
);
// RG-T09 : au drive, le seul mode possible est 'drive'. Le comptoir choisit librement.
$modeOptions = $chan === 'drive'
? ['drive' => 'Drive']
: ['dine_in' => 'Sur place', 'takeaway' => 'A emporter'];
// JSON inerte (type="application/json") plutot que data-* : la charge (compo de chaque
// produit + slots de chaque menu) peut etre volumineuse ; un script JSON reste CSP-safe
// (non execute) et plus lisible qu'un long attribut data-*. JSON_HEX_* echappe < > & '
// pour que la sortie soit sure a l'interieur d'un <script> (anti-XSS, RG-T15).
$jsonFlags = JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
?>
<div class="page-header">
<h1 class="page-title">Nouvelle commande <?= $chan === 'drive' ? 'drive' : 'comptoir' ?></h1>
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
</div>
<?php if ($errorMessage !== null): ?>
<p class="form-error" role="alert"><?= $esc($errorMessage) ?></p>
<?php endif; ?>
<form method="post" action="<?= $esc($action) ?>" class="form-card" id="counter-order-form"
data-products="<?= $attr($jsProducts) ?>"
data-menus="<?= $attr($jsMenus) ?>">
<form method="post" action="<?= $esc($action) ?>" class="pos" id="counter-order-form">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="hidden" name="items_json" id="items_json" value="">
<div class="form-group">
<label class="form-label" for="service_mode">Mode de service</label>
<select class="form-input" id="service_mode" name="service_mode"<?= $chan === 'drive' ? ' readonly' : '' ?>>
<?php foreach ($modeOptions as $value => $label): ?>
<option value="<?= $esc($value) ?>"<?= $mode === $value ? ' selected' : '' ?>><?= $esc($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php /* Donnees du catalogue pour counter-order.js : script JSON inerte (CSP-safe). */ ?>
<script type="application/json" id="pos-products"><?= (string) json_encode($jsProducts, $jsonFlags) ?></script>
<script type="application/json" id="pos-menus"><?= (string) json_encode($jsMenus, $jsonFlags) ?></script>
<fieldset class="form-group">
<legend>Produits</legend>
<?php if ($productRows === []): ?>
<p class="admin-empty">Aucun produit commandable pour le moment.</p>
<?php else: ?>
<table class="admin-table">
<thead>
<tr>
<th>Produit</th>
<th>Prix</th>
<th>Quantite</th>
<th>Personnaliser</th>
</tr>
</thead>
<tbody>
<?php foreach ($productRows as $p): ?>
<?php
$pid = (int) ($p['id'] ?? 0);
// Un produit ne porte un bouton "Personnaliser" que si sa recette
// offre au moins un ingredient retirable/ajoutable (data-* modifiers).
$hasModifiers = isset($p['modifiers']) && is_array($p['modifiers']) && $p['modifiers'] !== [];
?>
<tr>
<td><?= $esc($p['name'] ?? '') ?></td>
<td><?= $esc($euros($p['price_cents'] ?? 0)) ?></td>
<td>
<input class="form-input order-qty" type="number" min="0" value="0"
id="qty_<?= $pid ?>" name="qty_<?= $pid ?>"
data-product-id="<?= $pid ?>"
aria-label="Quantite <?= $esc($p['name'] ?? '') ?>">
</td>
<td>
<?php if ($hasModifiers): ?>
<button class="btn btn-secondary product-configure" type="button" data-product-id="<?= $pid ?>">
Personnaliser
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</fieldset>
<div class="pos__main">
<div class="pos__catalogue">
<?php /* Barre d'onglets categories (construite par le JS depuis le catalogue). */ ?>
<div class="pos__tabs" id="pos-tabs" role="tablist" aria-label="Categories"></div>
<fieldset class="form-group">
<legend>Menus</legend>
<?php if ($menuRows === []): ?>
<p class="admin-empty">Aucun menu commandable pour le moment.</p>
<?php else: ?>
<ul class="menu-list" id="menu-list">
<?php foreach ($menuRows as $m): ?>
<?php $mid = (int) ($m['id'] ?? 0); ?>
<li class="menu-list__item">
<span class="menu-list__name"><?= $esc($m['name'] ?? '') ?></span>
<span class="menu-list__price"><?= $esc($euros($m['price_normal_cents'] ?? 0)) ?></span>
<button class="btn btn-secondary menu-configure" type="button" data-menu-id="<?= $mid ?>">
Configurer
</button>
</li>
<?php endforeach; ?>
<?php if ($productRows === [] && $menuRows === []): ?>
<p class="admin-empty">Aucun produit ni menu commandable pour le moment.</p>
<?php else: ?>
<?php /* Grille de tuiles (remplie par le JS) + repli sans JS. role=tabpanel
relie au tablist (aria-labelledby pose par le JS vers l'onglet
actif). Pas d'aria-live ici : la grille est rebatie a chaque
changement de categorie, une re-annonce complete serait verbeuse. */ ?>
<div class="pos__grid" id="pos-grid" role="tabpanel" tabindex="0">
<p class="pos__nojs">Activez JavaScript pour saisir une commande sur cet ecran de caisse.</p>
</div>
<?php endif; ?>
</div>
<?php /* Panneau commande persistant (recap a droite, facon caisse). */ ?>
<aside class="pos__panel" aria-label="Commande en cours">
<div class="pos__panel-head">
<span class="pos__panel-title">Commande</span>
<div class="pos__service">
<?php if ($chan === 'drive'): ?>
<?php /* RG-T09 : au drive, le mode est impose. On AFFICHE 'Drive' fige et on
transmet la valeur par un champ cache (un select readonly resterait
editable, donc non fiable ; disabled ne serait pas soumis). */ ?>
<p class="form-static" id="service_mode_display">Drive</p>
<input type="hidden" name="service_mode" id="service_mode" value="drive">
<?php else: ?>
<label class="pos__service-label" for="service_mode">Mode</label>
<select class="form-input" id="service_mode" name="service_mode">
<option value="dine_in"<?= $mode === 'dine_in' ? ' selected' : '' ?>>Sur place</option>
<option value="takeaway"<?= $mode === 'takeaway' ? ' selected' : '' ?>>A emporter</option>
</select>
<?php endif; ?>
</div>
<?php if ($chan !== 'drive'): ?>
<?php /* 7a : numero de table, utile uniquement en sur place. Masque par defaut
hors dine_in (toggle JS sur le mode) ; le champ reste soumis tel quel,
persist() l'ignore hors dine_in. */ ?>
<div class="pos__service" id="service_tag_group"<?= $mode === 'dine_in' ? '' : ' hidden' ?>>
<label class="pos__service-label" for="service_tag">Table</label>
<input class="form-input" type="text" id="service_tag" name="service_tag"
maxlength="20" value="<?= $esc($tag) ?>" autocomplete="off">
</div>
<?php endif; ?>
</div>
<?php /* Pas d'aria-live sur la liste : elle est rebatie a chaque +/- (une
re-annonce de tout le panier serait verbeuse). Une region live dediee
(#pos-announce) annonce un message concis a chaque mutation. */ ?>
<ul class="order-cart" id="order-cart">
<li class="order-cart__empty" id="order-cart-empty">Panier vide.</li>
</ul>
<?php endif; ?>
</fieldset>
<fieldset class="form-group">
<legend>Panier</legend>
<ul class="order-cart" id="order-cart" aria-live="polite">
<li class="order-cart__empty" id="order-cart-empty">Panier vide.</li>
</ul>
</fieldset>
<div class="pos__panel-foot">
<?php /* Total indicatif du panier (recalcule cote serveur a l'encaissement). */ ?>
<p class="order-total" id="order-total">Total <span id="order-total-value"><?= $esc($euros(0)) ?></span></p>
<button class="btn btn-primary pos__pay" type="submit" id="order-submit">Encaisser <?= $esc($euros(0)) ?></button>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Encaisser la commande</button>
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
<?php /* Region live concise (C) : recoit "Total X EUR, N articles" a chaque
mutation du panier. Visuellement discrete (classe sr-only). */ ?>
<span class="sr-only" id="pos-announce" role="status" aria-live="polite"></span>
</aside>
</div>
</form>

View file

@ -3,107 +3,263 @@
declare(strict_types=1);
/**
* Liste du stock (READ_STOCK 9.3), injectee dans admin/layout.php. Affiche le
* pourcentage et la bande calcules (RG-2) ; les liens d'action sont conditionnes
* aux permissions (la garde reelle reste par-route). Texte echappe.
* Tableau de bord stock (READ_STOCK 9.3), injecte dans admin/layout.php. Oriente
* usage quotidien : on met en avant ce qui est bas a reapprovisionner, le CRUD de
* definition (config rare) est relegue. Le lien metier explique a quoi sert le stock :
* un ingredient requis sous le seuil critique rend les produits qui l'utilisent
* indisponibles sur la borne (RG-T21). Pourcentage/bande resolus cote depot ; les
* liens d'action restent conditionnes aux permissions (garde reelle par-route). Texte echappe.
*
* @var array<int, array<string, mixed>> $ingredients
* @var bool $canManage
* @var bool $canRestock
* @var bool $canCount
* @var string $csrfToken
* @var array<string, int> $bandCounts
* @var bool $canManage
* @var bool $canRestock
* @var bool $canCount
* @var string|null $thresholdError
* @var string $csrfToken
*/
/** @var array<int, array<string, mixed>> $rows */
$rows = isset($ingredients) && is_array($ingredients) ? $ingredients : [];
/** @var array<string, int> $counts */
$counts = isset($bandCounts) && is_array($bandCounts) ? $bandCounts : [];
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$manage = (bool) ($canManage ?? false);
$restock = (bool) ($canRestock ?? false);
$count = (bool) ($canCount ?? false);
$bandLabel = static fn (string $band): string => match ($band) {
'critical' => 'pill pill-danger',
'low' => 'pill pill-warning',
default => 'pill pill-success',
$nCritical = (int) ($counts['critical'] ?? 0);
$nLow = (int) ($counts['low'] ?? 0);
$nNormal = (int) ($counts['normal'] ?? 0);
$thresholdErr = isset($thresholdError) && is_string($thresholdError) ? $thresholdError : null;
/**
* Bouton "Regler les seuils" (F13). Ouvre la modale pre-remplie via des data-attributes
* (l'id pour l'action POST, le nom pour le titre, les trois valeurs courantes) ; le JS
* stock-thresholds.js intercepte le clic. Affiche seulement si le role peut calibrer le
* stock ($restock = stock.manage). Valeurs echappees (attributs HTML).
*
* @param array<string, mixed> $row
*/
$renderThresholdButton = static function (array $row) use ($esc, $restock): string {
if (!$restock) {
return '';
}
$id = (int) ($row['id'] ?? 0);
return '<button type="button" class="btn btn-ghost btn-sm" data-threshold-open'
. ' data-id="' . $id . '"'
. ' data-name="' . $esc($row['name'] ?? '') . '"'
. ' data-capacity="' . (int) ($row['stock_capacity'] ?? 0) . '"'
. ' data-low="' . (int) ($row['low_stock_pct'] ?? 0) . '"'
. ' data-critical="' . (int) ($row['critical_stock_pct'] ?? 0) . '">Regler les seuils</button>';
};
$bandText = static fn (string $band): string => match ($band) {
'critical' => 'Critique',
'low' => 'Alerte',
default => 'Normal',
// Les ingredients a reapprovisionner : critiques d'abord, puis en alerte. Le reste
// (au-dessus des seuils) va dans la liste calme "Tous les ingredients" plus bas.
$critical = [];
$low = [];
foreach ($rows as $row) {
$band = (string) ($row['stock_band'] ?? 'normal');
if ($band === 'critical') {
$critical[] = $row;
} elseif ($band === 'low') {
$low[] = $row;
}
}
$toRestock = array_merge($critical, $low);
$barClass = static fn (string $band): string => match ($band) {
'critical' => 'stock-bar__fill stock-bar--critical',
'low' => 'stock-bar__fill stock-bar--low',
default => 'stock-bar__fill stock-bar--normal',
};
/**
* Barre de niveau : conteneur + portion remplie (largeur = pct%, couleur = bande).
* La largeur est bornee a 100 pour rester dans le conteneur meme si le depot renvoie
* un pourcentage superieur. Style inline pour la largeur (deja la convention admin).
*
* @param array<string, mixed> $row
*/
$renderBar = static function (array $row) use ($esc, $barClass): string {
$pct = (int) ($row['stock_pct'] ?? 0);
$width = max(0, min(100, $pct));
$band = (string) ($row['stock_band'] ?? 'normal');
$qty = (int) ($row['stock_quantity'] ?? 0);
$cap = (int) ($row['stock_capacity'] ?? 0);
$state = match ($band) {
'critical' => 'critique',
'low' => 'en alerte',
default => 'au-dessus du seuil',
};
$html = '<div class="stock-bar" role="img" aria-label="Niveau de stock ' . $pct . ' pourcent, etat ' . $state . '">';
$html .= '<span class="' . $esc($barClass($band)) . '" style="width:' . $width . '%"></span>';
$html .= '</div>';
$html .= '<div class="stock-bar__meta"><span class="stock-bar__pct">' . $pct . '%</span>';
$html .= '<span class="stock-bar__qty">' . $esc((string) $qty) . ' / ' . $esc((string) $cap) . '</span></div>';
return $html;
};
?>
<div class="page-header">
<div>
<h1 class="page-title">Stock</h1>
<p class="page-subtitle">Ingredients, niveaux de stock et mouvements</p>
<h1 class="page-title">Stock des ingredients</h1>
<p class="page-subtitle">Ce qui est bas a reapprovisionner, en un coup d oeil</p>
</div>
<?php if ($manage): ?>
<div class="page-actions">
<a class="btn btn-primary" href="/admin/ingredients/new">Nouvel ingredient</a>
<a class="btn btn-secondary" href="/admin/ingredients/new">Nouvel ingredient</a>
</div>
<?php endif; ?>
</div>
<div class="table-container">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Ingredient</th>
<th>Unite</th>
<th>Stock</th>
<th>Niveau</th>
<th>Statut</th>
<th style="width:280px;"></th>
</tr>
</thead>
<tbody>
<?php if ($rows === []): ?>
<tr><td colspan="6" class="muted">Aucun ingredient.</td></tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<?php
$id = (int) ($row['id'] ?? 0);
$active = (int) ($row['is_active'] ?? 0) === 1;
$band = (string) ($row['stock_band'] ?? 'normal');
$pct = (int) ($row['stock_pct'] ?? 0);
?>
<tr>
<td class="fw-600"><?= $esc($row['name'] ?? '') ?></td>
<td class="muted"><?= $esc($row['unit'] ?? '') ?></td>
<td>
<?= $esc((string) ((int) ($row['stock_quantity'] ?? 0))) ?>
<span class="muted">/ <?= $esc((string) ((int) ($row['stock_capacity'] ?? 0))) ?> (<?= $pct ?>%)</span>
</td>
<td><span class="<?= $bandLabel($band) ?>"><?= $bandText($band) ?></span></td>
<td>
<?php if ($active): ?>
<span class="pill pill-success">Actif</span>
<?php else: ?>
<span class="pill pill-neutral">Inactif</span>
<?php endif; ?>
</td>
<td>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
<?php if ($restock): ?>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/restock">Reappro</a>
<?php endif; ?>
<?php if ($count): ?>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
<?php endif; ?>
<?php if ($manage): ?>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/edit">Modifier</a>
<form method="post" action="/admin/ingredients/<?= $id ?>/toggle" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button class="btn btn-secondary" type="submit"><?= $active ? 'Desactiver' : 'Reactiver' ?></button>
</form>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/delete">Supprimer</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ($thresholdErr !== null && $thresholdErr !== ''): ?>
<div class="flash flash-error" role="alert"><?= $esc($thresholdErr) ?></div>
<?php endif; ?>
<p class="stock-explainer">
Le stock pilote ce qui est commandable sur la borne. Un ingredient requis par une
recette qui passe sous son seuil critique rend les produits qui l utilisent
indisponibles a la commande. Tenez les niveaux a jour pour garder le menu ouvert.
</p>
<div class="stock-summary">
<div class="stock-summary__item stock-summary__item--danger">
<span class="stock-summary__count"><?= $nCritical ?></span>
<span class="stock-summary__label">critiques</span>
</div>
<div class="stock-summary__item stock-summary__item--warning">
<span class="stock-summary__count"><?= $nLow ?></span>
<span class="stock-summary__label">en alerte</span>
</div>
<div class="stock-summary__item stock-summary__item--success">
<span class="stock-summary__count"><?= $nNormal ?></span>
<span class="stock-summary__label">au-dessus du seuil</span>
</div>
</div>
<section class="stock-section stock-section--restock">
<h2 class="stock-section__title">A reapprovisionner</h2>
<?php if ($toRestock === []): ?>
<div class="stock-empty stock-empty--ok">
Tous les ingredients sont au-dessus de leurs seuils.
</div>
<?php else: ?>
<div class="stock-cards">
<?php foreach ($toRestock as $row): ?>
<?php
$id = (int) ($row['id'] ?? 0);
$band = (string) ($row['stock_band'] ?? 'normal');
$bandPill = $band === 'critical' ? 'pill pill-danger' : 'pill pill-warning';
$bandText = $band === 'critical' ? 'Critique' : 'Alerte';
?>
<div class="stock-card stock-card--<?= $esc($band) ?>">
<div class="stock-card__head">
<div>
<span class="stock-card__name"><?= $esc($row['name'] ?? '') ?></span>
<span class="stock-card__unit"><?= $esc($row['unit'] ?? '') ?></span>
</div>
<span class="<?= $bandPill ?>"><?= $bandText ?></span>
</div>
<?= $renderBar($row) ?>
<div class="stock-card__actions">
<?php if ($restock): ?>
<a class="btn btn-primary stock-card__action" href="/admin/ingredients/<?= $id ?>/restock">Reapprovisionner</a>
<?php endif; ?>
<?= $renderThresholdButton($row) ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="stock-section">
<h2 class="stock-section__title">Tous les ingredients</h2>
<?php if ($rows === []): ?>
<div class="stock-empty">Aucun ingredient.</div>
<?php else: ?>
<ul class="stock-list">
<?php foreach ($rows as $row): ?>
<?php
$id = (int) ($row['id'] ?? 0);
$active = (int) ($row['is_active'] ?? 0) === 1;
?>
<li class="stock-list__row">
<div class="stock-list__main">
<span class="stock-list__name"><?= $esc($row['name'] ?? '') ?></span>
<span class="stock-list__unit"><?= $esc($row['unit'] ?? '') ?></span>
<?php if ($active): ?>
<span class="pill pill-success">Actif</span>
<?php else: ?>
<span class="pill pill-neutral">Inactif</span>
<?php endif; ?>
</div>
<div class="stock-list__bar"><?= $renderBar($row) ?></div>
<div class="stock-list__actions">
<?php if ($count): ?>
<a class="btn btn-secondary btn-sm" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
<?php endif; ?>
<?= $renderThresholdButton($row) ?>
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
<?php if ($manage): ?>
<span class="stock-list__crud">
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/edit">Modifier</a>
<form method="post" action="/admin/ingredients/<?= $id ?>/toggle" class="stock-list__inline-form">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button class="btn btn-ghost btn-sm" type="submit"><?= $active ? 'Desactiver' : 'Reactiver' ?></button>
</form>
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/delete">Supprimer</a>
</span>
<?php endif; ?>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>
<?php if ($restock): ?>
<?php /*
Modale de reglage rapide des seuils (F13), rendue serveur (VRAI form POST + CSRF,
comme restock/inventory ; pas de fetch). Une seule modale pour la page : le bouton
clique (data-threshold-open) y injecte l'action /admin/ingredients/{id}/thresholds
et pre-remplit les trois champs depuis ses data-attributes (stock-thresholds.js).
Reutilise les classes .pin-modal-* (overlay generique). Cachee par defaut (pas de
classe .open) : sans JS, elle reste invisible et les actions classiques fonctionnent.
*/ ?>
<div class="pin-modal-overlay" data-threshold-modal role="dialog" aria-modal="true" aria-label="Reglage des seuils de stock">
<div class="pin-modal">
<div class="pin-modal-head">
<div>
<h2 class="pin-modal-title">Regler les seuils</h2>
<p class="pin-modal-sub" data-threshold-name>Capacite de reference et seuils d alerte de l ingredient.</p>
</div>
</div>
<form method="post" action="" data-threshold-form>
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group">
<label class="form-label" for="th-capacity">Capacite (quantite consideree comme 100%)</label>
<input class="form-input" type="number" id="th-capacity" name="stock_capacity" min="1" step="1" required>
</div>
<div class="form-group">
<label class="form-label" for="th-low">Seuil d alerte (% du plein)</label>
<input class="form-input" type="number" id="th-low" name="low_stock_pct" min="0" max="100" step="1" required>
</div>
<div class="form-group">
<label class="form-label" for="th-critical">Seuil critique (% du plein)</label>
<input class="form-input" type="number" id="th-critical" name="critical_stock_pct" min="0" max="100" step="1" required>
<p class="form-hint">Le seuil critique doit etre inferieur au seuil d alerte. Sous le critique, les produits qui utilisent cet ingredient passent indisponibles sur la borne.</p>
</div>
<p class="form-error" data-threshold-error hidden></p>
<div class="pin-modal-actions">
<button class="btn btn-secondary" type="button" data-threshold-cancel>Annuler</button>
<button class="btn btn-primary" type="submit">Enregistrer les seuils</button>
</div>
</form>
</div>
</div>
<?php endif; ?>

View file

@ -8,6 +8,11 @@ declare(strict_types=1);
* de remise n'apparait que pour les roles dotes de order.deliver (kitchen ne l'a pas :
* il voit la file en lecture seule ; counter/drive/admin remettent).
*
* Chaque commande porte son detail (items -> selections + modifiers) et une bande SLA
* (sla_band : fresh / warn / late) calculee cote serveur depuis (now - paid_at),
* mappee vers une classe CSS sur la carte (kds-order--fresh / --warn / --late). Le KDS
* est rendu exploitable pour PREPARER : la liste lisible des articles est affichee.
*
* @var list<array<string, mixed>> $orders
* @var bool $canDeliver
* @var string $csrfToken
@ -20,12 +25,65 @@ $can = !empty($canDeliver);
$sourceLabel = static fn (string $s): string => ['kiosk' => 'Borne', 'counter' => 'Comptoir', 'drive' => 'Drive'][$s] ?? $s;
$modeLabel = static fn (string $m): string => $m === 'dine_in' ? 'Sur place' : ($m === 'drive' ? 'Drive' : 'A emporter');
// Bande SLA (serveur) -> classe CSS de la carte. Defaut prudent sur valeur inconnue.
$slaClass = static fn (string $band): string => [
'fresh' => 'kds-order--fresh',
'warn' => 'kds-order--warn',
'late' => 'kds-order--late',
][$band] ?? 'kds-order--fresh';
/**
* Libelle lisible d'un article : "<qty>x <label> (Maxi) - <selections> - <modifs>".
* S'appuie sur les snapshots (label_snapshot, format) ; les modificateurs sont rendus
* "sans <ingredient>" (remove) / "+<ingredient>" (add). Tout est echappe a la sortie.
*
* @param array<string, mixed> $item
*/
$itemLabel = static function (array $item) use ($esc): string {
$qty = max(1, (int) ($item['quantity'] ?? 1));
$name = (string) ($item['label_snapshot'] ?? '');
$main = $esc($qty) . 'x ' . $esc($name);
if ((string) ($item['format'] ?? 'normal') === 'maxi') {
$main .= ' (Maxi)';
}
$parts = [];
$selections = isset($item['selections']) && is_array($item['selections']) ? $item['selections'] : [];
$selLabels = [];
foreach ($selections as $sel) {
$label = trim((string) ($sel['label_snapshot'] ?? ''));
if ($label !== '') {
$selLabels[] = $esc($label);
}
}
if ($selLabels !== []) {
$parts[] = implode(', ', $selLabels);
}
$modifiers = isset($item['modifiers']) && is_array($item['modifiers']) ? $item['modifiers'] : [];
$modLabels = [];
foreach ($modifiers as $mod) {
$ing = trim((string) ($mod['ingredient_name'] ?? ''));
if ($ing === '') {
continue;
}
$modLabels[] = ((string) ($mod['action'] ?? '') === 'add' ? '+' : 'sans ') . $esc($ing);
}
if ($modLabels !== []) {
$parts[] = implode(', ', $modLabels);
}
return $parts === [] ? $main : $main . ' - ' . implode(' - ', $parts);
};
?>
<div class="page-header">
<div>
<h1 class="page-title">Cuisine</h1>
<p class="page-subtitle">File des commandes payees, de la plus ancienne a la plus recente.</p>
</div>
<span class="kitchen-clock" id="kitchenTime" aria-hidden="true"></span>
</div>
<?php if ($rows === []): ?>
@ -33,9 +91,13 @@ $modeLabel = static fn (string $m): string => $m === 'dine_in' ? 'Sur place' : (
<?php else: ?>
<section class="kitchen-grid" aria-label="File des commandes payees">
<?php foreach ($rows as $o): ?>
<article class="kitchen-card">
<?php
$items = isset($o['items']) && is_array($o['items']) ? $o['items'] : [];
$band = (string) ($o['sla_band'] ?? 'fresh');
?>
<article class="kitchen-card <?= $esc($slaClass($band)) ?>">
<div class="kitchen-card-header">
<span class="kitchen-card-number"><?= $esc($o['order_number'] ?? '') ?></span>
<span class="kitchen-order-num"><?= $esc($o['order_number'] ?? '') ?></span>
<span class="kitchen-card-source"><?= $esc($sourceLabel((string) ($o['source'] ?? ''))) ?></span>
</div>
<div class="kitchen-card-body">
@ -44,6 +106,15 @@ $modeLabel = static fn (string $m): string => $m === 'dine_in' ? 'Sur place' : (
<p class="kitchen-line">Table : <?= $esc($o['service_tag']) ?></p>
<?php endif; ?>
<p class="kitchen-line">Payee a : <?= $esc($o['paid_at'] ?? '') ?></p>
<?php if ($items === []): ?>
<p class="kitchen-line">Aucun article.</p>
<?php else: ?>
<ul class="kds-items">
<?php foreach ($items as $item): ?>
<li class="kds-item"><?= $itemLabel($item) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<?php if ($can): ?>
<div class="kitchen-card-footer">

View file

@ -18,6 +18,7 @@ declare(strict_types=1);
* @var list<string> $permissions
* @var string $csrfToken
* @var string $activeNav
* @var string $orderChannel 'counter' | 'drive' : canal de saisie du role courant
* @var string|null $flash
*/
@ -27,6 +28,13 @@ $userRole = htmlspecialchars($currentUserRole ?? '', ENT_QUOTES, 'UTF-8');
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$active = is_string($activeNav ?? null) ? $activeNav : '';
// Canal de saisie du role courant : un equipier drive est route vers /drive/orders,
// les autres roles vers /counter/orders. CounterOrderController pose activeNav a
// 'counter' ou 'drive' selon le chemin ; on marque le lien actif sur l'un OU l'autre.
$channel = ($orderChannel ?? '') === 'drive' ? 'drive' : 'counter';
$orderHref = $channel === 'drive' ? '/drive/orders' : '/counter/orders';
$orderActive = ($active === 'counter' || $active === 'drive') ? 'sidebar-item active' : 'sidebar-item';
/** @var list<string> $perms */
$perms = isset($permissions) && is_array($permissions) ? $permissions : [];
$can = static fn (string $code): bool => in_array($code, $perms, true);
@ -119,9 +127,9 @@ $navClass = static function (string $code, string $current): string {
<div class="sidebar-section">
<div class="sidebar-section-label">Pilotage</div>
<?php if ($can('order.create')): ?>
<?php /* Lien generique vers le comptoir ; le canal effectif (counter/drive)
est derive du chemin par CounterOrderController (mlt 4.1). */ ?>
<a href="/counter/orders" class="<?= $navClass('counter', $active) ?>">Saisie commande</a>
<?php /* Le canal (counter/drive) vient de role.order_source : un equipier
drive est route vers /drive/orders, les autres vers /counter/orders. */ ?>
<a href="<?= htmlspecialchars($orderHref, ENT_QUOTES, 'UTF-8') ?>" class="<?= $orderActive ?>">Saisie commande</a>
<?php endif; ?>
<?php if ($can('stats.read')): ?>
<a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a>
@ -161,5 +169,6 @@ $navClass = static function (string $code, string $current): string {
</div>
<script src="/assets/js/admin.js"></script>
<script src="/assets/js/pin-modal.js"></script>
<script src="/assets/js/stock-thresholds.js"></script>
</body>
</html>

View file

@ -9,6 +9,7 @@ declare(strict_types=1);
*
* @var int $productId
* @var array<int, array<string, mixed>> $categories
* @var array<int, array<string, mixed>> $baseCandidates produits de base eligibles (R4)
* @var array<string, mixed> $values
* @var array<string, string> $errors
* @var string $csrfToken
@ -24,12 +25,16 @@ $vals = isset($values) && is_array($values) ? $values : [];
$errs = isset($errors) && is_array($errors) ? $errors : [];
/** @var array<int, array<string, mixed>> $cats */
$cats = isset($categories) && is_array($categories) ? $categories : [];
/** @var array<int, array<string, mixed>> $bases */
$bases = isset($baseCandidates) && is_array($baseCandidates) ? $baseCandidates : [];
$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8');
$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : '';
$selectedCat = (string) ($vals['category_id'] ?? '');
$selectedVat = (string) ($vals['vat_rate'] ?? '100');
$available = (bool) ($vals['is_available'] ?? true);
$selectedBase = (string) ($vals['base_product_id'] ?? '');
$selectedMaxi = (string) ($vals['maxi_variant_product_id'] ?? '');
?>
<div class="page-header">
<div>
@ -96,6 +101,48 @@ $available = (bool) ($vals['is_available'] ?? true);
<label class="form-label"><input type="checkbox" name="is_available" value="1"<?= $available ? ' checked' : '' ?>> Disponible</label>
</div>
<fieldset class="form-group">
<legend>Variantes (optionnel)</legend>
<p><small>A remplir seulement pour une boisson en plusieurs tailles ou un accompagnement servi en plus grand au format Maxi. Laissez vide pour un produit ordinaire.</small></p>
<div class="form-group">
<label class="form-label" for="size_cl">Taille en centilitres (boissons)</label>
<input class="form-input" type="number" id="size_cl" name="size_cl" min="0" max="65535" value="<?= $val('size_cl') ?>">
<small>Exemple : 30 ou 50 pour un soda. Laissez vide si le produit n'a pas de taille.</small>
<?php if ($err('size_cl') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('size_cl'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="base_product_id">Variante de taille de</label>
<select class="form-input" id="base_product_id" name="base_product_id">
<option value="">-- ce produit est un produit a part entiere --</option>
<?php foreach ($bases as $b): ?>
<?php $bid = (string) ($b['id'] ?? ''); ?>
<option value="<?= htmlspecialchars($bid, ENT_QUOTES, 'UTF-8') ?>"<?= $bid === $selectedBase ? ' selected' : '' ?>>
<?= htmlspecialchars((string) ($b['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
<small>Rattache ce produit a un produit principal comme une autre taille (exemple : "Coca 50cl" rattache a "Coca"). Une variante n'apparait pas seule sur la borne.</small>
<?php if ($err('base_product_id') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('base_product_id'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="maxi_variant_product_id">Version servie en format Maxi</label>
<select class="form-input" id="maxi_variant_product_id" name="maxi_variant_product_id">
<option value="">-- aucune (pas de version Maxi) --</option>
<?php foreach ($bases as $b): ?>
<?php $bid = (string) ($b['id'] ?? ''); ?>
<option value="<?= htmlspecialchars($bid, ENT_QUOTES, 'UTF-8') ?>"<?= $bid === $selectedMaxi ? ' selected' : '' ?>>
<?= htmlspecialchars((string) ($b['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
<small>Le produit servi a la place de celui-ci quand le menu est commande en Maxi (exemple : "Moyenne Frite" servie en "Grande Frite").</small>
<?php if ($err('maxi_variant_product_id') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('maxi_variant_product_id'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
</div>
</fieldset>
<?php if ($id !== 0): ?>
<fieldset class="form-group">
<legend>Changement de prix ou de TVA : confirmation par PIN</legend>

View file

@ -49,9 +49,22 @@ $euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', '
$available = (int) ($row['is_available'] ?? 0) === 1;
$autoRupture = in_array($id, $autoIds, true); // RG-T21 : stock-driven
$vat = (int) ($row['vat_rate'] ?? 100);
// R4/F9-4 : une ligne dont base_product_id est non nul est une
// VARIANTE de taille, pas un produit autonome. On la garde dans la
// liste (l'admin la voit et la gere) mais on la marque "Variante de
// X" pour qu'aucune confusion ne subsiste.
$baseProductId = isset($row['base_product_id']) && $row['base_product_id'] !== null
? (int) $row['base_product_id'] : 0;
$isVariant = $baseProductId > 0;
$baseName = (string) ($row['base_name'] ?? '');
?>
<tr>
<td class="fw-600"><?= $esc($row['name'] ?? '') ?></td>
<td class="fw-600">
<?= $esc($row['name'] ?? '') ?>
<?php if ($isVariant): ?>
<span class="pill pill-neutral" title="Cette ligne est une variante de taille, pas un produit affiche seul sur la borne">Variante de <?= $esc($baseName !== '' ? $baseName : '?') ?></span>
<?php endif; ?>
</td>
<td class="muted"><?= $esc($row['category_name'] ?? '') ?></td>
<td><?= $esc($euros((int) ($row['price_cents'] ?? 0))) ?></td>
<td class="muted"><?= $vat === 55 ? '5,5%' : '10%' ?></td>

View file

@ -32,6 +32,12 @@ $errorMessage = isset($error) && is_string($error) ? $error : null;
<form method="post" action="/admin/profile/pin" class="form-card">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group">
<label class="form-label" for="current_password">Mot de passe actuel</label>
<input class="form-input" type="password" id="current_password" name="current_password" autocomplete="current-password" required>
<small>Confirme votre identite avant de definir un PIN d action sensible.</small>
</div>
<div class="form-group">
<label class="form-label" for="pin">Nouveau PIN</label>
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off" required>

View file

@ -85,6 +85,36 @@ button {
cursor: pointer;
}
/* Utilitaire lecteur d'ecran : retire du flux visuel, lu par les technologies
d'assistance (regions live discretes type #pos-announce). */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
/* Message d'erreur de formulaire (global admin : erreurs de validation / serveur). */
.form-error {
color: var(--color-danger-text);
background: var(--color-danger-bg);
border-radius: var(--radius-md);
padding: 8px 12px;
margin: 6px 0;
font-size: 14px;
}
/* Etat vide d'une liste / d'un catalogue (global admin). */
.admin-empty {
color: var(--color-text-muted);
padding: 16px 0;
}
/* --- Layout Shell --- */
.admin-layout {
display: grid;
@ -1065,6 +1095,48 @@ tbody td.mono {
gap: 8px;
}
/* --- KDS : detail des articles --- */
.kds-items {
list-style: none;
margin: 8px 0 0;
padding: 0;
}
.kds-item {
font-size: 13px;
line-height: 1.35;
padding: 4px 0;
border-top: 1px solid var(--color-border);
color: var(--color-text);
}
.kds-item:first-child {
border-top: none;
}
/* --- KDS : bande SLA (now - paid_at), calculee cote serveur --- */
/* Bande couleur a gauche de la carte : vert < 5 min, ambre 5-10 min, rouge > 10 min. */
.kds-order--fresh {
border-left: 4px solid var(--color-success);
}
.kds-order--warn {
border-left: 4px solid var(--color-warning);
background: var(--color-warning-bg);
}
.kds-order--late {
border-left: 4px solid var(--color-danger);
background: var(--color-danger-bg);
}
.kitchen-clock {
font-size: 15px;
font-weight: 600;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
/* --- Login page --- */
.login-page {
min-height: 100vh;
@ -1431,3 +1503,636 @@ tbody td.mono {
padding: 0;
margin-bottom: 4px;
}
/* =============================================================================
POS tactile a tuiles comptoir/drive (counter-order.js + admin/counter/new.php)
Ecran de caisse facon borne : onglets categories, grille de tuiles, panneau
commande persistant (lignes + stepper + total), modale de composition.
Cibles tactiles superieures ou egales a 44px (usage tablette).
============================================================================= */
/* Mode de service fige (drive) : affichage non editable a la place du select. */
.form-static {
font-weight: 700;
color: var(--color-text);
padding: 8px 0;
margin: 0;
}
/* Disposition POS : catalogue a gauche (flex 1), panneau commande a droite (fixe). */
.pos { margin: 0; }
.pos__main {
display: flex;
gap: 20px;
align-items: flex-start;
}
.pos__catalogue {
flex: 1 1 auto;
min-width: 0;
}
/* Onglets categories : bandeau scrollable horizontal (un onglet par categorie). */
.pos__tabs {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 8px;
margin-bottom: 16px;
border-bottom: 1px solid var(--color-border);
-webkit-overflow-scrolling: touch;
}
.pos__tab {
flex: 0 0 auto;
min-height: 44px;
padding: 10px 18px;
border: 2px solid var(--color-border-dark);
border-radius: var(--radius-pill, 9999px);
background: var(--color-white);
color: var(--color-text-sec);
font-size: 15px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease;
}
.pos__tab:hover,
.pos__tab:focus-visible {
border-color: var(--color-yellow);
color: var(--color-text);
outline: none;
}
.pos__tab.is-active {
border-color: var(--color-yellow);
background: var(--color-yellow);
color: var(--color-text);
}
/* Grille de tuiles produits/menus : auto-fit, grandes cibles tactiles. */
.pos__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
}
.pos__nojs {
grid-column: 1 / -1;
color: var(--color-text-muted);
padding: 16px 0;
}
/* Tuile : image/pastille + nom + prix. Bouton plein (tap = ajout ou modale). */
.pos-tile {
display: flex;
flex-direction: column;
min-height: 44px;
padding: 0;
background: var(--color-white);
border: 2px solid var(--color-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
overflow: hidden;
cursor: pointer;
text-align: left;
position: relative;
transition: border-color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease;
}
.pos-tile:hover,
.pos-tile:focus-visible {
border-color: var(--color-yellow);
box-shadow: var(--shadow-card-hover);
transform: translateY(-2px);
outline: none;
}
.pos-tile:active { transform: translateY(0); box-shadow: var(--shadow-card); }
.pos-tile__media {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: var(--color-surface);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.pos-tile__image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
padding: 10px;
background: var(--color-surface);
}
/* Pastille de repli (initiale) quand aucune image n'est disponible cote back-office. */
.pos-tile__pastille {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--color-yellow-soft, #FFF3D1);
color: var(--color-yellow-ink, #C8920A);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 800;
}
.pos-tile__body {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px 12px;
flex: 1;
}
.pos-tile__name {
font-size: 14px;
font-weight: 700;
color: var(--color-text);
line-height: 1.25;
}
.pos-tile__price {
font-size: 13px;
font-weight: 700;
color: var(--color-text-sec);
font-variant-numeric: tabular-nums;
}
/* Badge "Menu" / "A composer" sur une tuile qui ouvre la modale au tap. */
.pos-tile__badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
padding: 2px 8px;
border-radius: var(--radius-sm);
background: var(--color-text);
color: var(--color-white);
font-size: 11px;
font-weight: 700;
}
/* Rupture de stock (RG-T21, parite borne product-card--unavailable) : tuile visible
mais non commandable. Grisee, curseur interdit, sans effet de survol (l'etat ne
ment pas) ; le tap est neutralise cote JS (return precoce). */
.pos-tile--unavailable {
opacity: 0.55;
filter: grayscale(0.6);
cursor: not-allowed;
}
.pos-tile--unavailable:hover,
.pos-tile--unavailable:focus-visible {
border-color: var(--color-border);
box-shadow: var(--shadow-card);
transform: none;
}
/* Badge "Indisponible" : pose en haut a gauche pour ne pas chevaucher le badge
"Menu"/"A composer" (top-right) sur une tuile menu en rupture. */
.pos-tile__badge--unavailable {
right: auto;
left: 8px;
background: var(--color-brand-dark, var(--color-text));
}
/* Panneau commande persistant a droite (facon caisse). */
.pos__panel {
flex: 0 0 340px;
position: sticky;
top: 16px;
display: flex;
flex-direction: column;
max-height: calc(100vh - 32px);
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.pos__panel-head {
padding: 16px;
border-bottom: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 10px;
}
.pos__panel-title {
font-size: 17px;
font-weight: 800;
color: var(--color-text);
}
.pos__service {
display: flex;
align-items: center;
gap: 8px;
}
.pos__service-label {
font-size: 14px;
font-weight: 700;
color: var(--color-text-sec);
min-width: 48px;
}
.pos__service .form-input { flex: 1; min-height: 44px; }
/* Lignes du panier (corps scrollable du panneau). */
.order-cart {
list-style: none;
padding: 0 16px;
margin: 0;
overflow-y: auto;
flex: 1 1 auto;
}
.order-cart__empty { color: var(--color-text-muted); padding: 16px 0; }
.order-cart__line {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 0;
border-bottom: 1px solid var(--color-border);
}
.order-cart__main {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.order-cart__label { font-weight: 600; flex: 1; }
.order-cart__price { font-weight: 800; font-variant-numeric: tabular-nums; white-space: nowrap; }
.order-cart__controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.order-cart__qty {
display: inline-flex;
align-items: center;
gap: 6px;
}
.order-cart__qty-btn {
width: 44px;
height: 44px;
border-radius: var(--radius-md);
border: 2px solid var(--color-border-dark);
background: var(--color-white);
font-size: 20px;
font-weight: 800;
color: var(--color-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.12s ease, background 0.12s ease;
}
.order-cart__qty-btn:hover,
.order-cart__qty-btn:focus-visible {
border-color: var(--color-yellow);
background: var(--color-yellow-bg);
outline: none;
}
.order-cart__qty-value {
min-width: 32px;
text-align: center;
font-size: 16px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
/* Pied du panneau : total indicatif + bouton d'encaissement (pleine largeur). */
.pos__panel-foot {
padding: 16px;
border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
.order-total {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 18px;
font-weight: 800;
margin: 0 0 12px;
}
.order-total span { font-variant-numeric: tabular-nums; }
.pos__pay {
width: 100%;
min-height: 52px;
font-size: 17px;
}
/* Tablette etroite / portrait : le panneau passe sous le catalogue. */
@media (max-width: 860px) {
.pos__main { flex-direction: column; }
.pos__panel {
flex-basis: auto;
width: 100%;
position: static;
max-height: none;
}
.order-cart { max-height: 320px; }
}
/* Modale de composition : overlay + panneau centre. */
.menu-composer__overlay {
position: fixed;
inset: 0;
background: rgba(26, 26, 26, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 24px;
}
.menu-composer {
background: var(--color-white);
border-radius: var(--radius-card);
border-top: 3px solid var(--color-yellow);
box-shadow: var(--shadow-card-hover);
width: 100%;
max-width: 460px;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
}
.menu-composer__title { font-size: 18px; font-weight: 800; margin-bottom: 14px; }
.menu-composer__legend { font-weight: 600; margin: 12px 0 4px; }
.menu-composer__slot, .menu-composer__format, .menu-composer__modifiers { margin-bottom: 10px; }
.menu-composer__radio, .menu-composer__modifier {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 3px 14px 3px 0;
}
.menu-composer__error {
color: var(--color-danger-text);
background: var(--color-danger-bg);
border-radius: var(--radius-md);
padding: 8px 10px;
margin: 10px 0;
font-size: 14px;
}
/* Le <p role=alert> reste dans le DOM en permanence (annonce lecteur d'ecran) ;
tant qu'il est vide, on l'efface visuellement pour ne pas afficher un bloc vide. */
.menu-composer__error:empty {
display: none;
}
.menu-composer__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
/* --- Stock dashboard (page d'accueil ingredients) --- */
.stock-explainer {
background: var(--color-yellow-bg);
border: 1px solid var(--color-yellow-soft);
border-radius: var(--radius-lg);
padding: 12px 16px;
font-size: 13px;
line-height: 1.5;
color: var(--color-text-sec);
margin-bottom: 20px;
}
.stock-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 28px;
}
.stock-summary__item {
display: flex;
align-items: baseline;
gap: 10px;
background: var(--color-white);
border: 1px solid var(--color-border);
border-left-width: 4px;
border-radius: var(--radius-card);
padding: 18px 22px;
box-shadow: var(--shadow-card);
}
.stock-summary__item--danger { border-left-color: var(--color-danger); }
.stock-summary__item--warning { border-left-color: var(--color-warning); }
.stock-summary__item--success { border-left-color: var(--color-success); }
.stock-summary__count {
font-size: 28px;
font-weight: 700;
line-height: 1;
color: var(--color-text);
}
.stock-summary__item--danger .stock-summary__count { color: var(--color-danger-text); }
.stock-summary__item--warning .stock-summary__count { color: var(--color-warning-text); }
.stock-summary__item--success .stock-summary__count { color: var(--color-success-text); }
.stock-summary__label {
font-size: 13px;
color: var(--color-text-muted);
}
.stock-section {
margin-bottom: 32px;
}
.stock-section__title {
font-size: 16px;
font-weight: 700;
color: var(--color-text);
margin-bottom: 14px;
}
.stock-empty {
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 20px;
font-size: 14px;
color: var(--color-text-muted);
}
.stock-empty--ok {
background: var(--color-success-bg);
border-color: var(--color-success-bg);
color: var(--color-success-text);
}
/* Barre de niveau : conteneur gris + portion remplie coloree selon la bande. */
.stock-bar {
height: 8px;
background: var(--color-neutral-bg);
border-radius: var(--radius-sm);
overflow: hidden;
}
.stock-bar__fill {
display: block;
height: 100%;
border-radius: var(--radius-sm);
}
.stock-bar--critical { background: var(--color-danger); }
.stock-bar--low { background: var(--color-warning); }
.stock-bar--normal { background: var(--color-success); }
.stock-bar__meta {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 12px;
}
.stock-bar__pct { font-weight: 600; color: var(--color-text); }
.stock-bar__qty { color: var(--color-text-muted); }
/* Section "A reapprovisionner" : cartes mises en avant. */
.stock-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
gap: 16px;
}
.stock-card {
background: var(--color-white);
border: 1px solid var(--color-border);
border-top-width: 3px;
border-radius: var(--radius-card);
padding: 16px 18px;
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
gap: 12px;
}
.stock-card--critical { border-top-color: var(--color-danger); }
.stock-card--low { border-top-color: var(--color-warning); }
.stock-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.stock-card__name {
font-size: 15px;
font-weight: 600;
color: var(--color-text);
}
.stock-card__unit {
font-size: 12px;
color: var(--color-text-muted);
margin-left: 6px;
}
.stock-card__action {
height: 44px;
justify-content: center;
width: 100%;
}
/* Section "Tous les ingredients" : liste calme, actions secondaires discretes. */
.stock-list {
list-style: none;
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.stock-list__row {
display: grid;
grid-template-columns: 1fr 200px auto;
align-items: center;
gap: 20px;
padding: 14px 18px;
border-bottom: 1px solid var(--color-border);
}
.stock-list__row:last-child {
border-bottom: none;
}
.stock-list__main {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.stock-list__name {
font-weight: 600;
color: var(--color-text);
}
.stock-list__unit {
font-size: 12px;
color: var(--color-text-muted);
}
.stock-list__actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.stock-list__crud {
display: inline-flex;
align-items: center;
gap: 6px;
padding-left: 6px;
margin-left: 2px;
border-left: 1px solid var(--color-border);
}
.stock-list__inline-form {
display: inline;
}
@media (max-width: 900px) {
.stock-summary {
grid-template-columns: 1fr;
}
.stock-list__row {
grid-template-columns: 1fr;
gap: 10px;
}
.stock-list__actions {
justify-content: flex-start;
}
}
/* Bandeau de confirmation (flash succes, pose par setFlash apres redirection) et sa
variante erreur (re-rendu 422 du reglage rapide de seuils, F13). */
.flash {
color: var(--color-success-text);
background: var(--color-success-bg);
border-radius: var(--radius-md);
padding: 10px 14px;
margin-bottom: 16px;
font-size: 14px;
}
.flash-error {
color: var(--color-danger-text);
background: var(--color-danger-bg);
}
/* Aide sous un champ de formulaire (texte explicatif, pas une erreur). */
.form-hint {
color: var(--color-text-muted);
font-size: 12px;
margin-top: 4px;
}
/* Zone d'actions d'une carte a reapprovisionner : reappro + reglage des seuils empiles. */
.stock-card__actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.stock-card__actions .btn-sm {
width: 100%;
justify-content: center;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,130 @@
/**
* stock-thresholds.js Reglage rapide des seuils de stock depuis le tableau de bord (F13).
*
* Chaque carte/ligne ingredient porte un bouton "Regler les seuils" (data-threshold-open)
* decore de ses valeurs courantes (data-id, data-name, data-capacity, data-low,
* data-critical). Au clic, on pre-remplit l'unique modale rendue serveur (un VRAI form POST
* avec CSRF, pas de fetch), on pointe son action sur /admin/ingredients/{id}/thresholds, et
* on l'ouvre. La validation finale reste cote serveur (validateThresholds) ; on ajoute ici
* un garde-fou client leger (capacite >= 1, % 0-100, critique < alerte strict) pour eviter
* un aller-retour evident. Sans JS, la modale reste cachee et le reste de la page marche.
*
* CSP 'self' : script externe, aucun handler inline. Style CommonJS testable + browser-safe.
*/
(function () {
'use strict';
function init(doc) {
var overlay = doc.querySelector('[data-threshold-modal]');
if (!overlay) {
return; // role sans stock.manage : la modale n'est pas rendue.
}
var form = overlay.querySelector('[data-threshold-form]');
var inCapacity = doc.getElementById('th-capacity');
var inLow = doc.getElementById('th-low');
var inCritical = doc.getElementById('th-critical');
var nameLabel = overlay.querySelector('[data-threshold-name]');
var errorBox = overlay.querySelector('[data-threshold-error]');
if (!form || !inCapacity || !inLow || !inCritical) {
return;
}
var openers = doc.querySelectorAll('[data-threshold-open]');
for (var i = 0; i < openers.length; i++) {
openers[i].addEventListener('click', function (e) {
openModal(e.currentTarget);
});
}
overlay.querySelector('[data-threshold-cancel]').addEventListener('click', closeModal);
overlay.addEventListener('mousedown', function (e) {
if (e.target === overlay) {
closeModal();
}
});
doc.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlay.classList.contains('open')) {
closeModal();
}
});
// Garde-fou client : on ne bloque que les cas evidents (le serveur reste l'autorite).
form.addEventListener('submit', function (e) {
var error = validate(inCapacity.value, inLow.value, inCritical.value);
if (error !== null) {
e.preventDefault();
if (errorBox) {
errorBox.textContent = error;
errorBox.hidden = false;
}
}
});
function openModal(button) {
var id = button.getAttribute('data-id') || '';
form.setAttribute('action', '/admin/ingredients/' + id + '/thresholds');
if (nameLabel) {
var name = button.getAttribute('data-name') || '';
nameLabel.textContent = name === '' ? '' : 'Ingredient : ' + name;
}
inCapacity.value = button.getAttribute('data-capacity') || '';
inLow.value = button.getAttribute('data-low') || '';
inCritical.value = button.getAttribute('data-critical') || '';
if (errorBox) {
errorBox.hidden = true;
}
overlay.classList.add('open');
inCapacity.focus();
}
function closeModal() {
overlay.classList.remove('open');
}
}
/**
* Validation cliente legere, miroir de validateThresholds() cote serveur :
* capacite entiere >= 1 ; seuils entiers 0-100 ; critique STRICTEMENT < alerte.
* Renvoie un message d'erreur (string) ou null si tout est coherent.
*/
function validate(capacityRaw, lowRaw, criticalRaw) {
var capacity = toInt(capacityRaw);
var low = toInt(lowRaw);
var critical = toInt(criticalRaw);
if (capacity === null || capacity < 1) {
return 'La capacite (reference 100%) doit etre un entier superieur ou egal a 1.';
}
if (low === null || low < 0 || low > 100) {
return 'Le seuil d alerte doit etre un entier entre 0 et 100.';
}
if (critical === null || critical < 0 || critical > 100) {
return 'Le seuil critique doit etre un entier entre 0 et 100.';
}
if (critical >= low) {
return 'Le seuil critique doit etre strictement inferieur au seuil d alerte.';
}
return null;
}
/** Entier strict (suite de chiffres) ou null : refuse "", " 5", "5.0", "abc". */
function toInt(raw) {
var value = String(raw === undefined || raw === null ? '' : raw).trim();
if (!/^[0-9]+$/.test(value)) {
return null;
}
return parseInt(value, 10);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { init: init, validate: validate };
}
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('DOMContentLoaded', function () {
init(document);
});
}
})();

View file

@ -221,6 +221,11 @@ try {
$router->add('POST', '/admin/ingredients/{id}/delete', [IngredientController::class, 'destroy']);
$router->add('GET', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restockForm']);
$router->add('POST', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restock']);
// Reglage rapide des seuils (F13) : capacite/alerte/critique edites depuis la page
// Stock via une modale, sans passer par le formulaire complet. stock.manage (calibrage
// du stock), CSRF, SANS PIN (config, pas un comptage d'inventaire). {id} = un seul
// segment ; /thresholds ne chevauche ni /restock ni /inventory.
$router->add('POST', '/admin/ingredients/{id}/thresholds', [IngredientController::class, 'updateThresholds']);
$router->add('GET', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventoryForm']);
$router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']);
$router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']);

View file

@ -292,7 +292,6 @@ button {
justify-self: center;
}
.site-header__cart,
.site-header__mode {
justify-self: end;
}
@ -454,49 +453,6 @@ button {
7. SHARED COMPONENTS header extensions + badges + buttons
============================================================ */
/* Cart link in header (products / product / cart pages) */
.site-header__cart {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
font-size: 1.6rem;
color: var(--color-text-primary);
text-decoration: none;
}
.site-header__cart:hover,
.site-header__cart:focus-visible {
background: rgba(255, 199, 44, 0.18);
outline: none;
}
.cart-icon {
line-height: 1;
}
.cart-badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 20px;
height: 20px;
padding: 0 4px;
border-radius: var(--radius-pill);
background: var(--color-brand-red);
color: #fff;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
/* Mode badge — shown in header for context */
.mode-badge {
display: inline-block;
@ -676,6 +632,35 @@ button {
box-shadow: var(--shadow-card);
}
/* Rupture de stock (RG-T21) : tuile visible mais non commandable. Grisee, non
cliquable, sans effet de survol (l'image est attenuee, pas l'etat ne ment pas). */
.product-card--unavailable {
opacity: 0.55;
filter: grayscale(0.6);
cursor: not-allowed;
}
.product-card--unavailable:hover,
.product-card--unavailable:focus-visible {
border-color: var(--color-border-default);
box-shadow: var(--shadow-card);
transform: none;
}
/* Badge "Indisponible" superpose a l'image d'une tuile en rupture. */
.product-card__badge {
position: absolute;
top: var(--space-2);
left: var(--space-2);
z-index: 2;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
background: var(--color-brand-dark);
color: #fff;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
}
.product-card__image-wrap {
width: 100%;
aspect-ratio: 1 / 1;
@ -715,215 +700,9 @@ button {
}
/* ============================================================
9. COMPONENT PRODUCT DETAIL (product.html)
10. COMPONENT QUANTITY CONTROLS (modale options produit)
============================================================ */
.product-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-bg-page);
}
.product-main {
flex: 1;
padding: var(--space-6);
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.product-error {
color: var(--color-brand-red);
font-size: var(--font-size-md);
padding: var(--space-5);
}
.product-detail {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.product-detail__skeleton {
/* Placeholder shown while JS loads data */
width: 100%;
height: 300px;
background: var(--color-bg-card);
border-radius: var(--radius-md);
animation: skeleton-pulse 1.4s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.product-detail__image-wrap {
width: 100%;
max-width: 480px;
margin: 0 auto;
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
overflow: hidden;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
}
.product-detail__image {
width: 100%;
height: 100%;
object-fit: contain;
padding: var(--space-5);
}
.product-detail__info {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.product-detail__name {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
line-height: 1.2;
}
.product-detail__price {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-brand-dark);
}
.product-detail__composition {
background: var(--color-bg-card);
border-left: 4px solid var(--color-brand-yellow);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
padding: var(--space-4) var(--space-5);
}
.product-detail__composition-title {
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-2);
color: var(--color-text-primary);
}
.product-detail__composition-text {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: 1.5;
}
.product-detail__add {
width: 100%;
margin-top: var(--space-2);
}
/* ============================================================
10. COMPONENT CART (cart.html)
============================================================ */
.cart-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-bg-page);
}
.cart-main {
flex: 1;
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-5);
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.cart-main__heading {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.cart-empty {
text-align: center;
padding: var(--space-10) var(--space-6);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-5);
}
.cart-empty__message {
font-size: var(--font-size-lg);
color: var(--color-text-secondary);
}
.cart-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
list-style: none;
padding: 0;
margin: 0;
}
/* One cart line */
.cart-line {
display: flex;
align-items: center;
gap: var(--space-4);
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: var(--space-3) var(--space-4);
}
.cart-line__image {
width: 72px;
height: 72px;
object-fit: contain;
flex-shrink: 0;
border-radius: var(--radius-sm);
background: var(--color-bg-page);
}
.cart-line__info {
flex: 1;
min-width: 0;
}
.cart-line__name {
display: block;
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cart-line__unit-price {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
/* Quantity controls */
.cart-line__qty {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.qty-btn {
width: 44px;
height: 44px;
@ -955,73 +734,6 @@ button {
font-weight: var(--font-weight-bold);
}
.cart-line__total {
font-size: var(--font-size-md);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
flex-shrink: 0;
min-width: 80px;
text-align: right;
}
.cart-line__remove {
background: none;
border: none;
cursor: pointer;
padding: var(--space-2);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background var(--transition-fast);
}
.cart-line__remove:hover,
.cart-line__remove:focus-visible {
background: rgba(218, 2, 14, 0.10);
outline: none;
}
/* Order summary block */
.cart-summary {
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.cart-summary__line {
display: flex;
justify-content: space-between;
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
.cart-summary__line--total {
padding-top: var(--space-3);
border-top: 2px solid var(--color-border-default);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
/* Cart action row */
.cart-actions {
display: flex;
gap: var(--space-4);
justify-content: space-between;
flex-wrap: wrap;
padding-bottom: var(--space-8);
}
.cart-actions .btn {
flex: 1 1 200px;
}
/* ============================================================
11. COMPONENT PAYMENT (payment.html)
============================================================ */
@ -1239,15 +951,6 @@ button {
grid-template-columns: repeat(2, 1fr);
}
.cart-line {
flex-wrap: wrap;
gap: var(--space-3);
}
.cart-line__info {
flex: 1 1 calc(100% - 80px);
}
.payment-methods {
flex-direction: column;
align-items: stretch;
@ -1263,14 +966,10 @@ button {
.products-main {
padding: var(--space-4);
}
.cart-main {
padding: var(--space-4);
}
}
/* ============================================================
14. COMPONENT MENU COMPOSER MODAL (product.html, type=menu)
14. COMPONENT MENU COMPOSER MODAL (modale ouverte depuis la grille produits)
============================================================ */
/*
@ -1290,6 +989,42 @@ button {
animation: composer-fade-in var(--transition-base) both;
}
/* Modale de confirmation d'un geste destructeur (Abandon). */
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 210;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
animation: composer-fade-in var(--transition-base) both;
}
.confirm-modal {
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-overlay);
padding: var(--space-6);
max-width: 28rem;
width: 100%;
text-align: center;
}
.confirm-modal__message {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
margin: 0 0 var(--space-5);
}
.confirm-modal__actions {
display: flex;
gap: var(--space-3);
justify-content: center;
flex-wrap: wrap;
}
@keyframes composer-fade-in {
from { opacity: 0; }
to { opacity: 1; }
@ -1486,10 +1221,6 @@ button {
z-index: 2;
}
.product-detail__info .allergen-info-btn {
margin-top: var(--space-3);
}
.allergen-modal-overlay {
position: fixed;
inset: 0;
@ -1759,31 +1490,6 @@ button {
margin-right: auto;
}
/* ---------- Cart line composition display -------------------- */
.cart-line__composition {
list-style: none;
padding: var(--space-1) 0 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.cart-line__comp-item {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
line-height: 1.4;
}
.cart-line__comp-suppl {
font-size: var(--font-size-xs);
color: var(--color-brand-yellow-dk);
font-weight: var(--font-weight-bold);
line-height: 1.4;
padding-top: var(--space-1);
}
/* ---------- Responsive — narrow screens --------------------- */
@media (max-width: 600px) {
@ -1891,8 +1597,7 @@ button {
}
.order-panel__line {
position: relative;
padding: var(--space-3) var(--space-6) var(--space-3) 0;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border-default);
}
@ -1923,14 +1628,49 @@ button {
content: "+ ";
}
/* Ligne de controles : stepper de quantite a gauche, retrait a droite. */
.order-panel__line-controls {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: var(--space-2);
}
.order-panel__qty {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Boutons +/- : cible tactile confortable (borne). */
.order-panel__qty-btn {
min-width: 44px;
min-height: 44px;
border: 2px solid var(--color-border-default);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-primary);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
line-height: 1;
cursor: pointer;
}
.order-panel__qty-value {
min-width: 2ch;
text-align: center;
font-weight: var(--font-weight-bold);
}
.order-panel__remove {
position: absolute;
top: var(--space-3);
right: 0;
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border: none;
background: none;
cursor: pointer;
padding: var(--space-1);
line-height: 0;
}

View file

@ -7,9 +7,9 @@
*
* CSP 'self' : aucun script inline, aucun handler inline. Le DOM est construit par
* l'API (createElement/textContent) ; textContent neutralise toute injection.
* Les donnees viennent de data.js (loadAllergens) : liste fixe en P5, /api/allergens
* au swap P4. openAllergenModal prend la liste en parametre pour rester independant
* de la couche de chargement (et testable sans fetch).
* Les donnees viennent de data.js (loadAllergens), qui lit /api/allergens.
* openAllergenModal prend la liste en parametre pour rester independant de la
* couche de chargement (et testable sans fetch).
*/
const OVERLAY_CLASS = 'allergen-modal-overlay';

View file

@ -8,7 +8,8 @@
* Traduction panier borne -> contrat API :
* - produit simple -> { type:'product', product_id, quantity }
* - menu -> { type:'menu', menu_id, quantity, format, selections }
* format = 'maxi' si supplement_cents>0, sinon 'normal'.
* format = cartItem.format (choix Normal/Maxi porte par l'item panier) ; repli
* historique sur supplement_cents>0 pour un panier serialise avant cette version.
* selections = [{menu_slot_id, product_id}] reconstruites depuis la composition
* (accompagnement/boisson/sauce) mappee aux slots reels du menu (re-fetch).
* - service_mode : 'sur-place' -> 'dine_in', 'a-emporter' -> 'takeaway'.
@ -64,7 +65,10 @@ export function buildOrderItem(cartItem, menuSlotsById) {
type: 'menu',
menu_id: cartItem.id,
quantity: cartItem.quantite,
format: (cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal',
// Format choisi par l'utilisateur, transporte explicitement. Repli sur
// l'ancienne inference (supplement_cents>0) pour un panier serialise en
// sessionStorage avant l'ajout du champ format.
format: cartItem.format ?? ((cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal'),
selections: buildSelections(cartItem.composition, menuSlotsById[cartItem.id] || []),
};
}

View file

@ -0,0 +1,73 @@
/*
* confirm-modal.js Modale de confirmation reutilisable pour un geste destructeur
* (ex. Abandon de toute la commande). CSP-safe (createElement + addEventListener,
* aucun handler inline). Accessible : role="dialog" aria-modal, fond mis aria-hidden,
* Echap et clic sur le fond = annuler, focus piege dans la modale et rendu au
* declencheur a la fermeture. Defaut sur Annuler : un appui accidentel sur Entree
* n'execute pas l'action destructrice. Public non-technique : message + 2 boutons clairs.
*/
import { escHtml } from './state.js';
/**
* Affiche une demande de confirmation. onConfirm n'est appele que si l'utilisateur
* confirme explicitement ; Annuler / Echap / clic-fond ferment sans rien faire.
* @param {{message:string, confirmLabel?:string, cancelLabel?:string, onConfirm:Function}} opts
* @returns {{close:Function}}
*/
export function confirmAction({ message, confirmLabel = 'Confirmer', cancelLabel = 'Annuler', onConfirm }) {
const previouslyFocused = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.innerHTML = `
<div class="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-msg">
<p class="confirm-modal__message" id="confirm-modal-msg">${escHtml(message)}</p>
<div class="confirm-modal__actions">
<button type="button" class="confirm-modal__cancel btn btn--secondary">${escHtml(cancelLabel)}</button>
<button type="button" class="confirm-modal__confirm btn btn--primary">${escHtml(confirmLabel)}</button>
</div>
</div>
`;
const prevOverflow = document.body.style.overflow;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
const bgSiblings = Array.from(document.body.children).filter(el => el !== overlay);
bgSiblings.forEach(el => el.setAttribute('aria-hidden', 'true'));
const close = () => {
document.removeEventListener('keydown', onKey);
bgSiblings.forEach(el => el.removeAttribute('aria-hidden'));
overlay.remove();
document.body.style.overflow = prevOverflow;
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
}
};
// Echap = annuler ; Tab/Shift+Tab pieges sur les boutons de la modale.
const onKey = (e) => {
if (e.key === 'Escape') { close(); return; }
if (e.key !== 'Tab') return;
const focusable = Array.from(overlay.querySelectorAll('button:not([disabled])'));
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelector('.confirm-modal__cancel').addEventListener('click', close);
overlay.querySelector('.confirm-modal__confirm').addEventListener('click', () => {
close();
onConfirm();
});
// Focus initial sur Annuler (defaut sur), pour ne pas confirmer par inadvertance.
requestAnimationFrame(() => overlay.querySelector('.confirm-modal__cancel').focus());
return { close };
}

View file

@ -10,18 +10,17 @@
* indexe par slug de categorie ; menus glisses sous la cle 'menus'). Les signatures
* publiques et les formes de retour sont inchangees -> les pages n'ont pas bouge.
*
* Les allergenes restent un repli statique (data/allergens.json) : leur bascule
* sur /api/allergens est un chunk ulterieur.
* Les allergenes sont desormais lus depuis /api/allergens (id/code/name/description),
* comme les autres collections catalogue : le repli statique a ete retire.
*/
const CATEGORIES_URL = '/api/categories';
const PRODUCTS_URL = '/api/products';
const MENUS_URL = '/api/menus';
/* Liste fixe des 14 allergenes INCO (info generale, modale borne). L'endpoint
* /api/allergens existe desormais (id/code/name), mais la borne garde ce JSON
* statique : il porte les DESCRIPTIONS riches, absentes du schema allergen. Bascule
* possible si les descriptions sont ajoutees cote API. */
const ALLERGENS_URL = 'data/allergens.json';
/* Les 14 allergenes INCO (info generale, modale borne). L'endpoint /api/allergens
* porte id/code/name/description (la description INCO est seede en base) -> la borne
* la consomme via l'API, comme les autres collections catalogue. */
const ALLERGENS_URL = '/api/allergens';
/* Memoisation par PROMESSE (pas par resultat) : N appelants concurrents au meme
* chargement partagent UNE seule requete reseau (evite les fetch /api/* redondants
@ -89,12 +88,16 @@ export function loadProducts() {
// en a une, sinon null. Le composeur de menu l'affiche en format Maxi.
maxiNom: p.maxi_variant_name ?? null,
sizes: Array.isArray(p.sizes) ? p.sizes : [],
// commandable : false si rupture de stock calculee (RG-T21, is_orderable
// serveur) -> la borne grise la tuile et bloque le clic. Defaut true si
// l'API ne porte pas le flag (compat).
commandable: p.is_orderable !== false,
});
}
for (const m of menus) {
const slug = slugByCategoryId[m.category_id];
if (slug === undefined) continue;
bySlug[slug].push({ id: m.id, nom: m.name, prix: m.price_normal_cents, image: m.image_path, type: 'menu' });
bySlug[slug].push({ id: m.id, nom: m.name, prix: m.price_normal_cents, image: m.image_path, type: 'menu', commandable: m.is_orderable !== false });
}
return bySlug;
}).catch(e => { _productsPromise = null; throw e; });
@ -144,17 +147,16 @@ export async function loadMenu(id) {
}
/**
* Fetches and caches the 14 INCO allergens (general info modal). Repli statique :
* la reponse est un tableau nu (pas d'enveloppe), conserve tel quel.
* @returns {Promise<Array>}
* Fetches and caches the 14 INCO allergens (general info modal). Consomme
* /api/allergens (enveloppe { data }, forme canonique id/code/name/description) et
* ramene chaque entree a la forme borne { id, name, description } attendue par la
* modale (allergens.js) ; le champ `code` n'est pas utilise cote borne.
* @returns {Promise<Array<{id:number, name:string, description:?string}>>}
*/
export function loadAllergens() {
if (!_allergensPromise) {
_allergensPromise = fetch(ALLERGENS_URL)
.then(res => {
if (!res.ok) throw new Error(`Failed to load allergens: HTTP ${res.status}`);
return res.json();
})
_allergensPromise = fetchCollection(ALLERGENS_URL)
.then(rows => rows.map(a => ({ id: a.id, name: a.name, description: a.description ?? null })))
.catch(e => { _allergensPromise = null; throw e; });
}
return _allergensPromise;

View file

@ -1,18 +1,18 @@
/*
* order-panel.js Panneau de commande persistant (maquette : recap a droite de
* l'ecran de commande). Rendu sur les ecrans de commande (products, product) pour
* que le panier reste visible en permanence, comme sur la maquette borne.
* l'ecran de commande). Rendu sur l'ecran de commande (products) pour que le panier
* reste visible en permanence, comme sur la maquette borne.
*
* C'est un miroir COMPACT de page-cart.js : meme modele d'item, meme rendu de la
* composition de menu. La page panier (cart.html) reste la vue detaillee (TVA, +/-) ;
* le panneau, lui, montre lignes + total + Abandon/Payer et permet de retirer une
* ligne. La logique de mise en forme est extraite en fonctions PURES (buildPanelModel,
* compositionLabels) pour etre testable sans DOM.
* C'est l'UNIQUE vue panier : il montre lignes + total + Abandon/Payer, permet
* d'ajuster la quantite de chaque ligne (+/-) et de la retirer. La logique de mise
* en forme est extraite en fonctions PURES (buildPanelModel, compositionLabels)
* pour etre testable sans DOM.
*/
import {
getCart,
removeFromCart,
updateQuantity,
computeMenuLineCents,
clearCart,
formatPrice,
@ -20,6 +20,7 @@ import {
getMode,
} from './state.js';
import { refreshCartBadge } from './nav.js';
import { confirmAction } from './confirm-modal.js';
/**
* Calcule le total d'une ligne en centimes (menu : avec supplement de taille ;
@ -35,8 +36,8 @@ export function lineCents(item) {
/**
* Construit les libelles des options d'un menu (puces sous le nom de ligne).
* Miroir de renderCompositionBlock() de page-cart.js, sans le supplement (le panneau
* affiche le total de ligne, pas le detail TVA). Tolerant aux composants absents.
* Sans le supplement (le panneau affiche le total de ligne, pas le detail TVA).
* Tolerant aux composants absents.
* @param {Object|undefined} c objet composition de l'item menu
* @returns {string[]}
*/
@ -93,7 +94,7 @@ function modeLabel() {
/**
* Construit le HTML d'une ligne du panneau. Toute valeur derivee du catalogue est
* echappee (RG-T15 anti-XSS), comme dans page-cart.js.
* echappee (RG-T15 anti-XSS).
* @param {Object} line element de buildPanelModel().lines
* @returns {string}
*/
@ -106,18 +107,37 @@ function lineHtml(line) {
return `
<li class="order-panel__line">
<div class="order-panel__line-main">
<span class="order-panel__line-name">${line.quantite}&times; ${escHtml(line.libelle)}</span>
<span class="order-panel__line-name">${escHtml(line.libelle)}</span>
<span class="order-panel__line-price">${formatPrice(line.lineCents)}</span>
</div>
${options}
<button
class="order-panel__remove"
data-index="${line.index}"
type="button"
aria-label="Retirer ${escHtml(line.libelle)} de la commande"
>
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="20" height="20">
</button>
<div class="order-panel__line-controls">
<div class="order-panel__qty" role="group" aria-label="Quantite de ${escHtml(line.libelle)}">
<button
class="order-panel__qty-btn"
data-action="dec"
data-index="${line.index}"
type="button"
aria-label="Diminuer la quantite de ${escHtml(line.libelle)}"
>&minus;</button>
<span class="order-panel__qty-value">${line.quantite}</span>
<button
class="order-panel__qty-btn"
data-action="inc"
data-index="${line.index}"
type="button"
aria-label="Augmenter la quantite de ${escHtml(line.libelle)}"
>+</button>
</div>
<button
class="order-panel__remove"
data-index="${line.index}"
type="button"
aria-label="Retirer ${escHtml(line.libelle)} de la commande"
>
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="20" height="20">
</button>
</div>
</li>
`;
}
@ -165,6 +185,19 @@ export function renderOrderPanel(container) {
</div>
`;
// Stepper +/- : ajuste la quantite de la ligne. Decrementer a 0 retire la ligne
// (updateQuantity supprime quand qty <= 0). Couvre produits ET menus (un menu a
// quantite > 1 = N menus identiques, facture par quantite cote serveur).
container.querySelectorAll('.order-panel__qty-btn').forEach(btn => {
btn.addEventListener('click', () => {
const index = parseInt(btn.dataset.index, 10);
const cart = getCart();
const current = cart[index] ? cart[index].quantite : 0;
updateQuantity(index, btn.dataset.action === 'inc' ? current + 1 : current - 1);
renderOrderPanel(container);
});
});
container.querySelectorAll('.order-panel__remove').forEach(btn => {
btn.addEventListener('click', () => {
removeFromCart(parseInt(btn.dataset.index, 10));
@ -174,14 +207,23 @@ export function renderOrderPanel(container) {
const abandon = container.querySelector('.order-panel__abandon');
if (abandon) {
// Geste destructeur (efface toute la commande) -> confirmation explicite
// avant d'agir, plutot qu'un effacement immediat au moindre tap.
abandon.addEventListener('click', () => {
clearCart();
window.location.href = 'index.html';
confirmAction({
message: 'Abandonner toute la commande ? Votre selection sera perdue.',
confirmLabel: 'Oui, abandonner',
cancelLabel: 'Continuer ma commande',
onConfirm: () => {
clearCart();
window.location.href = 'index.html';
},
});
});
}
// Payer desactive sur panier vide : un <a> ignore `disabled`, on bloque le clic
// via aria-disabled (meme parade que page-cart.js / le fix a11y E2E #45).
// via aria-disabled (parade a11y, cf. fix E2E #45).
const pay = container.querySelector('.order-panel__pay');
if (pay) {
pay.addEventListener('click', e => {

View file

@ -1,194 +0,0 @@
/*
* page-cart.js Shopping cart screen.
*
* Displays all cart lines with quantity controls and totals.
* Handles two item shapes:
* - Simple product: { id, type, libelle, prix_cents, quantite, image }
* - Composed menu: { ...above, composition: {...}, supplement_cents: number }
*
* Menu lines render a composition breakdown beneath the product name.
* Simple product lines render as before (no composition block).
*
* TVA: 10% (taux normal restauration, France 2024 simplification MVP).
* TODO: verify exact applicable TVA rate with an accountant in P3.
* The real rate depends on sur-place vs a-emporter, alcohol content, etc.
*
* The total displayed is TTC (tax inclusive) because French consumer law
* requires prices shown to end-consumers to include all taxes.
*/
import { getCart, removeFromCart, updateQuantity, getTotalCents, computeMenuLineCents, clearCart, formatPrice, escHtml } from './state.js';
import { refreshCartBadge } from './nav.js';
/* TVA rate used for display breakdown only — stored prices are already TTC */
const TVA_RATE = 0.10;
const cartList = document.getElementById('cart-list');
const emptyBlock = document.getElementById('cart-empty');
const summaryBlock= document.getElementById('cart-summary');
const totalTTC = document.getElementById('total-ttc');
const totalHT = document.getElementById('total-ht');
const totalTVA = document.getElementById('total-tva');
const payBtn = document.getElementById('pay-btn');
const abandonBtn = document.getElementById('abandon-btn');
function renderCart() {
const items = getCart();
refreshCartBadge();
if (!items.length) {
cartList.innerHTML = '';
emptyBlock.hidden = false;
summaryBlock.hidden = true;
// pay-btn est un <a> : `.disabled` n'existe pas dessus, il faut piloter
// aria-disabled (sinon le bouton reste annonce desactive panier rempli).
if (payBtn) payBtn.setAttribute('aria-disabled', 'true');
return;
}
emptyBlock.hidden = true;
summaryBlock.hidden = false;
if (payBtn) payBtn.setAttribute('aria-disabled', 'false');
cartList.innerHTML = '';
items.forEach((item, index) => {
const isMenu = item.type === 'menu';
const lineTotalCents = isMenu
? computeMenuLineCents(item)
: item.prix_cents * item.quantite;
const row = document.createElement('li');
row.className = 'cart-line';
row.setAttribute('aria-label', `${item.libelle}, quantite ${item.quantite}`);
row.innerHTML = `
<img
class="cart-line__image"
src="${escHtml(item.image)}"
alt="${escHtml(item.libelle)}"
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
>
<div class="cart-line__info">
<span class="cart-line__name">${escHtml(item.libelle)}</span>
<span class="cart-line__unit-price">${formatPrice(item.prix_cents)} / unite${isMenu && (item.supplement_cents ?? 0) > 0 ? ` + ${formatPrice(item.supplement_cents)} suppl.` : ''}</span>
${isMenu && item.composition ? renderCompositionBlock(item) : ''}
</div>
<div class="cart-line__qty" role="group" aria-label="Quantite de ${escHtml(item.libelle)}">
<button
class="qty-btn qty-btn--minus"
data-index="${index}"
aria-label="Diminuer la quantite de ${escHtml(item.libelle)}"
type="button"
>-</button>
<span class="qty-value" aria-live="polite">${item.quantite}</span>
<button
class="qty-btn qty-btn--plus"
data-index="${index}"
aria-label="Augmenter la quantite de ${escHtml(item.libelle)}"
type="button"
>+</button>
</div>
<span class="cart-line__total">${formatPrice(lineTotalCents)}</span>
<button
class="cart-line__remove"
data-index="${index}"
aria-label="Supprimer ${escHtml(item.libelle)} du panier"
type="button"
>
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="24" height="24">
</button>
`;
cartList.appendChild(row);
});
/* Attach event listeners after render */
cartList.querySelectorAll('.qty-btn--minus').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index, 10);
const cart = getCart();
updateQuantity(idx, cart[idx].quantite - 1);
renderCart();
});
});
cartList.querySelectorAll('.qty-btn--plus').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index, 10);
const cart = getCart();
updateQuantity(idx, cart[idx].quantite + 1);
renderCart();
});
});
cartList.querySelectorAll('.cart-line__remove').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index, 10);
removeFromCart(idx);
renderCart();
});
});
/* Update totals */
const ttcCents = getTotalCents();
/* Back-calculate HT from TTC (prices assumed to be TTC already) */
const htCents = Math.round(ttcCents / (1 + TVA_RATE));
const tvaCents = ttcCents - htCents;
if (totalTTC) totalTTC.textContent = formatPrice(ttcCents);
if (totalHT) totalHT.textContent = formatPrice(htCents);
if (totalTVA) totalTVA.textContent = formatPrice(tvaCents);
}
/**
* Builds the composition breakdown HTML for a menu cart line.
* Renders burger (with personalisation options), accompagnement, boisson, sauce,
* and the supplement summary if applicable. Le format Maxi se lit dans le libelle de
* l'accompagnement (variante "Grande ...") et la ligne de supplement, pas un suffixe.
*
* @param {Object} item cart item with type === 'menu' and composition object
* @returns {string} HTML string
*/
function renderCompositionBlock(item) {
const c = item.composition;
if (!c) return '';
// Tolerant aux champs absents : depuis L2 (composeur slot-driven), un menu peut
// ne pas avoir tous les slots (ex. pas de sauce) -> ne pas supposer leur presence.
const parts = [];
if (c.burger) {
const burgerOpts = c.burger.options && c.burger.options.length
? ` (${c.burger.options.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')})`
: '';
parts.push(`${escHtml(c.burger.libelle)}${burgerOpts}`);
}
// libelle fait foi : en Maxi l'accompagnement porte deja sa variante par nom
// ("Grande Frite"). Plus de suffixe taille -- il doublait le nom ("Grande Frite
// grande") et "normale"/"grande" mentait pour la boisson (le Maxi ne l'agrandit pas).
if (c.accompagnement) {
parts.push(escHtml(c.accompagnement.libelle));
}
if (c.boisson) {
parts.push(escHtml(c.boisson.libelle));
}
if (c.sauce) {
parts.push(escHtml(c.sauce.libelle));
}
const supplTotal = item.supplement_cents ?? 0;
return `
<ul class="cart-line__composition" aria-label="Composition du menu">
${parts.map(t => `<li class="cart-line__comp-item">+ ${t}</li>`).join('')}
${supplTotal > 0 ? `<li class="cart-line__comp-suppl">Format Maxi : +${formatPrice(supplTotal)}</li>` : ''}
</ul>
`;
}
if (abandonBtn) {
abandonBtn.addEventListener('click', () => {
clearCart();
window.location.href = 'categories.html';
});
}
document.addEventListener('DOMContentLoaded', renderCart);

View file

@ -181,7 +181,7 @@ document.addEventListener('DOMContentLoaded', () => {
const items = getCart();
if (!items.length) {
window.location.href = 'cart.html';
window.location.href = 'categories.html';
return;
}
if (recap) {

View file

@ -1,7 +1,7 @@
/*
* page-product-menu.js Composeur de menu PILOTE PAR LES SLOTS (P5 L2).
*
* Importe par page-product.js quand le produit charge est un menu (type === 'menu').
* Importe par page-products.js quand le produit clique est un menu (type === 'menu').
*
* Avant L2 : le composeur composait LIBREMENT a partir des categories (burgers,
* frites, boissons, sauces) sans tenir compte du menu reel. Desormais il consomme
@ -12,9 +12,9 @@
* Etapes : Format (Normal/Maxi, burger impose affiche) -> 1 pas par slot (dans
* l'ordre display_order ; requis = choix obligatoire, optionnel = "sans") -> recap.
*
* La forme de `composition` produite reste compatible avec page-cart.js et
* order-panel.js (burger / accompagnement / boisson / sauce + taille), le slot_type
* mappant vers le bon champ ; Maxi pose taille 'G' + supplement = prix_maxi - prix_normal.
* La forme de `composition` produite reste compatible avec order-panel.js (burger /
* accompagnement / boisson / sauce + taille), le slot_type mappant vers le bon champ ;
* Maxi pose taille 'G' + supplement = prix_maxi - prix_normal.
*
* A11y : role=dialog, aria-modal, focus-trap, ESC annule, focus au 1er interactif.
*/
@ -121,6 +121,11 @@ export function buildMenuCartItem(menu, model, { size, selections }) {
quantite: 1,
image: menu.image,
supplement_cents: supplement,
// format PORTE le choix Normal/Maxi de l'utilisateur, transporte tel quel
// jusqu'au contrat API. Le serveur l'utilise pour le prix Maxi ET la
// substitution des variantes (accompagnement Grande, boisson 50 cl). A NE
// PAS re-deviner depuis supplement_cents (faux negatif si maxi == normal).
format: isMaxi ? 'maxi' : 'normal',
composition,
};
}
@ -150,7 +155,7 @@ export function composerIsViable(model) {
}
/* ------------------------------------------------------------------ */
/* Entree publique — appelee par page-product.js */
/* Entree publique — appelee par page-products.js */
/* ------------------------------------------------------------------ */
/**

View file

@ -1,142 +0,0 @@
/*
* page-product.js Product detail screen.
*
* Reads ?id=<int>&category=<slug> from the query string.
*
* Branch on product type:
* - type === 'menu' open the multi-step composer modal (page-product-menu.js).
* The standard detail layout is bypassed because a menu
* cannot be added to the cart without composition choices.
* - type === 'produit' render the standard detail card with "Ajouter au panier".
*
* After "Ajouter au panier" (simple product):
* 1. Item added to cart via state.addToCart()
* 2. Button changes to "Ajoute !" for 1 second (visual feedback)
* 3. Redirect to products.html?category=<slug>
*/
import { findProduct, loadAllergens } from './data.js';
import { addToCart, formatPrice, escHtml } from './state.js';
import { refreshCartBadge } from './nav.js';
import { openMenuComposer } from './page-product-menu.js';
import { openProductOptions, productSizes } from './product-options.js';
import { buildAllergenInfoButton, openAllergenModal } from './allergens.js';
const params = new URLSearchParams(window.location.search);
const productId = parseInt(params.get('id'), 10);
const categorySlug = params.get('category') ?? 'menus';
const container = document.getElementById('product-detail');
const errorBlock = document.getElementById('product-error');
const backBtn = document.getElementById('back-to-products');
if (backBtn) {
backBtn.href = `products.html?category=${categorySlug}`;
}
async function renderProduct() {
if (!productId) {
showError('Produit introuvable.');
return;
}
try {
const product = await findProduct(productId, categorySlug);
if (!product) {
showError('Ce produit n\'existe pas.');
return;
}
document.title = `Wakdo - ${product.nom}`;
if (product.type === 'menu') {
/* Hide the standard product detail area; the composer will overlay the page.
* The container stays in the DOM so the skeleton does not flash. */
container.hidden = true;
await openMenuComposer(product, categorySlug);
return;
}
/* Produit a tailles multiples (R4, ex. boisson 30/50 cl) : on delegue a la
* modale d'options (meme picker que la grille) plutot que de dupliquer la
* selection de taille dans la fiche -> un seul chemin pour choisir la taille. */
if (productSizes(product).length) {
container.hidden = true;
openProductOptions(product, categorySlug);
return;
}
container.innerHTML = `
<div class="product-detail__image-wrap">
<img
class="product-detail__image"
src="${escHtml(product.image)}"
alt="${escHtml(product.nom)}"
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
>
</div>
<div class="product-detail__info">
<h1 class="product-detail__name">${escHtml(product.nom)}</h1>
<p class="product-detail__price">${formatPrice(product.prix)}</p>
<button
class="btn btn--primary btn--large product-detail__add"
id="add-to-cart-btn"
aria-label="Ajouter ${escHtml(product.nom)} au panier"
type="button"
>
Ajouter au panier
</button>
</div>
`;
// Bouton "i" allergenes (modale generale) dans le bloc info de la fiche.
// Echec de chargement non bloquant : la fiche reste fonctionnelle.
try {
const allergens = await loadAllergens();
const info = container.querySelector('.product-detail__info');
if (info) {
info.appendChild(buildAllergenInfoButton(() => openAllergenModal(allergens)));
}
} catch (e) {
console.error('loadAllergens error:', e);
}
document.getElementById('add-to-cart-btn').addEventListener('click', () => {
addToCart({
id: product.id,
type: product.type,
categorie: product.categorie ?? categorySlug,
libelle: product.nom,
prix_cents: product.prix,
quantite: 1,
image: product.image
});
refreshCartBadge();
const btn = document.getElementById('add-to-cart-btn');
btn.textContent = 'Ajoute !';
btn.disabled = true;
/* Redirect after brief confirmation pause */
setTimeout(() => {
window.location.href = `products.html?category=${categorySlug}`;
}, 1000);
});
} catch (err) {
showError('Erreur lors du chargement du produit.');
console.error('renderProduct error:', err);
}
}
function showError(msg) {
if (errorBlock) {
errorBlock.hidden = false;
errorBlock.textContent = msg;
}
if (container) {
container.hidden = true;
}
}
document.addEventListener('DOMContentLoaded', renderProduct);

View file

@ -3,7 +3,8 @@
*
* Reads ?category=<id> from the query string, maps to a slug via
* CATEGORY_ID_TO_SLUG, then fetches the matching product array.
* On product card click, navigates to product.html?id=<id>&category=<slug>.
* On product card click, opens an in-page modal (composer for a menu, options
* for a simple product) above the grid ; the order panel reflects the addition.
*/
import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG, loadAllergens } from './data.js';
@ -63,10 +64,16 @@ async function renderProducts() {
grid.innerHTML = '';
products.forEach(product => {
// commandable : false = rupture de stock (RG-T21). La tuile reste visible
// (le client voit le produit de la carte) mais grisee et non cliquable.
const orderable = product.commandable !== false;
const card = document.createElement('a');
card.className = 'product-card';
card.href = `product.html?id=${product.id}&category=${categorySlug}`;
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}`);
card.className = orderable ? 'product-card' : 'product-card product-card--unavailable';
// Le <a> reste pour le focus/clavier (a11y) ; href='#' inerte, le handler
// click ci-dessous fait foi (preventDefault + ouverture de la modale).
card.href = '#';
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}${orderable ? '' : ' - indisponible'}`);
if (!orderable) card.setAttribute('aria-disabled', 'true');
card.innerHTML = `
<div class="product-card__image-wrap">
@ -77,6 +84,7 @@ async function renderProducts() {
loading="lazy"
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
>
${orderable ? '' : '<span class="product-card__badge">Indisponible</span>'}
</div>
<div class="product-card__body">
<span class="product-card__name">${escHtml(product.nom)}</span>
@ -89,11 +97,13 @@ async function renderProducts() {
const infoBtn = buildAllergenInfoButton(() => openAllergenModal(allergens));
card.querySelector('.product-card__image-wrap').appendChild(infoBtn);
// Clic produit -> modale au-dessus de la grille (paradigme maquette) au lieu
// de naviguer vers product.html : menu -> composeur (L2), produit -> options
// (L3). Le <a href> reste un repli (lien direct / sans JS).
// Clic produit -> modale au-dessus de la grille (paradigme maquette) :
// menu -> composeur (L2), produit -> options (L3). Le panneau de droite est
// l'unique vue panier ; pas de navigation au clic. Une tuile en rupture ne
// fait rien (ni navigation ni modale).
card.addEventListener('click', (e) => {
e.preventDefault();
if (!orderable) return;
if (product.type === 'menu') openMenuComposer(product, categorySlug);
else openProductOptions(product, categorySlug);
});

View file

@ -1,10 +1,10 @@
/*
* product-options.js Modale d'options produit (P5 L3, taille R4).
*
* Remplace la navigation vers product.html : cliquer un produit simple ouvre une
* modale (image, prix unitaire, stepper de quantite, total) au-dessus de la grille,
* facon maquette ("Une petite soif ?"). A l'ajout, le panneau de commande persistant
* (L1) est re-rendu pour refleter immediatement la commande -> pas de navigation.
* Ouvre une modale d'options au clic produit, au lieu d'une navigation : cliquer un
* produit simple ouvre une modale (image, prix unitaire, stepper de quantite, total)
* au-dessus de la grille, facon maquette ("Une petite soif ?"). A l'ajout, le panneau
* de commande persistant (L1) est re-rendu pour refleter immediatement la commande.
*
* Taille (R4) : la dimension 30/50 cl de la maquette existe desormais en base sous
* forme de LIGNES produit distinctes (product.sizes : [{product_id, size_cl,

View file

@ -1,102 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<meta name="description" content="Wakdo - Votre panier">
<title>Wakdo - Panier</title>
<link rel="canonical" href="https://corentin-wakdo.stark.a3n.fr/cart.html">
<link rel="icon" type="image/svg+xml" href="assets/images/favicon.svg">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body class="cart-page">
<!--
cart.html — Shopping cart.
page-cart.js renders all cart lines, handles qty controls and removal.
TVA: 10% (restauration France 2024 — simplified rate).
TODO: verify exact rate with accountant in P3 — actual rate depends
on sur-place vs a-emporter and product type (alcohol, etc.).
The stored prices are TTC. HT is back-calculated at display time only.
-->
<header class="site-header">
<a
class="site-header__back"
href="categories.html"
aria-label="Continuer mes achats"
>
&#8592; Continuer
</a>
<img
class="site-header__logo"
src="assets/images/ui/logo.png"
alt="Wakdo"
>
<span class="mode-badge site-header__mode" data-mode-badge aria-label="Mode de consommation">Sur place</span>
</header>
<main class="cart-main" aria-label="Votre panier">
<h1 class="cart-main__heading">Votre panier</h1>
<!-- Empty cart state -->
<div id="cart-empty" class="cart-empty" hidden>
<p class="cart-empty__message">Votre panier est vide.</p>
<a class="btn btn--secondary" href="categories.html">Decouvrir nos produits</a>
</div>
<!-- Cart lines -->
<ul id="cart-list" class="cart-list" aria-label="Lignes du panier">
<!-- Filled by page-cart.js -->
</ul>
<!-- Order summary -->
<aside id="cart-summary" class="cart-summary" hidden aria-label="Recapitulatif de commande">
<div class="cart-summary__line">
<span>Total HT</span>
<span id="total-ht"></span>
</div>
<div class="cart-summary__line">
<!-- TVA 10% — taux restauration FR 2024 (simplifie, voir commentaire ci-dessus) -->
<span>TVA (10%)</span>
<span id="total-tva"></span>
</div>
<div class="cart-summary__line cart-summary__line--total">
<span>Total TTC</span>
<strong id="total-ttc"></strong>
</div>
</aside>
<!-- Actions -->
<div class="cart-actions">
<button
id="abandon-btn"
class="btn btn--secondary"
type="button"
aria-label="Abandonner la commande et retourner aux categories"
>
Abandonner
</button>
<a
id="pay-btn"
class="btn btn--primary"
href="payment.html"
role="button"
aria-label="Passer au paiement"
aria-disabled="true"
>
Valider ma commande
</a>
</div>
</main>
<script type="module" src="assets/js/nav.js"></script>
<script type="module" src="assets/js/page-cart.js"></script>
<script type="module" src="assets/js/a11y.js"></script>
</body>
</html>

View file

@ -13,11 +13,11 @@
<body class="categories-page">
<!--
Categories screen.
Data source: docs/merise/_sources/categories.json (9 categories).
Categories screen — static scaffold (9 categories) listed in catalogue order.
Image paths: assets/images/categories/{title}.png — verified against filesystem.
In P4 this page will be generated dynamically from GET /api/categories.
For now it is a static scaffold that matches the data contract exactly.
The cards link to products.html?category=<id>; the product/menu/allergen data
is fetched from the REST API by data.js. Generating this list from
GET /api/categories is a later UI alignment step.
-->
<header class="site-header">
@ -41,10 +41,9 @@
<p class="categories-main__sub">Choisissez une categorie pour decouvrir nos produits</p>
<!--
9 categories from categories.json, in the same order as the source.
Each card links to a product page (products.html?category=<id>) — stub URL
for future P5 implementation. The link is functional HTML; no JS needed.
title field from JSON used as alt text and visible label.
9 categories in catalogue order. Each card links to a product page
(products.html?category=<id>). The link is functional HTML; no JS needed.
The category title is used as alt text and visible label.
-->
<nav class="category-grid" aria-label="Navigation par categorie">

View file

@ -1,22 +1,10 @@
# Donnees statiques de la borne (repli P5)
# Donnees de la borne
`categories.json` et `produits.json` sont un **repli statique fige** consomme par
le front de la borne (Bloc 1 / P5) tant que l'API REST n'existe pas. Ils sont
copies du jeu de donnees source de l'ecole (`docs/merise/_sources/`), **pas**
generes depuis la base.
La borne consomme l'API REST en lecture : `/api/categories`, `/api/products`,
`/api/menus` et `/api/allergens` (cf. `docs/api/conventions.md` section 5.2). La
couche `assets/js/data.js` deballe l'enveloppe `{ data }` et traduit la forme
canonique vers la forme attendue par les pages.
## Ces fichiers ne refletent pas la base
Le catalogue servi ici est le jeu source complet (66 produits) ; le seed de la
base (`db/seeds/0002_catalogue.sql`) en est un sous-ensemble curate (53 produits).
Les categories, elles, coincident (9 de chaque cote). La borne est une demo front
sur donnees statiques : un ecart de comptage produits avec la table `product` est
**attendu**, ce n'est pas une incoherence a corriger.
## Point de bascule (P4)
`assets/js/data.js` lit ces fichiers via les constantes `CATEGORIES_URL` /
`PRODUCTS_URL`. En P4, ces constantes pointeront vers `/api/categories` et
`/api/products` (memes formes de retour, le reste du code est agnostique). La
borne refletera alors la base via l'API, et ces fichiers deviendront obsoletes
(a retirer a ce moment-la).
Les anciens fichiers JSON statiques (`categories.json`, `produits.json`,
`allergens.json`) qui servaient de repli avant l'API ont ete retires : la borne
reflete la base via l'API.

View file

@ -1,16 +0,0 @@
[
{ "id": 1, "name": "Cereales contenant du gluten", "description": "Ble, seigle, orge, avoine, epeautre, kamut et produits derives." },
{ "id": 2, "name": "Crustaces", "description": "Et produits a base de crustaces." },
{ "id": 3, "name": "Oeufs", "description": "Et produits a base d'oeufs." },
{ "id": 4, "name": "Poissons", "description": "Et produits a base de poissons." },
{ "id": 5, "name": "Arachides", "description": "Et produits a base d'arachides." },
{ "id": 6, "name": "Soja", "description": "Et produits a base de soja." },
{ "id": 7, "name": "Lait", "description": "Et produits a base de lait (y compris le lactose)." },
{ "id": 8, "name": "Fruits a coque", "description": "Amandes, noisettes, noix, noix de cajou, pistaches et autres." },
{ "id": 9, "name": "Celeri", "description": "Et produits a base de celeri." },
{ "id": 10, "name": "Moutarde", "description": "Et produits a base de moutarde." },
{ "id": 11, "name": "Graines de sesame", "description": "Et produits a base de graines de sesame." },
{ "id": 12, "name": "Anhydride sulfureux et sulfites", "description": "En concentration de plus de 10 mg/kg ou 10 mg/l." },
{ "id": 13, "name": "Lupin", "description": "Et produits a base de lupin." },
{ "id": 14, "name": "Mollusques", "description": "Et produits a base de mollusques." }
]

View file

@ -1,11 +0,0 @@
[
{ "id": 1, "title": "menus", "slug": "menus", "image": "assets/images/categories/menus.png" },
{ "id": 2, "title": "boissons", "slug": "boissons", "image": "assets/images/categories/boissons.png" },
{ "id": 3, "title": "burgers", "slug": "burgers", "image": "assets/images/categories/burgers.png" },
{ "id": 4, "title": "frites", "slug": "frites", "image": "assets/images/categories/frites.png" },
{ "id": 5, "title": "encas", "slug": "encas", "image": "assets/images/categories/encas.png" },
{ "id": 6, "title": "wraps", "slug": "wraps", "image": "assets/images/categories/wraps.png" },
{ "id": 7, "title": "salades", "slug": "salades", "image": "assets/images/categories/salades.png" },
{ "id": 8, "title": "desserts", "slug": "desserts", "image": "assets/images/categories/desserts.png" },
{ "id": 9, "title": "sauces", "slug": "sauces", "image": "assets/images/categories/sauces.png" }
]

View file

@ -1,86 +0,0 @@
{
"menus": [
{ "id": 1, "nom": "Menu Le 280", "prix": 880, "image": "assets/images/produits/burgers/280.png", "type": "menu" },
{ "id": 2, "nom": "Menu Big Tasty", "prix": 1060, "image": "assets/images/produits/burgers/big-tasty-1-viande.png", "type": "menu" },
{ "id": 3, "nom": "Menu Big Tasty Bacon", "prix": 1090, "image": "assets/images/produits/burgers/big-tasty-bacon-1-viande.png", "type": "menu" },
{ "id": 4, "nom": "Menu Big Mac", "prix": 800, "image": "assets/images/produits/burgers/bigmac.png", "type": "menu" },
{ "id": 5, "nom": "Menu CBO", "prix": 1090, "image": "assets/images/produits/burgers/cbo.png", "type": "menu" },
{ "id": 6, "nom": "Menu MC Chicken", "prix": 930, "image": "assets/images/produits/burgers/mcchicken.png", "type": "menu" },
{ "id": 7, "nom": "Menu MC Crispy", "prix": 720, "image": "assets/images/produits/burgers/mccrispy.png", "type": "menu" },
{ "id": 8, "nom": "Menu MC Fish", "prix": 720, "image": "assets/images/produits/burgers/mcfish.png", "type": "menu" },
{ "id": 9, "nom": "Menu Royal Bacon", "prix": 705, "image": "assets/images/produits/burgers/royalbacon.png", "type": "menu" },
{ "id": 10, "nom": "Menu Royal Cheese", "prix": 640, "image": "assets/images/produits/burgers/royalcheese.png", "type": "menu" },
{ "id": 11, "nom": "Menu Royal Deluxe", "prix": 740, "image": "assets/images/produits/burgers/royaldeluxe.png", "type": "menu" },
{ "id": 12, "nom": "Menu Signature BBQ Beef 2 viandes","prix": 1350,"image": "assets/images/produits/burgers/signature-bbq-beef-2-viandes.png", "type": "menu" },
{ "id": 13, "nom": "Menu Signature Beef BBQ", "prix": 1190, "image": "assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png", "type": "menu" }
],
"burgers": [
{ "id": 14, "nom": "Le 280", "prix": 680, "image": "assets/images/produits/burgers/280.png", "type": "produit" },
{ "id": 15, "nom": "Big Tasty", "prix": 860, "image": "assets/images/produits/burgers/big-tasty-1-viande.png", "type": "produit" },
{ "id": 16, "nom": "Big Tasty Bacon", "prix": 890, "image": "assets/images/produits/burgers/big-tasty-bacon-1-viande.png", "type": "produit" },
{ "id": 17, "nom": "Big Mac", "prix": 600, "image": "assets/images/produits/burgers/bigmac.png", "type": "produit" },
{ "id": 18, "nom": "CBO", "prix": 890, "image": "assets/images/produits/burgers/cbo.png", "type": "produit" },
{ "id": 19, "nom": "MC Chicken", "prix": 730, "image": "assets/images/produits/burgers/mcchicken.png", "type": "produit" },
{ "id": 20, "nom": "MC Crispy", "prix": 530, "image": "assets/images/produits/burgers/mccrispy.png", "type": "produit" },
{ "id": 21, "nom": "MC Fish", "prix": 485, "image": "assets/images/produits/burgers/mcfish.png", "type": "produit" },
{ "id": 22, "nom": "Royal Bacon", "prix": 510, "image": "assets/images/produits/burgers/royalbacon.png", "type": "produit" },
{ "id": 23, "nom": "Royal Cheese", "prix": 440, "image": "assets/images/produits/burgers/royalcheese.png", "type": "produit" },
{ "id": 24, "nom": "Royal Deluxe", "prix": 540, "image": "assets/images/produits/burgers/royaldeluxe.png", "type": "produit" },
{ "id": 25, "nom": "Signature BBQ Beef 2 viandes","prix": 1140,"image": "assets/images/produits/burgers/signature-bbq-beef-2-viandes.png","type": "produit" },
{ "id": 26, "nom": "Signature Beef BBQ", "prix": 1030, "image": "assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png","type": "produit" }
],
"boissons": [
{ "id": 27, "nom": "Coca Cola", "prix": 190, "image": "assets/images/produits/boissons/coca-cola.png", "type": "produit" },
{ "id": 28, "nom": "Coca Sans Sucres", "prix": 190, "image": "assets/images/produits/boissons/coca-sans-sucres.png", "type": "produit" },
{ "id": 29, "nom": "Eau", "prix": 100, "image": "assets/images/produits/boissons/eau.png", "type": "produit" },
{ "id": 30, "nom": "Fanta Orange", "prix": 190, "image": "assets/images/produits/boissons/fanta.png", "type": "produit" },
{ "id": 31, "nom": "Ice Tea Peche", "prix": 190, "image": "assets/images/produits/boissons/ice-tea-peche.png", "type": "produit" },
{ "id": 32, "nom": "Ice Tea Citron", "prix": 190, "image": "assets/images/produits/boissons/the-vert-citron-sans-sucres.png", "type": "produit" },
{ "id": 33, "nom": "Jus d'Orange", "prix": 210, "image": "assets/images/produits/boissons/jus-orange.png", "type": "produit" },
{ "id": 34, "nom": "Jus de Pommes Bio", "prix": 230, "image": "assets/images/produits/boissons/jus-pomme-bio.png", "type": "produit" }
],
"frites": [
{ "id": 35, "nom": "Petite Frite", "prix": 145, "image": "assets/images/produits/frites/petite-frite.png", "type": "produit" },
{ "id": 36, "nom": "Moyenne Frite", "prix": 275, "image": "assets/images/produits/frites/moyenne-frite.png", "type": "produit" },
{ "id": 37, "nom": "Grande Frite", "prix": 350, "image": "assets/images/produits/frites/grande-frite.png", "type": "produit" },
{ "id": 38, "nom": "Potatoes", "prix": 215, "image": "assets/images/produits/frites/potatoes.png", "type": "produit" },
{ "id": 39, "nom": "Grande Potatoes", "prix": 340, "image": "assets/images/produits/frites/grande-potatoes.png", "type": "produit" }
],
"encas": [
{ "id": 40, "nom": "Cheeseburger", "prix": 260, "image": "assets/images/produits/encas/cheeseburger.png", "type": "produit" },
{ "id": 41, "nom": "Croc MCdo", "prix": 320, "image": "assets/images/produits/encas/croc-mc-do.png", "type": "produit" },
{ "id": 42, "nom": "Nuggets x4", "prix": 420, "image": "assets/images/produits/encas/nuggets-4.png", "type": "produit" },
{ "id": 43, "nom": "Nuggets x20", "prix": 1300, "image": "assets/images/produits/encas/nuggets-20.png", "type": "produit" }
],
"desserts": [
{ "id": 44, "nom": "Brownie", "prix": 260, "image": "assets/images/produits/desserts/brownies.png", "type": "produit" },
{ "id": 45, "nom": "Cheesecake Chocolat M&M's","prix": 310, "image": "assets/images/produits/desserts/cheesecake-choconuts-m&m-s.png", "type": "produit" },
{ "id": 46, "nom": "Cheesecake Fraise", "prix": 310, "image": "assets/images/produits/desserts/cheesecake-fraise.png", "type": "produit" },
{ "id": 47, "nom": "Cookie", "prix": 320, "image": "assets/images/produits/desserts/cookie.png", "type": "produit" },
{ "id": 48, "nom": "Donut", "prix": 260, "image": "assets/images/produits/desserts/doghnut.png", "type": "produit" },
{ "id": 49, "nom": "Macarons", "prix": 270, "image": "assets/images/produits/desserts/macarons.png", "type": "produit" },
{ "id": 50, "nom": "MC Fleury", "prix": 440, "image": "assets/images/produits/desserts/mcfleury.png", "type": "produit" },
{ "id": 51, "nom": "Muffin", "prix": 360, "image": "assets/images/produits/desserts/muffin.png", "type": "produit" },
{ "id": 52, "nom": "Sunday", "prix": 100, "image": "assets/images/produits/desserts/sunday.png", "type": "produit" }
],
"sauces": [
{ "id": 53, "nom": "Classic Barbecue", "prix": 70, "image": "assets/images/produits/sauces/classic-barbecue.png", "type": "produit" },
{ "id": 54, "nom": "Classic Moutarde", "prix": 70, "image": "assets/images/produits/sauces/classic-moutarde.png", "type": "produit" },
{ "id": 55, "nom": "Creamy Deluxe", "prix": 70, "image": "assets/images/produits/sauces/cremy-deluxe.png", "type": "produit" },
{ "id": 56, "nom": "Ketchup", "prix": 70, "image": "assets/images/produits/sauces/ketchup.png", "type": "produit" },
{ "id": 57, "nom": "Chinoise", "prix": 70, "image": "assets/images/produits/sauces/sauce-chinoise.png", "type": "produit" },
{ "id": 58, "nom": "Curry", "prix": 70, "image": "assets/images/produits/sauces/sauce-curry.png", "type": "produit" },
{ "id": 59, "nom": "Pommes Frites", "prix": 70, "image": "assets/images/produits/sauces/sauce-pommes-frite.png", "type": "produit" }
],
"salades": [
{ "id": 60, "nom": "Petite Salade", "prix": 330, "image": "assets/images/produits/salades/petite-salade.png", "type": "produit" },
{ "id": 61, "nom": "Cesar Classic", "prix": 880, "image": "assets/images/produits/salades/salade-classic-caesar.png","type": "produit" },
{ "id": 62, "nom": "Italienne Mozza", "prix": 880, "image": "assets/images/produits/salades/salade-italian-mozza.png", "type": "produit" }
],
"wraps": [
{ "id": 63, "nom": "MC Wrap Chevre", "prix": 310, "image": "assets/images/produits/wraps/mcwrap-chevre.png", "type": "produit" },
{ "id": 64, "nom": "MC Wrap Poulet Bacon", "prix": 330, "image": "assets/images/produits/wraps/mcwrap-poulet-bacon.png","type": "produit" },
{ "id": 65, "nom": "Ptit Wrap Chevre", "prix": 260, "image": "assets/images/produits/wraps/ptit-wrap-chevre.png", "type": "produit" },
{ "id": 66, "nom": "Ptit Wrap Ranch", "prix": 260, "image": "assets/images/produits/wraps/ptit-wrap-ranch.png", "type": "produit" }
]
}

View file

@ -21,10 +21,10 @@
<header class="site-header">
<a
class="site-header__back"
href="cart.html"
aria-label="Retour au panier"
href="categories.html"
aria-label="Retour aux categories"
>
&#8592; Panier
&#8592; Retour
</a>
<img
class="site-header__logo"

View file

@ -1,73 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<meta name="description" content="Wakdo - Detail du produit">
<title>Wakdo - Produit</title>
<link rel="canonical" href="https://corentin-wakdo.stark.a3n.fr/product.html">
<link rel="icon" type="image/svg+xml" href="assets/images/favicon.svg">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body class="product-page">
<!--
product.html — Product detail screen.
Reads ?id=<int>&category=<slug>.
JS (page-product.js) fetches the product, renders detail and handles
the "Ajouter au panier" action.
Menu composition is shown as a fixed note (MVP: no composition selection).
-->
<header class="site-header">
<a
id="back-to-products"
class="site-header__back"
href="products.html"
aria-label="Retour a la liste des produits"
>
&#8592; Retour
</a>
<img
class="site-header__logo"
src="assets/images/ui/logo.png"
alt="Wakdo"
>
<a
class="site-header__cart"
href="cart.html"
aria-label="Voir le panier"
>
<span class="cart-icon" aria-hidden="true">&#128722;</span>
<span class="cart-badge" data-cart-count hidden aria-live="polite">0</span>
<span class="sr-only">Panier</span>
</a>
</header>
<div class="order-layout">
<main class="product-main" aria-label="Detail du produit">
<!-- Error block: hidden unless fetch fails or id invalid -->
<p id="product-error" class="product-error" hidden role="alert"></p>
<!--
Container filled by page-product.js.
The JS replaces innerHTML once data is ready.
-->
<div id="product-detail" class="product-detail" aria-live="polite">
<!-- Skeleton placeholder visible during fetch -->
<div class="product-detail__skeleton" aria-hidden="true"></div>
</div>
</main>
<aside class="order-panel" data-order-panel aria-label="Ma commande" aria-live="polite"></aside>
</div>
<script type="module" src="assets/js/nav.js"></script>
<script type="module" src="assets/js/page-product.js"></script>
<script type="module" src="assets/js/order-panel.js"></script>
<script type="module" src="assets/js/a11y.js"></script>
</body>
</html>

View file

@ -15,8 +15,8 @@
<!--
products.html — List of products in a category.
Category is determined at runtime from ?category=<id>.
JS (page-products.js) fetches data/produits.json and renders cards.
In P4: swap fetch URL in data.js to point to GET /api/products?category=<slug>.
JS (page-products.js) reads the catalogue via data.js, which fetches
GET /api/products (and /api/categories, /api/menus), then renders cards.
-->
<header class="site-header">
@ -33,15 +33,6 @@
src="assets/images/ui/logo.png"
alt="Wakdo"
>
<a
class="site-header__cart"
href="cart.html"
aria-label="Voir le panier"
>
<span class="cart-icon" aria-hidden="true">&#128722;</span>
<span class="cart-badge" data-cart-count hidden aria-live="polite">0</span>
<span class="sr-only">Panier</span>
</a>
</header>
<div class="order-layout">

View file

@ -13,7 +13,8 @@ use App\Core\Database;
/**
* AllergenRepository contre une vraie MariaDB (schema migre + seed reference).
* Auto-skip si WAKDO_DB_TESTS != 1. Lecture seule (donnees de reference) : aucun
* fixture/teardown. Verifie que les 14 allergenes INCO sont references avec code+name.
* fixture/teardown. Verifie que les 14 allergenes INCO sont references avec
* code + name + description.
*/
final class AllergenReadDbTest extends TestCase
{
@ -34,7 +35,7 @@ final class AllergenReadDbTest extends TestCase
}
}
public function testListsIncoReferenceWithCodeAndName(): void
public function testListsIncoReferenceWithCodeNameAndDescription(): void
{
$rows = (new AllergenRepository($this->db))->all();
@ -42,7 +43,11 @@ final class AllergenReadDbTest extends TestCase
foreach ($rows as $a) {
self::assertArrayHasKey('code', $a);
self::assertArrayHasKey('name', $a);
self::assertArrayHasKey('description', $a);
self::assertNotSame('', (string) ($a['name'] ?? ''));
}
// La description INCO est seede (migration 0001 + seed 0001) : au moins une non vide.
$descriptions = array_filter($rows, static fn (array $a): bool => (string) ($a['description'] ?? '') !== '');
self::assertNotEmpty($descriptions, 'la description INCO doit etre exposee');
}
}

View file

@ -199,7 +199,7 @@ final class CatalogueReadDbTest extends TestCase
}
/**
* @return array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}
* @return array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}
*/
private function productData(string $name, int $categoryId, int $available): array
{
@ -208,6 +208,9 @@ final class CatalogueReadDbTest extends TestCase
'name' => $name,
'description' => null,
'price_cents' => 500,
'size_cl' => null,
'base_product_id' => null,
'maxi_variant_product_id' => null,
'vat_rate' => 100,
'image_path' => null,
'is_available' => $available,

View file

@ -100,4 +100,66 @@ final class OrderQueryRepositoryDbTest extends TestCase
$repo = new OrderQueryRepository($this->db);
self::assertLessThanOrEqual(3, count($repo->recent(3)));
}
/**
* paidQueueWithDetail (LIST_ORDERS_DISPLAY) contre le schema reel : insere une
* commande `paid` avec une ligne produit + un modificateur, et verifie que la file
* porte l'article (label_snapshot, format) et le modificateur (ingredient_name via
* la jointure ingredient + action). Les FK sont resolues par nom (convention des
* seeds : produit 'Le 280', ingredient 'Oignon'). Auto-skip si seeds absents.
*/
public function testPaidQueueWithDetailReturnsItemsAndModifiers(): void
{
$product = $this->db->fetch("SELECT id FROM product WHERE name = 'Le 280'");
$ingredient = $this->db->fetch("SELECT id FROM ingredient WHERE name = 'Oignon'");
if ($product === null || $ingredient === null) {
self::markTestSkipped('Seeds catalogue/ingredients absents (produit/ingredient introuvable).');
}
$num = 'IT-' . $this->suffix . '-KDS';
$this->insertOrder($num, 'paid', 1090);
// paid_at explicite : la file trie sur paid_at, et la bande SLA en derive.
$orderId = $this->orderIdByNumber($num);
$this->db->execute('UPDATE customer_order SET paid_at = NOW() WHERE id = :id', ['id' => $orderId]);
$this->db->execute(
'INSERT INTO order_item (order_id, item_type, product_id, format, label_snapshot, '
. 'unit_price_cents_snapshot, vat_rate_snapshot, quantity) '
. "VALUES (:oid, 'product', :pid, 'normal', 'Le 280', 1090, 100, 1)",
['oid' => $orderId, 'pid' => (int) $product['id']],
);
$itemId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
$this->db->execute(
'INSERT INTO order_item_modifier (order_item_id, ingredient_id, action, extra_price_cents) '
. "VALUES (:iid, :ing, 'remove', 0)",
['iid' => $itemId, 'ing' => (int) $ingredient['id']],
);
$queue = (new OrderQueryRepository($this->db))->paidQueueWithDetail(['kiosk', 'counter', 'drive']);
$mine = array_values(array_filter(
$queue,
static fn (array $o): bool => ($o['order_number'] ?? '') === $num,
));
self::assertCount(1, $mine, 'la commande inseree doit apparaitre dans la file KDS');
$order = $mine[0];
self::assertArrayNotHasKey('id', $order, 'l\'id technique ne doit pas etre expose');
self::assertContains($order['sla_band'], ['fresh', 'warn', 'late']);
self::assertCount(1, $order['items']);
$item = $order['items'][0];
self::assertSame('Le 280', (string) $item['label_snapshot']);
self::assertSame('normal', (string) $item['format']);
self::assertCount(1, $item['modifiers']);
self::assertSame('remove', (string) $item['modifiers'][0]['action']);
self::assertSame('Oignon', (string) $item['modifiers'][0]['ingredient_name']);
}
private function orderIdByNumber(string $number): int
{
return (int) ($this->db->fetch(
'SELECT id FROM customer_order WHERE order_number = :n',
['n' => $number],
)['id'] ?? 0);
}
}

View file

@ -169,6 +169,9 @@ final class ProductIngredientDbTest extends TestCase
'name' => $this->product,
'description' => null,
'price_cents' => 590,
'size_cl' => null,
'base_product_id' => null,
'maxi_variant_product_id' => null,
'vat_rate' => 100,
'image_path' => null,
'is_available' => 1,

View file

@ -59,6 +59,9 @@ final class ProductRepositoryDbTest extends TestCase
'name' => $this->name,
'description' => null,
'price_cents' => 999,
'size_cl' => null,
'base_product_id' => null,
'maxi_variant_product_id' => null,
'vat_rate' => 100,
'image_path' => null,
'is_available' => 1,
@ -77,6 +80,9 @@ final class ProductRepositoryDbTest extends TestCase
'name' => $this->name,
'description' => 'maj',
'price_cents' => 1099,
'size_cl' => null,
'base_product_id' => null,
'maxi_variant_product_id' => null,
'vat_rate' => 55,
'image_path' => null,
'is_available' => 0,

View file

@ -33,6 +33,20 @@ final class FakeCatalogueDatabase implements DatabaseInterface
*/
public array $productsRows = [];
/**
* Lignes {id, name} renvoyees par ProductRepository::basesOnly() (R4/F9-1).
*
* @var list<array<string, mixed>>
*/
public array $baseProductsRows = [];
/**
* Lignes renvoyees par ProductRepository::all() (liste admin enrichie, F9-4).
*
* @var list<array<string, mixed>>
*/
public array $allProductsRows = [];
/**
* Ligne renvoyee par ProductRepository::findForCatalogue() ; null = absent /
* indisponible / categorie inactive.
@ -79,6 +93,22 @@ final class FakeCatalogueDatabase implements DatabaseInterface
*/
public array $productSizes = [];
/**
* Lignes {product_id} renvoyees par ProductRepository::autoUnavailableIds()
* (RG-T21 : produits en rupture calculee par le stock). Vide = rien en rupture.
*
* @var list<array<string, mixed>>
*/
public array $autoUnavailableRows = [];
/**
* Lignes renvoyees par AllergenRepository::all() (14 allergenes INCO :
* id, code, name, description).
*
* @var list<array<string, mixed>>
*/
public array $allergensRows = [];
/**
* Trace des lectures pour asserter le court-circuit du detail (id <= 0).
*
@ -109,6 +139,12 @@ final class FakeCatalogueDatabase implements DatabaseInterface
return $this->categoriesRows;
}
// RG-T21 : ids des produits en rupture calculee (autoUnavailableIds). Desambigue
// de composition() (meme table) par SELECT DISTINCT, propre a cette requete.
if (str_contains($sql, 'SELECT DISTINCT pi.product_id')) {
return $this->autoUnavailableRows;
}
// R4 : tailles groupees (sizesByBase) et tailles d'un produit (sizesForProduct).
// Testees avant la branche catalogue : toutes deux lisent FROM product.
if (str_contains($sql, 'AS base_id')) {
@ -118,6 +154,18 @@ final class FakeCatalogueDatabase implements DatabaseInterface
return $this->productSizes;
}
// F9-1 : liste base-only (basesOnly) pour les selects menu/produit.
if (str_contains($sql, 'FROM product WHERE base_product_id IS NULL')) {
return $this->baseProductsRows;
}
// F9-4 : liste admin enrichie (all()) -- LEFT JOIN base, sans le filtre de
// disponibilite borne. Distinguee de availableForCatalogue() par l'absence
// de 'WHERE p.is_available = 1' et la presence de 'LEFT JOIN product b'.
if (str_contains($sql, 'FROM product p JOIN category') && str_contains($sql, 'LEFT JOIN product b')) {
return $this->allProductsRows;
}
if (str_contains($sql, 'FROM product p JOIN category') && str_contains($sql, 'WHERE p.is_available = 1')) {
return $this->productsRows;
}
@ -130,6 +178,10 @@ final class FakeCatalogueDatabase implements DatabaseInterface
return $this->menuSlotRows;
}
if (str_contains($sql, 'FROM allergen')) {
return $this->allergensRows;
}
return [];
}

View file

@ -120,6 +120,14 @@ final class FakeDatabase implements DatabaseInterface
/** Resultat de UserRepository::pinIsSet() (true = un PIN est defini). */
public bool $userPinSet = false;
/**
* Ligne {password_hash} renvoyee pour la re-verification d'identite au set de PIN
* (ProfileController::currentPasswordHash) ; null = compte absent/inactif.
*
* @var array<string, mixed>|null
*/
public ?array $currentPasswordRow = null;
/**
* Lignes renvoyees par ProductRepository::all().
*
@ -134,6 +142,22 @@ final class FakeDatabase implements DatabaseInterface
*/
public ?array $productRow = null;
/**
* Lignes {id, name} renvoyees par ProductRepository::basesOnly() (R4/F9-1) :
* produits de base eligibles aux selects menu / formulaire produit.
*
* @var list<array<string, mixed>>
*/
public array $baseProductsRows = [];
/**
* Resultat de ProductRepository::productIsBase() / MenuRepository::productIsBase()
* (R4/F9-2) : true => l'id designe un produit de BASE (base_product_id IS NULL).
* Defaut true : un produit ordinaire est une base ; un test le passe a false pour
* simuler une VARIANTE de taille presentee la ou seules les bases sont eligibles.
*/
public bool $productIsBase = true;
/**
* Ligne renvoyee par MenuRepository::find() ; null = introuvable.
*
@ -149,6 +173,14 @@ final class FakeDatabase implements DatabaseInterface
*/
public ?array $orderByNumberRow = null;
/**
* Ligne {source} renvoyee pour OrderAdminController::orderSource (garde de
* visibilite PRE-3, 6.1) ; null = numero inconnu (traite comme non visible).
*
* @var array<string, mixed>|null
*/
public ?array $orderSourceRow = null;
/**
* Lignes renvoyees par MenuRepository::all().
*
@ -418,12 +450,25 @@ final class FakeDatabase implements DatabaseInterface
return $this->userPinSet ? ['id' => 1] : null;
}
// Re-verification d'identite au set de PIN (ProfileController) : lecture du
// password_hash du compte actif de session. is_active = 1 dans le predicat :
// retirer ce filtre en production ferait virer au rouge le test du compte inactif.
if (str_contains($sql, 'SELECT password_hash FROM user WHERE id') && str_contains($sql, 'is_active = 1')) {
return $this->currentPasswordRow;
}
// Exige is_active = 1 (garde RG-T13) : retirer le predicat en production
// ferait virer au rouge les tests de resolveActingUser.
if (str_contains($sql, 'pin_hash FROM user WHERE email') && str_contains($sql, 'is_active = 1')) {
return $this->actingUserRow;
}
// R4/F9-2 : predicat base-only (productIsBase). Doit passer AVANT la route
// generique 'FROM product WHERE id = :id' (productRow) qu'elle matche aussi.
if (str_contains($sql, 'FROM product WHERE id = :id') && str_contains($sql, 'base_product_id IS NULL')) {
return $this->productIsBase ? ['id' => 1] : null;
}
if (str_contains($sql, 'FROM product WHERE id = :id')) {
return $this->productRow;
}
@ -436,6 +481,14 @@ final class FakeDatabase implements DatabaseInterface
return $this->menuRow;
}
// Garde de visibilite PRE-3 (6.1) : lecture ciblee de la seule colonne source
// par OrderAdminController::orderSource. Doit passer AVANT la route generique
// 'FROM customer_order WHERE order_number' (orderByNumberRow) qu'elle matche
// aussi. null = numero inconnu (l'appelant le traite comme non visible).
if (str_contains($sql, 'SELECT source FROM customer_order WHERE order_number')) {
return $this->orderSourceRow;
}
if (str_contains($sql, 'FROM customer_order WHERE order_number')) {
return $this->orderByNumberRow;
}
@ -493,6 +546,12 @@ final class FakeDatabase implements DatabaseInterface
return $this->categoriesRows;
}
// R4/F9-1 : liste base-only (basesOnly) pour les selects. Distincte de la
// liste admin enrichie (all(), 'FROM product p JOIN category').
if (str_contains($sql, 'FROM product WHERE base_product_id IS NULL')) {
return $this->baseProductsRows;
}
if (str_contains($sql, 'FROM product p JOIN category')) {
return $this->productsRows;
}

Some files were not shown because too many files have changed in this diff Show more