Compare commits

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

15 commits

Author SHA1 Message Date
Imugiii
aec417df3e docs: corrige des assertions documentaires fausses (upload images, fallback JSON, workflow commande)
All checks were successful
CI / secret-scan (pull_request) Successful in 15s
CI / php-lint (pull_request) Successful in 28s
CI / static-tests (pull_request) Successful in 1m32s
CI / js-tests (pull_request) Successful in 38s
2026-06-25 07:58:27 +00:00
Imugiii
0604d743fc docs: journal #94-#105 + ADR-0011 (POS tactile) + ADR-0012 (dashboard stock) + reindex 2026-06-25 07:58:27 +00:00
Imugiii
10440bb266 docs(merise): synchronise le referentiel avec la base deployee (5 migrations additives + order_number reel) 2026-06-25 07:58:19 +00:00
Imugiii
03ef99d67b feat(back-office): page Stock en tableau de bord (alertes + reappro en avant)
All checks were successful
CI / secret-scan (pull_request) Successful in 34s
CI / php-lint (pull_request) Successful in 1m6s
CI / static-tests (pull_request) Successful in 1m45s
CI / js-tests (pull_request) Successful in 54s
Refonte de la page d'accueil Ingredients/Stock, jugee trop chargee et opaque.
Desormais : un bandeau explique le lien stock -> disponibilite borne (un
ingredient requis sous le seuil critique rend les produits qui l'utilisent
indisponibles a la commande, 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 +
bouton Reapprovisionner direct ; la liste complete passe au second plan et le
CRUD (creer / modifier / supprimer) est relegue. Les sous-pages (reappro,
inventaire, mouvements, creation) restent inchangees.

index() expose les compteurs par etat (testables). Tests : IngredientController
+4 cas (bandeau, promotion d'un critique en section reappro, etat vide positif,
compteurs par etat). PHP unit 409, JS 119, PHPStan L6.
2026-06-24 12:39:52 +00: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
95 changed files with 5142 additions and 1693 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

@ -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

@ -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 : 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"
# 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],
);
@ -36,6 +40,7 @@ final class UserDirectory
'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

@ -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>>
*/

View file

@ -63,8 +63,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 +84,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',
);
}

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,7 +187,7 @@ 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
{
@ -176,6 +195,7 @@ class CatalogueController extends Controller
'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,
'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,
);
@ -360,6 +377,7 @@ class CounterOrderController extends AdminController
'products' => $products,
'menus' => $this->menusWithSlots($productRepository),
'serviceMode' => (string) ($values['service_mode'] ?? ($source === 'drive' ? 'drive' : 'dine_in')),
'serviceTag' => (string) ($values['service_tag'] ?? ''),
'error' => $error,
], $guard, $status);
}

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,10 +47,22 @@ class IngredientController extends AdminController
return $guard;
}
$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(),
'ingredients' => $ingredients,
'bandCounts' => $counts,
'canManage' => $this->may($guard, 'ingredient.manage'),
'canRestock' => $this->may($guard, 'stock.manage'),
'canCount' => $this->may($guard, 'stock.count'),

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

@ -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,28 @@ $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),
'image' => (string) ($p['image_path'] ?? ''),
'category_id' => (int) ($p['category_id'] ?? 0),
'category_name' => $catNameOf($p),
'modifiers' => $jsModifiers($p['modifiers'] ?? null),
],
$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 +95,9 @@ $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),
// 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 +117,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>
<?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>
<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>
<?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>
<fieldset class="form-group">
<legend>Produits</legend>
<?php if ($productRows === []): ?>
<p class="admin-empty">Aucun produit commandable pour le moment.</p>
<?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: ?>
<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>
<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; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</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; ?>
</fieldset>
</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; ?>
</ul>
<?php endif; ?>
</fieldset>
<fieldset class="form-group">
<legend>Panier</legend>
<ul class="order-cart" id="order-cart" aria-live="polite">
<?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>
</fieldset>
<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>
<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>
<?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,11 +3,15 @@
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 array<string, int> $bandCounts
* @var bool $canManage
* @var bool $canRestock
* @var bool $canCount
@ -16,94 +20,172 @@ declare(strict_types=1);
/** @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);
// 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',
};
$bandText = static fn (string $band): string => match ($band) {
'critical' => 'Critique',
'low' => 'Alerte',
default => '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>
<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) ?>
<?php if ($restock): ?>
<a class="btn btn-primary stock-card__action" href="/admin/ingredients/<?= $id ?>/restock">Reapprovisionner</a>
<?php endif; ?>
</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;
$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>
<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; ?>
</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; ?>
</div>
<div class="stock-list__bar"><?= $renderBar($row) ?></div>
<div class="stock-list__actions">
<?php if ($count): ?>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
<a class="btn btn-secondary btn-sm" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
<?php endif; ?>
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
<?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;">
<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-secondary" type="submit"><?= $active ? 'Desactiver' : 'Reactiver' ?></button>
<button class="btn btn-ghost btn-sm" type="submit"><?= $active ? 'Desactiver' : 'Reactiver' ?></button>
</form>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/delete">Supprimer</a>
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/delete">Supprimer</a>
</span>
<?php endif; ?>
</td>
</tr>
</div>
</li>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</ul>
<?php endif; ?>
</section>

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>

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;
@ -1431,3 +1461,581 @@ 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;
}
/* 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;
}
}

View file

@ -1,26 +1,28 @@
/*
* counter-order.js Composeur de commande comptoir/drive (back-office, sous-lot 3c).
* counter-order.js POS tactile a tuiles (comptoir / drive, back-office).
*
* CSP 'self' : script externe (pas d'inline, zero handler dans le HTML). Les donnees
* (produits commandables + leurs modificateurs, menus + slots + format + modificateurs
* du burger) sont lues depuis les attributs data-* de #counter-order-form. L'equipier
* ajoute des produits (champ quantite), personnalise un produit a la carte (retrait/
* ajout d'ingredients) ou configure un menu (slots + format + retrait/ajout sur le
* burger). A la soumission, le panier est serialise en JSON dans le champ cache
* #items_json (Request::formBody cote serveur ne garde que les scalaires, d'ou le
* passage par une chaine JSON). Le serveur revalide la forme (RG-T18), revalide chaque
* modificateur metier (resolveModifiers) et recalcule les prix (RG-T16) : les libelles/
* prix affiches ici sont indicatifs, jamais source de verite.
* du burger) sont lues depuis deux scripts JSON inertes (#pos-products, #pos-menus,
* type="application/json") du formulaire #counter-order-form. L'ecran imite la borne
* client : des onglets de categories en haut, une grille de tuiles a gauche, un panneau
* commande persistant a droite. Un tap sur une tuile de produit simple ajoute le produit
* (qty 1) ; un tap sur un menu ou un produit a modificateurs ouvre la modale de
* composition (slots + format + retrait / ajout d'ingredients). Le panneau commande
* affiche les lignes (qty x nom + prix de ligne) avec +/- et retrait, le total, et un
* bouton "Encaisser X,XX EUR".
*
* A la soumission, le panier est serialise en JSON dans le champ cache #items_json
* (Request::formBody cote serveur ne garde que les scalaires, d'ou le passage par une
* chaine JSON). Le serveur revalide la forme (RG-T18), revalide chaque modificateur
* metier (resolveModifiers) et recalcule les prix (RG-T16) : les libelles / prix
* affiches ici restent indicatifs, pas une source de verite.
*
* La logique de slots (un pas par slot, requis / optionnel, format) calque
* page-product-menu.js (borne) ; la logique de modificateurs (cases "retirer" pour les
* ingredients is_removable, "ajouter +X.XX EUR" pour les is_addable) calque l'UX
* borne. Seul le rendu differe (idiome back-office, pas de style borne). Les lignes
* configurees (produit personnalise / menu) vivent dans un etat JS et sont rendues dans
* le panier ; les produits sans modificateur sont derives a la soumission depuis les
* champs qty_<id> (repli sans JS conserve : le serveur accepte aussi qty_<id> si
* #items_json est vide). Un produit personnalisable est routé par la modale (sa
* quantite directe est ignoree quand JS s'execute) pour ne pas le compter deux fois.
* ingredients is_removable, "ajouter +X.XX EUR" pour les is_addable) calque l'UX borne ;
* le panneau commande calque order-panel.js (lignes, stepper +/-, total). Seul le rendu
* differe (idiome back-office, palette admin).
*
* Module CommonJS (admin = racine CommonJS, comme pin-modal.js) : init(doc) est
* exporte pour les tests et auto-appele au DOMContentLoaded en production.
@ -32,19 +34,66 @@
// aussi dessert/extra). Aligne sur page-product-menu.js (anti-perte silencieuse).
var SLOT_LABEL = { side: 'Accompagnement', drink: 'Boisson', sauce: 'Sauce' };
function parseData(form, key, fallback) {
try {
var v = JSON.parse(form.dataset[key] || fallback);
return Array.isArray(v) ? v : JSON.parse(fallback);
} catch (e) {
return JSON.parse(fallback);
// Lit un script JSON inerte (type="application/json") par id et retourne le tableau
// decode. Tolerant : un script absent / mal forme retombe sur un tableau vide.
function parseJsonScript(doc, id) {
var node = doc.getElementById(id);
if (!node) {
return [];
}
try {
var v = JSON.parse(node.textContent || '[]');
return Array.isArray(v) ? v : [];
} catch (e) {
return [];
}
}
// Montant en euros formate comme le PHP number_format(.../100, 2, ',', ' ') des
// vues : virgule decimale ET espace separateur de milliers. Aligne l'affichage
// client sur le rendu serveur (ex. 1 234,50 EUR) pour eviter une divergence visible
// sur les montants superieurs a 1000. Indicatif : le serveur recalcule tout (RG-T16).
function moneyParts(cents) {
var fixed = (Number(cents) / 100).toFixed(2);
var dot = fixed.indexOf('.');
var intPart = fixed.slice(0, dot);
var decPart = fixed.slice(dot + 1);
var sign = '';
if (intPart.charAt(0) === '-') {
sign = '-';
intPart = intPart.slice(1);
}
// Insere un espace tous les 3 chiffres depuis la droite (separateur de milliers).
intPart = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return sign + intPart + ',' + decPart;
}
// Surcout d'un ajout, formate en euros (affichage local indicatif ; le serveur
// refige extra_price_cents, RG-T16).
function formatExtra(cents) {
return '+' + (Number(cents) / 100).toFixed(2).replace('.', ',') + ' EUR';
return '+' + moneyParts(cents) + ' EUR';
}
// Montant en euros (sans signe), pour les prix de ligne et le total. Meme format
// que les vues PHP (cf. moneyParts).
function formatEuros(cents) {
return moneyParts(cents) + ' EUR';
}
// Somme des surcouts d'ajout (action 'add') d'une liste de modificateurs choisis,
// resolus via la liste proposable (extra_price_cents). Les retraits ne changent pas
// le prix indicatif. Pur ; le serveur reste seul juge du surcout reel.
function modifiersExtra(proposable, chosen) {
if (!chosen || !chosen.length) {
return 0;
}
var extraById = {};
(proposable || []).forEach(function (m) {
extraById[Number(m.ingredient_id)] = Number(m.extra_price_cents) || 0;
});
return chosen.reduce(function (sum, c) {
return c.action === 'add' ? sum + (extraById[Number(c.ingredient_id)] || 0) : sum;
}, 0);
}
// Etapes composables d'un menu : burger impose ignore (non choisi ici), un pas par
@ -72,6 +121,26 @@
});
}
// Onglets de categories construits depuis les produits ET menus : une entree par
// categorie distincte, dans l'ordre d'apparition du catalogue (deja trie par
// categorie / display_order cote serveur). Pur ; chaque entree porte le libelle et
// le nombre de tuiles. La cle est l'id de categorie (0 = "Autres" par defaut).
function buildCategoryTabs(products, menus) {
var order = [];
var byKey = {};
function add(row) {
var key = Number(row.category_id) || 0;
if (!byKey[key]) {
byKey[key] = { id: key, name: row.category_name || 'Autres', count: 0 };
order.push(key);
}
byKey[key].count += 1;
}
(products || []).forEach(add);
(menus || []).forEach(add);
return order.map(function (key) { return byKey[key]; });
}
function init(doc) {
var form = doc.getElementById('counter-order-form');
var hidden = doc.getElementById('items_json');
@ -82,8 +151,28 @@
return;
}
var products = parseData(form, 'products', '[]'); // [{id, name, price, modifiers:[...]}]
var menus = parseData(form, 'menus', '[]'); // [{id, name, price_normal, price_maxi, burger_modifiers:[...], slots:[...]}]
// Conteneurs du POS : onglets categories + grille de tuiles. Optionnels (rendu
// degrade sans eux) -> gardes au moment d'ecrire.
var tabsHost = doc.getElementById('pos-tabs');
var grid = doc.getElementById('pos-grid');
// Elements de prix (1) : valeur du total + libelle du bouton d'encaissement.
var totalValue = doc.getElementById('order-total-value');
var submitBtn = doc.getElementById('order-submit');
// Region live concise (C) : un message court (total + nombre d'articles) annonce
// a chaque mutation du panier, sans deballer toute la liste au lecteur d'ecran.
var announce = doc.getElementById('pos-announce');
// 7a : champ numero de table, visible seulement en sur place (toggle au mode).
var serviceMode = doc.getElementById('service_mode');
var serviceTagGroup = doc.getElementById('service_tag_group');
// [{id, name, price, image, category_id, category_name, modifiers:[...]}]
var products = parseJsonScript(doc, 'pos-products');
// [{id, name, price_normal, price_maxi, image, category_id, category_name,
// burger_modifiers:[...], slots:[...]}]
var menus = parseJsonScript(doc, 'pos-menus');
// Index produit par id : resolution des libelles d'options de slot + acces aux
// modificateurs proposables d'un produit a la carte.
@ -92,19 +181,19 @@
productById[Number(p.id)] = p;
});
// Lignes configurees par l'equipier : items prets a serialiser, avec libelle recap.
// menuLines : menus configures ; productLines : produits personnalises (modifiers).
var menuLines = [];
var productLines = [];
// Panier unifie : une liste de lignes. Chaque ligne porte un kind :
// - 'product' simple : { kind, localId, productId, productName, quantity }
// - 'product' modifie : ... + proposable, modifiers (config par la modale)
// - 'menu' : { kind, localId, menuId, menuName, format,
// selections, proposable, modifiers } (quantity ajustable)
// Le tap d'une tuile simple FUSIONNE avec une ligne simple existante (meme
// produit) en incrementant la quantite, comme une caisse ; les lignes
// configurees (modifiers / menu) restent distinctes (compositions differentes).
var cartLines = [];
var lineSeq = 0;
// Produits routes par la modale (ils portent un bouton "Personnaliser") : leur
// quantite directe qty_<id> est ignoree a la serialisation pour eviter le double
// comptage (le champ reste present pour le repli sans JS).
var configurableIds = {};
Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) {
configurableIds[Number(btn.dataset.productId)] = true;
});
// Categorie active (filtre la grille) : 1er onglet par defaut.
var activeCategory = null;
function el(tag, className) {
var e = doc.createElement(tag);
@ -203,28 +292,37 @@
return parts.join(', ');
}
// Vrai si la ligne porte au moins un modificateur (produit personnalise).
function hasMods(line) {
return line.modifiers && line.modifiers.length;
}
/* ----------------------------------------------------------------- */
/* Serialisation du panier -> #items_json */
/* ----------------------------------------------------------------- */
// Produits sans modificateur : derives des champs qty_<id> (>= 1) NON routes par
// la modale. Produits personnalises : productLines. Menus : menuLines. La forme
// calque ce qu'attend OrderRepository::resolveLine (revalide cote serveur).
// Forme calquee sur ce qu'attend OrderRepository::resolveLine (revalide cote
// serveur). Produits (simples ou personnalises) -> {type:'product', ...} ;
// menus -> {type:'menu', ...}. La quantite d'un menu vaut sa quantite de ligne
// (N menus identiques = un menu x N, facture par quantite cote serveur).
function serialize() {
var items = [];
Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) {
var productId = Number(input.dataset.productId);
if (configurableIds[productId]) {
return; // route par la modale -> pas de double comptage.
}
var quantity = parseInt(input.value, 10);
if (productId > 0 && quantity >= 1) {
items.push({ type: 'product', product_id: productId, quantity: quantity });
}
cartLines.forEach(function (line) {
if (line.kind === 'menu') {
items.push({
type: 'menu',
menu_id: line.menuId,
quantity: line.quantity,
format: line.format,
selections: line.selections.map(function (s) {
return { menu_slot_id: s.slotId, product_id: s.productId };
}),
modifiers: line.modifiers.map(function (m) {
return { ingredient_id: m.ingredient_id, action: m.action };
}),
});
productLines.forEach(function (line) {
return;
}
items.push({
type: 'product',
product_id: line.productId,
@ -234,62 +332,62 @@
}),
});
});
menuLines.forEach(function (line) {
items.push({
type: 'menu',
menu_id: line.menuId,
quantity: 1,
format: line.format,
selections: line.selections.map(function (s) {
return { menu_slot_id: s.slotId, product_id: s.productId };
}),
modifiers: line.modifiers.map(function (m) {
return { ingredient_id: m.ingredient_id, action: m.action };
}),
});
});
hidden.value = JSON.stringify(items);
}
/* ----------------------------------------------------------------- */
/* Rendu du panier (recap des lignes configurees) */
/* Prix indicatifs (1, 6) : par ligne + total + libelle du bouton */
/* ----------------------------------------------------------------- */
function renderCart() {
Array.prototype.forEach.call(cart.querySelectorAll('.order-cart__line'), function (node) {
node.parentNode.removeChild(node);
});
productLines.forEach(function (line) {
var li = el('li', 'order-cart__line');
var label = el('span', 'order-cart__label');
var text = line.productName + ' x' + line.quantity;
var modLabel = modifierLabel(line.proposable, line.modifiers);
if (modLabel) {
text += ' (' + modLabel + ')';
// Prix d'une ligne PRODUIT : prix de base + surcout des ajouts, le tout
// multiplie par la quantite. Indicatif (RG-T16 serveur).
function productLineTotal(line) {
var base = (productById[Number(line.productId)] || {}).price || 0;
var extra = modifiersExtra(line.proposable, line.modifiers);
return (Number(base) + extra) * Number(line.quantity || 1);
}
label.textContent = text;
li.appendChild(label);
var removeBtn = el('button', 'btn btn-secondary order-cart__remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
removeBtn.addEventListener('click', function () {
productLines = productLines.filter(function (l) { return l.localId !== line.localId; });
renderCart();
});
li.appendChild(removeBtn);
// Prix d'une ligne MENU : price_maxi si format maxi sinon price_normal, plus le
// surcout des ajouts sur le burger, multiplie par la quantite. Les selections de
// slot n'ajoutent rien (le prix du menu est forfaitaire cote serveur). Indicatif.
function menuLineTotal(line) {
var menu = menus.filter(function (m) { return Number(m.id) === Number(line.menuId); })[0] || {};
var base = line.format === 'maxi' ? (menu.price_maxi || 0) : (menu.price_normal || 0);
var extra = modifiersExtra(line.proposable, line.modifiers);
return (Number(base) + extra) * Number(line.quantity || 1);
}
cart.appendChild(li);
});
function lineTotal(line) {
return line.kind === 'menu' ? menuLineTotal(line) : productLineTotal(line);
}
menuLines.forEach(function (line) {
var li = el('li', 'order-cart__line');
// Total indicatif du panier : somme des lignes. Met a jour le pied de panier, le
// libelle du bouton ("Encaisser X,XX EUR") ET la region live concise (C) : un
// message court "Total X EUR, N articles" tient le lecteur d'ecran informe de
// l'essentiel a chaque mutation, sans re-annoncer toute la liste du panier.
function updateTotal() {
var total = cartLines.reduce(function (sum, line) { return sum + lineTotal(line); }, 0);
var count = cartLines.reduce(function (sum, line) { return sum + Number(line.quantity || 0); }, 0);
if (totalValue) {
totalValue.textContent = formatEuros(total);
}
if (submitBtn) {
submitBtn.textContent = 'Encaisser ' + formatEuros(total);
}
if (announce) {
announce.textContent = count === 0
? 'Panier vide'
: 'Total ' + formatEuros(total) + ', ' + count + (count > 1 ? ' articles' : ' article');
}
}
var label = el('span', 'order-cart__label');
/* ----------------------------------------------------------------- */
/* Panier (panneau commande : lignes + stepper +/- + retrait) */
/* ----------------------------------------------------------------- */
// Libelle d'une ligne du panneau (nom + composition recap).
function lineLabel(line) {
if (line.kind === 'menu') {
var parts = [line.menuName + ' (' + (line.format === 'maxi' ? 'Maxi' : 'Normal') + ')'];
line.selections.forEach(function (s) {
var p = productById[Number(s.productId)];
@ -299,36 +397,225 @@
});
var text = parts.join(' - ');
var modLabel = modifierLabel(line.proposable, line.modifiers);
if (modLabel) {
text += ' (' + modLabel + ')';
return modLabel ? (text + ' (' + modLabel + ')') : text;
}
label.textContent = text;
li.appendChild(label);
var label = line.productName;
var pm = modifierLabel(line.proposable, line.modifiers);
return pm ? (label + ' (' + pm + ')') : label;
}
// Ajuste la quantite d'une ligne (delta +1 / -1). Tomber a 0 retire la ligne
// (comme order-panel.js borne : decrementer a zero = retrait).
function adjustQuantity(line, delta) {
var next = Number(line.quantity || 1) + delta;
if (next <= 0) {
cartLines = cartLines.filter(function (l) { return l.localId !== line.localId; });
} else {
line.quantity = next;
}
renderCart();
}
function removeLine(line) {
cartLines = cartLines.filter(function (l) { return l.localId !== line.localId; });
renderCart();
}
// Construit une ligne du panneau : libelle + prix + stepper (-/qty/+) + retrait.
function cartLineNode(line) {
var li = el('li', 'order-cart__line');
var main = el('div', 'order-cart__main');
var label = el('span', 'order-cart__label');
label.textContent = lineLabel(line);
main.appendChild(label);
var price = el('span', 'order-cart__price');
price.textContent = formatEuros(lineTotal(line));
main.appendChild(price);
li.appendChild(main);
var controls = el('div', 'order-cart__controls');
var stepper = el('div', 'order-cart__qty');
stepper.setAttribute('role', 'group');
stepper.setAttribute('aria-label', 'Quantite de ' + lineLabel(line));
var dec = el('button', 'order-cart__qty-btn');
dec.type = 'button';
dec.textContent = ''; // signe moins
dec.setAttribute('aria-label', 'Diminuer la quantite de ' + lineLabel(line));
dec.addEventListener('click', function () { adjustQuantity(line, -1); });
stepper.appendChild(dec);
var qty = el('span', 'order-cart__qty-value');
qty.textContent = String(line.quantity);
stepper.appendChild(qty);
var inc = el('button', 'order-cart__qty-btn');
inc.type = 'button';
inc.textContent = '+';
inc.setAttribute('aria-label', 'Augmenter la quantite de ' + lineLabel(line));
inc.addEventListener('click', function () { adjustQuantity(line, 1); });
stepper.appendChild(inc);
controls.appendChild(stepper);
var removeBtn = el('button', 'btn btn-secondary order-cart__remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
removeBtn.addEventListener('click', function () {
menuLines = menuLines.filter(function (l) { return l.localId !== line.localId; });
renderCart();
});
li.appendChild(removeBtn);
removeBtn.setAttribute('aria-label', 'Retirer ' + lineLabel(line) + ' de la commande');
removeBtn.addEventListener('click', function () { removeLine(line); });
controls.appendChild(removeBtn);
cart.appendChild(li);
li.appendChild(controls);
return li;
}
function renderCart() {
Array.prototype.forEach.call(cart.querySelectorAll('.order-cart__line'), function (node) {
node.parentNode.removeChild(node);
});
cartLines.forEach(function (line) {
cart.appendChild(cartLineNode(line));
});
if (cartEmpty) {
cartEmpty.style.display = (productLines.length || menuLines.length) ? 'none' : '';
cartEmpty.style.display = cartLines.length ? 'none' : '';
}
updateTotal();
}
/* ----------------------------------------------------------------- */
/* Ajout au panier */
/* ----------------------------------------------------------------- */
// Tap d'une tuile produit simple (sans modificateur) : fusionne avec une ligne
// simple existante du meme produit (increment), sinon cree une ligne qty 1.
function addSimpleProduct(product) {
var existing = cartLines.filter(function (l) {
return l.kind === 'product' && l.productId === Number(product.id) && !hasMods(l);
})[0];
if (existing) {
existing.quantity += 1;
} else {
cartLines.push({
kind: 'product',
localId: ++lineSeq,
productId: Number(product.id),
productName: product.name,
quantity: 1,
proposable: product.modifiers || [],
modifiers: [],
});
}
renderCart();
}
/* ----------------------------------------------------------------- */
/* Modales de configuration */
/* ----------------------------------------------------------------- */
// Handlers de modale courants (un jeu a la fois) : retires a la fermeture pour
// ne pas accumuler de listeners a chaque ouverture. lastFocused memorise
// l'element qui avait le focus AVANT l'ouverture, pour le restaurer a la
// fermeture (a11y : le focus ne doit pas retomber en haut de page).
var escHandler = null;
var trapHandler = null;
var lastFocused = null;
// Selecteur des controles focusables d'une modale (boutons, champs, selects ;
// les champs desactives / caches sont exclus). Le trap cycle sur cet ensemble.
var FOCUSABLE = 'button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
function focusableIn(root) {
return Array.prototype.slice.call(root.querySelectorAll(FOCUSABLE));
}
function closeComposer() {
if (escHandler) {
doc.removeEventListener('keydown', escHandler);
escHandler = null;
}
if (trapHandler) {
doc.removeEventListener('keydown', trapHandler);
trapHandler = null;
}
modalHost.textContent = '';
modalHost.setAttribute('hidden', '');
// Restaure le focus sur l'element declencheur (tuile produit / menu).
if (lastFocused && typeof lastFocused.focus === 'function') {
lastFocused.focus();
}
lastFocused = null;
}
// 7c + a11y : monte un panneau dans la modale avec un overlay (clic = fermeture),
// pose role=dialog / aria-modal / aria-labelledby (titre h2), gere Echap, piege
// Tab/Shift+Tab dans le panneau, memorise et restaure le focus. Le panel est
// deja construit par l'appelant ; on ne fait qu'habiller l'ouverture.
function openModal(panel) {
lastFocused = doc.activeElement;
modalHost.textContent = '';
// role=dialog modal + libelle = titre h2 de la modale (id stable, partage
// par les deux composeurs car une seule modale est ouverte a la fois).
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'true');
var titleEl = panel.querySelector('.menu-composer__title');
if (titleEl) {
titleEl.id = 'menu-composer-title';
panel.setAttribute('aria-labelledby', 'menu-composer-title');
}
var overlay = el('div', 'menu-composer__overlay');
// Clic sur le fond (overlay lui-meme, pas un enfant) -> fermeture.
overlay.addEventListener('click', function (event) {
if (event.target === overlay) {
closeComposer();
}
});
overlay.appendChild(panel);
modalHost.appendChild(overlay);
modalHost.removeAttribute('hidden');
escHandler = function (event) {
if (event.key === 'Escape' || event.keyCode === 27) {
closeComposer();
}
};
doc.addEventListener('keydown', escHandler);
// Focus-trap : Tab/Shift+Tab cyclent dans les controles focusables du panel.
trapHandler = function (event) {
if (event.key !== 'Tab' && event.keyCode !== 9) {
return;
}
var focusable = focusableIn(panel);
if (!focusable.length) {
return;
}
var first = focusable[0];
var last = focusable[focusable.length - 1];
var active = doc.activeElement;
if (event.shiftKey && (active === first || !panel.contains(active))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
doc.addEventListener('keydown', trapHandler);
// Focus sur le premier controle pour la saisie clavier.
var firstControl = focusableIn(panel)[0];
if (firstControl && typeof firstControl.focus === 'function') {
firstControl.focus();
}
}
// Modale d'un produit a la carte : quantite + modificateurs (retrait / ajout).
@ -336,7 +623,6 @@
var proposable = product.modifiers || [];
var state = { quantity: 1, selectedRemove: {}, selectedAdd: {} };
modalHost.textContent = '';
var panel = el('div', 'menu-composer');
var title = el('h2', 'menu-composer__title');
@ -357,6 +643,9 @@
qtyInput.addEventListener('change', function () {
var v = parseInt(qtyInput.value, 10);
state.quantity = v >= 1 ? v : 1;
// E : une saisie invalide (0 / vide / non numerique) est ramenee a 1 ; on
// reaffiche la valeur corrigee pour que l'equipier voie ce qui sera ajoute.
qtyInput.value = String(state.quantity);
});
qtyBlock.appendChild(qtyInput);
panel.appendChild(qtyBlock);
@ -369,7 +658,8 @@
addBtn.type = 'button';
addBtn.textContent = 'Ajouter au panier';
addBtn.addEventListener('click', function () {
productLines.push({
cartLines.push({
kind: 'product',
localId: ++lineSeq,
productId: Number(product.id),
productName: product.name,
@ -389,8 +679,7 @@
actions.appendChild(cancelBtn);
panel.appendChild(actions);
modalHost.appendChild(panel);
modalHost.removeAttribute('hidden');
openModal(panel);
}
// Ouvre la modale d'un menu : choix du format, une selection par slot, puis les
@ -405,7 +694,6 @@
}
});
modalHost.textContent = '';
var panel = el('div', 'menu-composer');
var title = el('h2', 'menu-composer__title');
@ -478,15 +766,37 @@
// Modificateurs du burger support (retrait / ajout d'ingredients).
panel.appendChild(renderModifierControls(proposable, state.selectedRemove, state.selectedAdd));
// 7c : message inline au lieu d'un return muet quand un slot requis n'est pas
// choisi. Le <p role=alert> reste present en permanence (non hidden), vide au
// depart : on ne change que textContent a l'erreur, pour fiabiliser l'annonce
// lecteur d'ecran (un element revele apres coup peut ne pas etre annonce).
var inlineError = el('p', 'menu-composer__error');
inlineError.setAttribute('role', 'alert');
inlineError.textContent = '';
panel.appendChild(inlineError);
// Impasse : un slot requis sans aucune option resoluble rend le menu non
// composable. On desactive l'ajout et on affiche un message clair plutot que
// de laisser l'equipier buter sur "options obligatoires" sans pouvoir corriger.
var deadEnd = steps.some(function (s) { return s.isRequired && !s.options.length; });
// Actions : ajouter (si tous les requis choisis) / annuler.
var actions = el('div', 'menu-composer__actions');
var addBtn = el('button', 'btn btn-primary menu-composer__add');
addBtn.type = 'button';
addBtn.textContent = 'Ajouter au panier';
if (deadEnd) {
addBtn.disabled = true;
inlineError.textContent = 'Ce menu n\'est pas composable : une option obligatoire est indisponible.';
}
addBtn.addEventListener('click', function () {
if (deadEnd) {
return;
}
var allRequired = steps.filter(function (s) { return s.isRequired; })
.every(function (s) { return state.selections[s.id] != null; });
if (!allRequired) {
inlineError.textContent = 'Choisissez toutes les options obligatoires avant d\'ajouter.';
return;
}
var selections = [];
@ -496,10 +806,12 @@
selections.push({ slotId: step.id, productId: chosen });
}
});
menuLines.push({
cartLines.push({
kind: 'menu',
localId: ++lineSeq,
menuId: Number(menu.id),
menuName: menu.name,
quantity: 1,
format: state.format,
selections: selections,
proposable: proposable,
@ -517,43 +829,255 @@
actions.appendChild(cancelBtn);
panel.appendChild(actions);
modalHost.appendChild(panel);
modalHost.removeAttribute('hidden');
openModal(panel);
}
/* ----------------------------------------------------------------- */
/* Grille de tuiles + onglets categories */
/* ----------------------------------------------------------------- */
// Pastille de repli : initiale du nom sur fond colore, quand aucune image
// exploitable n'est disponible cote back-office (image_path vide ou injoignable).
function buildPastille(name) {
var pastille = el('span', 'pos-tile__pastille');
pastille.setAttribute('aria-hidden', 'true');
var initial = (String(name || '').trim().charAt(0) || '?').toUpperCase();
pastille.textContent = initial;
return pastille;
}
// Construit une tuile. kind : 'product' | 'menu'. Le tap declenche onTap. Une
// image n'est tentee que si image_path est non vide ; sur erreur de chargement,
// un listener (CSP-safe, pas d'onerror inline) masque l'image et revele la
// pastille de repli (le back-office n'a pas garantie d'image exploitable).
function buildTile(entry, kind, priceLabel, onTap) {
var tile = el('button', 'pos-tile');
tile.type = 'button';
// Une tuile qui ouvre la modale (menu ou produit a modificateurs) annonce
// l'intention dans son nom accessible (D) et porte aria-haspopup=dialog : le
// lecteur d'ecran sait qu'un tap ouvre une boite de dialogue de composition,
// pas un ajout sec. Le badge visuel "Menu"/"A composer" reste decoratif.
var opensModal = kind === 'menu' || (entry.modifiers && entry.modifiers.length);
var intent = opensModal ? (kind === 'menu' ? ', menu a composer' : ', a composer') : '';
tile.setAttribute('aria-label', entry.name + ', ' + priceLabel + intent);
if (opensModal) {
tile.setAttribute('aria-haspopup', 'dialog');
}
var media = el('span', 'pos-tile__media');
var pastille = buildPastille(entry.name);
media.appendChild(pastille);
var src = String(entry.image || '');
if (src !== '') {
var img = el('img', 'pos-tile__image');
img.src = src;
img.alt = '';
img.setAttribute('aria-hidden', 'true');
img.setAttribute('loading', 'lazy');
// CSP-safe : pas d'onerror inline. Sur echec, masque l'image (la
// pastille dessous redevient visible).
img.addEventListener('error', function () {
img.style.display = 'none';
});
media.appendChild(img);
}
tile.appendChild(media);
var body = el('span', 'pos-tile__body');
var nameEl = el('span', 'pos-tile__name');
nameEl.textContent = entry.name;
body.appendChild(nameEl);
var priceEl = el('span', 'pos-tile__price');
priceEl.textContent = priceLabel;
body.appendChild(priceEl);
tile.appendChild(body);
// Badge visuel "Menu"/"A composer" (decoratif : l'intention est deja dans
// l'aria-label ci-dessus ; aria-hidden evite la double annonce).
if (opensModal) {
var badge = el('span', 'pos-tile__badge');
badge.setAttribute('aria-hidden', 'true');
badge.textContent = kind === 'menu' ? 'Menu' : 'A composer';
tile.appendChild(badge);
}
tile.addEventListener('click', onTap);
return tile;
}
// Rend la grille pour la categorie active : produits puis menus de cette
// categorie. Un produit simple -> ajout direct ; un produit a modificateurs ou
// un menu -> modale.
function renderGrid() {
if (!grid) {
return;
}
grid.textContent = '';
var catProducts = products.filter(function (p) { return (Number(p.category_id) || 0) === activeCategory; });
var catMenus = menus.filter(function (m) { return (Number(m.category_id) || 0) === activeCategory; });
if (!catProducts.length && !catMenus.length) {
var empty = el('p', 'pos__nojs');
empty.textContent = 'Aucun produit dans cette categorie.';
grid.appendChild(empty);
return;
}
catProducts.forEach(function (product) {
var tile = buildTile(product, 'product', formatEuros(product.price), function () {
if (product.modifiers && product.modifiers.length) {
openProductComposer(product);
} else {
addSimpleProduct(product);
}
});
grid.appendChild(tile);
});
catMenus.forEach(function (menu) {
var label = 'Normal ' + formatEuros(menu.price_normal) + ' / Maxi ' + formatEuros(menu.price_maxi);
var tile = buildTile(menu, 'menu', label, function () {
openComposer(menu);
});
grid.appendChild(tile);
});
}
// Boutons d'onglet (references stables), construits UNE fois au demarrage. On les
// garde pour MUTER l'etat actif (A) plutot que de reconstruire la barre au clic :
// reconstruire detruisait le bouton focalise et faisait retomber le focus sur body.
var tabButtons = [];
// Bascule la categorie active : mute les boutons existants (classe is-active +
// aria-selected + roving tabindex), met a jour activeCategory et le tabpanel
// (aria-labelledby vers l'onglet actif), rerend la grille. Si moveFocus, pose le
// focus sur l'onglet actif (navigation clavier : le focus suit la selection).
function setActiveCategory(catId, moveFocus) {
activeCategory = catId;
tabButtons.forEach(function (btn) {
var selected = Number(btn.dataset.categoryId) === Number(catId);
btn.classList.toggle('is-active', selected);
btn.setAttribute('aria-selected', selected ? 'true' : 'false');
// Roving tabindex (B) : seul l'onglet actif est dans l'ordre de tabulation ;
// les autres sont atteints par les fleches une fois la barre focalisee.
btn.tabIndex = selected ? 0 : -1;
if (selected) {
// Le tabpanel (grille) est libelle par l'onglet actif (B).
if (grid) {
grid.setAttribute('aria-labelledby', btn.id);
}
if (moveFocus && typeof btn.focus === 'function') {
btn.focus();
}
}
});
renderGrid();
}
// Navigation clavier WAI-ARIA tablist (B) : Fleche gauche/droite (cycliques) +
// Home/Fin deplacent le focus ET activent l'onglet (le focus suit la selection).
function onTabsKeydown(event) {
var idx = tabButtons.indexOf(event.target);
if (idx < 0 || !tabButtons.length) {
return;
}
var next = null;
var key = event.key;
if (key === 'ArrowRight' || key === 'ArrowDown') {
next = (idx + 1) % tabButtons.length;
} else if (key === 'ArrowLeft' || key === 'ArrowUp') {
next = (idx - 1 + tabButtons.length) % tabButtons.length;
} else if (key === 'Home') {
next = 0;
} else if (key === 'End') {
next = tabButtons.length - 1;
}
if (next === null) {
return;
}
event.preventDefault();
setActiveCategory(Number(tabButtons[next].dataset.categoryId), true);
}
// Construit la barre d'onglets UNE fois (A) et cable clic + clavier.
function buildTabs() {
if (!tabsHost) {
return;
}
tabsHost.textContent = '';
tabButtons = [];
var tabs = buildCategoryTabs(products, menus);
if (!tabs.length) {
return;
}
if (activeCategory === null) {
activeCategory = tabs[0].id;
}
tabs.forEach(function (tab, i) {
var selected = tab.id === activeCategory;
var btn = el('button', 'pos__tab' + (selected ? ' is-active' : ''));
btn.type = 'button';
btn.id = 'pos-tab-' + tab.id;
btn.dataset.categoryId = String(tab.id);
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', selected ? 'true' : 'false');
// aria-controls relie l'onglet au tabpanel unique (la grille filtree, B).
if (grid && grid.id) {
btn.setAttribute('aria-controls', grid.id);
}
btn.tabIndex = selected ? 0 : -1;
btn.textContent = tab.name;
btn.addEventListener('click', function () {
setActiveCategory(tab.id, false);
});
tabButtons.push(btn);
tabsHost.appendChild(btn);
});
tabsHost.addEventListener('keydown', onTabsKeydown);
// Pose le libelle initial du tabpanel sur l'onglet actif.
if (grid) {
grid.setAttribute('aria-labelledby', 'pos-tab-' + activeCategory);
}
}
/* ----------------------------------------------------------------- */
/* Cablage */
/* ----------------------------------------------------------------- */
Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) {
btn.addEventListener('click', function () {
var productId = Number(btn.dataset.productId);
var product = productById[productId];
if (product) {
openProductComposer(product);
// 7a : le numero de table n'a de sens qu'en sur place -> visible seulement quand
// service_mode = dine_in (au comptoir ; au drive le champ n'existe pas).
function syncServiceTag() {
if (!serviceTagGroup) {
return;
}
});
});
Array.prototype.forEach.call(doc.querySelectorAll('.menu-configure'), function (btn) {
btn.addEventListener('click', function () {
var menuId = Number(btn.dataset.menuId);
var menu = menus.filter(function (m) { return Number(m.id) === menuId; })[0];
if (menu) {
openComposer(menu);
var dineIn = serviceMode && serviceMode.value === 'dine_in';
if (dineIn) {
serviceTagGroup.removeAttribute('hidden');
} else {
serviceTagGroup.setAttribute('hidden', '');
}
});
});
}
if (serviceMode) {
serviceMode.addEventListener('change', syncServiceTag);
}
syncServiceTag();
form.addEventListener('submit', function () {
serialize();
});
buildTabs();
renderGrid();
renderCart();
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { init: init, composerSteps: composerSteps };
module.exports = { init: init, composerSteps: composerSteps, buildCategoryTabs: buildCategoryTabs };
}
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('DOMContentLoaded', function () {

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,10 +107,28 @@ 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}
<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}"
@ -118,6 +137,7 @@ function lineHtml(line) {
>
<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', () => {
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

@ -79,6 +79,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 +125,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')) {
@ -130,6 +152,10 @@ final class FakeCatalogueDatabase implements DatabaseInterface
return $this->menuSlotRows;
}
if (str_contains($sql, 'FROM allergen')) {
return $this->allergensRows;
}
return [];
}

View file

@ -28,6 +28,8 @@ final class FakeOrderDatabase implements DatabaseInterface
public array $slotRows = [];
/** @var array<int, list<array<string,mixed>>> recettes (composition) par produit id. */
public array $compositions = [];
/** @var list<array<string,mixed>> ids produits en rupture calculee (autoUnavailableIds, RG-T21). */
public array $autoUnavailableRows = [];
/** Commande existante renvoyee par la recherche idempotency_key ; null = aucune. */
/** @var array<string,mixed>|null */
@ -93,6 +95,11 @@ final class FakeOrderDatabase implements DatabaseInterface
if (str_contains($sql, 'FROM menu_slot s')) {
return $this->slotRows[(int) $params['id']] ?? [];
}
// RG-T21 : autoUnavailableIds() (sans param) AVANT composition() (avec :id) :
// les deux lisent product_ingredient ; on desambiguise sur SELECT DISTINCT.
if (str_contains($sql, 'SELECT DISTINCT pi.product_id')) {
return $this->autoUnavailableRows;
}
if (str_contains($sql, 'FROM product_ingredient pi')) {
return $this->compositions[(int) $params['id']] ?? [];
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Auth\SmtpTransport;
use RuntimeException;
/**
* Transport SMTP double : rejoue des reponses serveur scriptees et enregistre les
* ecritures du client, pour tester la logique du protocole sans reseau.
*/
final class FakeSmtpTransport implements SmtpTransport
{
/** @var list<string> ce que le client a ecrit, dans l'ordre */
public array $writes = [];
public bool $cryptoEnabled = false;
public bool $closed = false;
public bool $opened = false;
/** @var list<string> reponses a rendre, dans l'ordre des readReply() */
private array $replies;
/** @param list<string> $replies */
public function __construct(array $replies)
{
$this->replies = $replies;
}
public function open(string $host, int $port, int $timeoutSeconds): void
{
$this->opened = true;
}
public function write(string $raw): void
{
$this->writes[] = $raw;
}
public function readReply(): string
{
if ($this->replies === []) {
throw new RuntimeException('FakeSmtpTransport : plus de reponse scriptee');
}
return array_shift($this->replies);
}
public function enableCrypto(): void
{
$this->cryptoEnabled = true;
}
public function close(): void
{
$this->closed = true;
}
/** Concatene toutes les ecritures (pratique pour assertions sur le message). */
public function written(): string
{
return implode('', $this->writes);
}
}

View file

@ -18,6 +18,7 @@ use App\Tests\Support\FakeDatabase;
/**
* Stub OrderQueryRepository : liste canned multi-source (rendu de la liste teste sans
* base). recent() ramene tous canaux ; le controleur filtre par source derivee du chemin.
* paidQueue() ramene la file "En cours" canned, deja filtree par source par l'appelant.
*/
final class StubChannelOrders extends OrderQueryRepository
{
@ -29,6 +30,22 @@ final class StubChannelOrders extends OrderQueryRepository
['order_number' => 'K9', 'source' => 'kiosk', 'service_mode' => 'takeaway', 'service_tag' => null, 'status' => 'paid', 'total_ttc_cents' => 500, 'created_at' => '2026-06-22 10:06:00', 'paid_at' => '2026-06-22 10:06:01'],
];
}
public function paidQueue(array $sources): array
{
// File "En cours" du canal : ne ramene que des commandes dont la source est
// dans $sources (le controleur passe la SEULE source du canal courant). C100 est
// sur place avec un numero de table (12) ; D200 est un drive sans table.
$all = [
['order_number' => 'C100', 'source' => 'counter', 'service_mode' => 'dine_in', 'service_tag' => '12', 'total_ttc_cents' => 890, 'paid_at' => '2026-06-22 10:00:01'],
['order_number' => 'D200', 'source' => 'drive', 'service_mode' => 'drive', 'service_tag' => null, 'total_ttc_cents' => 990, 'paid_at' => '2026-06-22 10:05:01'],
];
return array_values(array_filter(
$all,
static fn (array $o): bool => in_array($o['source'], $sources, true),
));
}
}
final class TestCounterOrderController extends CounterOrderController
@ -167,7 +184,11 @@ final class CounterOrderControllerTest extends TestCase
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('Cheeseburger', $body);
self::assertStringContainsString('qty_12', $body); // champ quantite par produit
// POS tactile : le catalogue est embarque dans un script JSON inerte (id/prix),
// pas en champs qty_<id>. La grille de tuiles est rendue cote client.
self::assertStringContainsString('id="pos-products"', $body);
self::assertStringContainsString('"id":12', $body);
self::assertStringContainsString('id="pos-grid"', $body);
self::assertStringContainsString('service_mode', $body);
}
@ -232,7 +253,10 @@ final class CounterOrderControllerTest extends TestCase
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('Menu Cheeseburger', $body);
self::assertStringContainsString('data-menu-id="5"', $body); // bouton configurer
// POS tactile : les menus + slots sont embarques dans un script JSON inerte
// (la tuile menu et la modale sont rendues cote client par counter-order.js).
self::assertStringContainsString('id="pos-menus"', $body);
self::assertStringContainsString('"id":5', $body);
self::assertStringContainsString('items_json', $body); // champ cache du panier
self::assertStringContainsString('counter-order.js', $body); // script du composeur
}
@ -273,6 +297,39 @@ final class CounterOrderControllerTest extends TestCase
self::assertSame(22, $selInsert['pid']);
}
public function testStoreCreatesMenuOrderWithQuantityTwo(): void
{
// G : un item menu via items_json avec quantity:2 persiste qty=2 sur order_item ;
// les selections de slot ne sont pas dupliquees par la quantite (un seul INSERT).
$db = $this->permittedDb();
$db->menuRow = ['id' => 5, 'name' => 'Menu Cheeseburger', 'burger_product_id' => 12, 'price_normal_cents' => 990, 'price_maxi_cents' => 1190, 'is_available' => 1];
$db->productRow = ['id' => 22, 'name' => 'Frites', 'price_cents' => 250, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1];
$db->menuSlotRows = [
['id' => 16, 'name' => 'Accompagnement', 'slot_type' => 'side', 'is_required' => 1, 'display_order' => 1, 'product_id' => 22],
];
$db->lastInsertId = 100;
$db->orderByNumberRow = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 1980, 'status' => 'pending_payment'];
$items = json_encode([
['type' => 'menu', 'menu_id' => 5, 'quantity' => 2, 'format' => 'normal', 'selections' => [['menu_slot_id' => 16, 'product_id' => 22]]],
]);
$request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'dine_in', 'items_json' => (string) $items], '/counter/orders');
$response = $this->controller($request, $db)->store();
self::assertSame(302, $response->status());
$itemInsert = $this->writeParams($db, 'INSERT INTO order_item ');
self::assertSame('menu', $itemInsert['type']);
self::assertSame(5, $itemInsert['mid']);
self::assertSame(2, $itemInsert['qty']);
// La selection de slot est persistee UNE fois (independante de la quantite).
$selectionWrites = array_values(array_filter(
$db->writes,
static fn (array $w): bool => str_contains($w['sql'], 'INSERT INTO order_item_selection'),
));
self::assertCount(1, $selectionWrites);
}
public function testStoreCreatesProductOrderViaItemsJson(): void
{
// Chemin unifie : items_json est prefere a qty_<id>, et un produit y passe aussi.
@ -340,15 +397,16 @@ final class CounterOrderControllerTest extends TestCase
self::assertSame(200, $response->status());
$body = $response->body();
// data-products encode le JSON avec htmlspecialchars : les guillemets sont
// echappes en &quot;. On cherche les fragments echappes (forme reellement rendue).
self::assertStringContainsString('ingredient_id&quot;:3', $body);
self::assertStringContainsString('ingredient_id&quot;:8', $body);
// POS tactile : la composition PROPOSABLE est embarquee dans le script JSON inerte
// #pos-products (type="application/json"). json_encode avec JSON_HEX_TAG protege
// l'insertion dans un <script> (un '<' deviendrait < ; pas de </script>
// injectable). On cherche les fragments JSON reellement rendus (guillemets bruts,
// surs dans un script). La tuile "A composer" et la modale sont rendues client-side.
self::assertStringContainsString('id="pos-products"', $body);
self::assertStringContainsString('"ingredient_id":3', $body);
self::assertStringContainsString('"ingredient_id":8', $body);
self::assertStringContainsString('Oignon', $body);
self::assertStringContainsString('Bacon', $body);
// Bouton de personnalisation expose pour le produit a modificateurs.
self::assertStringContainsString('product-configure', $body);
self::assertStringContainsString('Personnaliser', $body);
}
public function testStoreCreatesProductOrderWithModifiers(): void
@ -456,6 +514,181 @@ final class CounterOrderControllerTest extends TestCase
self::assertFalse($db->wrote('INSERT INTO customer_order'));
}
public function testCounterIndexShowsInProgressQueueSection(): void
{
// 5 : la file "En cours" du canal (paid non livre) apparait en haut, filtree
// a la source counter (C100 present, D200 du drive absent).
$response = $this->controller($this->get('/counter/orders'), $this->permittedDb())->index();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('En cours', $body);
self::assertStringContainsString('Historique recent', $body);
self::assertStringContainsString('C100', $body);
self::assertStringNotContainsString('D200', $body);
// 4 : la file porte une colonne "Table" et affiche le numero de la commande
// sur place (C100 -> table 12).
self::assertStringContainsString('<th>Table</th>', $body);
self::assertStringContainsString('>12</td>', $body);
}
public function testDriveCreateFreezesServiceModeToDrive(): void
{
// 2 : au drive, service_mode n'est PAS un select editable. Il est fige a 'Drive'
// (affichage) + transmis par un champ cache (un select readonly resterait
// editable, donc on ne s'y fie pas).
$response = $this->controller($this->get('/drive/orders/new'), $this->permittedDb())->create();
self::assertSame(200, $response->status());
$body = $response->body();
// Champ cache porteur de la valeur drive (soumis).
self::assertStringContainsString('type="hidden" name="service_mode" id="service_mode" value="drive"', $body);
// Aucun select de mode au drive (l'affichage est fige).
self::assertStringNotContainsString('<select class="form-input" id="service_mode"', $body);
}
public function testCounterCreateKeepsEditableServiceModeSelect(): void
{
// 2 (contre-exemple) : au comptoir, le select dine_in/takeaway reste editable.
$response = $this->controller($this->get('/counter/orders/new'), $this->permittedDb())->create();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('<select class="form-input" id="service_mode"', $body);
self::assertStringContainsString('Sur place', $body);
self::assertStringContainsString('A emporter', $body);
}
public function testCreateExposesConfigurableProductModifiersInJson(): void
{
// POS tactile : un produit a modificateurs est expose dans #pos-products avec sa
// composition proposable. Le client en rend une tuile "A composer" qui ouvre la
// modale au tap (la saisie de la quantite et des modificateurs se fait en modale).
$db = $this->permittedDb();
$db->productsRows = [
['id' => 12, 'category_id' => 1, 'category_name' => 'Burgers', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1],
];
$db->compositionRows = [
['product_id' => 12, 'ingredient_id' => 3, 'ingredient_name' => 'Oignon', 'is_removable' => 1, 'is_addable' => 0, 'extra_price_cents' => 0, 'quantity_normal' => 1, 'quantity_maxi' => 1],
];
$response = $this->controller($this->get('/counter/orders/new'), $db)->create();
self::assertSame(200, $response->status());
$body = $response->body();
// Le produit et sa composition sont dans le JSON inerte (pas de champ qty_<id>).
self::assertStringContainsString('id="pos-products"', $body);
self::assertStringContainsString('"id":12', $body);
self::assertStringContainsString('"ingredient_id":3', $body);
self::assertStringContainsString('Oignon', $body);
// Plus de champ quantite par produit (la saisie passe par les tuiles + modale).
self::assertStringNotContainsString('name="qty_12"', $body);
}
public function testCreateExposesCategoryNamesForTabs(): void
{
// POS tactile : les onglets de categories sont construits cote client a partir
// de category_name embarque dans le JSON de chaque produit/menu.
$db = $this->permittedDb();
$db->productsRows = [
['id' => 12, 'category_id' => 1, 'category_name' => 'Burgers', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1],
['id' => 22, 'category_id' => 2, 'category_name' => 'Accompagnements', 'name' => 'Frites', 'description' => null, 'price_cents' => 250, 'image_path' => null, 'display_order' => 1],
];
$response = $this->controller($this->get('/counter/orders/new'), $db)->create();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('id="pos-tabs"', $body);
self::assertStringContainsString('"category_name":"Burgers"', $body);
self::assertStringContainsString('"category_name":"Accompagnements"', $body);
}
public function testCreateExposesBothMenuPrices(): void
{
// 6 : les deux prix d'un menu (Normal / Maxi, en centimes) sont exposes dans le
// JSON inerte ; le client affiche "Normal X / Maxi Y" sur la tuile et la modale.
$db = $this->permittedDb();
$db->menusRows = [
['id' => 5, 'category_id' => 1, 'burger_product_id' => 12, 'name' => 'Menu Cheeseburger', 'description' => null, 'price_normal_cents' => 990, 'price_maxi_cents' => 1190, 'image_path' => null, 'display_order' => 1],
];
$response = $this->controller($this->get('/counter/orders/new'), $db)->create();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('id="pos-menus"', $body);
self::assertStringContainsString('"price_normal":990', $body);
self::assertStringContainsString('"price_maxi":1190', $body);
}
public function testStorePassesServiceTagInDineIn(): void
{
// 7a : un numero de table saisi en sur place est transmis a createStaffOrder et
// persiste (service_tag) sur la commande.
$db = $this->permittedDb();
$db->productRow = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1];
$db->lastInsertId = 100;
$db->orderByNumberRow = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
$items = json_encode([['type' => 'product', 'product_id' => 12, 'quantity' => 1]]);
$request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'dine_in', 'service_tag' => '12', 'items_json' => (string) $items], '/counter/orders');
$response = $this->controller($request, $db)->store();
self::assertSame(302, $response->status());
$insert = $this->writeParams($db, 'INSERT INTO customer_order');
self::assertSame('12', $insert['tag']);
}
public function testStoreDropsServiceTagWhenNotDineIn(): void
{
// 7a : un numero de table soumis hors sur place (takeaway) n'est pas transmis ;
// service_tag persiste NULL (la table n'a de sens qu'en sur place).
$db = $this->permittedDb();
$db->productRow = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1];
$db->lastInsertId = 100;
$db->orderByNumberRow = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
$items = json_encode([['type' => 'product', 'product_id' => 12, 'quantity' => 1]]);
$request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'takeaway', 'service_tag' => '12', 'items_json' => (string) $items], '/counter/orders');
$response = $this->controller($request, $db)->store();
self::assertSame(302, $response->status());
$insert = $this->writeParams($db, 'INSERT INTO customer_order');
self::assertNull($insert['tag']);
}
public function testNavRoutesDriveRoleToDriveLanding(): void
{
// 3 : le lien "Saisie commande" du layout pointe vers le canal du role courant.
// Un equipier drive (role.order_source = drive, remonte par displayInfo) est
// route vers /drive/orders.
$db = $this->permittedDb();
$db->userDisplayRow = ['first_name' => 'Dana', 'last_name' => 'D', 'role_label' => 'Drive', 'order_source' => 'drive'];
$response = $this->controller($this->get('/drive/orders'), $db)->index();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('href="/drive/orders" class="sidebar-item active">Saisie commande', $body);
}
public function testNavRoutesCounterRoleToCounterLanding(): void
{
// 3 (contre-exemple) : un role comptoir (order_source counter / NULL) est route
// vers /counter/orders.
$db = $this->permittedDb();
$db->userDisplayRow = ['first_name' => 'Sam', 'last_name' => 'C', 'role_label' => 'Comptoir', 'order_source' => 'counter'];
$response = $this->controller($this->get('/counter/orders'), $db)->index();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('href="/counter/orders" class="sidebar-item active">Saisie commande', $body);
}
/**
* Parametres lies de la premiere ecriture dont le SQL contient $needle.
*

View file

@ -198,6 +198,64 @@ final class IngredientControllerTest extends TestCase
self::assertStringContainsString('Alerte', $response->body());
}
public function testIndexShowsBusinessExplainerBanner(): void
{
// Le bandeau explique le lien metier stock -> disponibilite borne (RG-T21),
// l'info qui manquait dans l'ancien tableau brut.
$db = $this->permittedDb();
$db->ingredientsRows = [$this->ingredient()];
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
self::assertStringContainsString('stock-explainer', $body);
self::assertStringContainsString('borne', $body);
}
public function testIndexPromotesLowAndCriticalIntoRestockSection(): void
{
// Un ingredient critique (3% < seuil 5) doit apparaitre dans la section
// "A reapprovisionner" mise en avant, pas seulement dans la liste calme.
$db = $this->permittedDb();
$db->ingredientsRows = [$this->ingredient(['name' => 'Buns', 'stock_quantity' => 3])];
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
self::assertStringContainsString('A reapprovisionner', $body);
self::assertStringContainsString('stock-section--restock', $body);
self::assertStringContainsString('Buns', $body);
self::assertStringContainsString('Critique', $body);
}
public function testIndexShowsPositiveEmptyStateWhenNothingLow(): void
{
// Tous au-dessus des seuils -> etat vide positif dans la section restock.
$db = $this->permittedDb();
$db->ingredientsRows = [$this->ingredient(['stock_quantity' => 100])]; // 100% -> normal
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
self::assertStringContainsString('au-dessus de leurs seuils', $body);
}
public function testIndexCountsIngredientsPerBand(): void
{
// Resume en haut : 1 critique (3%), 1 alerte (8%), 1 au-dessus (100%).
$db = $this->permittedDb();
$db->ingredientsRows = [
$this->ingredient(['name' => 'Buns', 'stock_quantity' => 3]),
$this->ingredient(['name' => 'Cheddar', 'stock_quantity' => 8]),
$this->ingredient(['name' => 'Salade', 'stock_quantity' => 100]),
];
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
// Chaque compteur est verrouille a SON libelle (sinon une regex generique
// passerait meme avec les trois compteurs inverses).
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">critiques/', $body);
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">en alerte/', $body);
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">au-dessus du seuil/', $body);
}
public function testIndexForbiddenWithoutStockRead(): void
{
$db = $this->permittedDb();

View file

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\SmtpClient;
use App\Tests\Support\FakeSmtpTransport;
use RuntimeException;
final class SmtpClientTest extends TestCase
{
/** @return list<string> sequence nominale de reponses serveur */
private function happyReplies(): array
{
return [
"220 smtp.brevo ready\r\n", // greeting
"250-smtp\r\n250 AUTH LOGIN\r\n", // EHLO (multiligne)
"220 go ahead\r\n", // STARTTLS
"250 ok\r\n", // EHLO post-TLS
"334 VXNlcm5hbWU6\r\n", // AUTH LOGIN
"334 UGFzc3dvcmQ6\r\n", // user
"235 authenticated\r\n", // password
"250 ok\r\n", // MAIL FROM
"250 ok\r\n", // RCPT TO
"354 end data with <CRLF>.<CRLF>\r\n", // DATA
"250 queued\r\n", // body
"221 bye\r\n", // QUIT
];
}
public function testNominalConversationAuthenticatesAndSends(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$client = new SmtpClient($t);
$client->send('smtp-relay.brevo.com', 587, 'user@x', 'secret', 'from@a.fr', 'to@b.fr', "Subject: hi\r\n\r\ncorps");
self::assertTrue($t->opened);
self::assertTrue($t->cryptoEnabled, 'STARTTLS doit basculer le transport en TLS');
self::assertTrue($t->closed, 'le transport doit etre ferme');
$sent = $t->written();
self::assertStringContainsString("STARTTLS\r\n", $sent);
self::assertStringContainsString("AUTH LOGIN\r\n", $sent);
self::assertStringContainsString(base64_encode('user@x') . "\r\n", $sent);
self::assertStringContainsString(base64_encode('secret') . "\r\n", $sent);
self::assertStringContainsString("MAIL FROM:<from@a.fr>\r\n", $sent);
self::assertStringContainsString("RCPT TO:<to@b.fr>\r\n", $sent);
self::assertStringContainsString("DATA\r\n", $sent);
self::assertStringContainsString("\r\n.\r\n", $sent, 'le corps doit finir par le terminateur DATA');
self::assertStringContainsString("QUIT\r\n", $sent);
}
public function testReEhloHappensAfterStarttls(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
(new SmtpClient($t))->send('h', 587, 'u', 'p', 'f@a.fr', 't@b.fr', "x");
// Deux EHLO : un avant STARTTLS, un apres (session repart a zero apres TLS).
$ehloCount = substr_count($t->written(), 'EHLO ');
self::assertSame(2, $ehloCount);
}
public function testRejectedAuthThrowsAndCloses(): void
{
$replies = $this->happyReplies();
$replies[6] = "535 authentication failed\r\n"; // reponse au mot de passe
$t = new FakeSmtpTransport($replies);
$client = new SmtpClient($t);
try {
$client->send('h', 587, 'u', 'bad', 'f@a.fr', 't@b.fr', 'x');
self::fail('une auth refusee doit lever');
} catch (RuntimeException $e) {
self::assertStringContainsString('AUTH password', $e->getMessage());
}
self::assertTrue($t->closed, 'le transport doit etre ferme meme en cas d echec (finally)');
}
public function testUnexpectedGreetingThrows(): void
{
$t = new FakeSmtpTransport(["554 service unavailable\r\n"]);
$this->expectException(RuntimeException::class);
(new SmtpClient($t))->send('h', 587, 'u', 'p', 'f@a.fr', 't@b.fr', 'x');
}
public function testRejectsCrlfInRecipientBeforeConnecting(): void
{
// Tentative d'injection d'une commande RCPT via le destinataire.
$t = new FakeSmtpTransport($this->happyReplies());
$client = new SmtpClient($t);
try {
$client->send('h', 587, 'u', 'p', 'f@a.fr', "t@b.fr>\r\nRCPT TO:<evil@x.com", 'x');
self::fail('un CRLF dans l adresse doit lever');
} catch (RuntimeException $e) {
self::assertStringContainsString('destinataire', $e->getMessage());
}
self::assertFalse($t->opened, 'aucune connexion ne doit s ouvrir si l adresse est invalide');
self::assertSame([], $t->writes, 'rien ne doit etre emis');
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\SmtpClient;
use App\Auth\SmtpMailer;
use App\Tests\Support\FakeSmtpTransport;
final class SmtpMailerTest extends TestCase
{
/** @return list<string> sequence nominale de reponses serveur */
private function happyReplies(): array
{
return [
"220 ready\r\n", "250 ok\r\n", "220 go\r\n", "250 ok\r\n",
"334 u\r\n", "334 p\r\n", "235 ok\r\n", "250 ok\r\n", "250 ok\r\n",
"354 data\r\n", "250 queued\r\n", "221 bye\r\n",
];
}
private function mailer(FakeSmtpTransport $t): SmtpMailer
{
return new SmtpMailer(
new SmtpClient($t),
'smtp-relay.brevo.com',
587,
'login@smtp-brevo.com',
'secret',
'noreply@a3n.fr',
'Wakdo',
);
}
public function testBuildsAndSendsResetMessage(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$this->mailer($t)->sendPasswordReset('client@example.fr', 'https://corentin-wakdo-admin.stark.a3n.fr/reset_password?token=abc');
$sent = $t->written();
self::assertStringContainsString('From: Wakdo <noreply@a3n.fr>', $sent);
self::assertStringContainsString('To: <client@example.fr>', $sent);
self::assertStringContainsString('Subject: Reinitialisation de votre mot de passe Wakdo', $sent);
self::assertStringContainsString('Content-Type: text/plain; charset=UTF-8', $sent);
self::assertStringContainsString('https://corentin-wakdo-admin.stark.a3n.fr/reset_password?token=abc', $sent);
// L'enveloppe SMTP doit porter l'expediteur et le destinataire reels.
self::assertStringContainsString('MAIL FROM:<noreply@a3n.fr>', $sent);
self::assertStringContainsString('RCPT TO:<client@example.fr>', $sent);
}
public function testRejectsInvalidRecipient(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$this->expectException(\RuntimeException::class);
$this->mailer($t)->sendPasswordReset("victim@x.fr\r\nBcc: evil@x.com", 'https://x/reset?token=t');
}
public function testHeaderAndBodySeparatedByBlankLine(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$this->mailer($t)->sendPasswordReset('c@e.fr', 'https://x/reset?token=t');
// En-tetes et corps separes par une ligne vide (CRLF CRLF).
self::assertStringContainsString("Content-Transfer-Encoding: 8bit\r\n\r\nBonjour,", $t->written());
}
}

View file

@ -27,20 +27,38 @@ final class UserDirectoryTest extends TestCase
'last_name' => 'J',
'email' => 'corentin@wakdo.local',
'role_label' => 'Administrateur',
'order_source' => null,
];
self::assertSame(
['name' => 'Corentin J', 'role_label' => 'Administrateur', 'email' => 'corentin@wakdo.local'],
['name' => 'Corentin J', 'role_label' => 'Administrateur', 'email' => 'corentin@wakdo.local', 'order_source' => ''],
(new UserDirectory($this->db))->displayInfo(7),
);
}
public function testDisplayInfoExposesOrderSourceForChannelRoles(): void
{
// order_source remonte du role : sert au layout a router "Saisie commande".
$this->db->userDisplayRow = [
'first_name' => 'Dana',
'last_name' => 'D',
'email' => 'dana@wakdo.local',
'role_label' => 'Drive Staff',
'order_source' => 'drive',
];
self::assertSame(
['name' => 'Dana D', 'role_label' => 'Drive Staff', 'email' => 'dana@wakdo.local', 'order_source' => 'drive'],
(new UserDirectory($this->db))->displayInfo(8),
);
}
public function testDisplayInfoDefaultsWhenAbsent(): void
{
$this->db->userDisplayRow = null;
self::assertSame(
['name' => 'Utilisateur', 'role_label' => '', 'email' => ''],
['name' => 'Utilisateur', 'role_label' => '', 'email' => '', 'order_source' => ''],
(new UserDirectory($this->db))->displayInfo(999),
);
}

View file

@ -94,6 +94,32 @@ final class CatalogueControllerTest extends TestCase
self::assertSame(0, $payload['total']);
}
public function testAllergensReturnsIncoCollectionWithDescription(): void
{
$db = new FakeCatalogueDatabase();
// Entiers en CHAINE (comme PDO peut les rendre) + une description null pour
// verifier la preservation du NULL.
$db->allergensRows = [
['id' => '1', 'code' => 'gluten', 'name' => 'Cereales contenant du gluten', 'description' => 'Ble, seigle, orge.'],
['id' => '7', 'code' => 'lait', 'name' => 'Lait', 'description' => null],
];
$response = $this->controller($db, '/api/allergens')->allergens();
self::assertSame(200, $response->status());
$payload = $this->decode($response->body());
self::assertSame(2, $payload['total']);
self::assertIsArray($payload['data']);
$first = $payload['data'][0];
self::assertSame(['id', 'code', 'name', 'description'], array_keys($first));
self::assertSame(1, $first['id']); // chaine '1' -> int 1
self::assertSame('gluten', $first['code']);
self::assertSame('Cereales contenant du gluten', $first['name']);
self::assertSame('Ble, seigle, orge.', $first['description']);
self::assertNull($payload['data'][1]['description']); // null preserve
}
public function testProductsReturnsAvailableCollectionWithoutVatRate(): void
{
$db = new FakeCatalogueDatabase();
@ -115,7 +141,7 @@ final class CatalogueControllerTest extends TestCase
$product = $payload['data'][0];
self::assertSame(
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'maxi_variant_name', 'sizes'],
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'maxi_variant_name', 'sizes', 'is_orderable'],
array_keys($product),
);
self::assertSame(12, $product['id']);
@ -125,6 +151,41 @@ final class CatalogueControllerTest extends TestCase
self::assertArrayNotHasKey('is_available', $product); // toujours dispo ici -> non expose
self::assertNull($product['maxi_variant_name']); // pas de variante -> null
self::assertSame([], $product['sizes']); // produit mono-taille -> sizes vide
self::assertTrue($product['is_orderable']); // aucune rupture -> commandable
}
public function testProductsMarksAutoUnavailableProductAsNotOrderable(): void
{
// RG-T21 : deux produits listes (is_available=1), mais l'un (id 12) est en
// rupture calculee par le stock -> is_orderable false ; l'autre (id 13) true.
$db = new FakeCatalogueDatabase();
$db->productsRows = [
['id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => '890', 'image_path' => null, 'display_order' => '1', 'maxi_variant_name' => null],
['id' => '13', 'category_id' => '3', 'name' => 'Hamburger', 'description' => null, 'price_cents' => '790', 'image_path' => null, 'display_order' => '2', 'maxi_variant_name' => null],
];
$db->autoUnavailableRows = [['product_id' => '12']]; // 12 en rupture
$response = $this->controller($db, '/api/products')->products();
self::assertSame(200, $response->status());
$data = $this->decode($response->body())['data'];
self::assertFalse($data[0]['is_orderable']); // 12 en rupture
self::assertTrue($data[1]['is_orderable']); // 13 commandable
}
public function testProductDetailAutoUnavailableIsNotOrderable(): void
{
$db = new FakeCatalogueDatabase();
$db->productRow = [
'id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger',
'description' => null, 'price_cents' => '890', 'image_path' => null, 'display_order' => '1',
];
$db->autoUnavailableRows = [['product_id' => '12']];
$response = $this->controller($db, '/api/products/12')->product(['id' => '12']);
self::assertSame(200, $response->status());
self::assertFalse($this->decode($response->body())['data']['is_orderable']);
}
public function testProductsListExposesMaxiVariantName(): void
@ -291,18 +352,52 @@ final class CatalogueControllerTest extends TestCase
$menu = $payload['data'][0];
self::assertSame(
['id', 'category_id', 'burger_product_id', 'name', 'description', 'price_normal_cents', 'price_maxi_cents', 'image_path', 'display_order'],
['id', 'category_id', 'burger_product_id', 'name', 'description', 'price_normal_cents', 'price_maxi_cents', 'image_path', 'display_order', 'is_orderable'],
array_keys($menu),
);
self::assertSame(1, $menu['id']);
self::assertSame(5, $menu['burger_product_id']);
self::assertSame(990, $menu['price_normal_cents']);
self::assertSame(1190, $menu['price_maxi_cents']);
self::assertTrue($menu['is_orderable']); // burger non en rupture
self::assertArrayNotHasKey('slots', $menu); // liste legere : pas de slots
self::assertArrayNotHasKey('is_available', $menu); // toujours dispo ici
self::assertArrayNotHasKey('vat_rate', $menu);
}
public function testMenuNotOrderableWhenBurgerAutoUnavailable(): void
{
// RG-T21 (granularite burger seul) : le burger impose (id 5) est en rupture
// calculee -> le menu n'est plus commandable, meme s'il est is_available=1.
$db = new FakeCatalogueDatabase();
$db->menusRows = [
['id' => '1', 'category_id' => '1', 'burger_product_id' => '5', 'name' => 'Menu Big', 'description' => null, 'price_normal_cents' => '990', 'price_maxi_cents' => '1190', 'image_path' => null, 'display_order' => '1'],
];
$db->autoUnavailableRows = [['product_id' => '5']]; // burger en rupture
$response = $this->controller($db, '/api/menus')->menus();
self::assertSame(200, $response->status());
self::assertFalse($this->decode($response->body())['data'][0]['is_orderable']);
}
public function testMenuDetailNotOrderableWhenBurgerAutoUnavailable(): void
{
$db = new FakeCatalogueDatabase();
$db->menuRow = [
'id' => '1', 'category_id' => '1', 'burger_product_id' => '5', 'name' => 'Menu Big',
'description' => null, 'price_normal_cents' => '990', 'price_maxi_cents' => '1190',
'image_path' => null, 'display_order' => '1',
];
$db->menuSlotRows = [];
$db->autoUnavailableRows = [['product_id' => '5']];
$response = $this->controller($db, '/api/menus/1')->menu(['id' => '1']);
self::assertSame(200, $response->status());
self::assertFalse($this->decode($response->body())['data']['is_orderable']);
}
public function testMenuDetailReturnsDataWithSlots(): void
{
$db = new FakeCatalogueDatabase();

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Controllers;
use PHPUnit\Framework\TestCase;
use App\Controllers\HealthController;
use App\Core\Config;
use App\Core\Database;
use App\Core\Request;
/**
* Sous-classe de test : pointe le fichier VERSION sur une fixture temporaire,
* pour couvrir l'exposition de la version deployee sans dependre d'un deploiement
* reel (le fichier est ecrit par scripts/deploy.sh sur l'hote, jamais en test).
*/
final class TestHealthController extends HealthController
{
public string $versionPath = '';
protected function versionFilePath(): string
{
return $this->versionPath;
}
}
/**
* La sonde expose la version deployee (SHA + date) pour prouver le CD : apres un
* deploiement, GET /api/health doit refleter le nouveau commit. Le test n'a pas de
* base : l'appel DB echoue et degrade le statut, mais les champs version restent
* presents (ils sont independants de la BDD).
*/
final class HealthControllerTest extends TestCase
{
private function controller(string $versionPath): TestHealthController
{
$request = new Request('GET', '/api/health', [], [], '', '203.0.113.5');
$c = new TestHealthController($request, new Config(), new Database(new Config()));
$c->versionPath = $versionPath;
return $c;
}
public function testExposesDeployedVersionWhenFilePresent(): void
{
$fixture = tempnam(sys_get_temp_dir(), 'wakdo_version_');
file_put_contents($fixture, "3dee190 2026-06-23T14:02:11+02:00\n");
try {
$body = $this->controller($fixture)->index()->body();
$payload = json_decode($body, true);
self::assertSame('3dee190', $payload['version']);
self::assertSame('2026-06-23T14:02:11+02:00', $payload['deployed_at']);
} finally {
@unlink($fixture);
}
}
public function testVersionNullWhenFileAbsent(): void
{
$missing = sys_get_temp_dir() . '/wakdo_version_does_not_exist_' . getmypid();
@unlink($missing);
$body = $this->controller($missing)->index()->body();
$payload = json_decode($body, true);
self::assertNull($payload['version']);
self::assertNull($payload['deployed_at']);
}
}

View file

@ -121,6 +121,86 @@ final class OrderRepositoryTest extends TestCase
self::assertSame(8, $sel['slot']);
}
public function testMenuMaxiSwapsDrinkSelectionToLargeVariant(): void
{
// Au format maxi, la boisson fontaine Coca Cola (variante = Coca Cola 50cl,
// id 15) doit etre persistee comme la 50 cl : meme mecanique que l'accompagnement
// Grande Frite (maxi_variant_product_id), pour que le stock decremente la 50 cl
// et que le snapshot reflete "Coca Cola 50cl". Aucune garde sur le slot_type.
$db = new FakeOrderDatabase();
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
$db->products[14] = ['id' => 14, 'name' => 'Coca Cola', 'price_cents' => 190, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => 15];
$db->products[15] = ['id' => 15, 'name' => 'Coca Cola 50cl', 'price_cents' => 240, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => null];
$db->slotRows[5] = [['id' => 9, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 1, 'product_id' => 14]];
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi',
'selections' => [['menu_slot_id' => 9, 'product_id' => 14]]]], // borne envoie la 30 cl
]);
$sel = $db->firstWrite('INSERT INTO order_item_selection');
self::assertSame(15, $sel['pid']); // swap -> Coca Cola 50cl
self::assertSame('Coca Cola 50cl', $sel['label']);
self::assertSame(9, $sel['slot']);
}
public function testMenuMaxiKeepsBottledDrinkWithoutVariant(): void
{
// Une boisson en bouteille (Eau) n'a pas de variante 50 cl : meme en Maxi la
// selection reste l'Eau de base (degradation gracieuse, modele fast-food).
$db = new FakeOrderDatabase();
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
$db->products[16] = ['id' => 16, 'name' => 'Eau', 'price_cents' => 150, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => null];
$db->slotRows[5] = [['id' => 9, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 1, 'product_id' => 16]];
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi',
'selections' => [['menu_slot_id' => 9, 'product_id' => 16]]]],
]);
$sel = $db->firstWrite('INSERT INTO order_item_selection');
self::assertSame(16, $sel['pid']); // pas de variante -> reste l'Eau
self::assertSame('Eau', $sel['label']);
}
public function testProductInStockRuptureRejectedAtOrderCreation(): void
{
// RG-T21 : un produit liste (is_available=1) mais en rupture calculee par le
// stock est REFUSE a la creation de commande (garde serveur load-bearing, pas
// seulement grise sur la borne). Couvre le bypass URL directe / repli sans-JS.
$db = new FakeOrderDatabase();
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
$db->autoUnavailableRows = [['product_id' => 12]];
$this->expectException(OrderValidationException::class);
$this->expectExceptionMessage('PRODUCT_UNAVAILABLE');
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]],
]);
}
public function testMenuRejectedAtOrderWhenBurgerInStockRupture(): void
{
// RG-T21 (granularite burger seul) : le burger impose en rupture calculee rend
// le menu non commandable cote serveur, meme is_available=1.
$db = new FakeOrderDatabase();
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
$db->autoUnavailableRows = [['product_id' => 12]];
$this->expectException(OrderValidationException::class);
$this->expectExceptionMessage('MENU_UNAVAILABLE');
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'normal', 'selections' => []]],
]);
}
public function testMenuNormalKeepsBaseSideSelection(): void
{
// Format normal : aucune substitution, l'accompagnement reste la Moyenne

View file

@ -1,6 +1,8 @@
// Parcours E2E borne : welcome -> categories -> produit -> ajout panier -> panier
// -> paiement -> confirmation. La stack est montee a part (run.sh) ; le panier vit
// dans localStorage (meme origine), donc on peut naviguer par goto sans perdre l'etat.
// Parcours E2E borne : welcome -> categories -> produits (grille) -> modale d'options
// au clic produit -> ajout -> le panneau de commande persistant (unique vue panier)
// reflete la ligne -> paiement -> confirmation. La stack est montee a part (run.sh) ;
// le panier vit dans localStorage (meme origine), on navigue sans perdre l'etat.
// Il n'existe plus de page panier ni de page produit separees (lot F3 panier unique).
const { test, expect } = require('@playwright/test');
test('parcours borne : de l\'accueil a la confirmation de commande', async ({ page }) => {
@ -16,40 +18,46 @@ test('parcours borne : de l\'accueil a la confirmation de commande', async ({ pa
await test.step('categories -> produits', async () => {
await expect(page.locator('h1.categories-main__heading')).toBeVisible();
// Categorie 2 = boissons : produits SIMPLES (la categorie 1 = menus, qui rendent
// un autre gabarit a slots, sans bouton d'ajout direct).
// Categorie 2 = boissons : produits SIMPLES (la categorie 1 = menus, qui ouvrent
// le composeur a slots, un autre parcours). Un produit simple ouvre la modale options.
await page.locator('a[href="products.html?category=2"]').click();
await expect(page).toHaveURL(/products\.html\?category=2/);
});
await test.step('produits -> fiche produit', async () => {
// Cartes rendues par JS depuis le JSON : auto-wait sur la 1re carte.
const firstCard = page.locator('#products-grid a.product-card').first();
await test.step('clic produit -> modale options', async () => {
// Cartes rendues par JS depuis le JSON : auto-wait sur la 1re carte commandable.
const firstCard = page.locator('#products-grid a.product-card:not(.product-card--unavailable)').first();
await expect(firstCard).toBeVisible();
await firstCard.click();
await expect(page).toHaveURL(/product\.html\?id=/);
// product-options.js monte une modale (.composer-overlay) avec le bouton d'ajout #po-add.
await expect(page.locator('.composer-overlay [role="dialog"]')).toBeVisible();
await expect(page.locator('#po-add')).toBeVisible();
});
await test.step('ajout au panier', async () => {
const addBtn = page.locator('#add-to-cart-btn');
await expect(addBtn).toBeVisible();
await addBtn.click();
// Feedback visuel "Ajoute !" (page-product.js) ; l'ecriture localStorage est synchrone.
await expect(addBtn).toHaveText(/Ajoute/);
await test.step('ajout -> le panneau de commande reflete la ligne', async () => {
await page.locator('#po-add').click();
// La modale se ferme et le panneau persistant (unique vue panier) montre la ligne.
await expect(page.locator('.composer-overlay')).toHaveCount(0);
const panel = page.locator('[data-order-panel]');
await expect(panel.locator('.order-panel__line')).toHaveCount(1);
// Total calcule (le panneau affiche un montant, plus le placeholder vide).
await expect(panel.locator('.order-panel__total-value')).not.toHaveText('');
});
await test.step('panier : recapitulatif', async () => {
await page.goto('/cart.html');
await expect(page.locator('#cart-summary')).toBeVisible();
await expect(page.locator('#cart-list li')).toHaveCount(1);
// Total calcule, plus le placeholder "—".
await expect(page.locator('#total-ttc')).not.toHaveText('—');
await page.locator('#pay-btn').click();
await test.step('panneau Payer -> paiement', async () => {
const payLink = page.locator('[data-order-panel] .order-panel__pay');
await expect(payLink).toHaveAttribute('aria-disabled', 'false');
await payLink.click();
await expect(page).toHaveURL(/payment\.html/);
});
await test.step('paiement -> confirmation', async () => {
await page.locator('#pay-card').click();
// Sur-place : page-payment.js ouvre la modale chevalet (numero de table) avant de
// soumettre. On saisit un numero puis on enregistre pour declencher le checkout.
await expect(page.locator('#chevalet-input')).toBeVisible();
await page.locator('#chevalet-input').fill('12');
await page.locator('#chevalet-ok').click();
await expect(page).toHaveURL(/confirmation\.html/);
await expect(page.locator('.confirmation-banner__title')).toHaveText(/Commande confirmee/);
// Numero de commande genere (plus le placeholder).

View file

@ -1,16 +1,14 @@
/*
* Tests du module allergens du front borne (node:test + jsdom).
*
* Couvre le contrat de PR-C : la liste fixe des 14 allergenes INCO (data borne,
* se branchera sur /api/allergens au swap P4), la construction du bouton "i", et
* la modale GENERALE (ouverture, listing des 14, fermeture par bouton/overlay/
* Escape, idempotence). DOM simule par jsdom : aucun navigateur requis.
* Couvre : la construction du bouton "i", la modale GENERALE (ouverture, listing,
* fermeture par bouton/overlay/Escape, idempotence) et le chargement via l'API
* (loadAllergens consomme /api/allergens et ramene la forme borne). Les cas de
* rendu utilisent une fixture INLINE pour rester independants de la source de
* donnees. DOM simule par jsdom : aucun navigateur requis.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { JSDOM } from 'jsdom';
import {
@ -19,8 +17,20 @@ import {
closeAllergenModal,
} from '../../src/public/borne/assets/js/allergens.js';
const here = dirname(fileURLToPath(import.meta.url));
const allergensJsonPath = join(here, '../../src/public/borne/data/allergens.json');
let _seq = 0;
/* Fixture INLINE : un echantillon des 14 allergenes INCO a la forme borne
* { id, name, description }. Suffisant pour couvrir le rendu de la modale sans
* dependre d'un fichier de donnees. */
function allergensFixture() {
return [
{ id: 1, name: 'Cereales contenant du gluten', description: 'Ble, seigle, orge, avoine.' },
{ 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.' },
{ id: 14, name: 'Mollusques', description: 'Et produits a base de mollusques.' },
];
}
function setupDom() {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
@ -29,28 +39,6 @@ function setupDom() {
return dom;
}
function loadAllergensFixture() {
return JSON.parse(readFileSync(allergensJsonPath, 'utf8'));
}
test('data/allergens.json liste exactement les 14 allergenes INCO', () => {
const list = loadAllergensFixture();
assert.ok(Array.isArray(list));
assert.equal(list.length, 14);
for (const a of list) {
assert.equal(typeof a.id, 'number');
assert.equal(typeof a.name, 'string');
assert.ok(a.name.trim().length > 0);
}
const names = list.map((a) => a.name);
assert.equal(new Set(names).size, 14, 'noms uniques');
// Quelques jalons de la liste reglementaire (UE INCO 1169/2011 annexe II).
const joined = names.join(' | ').toLowerCase();
for (const expected of ['gluten', 'lait', 'arachide', 'soja', 'mollusque']) {
assert.ok(joined.includes(expected), `attendu: ${expected}`);
}
});
test('buildAllergenInfoButton cree un bouton "i" qui declenche onOpen', () => {
setupDom();
let opened = 0;
@ -65,46 +53,78 @@ test('buildAllergenInfoButton cree un bouton "i" qui declenche onOpen', () => {
assert.equal(opened, 1, 'le clic ouvre la modale');
});
test('openAllergenModal affiche une modale listant les 14 allergenes', () => {
test('openAllergenModal affiche une modale listant les allergenes fournis', () => {
setupDom();
const list = loadAllergensFixture();
const list = allergensFixture();
const overlay = openAllergenModal(list);
assert.ok(document.body.contains(overlay));
assert.equal(overlay.getAttribute('role'), 'dialog');
assert.equal(overlay.getAttribute('aria-modal'), 'true');
const items = overlay.querySelectorAll('.allergen-modal-list li');
assert.equal(items.length, 14);
assert.equal(items.length, list.length);
assert.ok(overlay.textContent.toLowerCase().includes('lait'));
});
test('openAllergenModal affiche la description quand elle est fournie', () => {
setupDom();
const overlay = openAllergenModal([{ id: 7, name: 'Lait', description: 'Et produits a base de lait.' }]);
const desc = overlay.querySelector('.allergen-desc');
assert.ok(desc, 'la description doit etre rendue');
assert.ok(desc.textContent.toLowerCase().includes('lait'));
});
test('la modale se ferme via le bouton de fermeture', () => {
setupDom();
openAllergenModal(loadAllergensFixture());
openAllergenModal(allergensFixture());
document.querySelector('.allergen-modal-close').click();
assert.equal(document.querySelector('.allergen-modal-overlay'), null);
});
test('la modale se ferme par clic sur l overlay (hors contenu)', () => {
const dom = setupDom();
const overlay = openAllergenModal(loadAllergensFixture());
const overlay = openAllergenModal(allergensFixture());
overlay.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
assert.equal(document.querySelector('.allergen-modal-overlay'), null);
});
test('la modale se ferme avec la touche Echap', () => {
const dom = setupDom();
openAllergenModal(loadAllergensFixture());
openAllergenModal(allergensFixture());
document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape' }));
assert.equal(document.querySelector('.allergen-modal-overlay'), null);
});
test('ouvrir deux fois ne duplique pas la modale (idempotent)', () => {
setupDom();
const list = loadAllergensFixture();
const list = allergensFixture();
openAllergenModal(list);
openAllergenModal(list);
assert.equal(document.querySelectorAll('.allergen-modal-overlay').length, 1);
closeAllergenModal();
assert.equal(document.querySelector('.allergen-modal-overlay'), null);
});
test('loadAllergens consomme /api/allergens, deballe {data} et ramene la forme borne', async () => {
const calls = [];
// Reponse canonique de l'API : enveloppe { data, total }, entrees id/code/name/description.
const apiRows = [
{ id: 1, code: 'gluten', name: 'Cereales contenant du gluten', description: 'Ble, seigle, orge.' },
{ id: 7, code: 'lait', name: 'Lait', description: 'Et produits a base de lait.' },
];
global.fetch = async (url) => {
calls.push(url);
if (url !== '/api/allergens') throw new Error(`fetch inattendu: ${url}`);
return { ok: true, status: 200, json: async () => ({ data: apiRows, total: apiRows.length }) };
};
const { loadAllergens } = await import(`../../src/public/borne/assets/js/data.js?case=allergens${_seq++}`);
const list = await loadAllergens();
assert.ok(calls.includes('/api/allergens'), 'doit fetch /api/allergens');
assert.equal(list.length, 2);
// Forme borne : name + description presents, code ignore.
assert.deepEqual(list[0], { id: 1, name: 'Cereales contenant du gluten', description: 'Ble, seigle, orge.' });
assert.equal(list[1].name, 'Lait');
assert.equal(list[1].description, 'Et produits a base de lait.');
});

View file

@ -60,6 +60,17 @@ test('buildOrderItem: menu normal vs maxi (format + selections)', () => {
assert.equal(maxi.format, 'maxi');
});
test('buildOrderItem: format explicite prime sur l inference (maxi meme si supplement 0)', () => {
// Le choix utilisateur est transporte dans cartItem.format ; il ne doit PAS etre
// re-devine du prix (un menu maxi == normal serait sinon envoye en normal).
const explicit = { id: 1, type: 'menu', quantite: 1, supplement_cents: 0, format: 'maxi',
composition: { boisson: { id: 14 } } };
assert.equal(buildOrderItem(explicit, { 1: slots() }).format, 'maxi');
// Repli historique : un panier serialise sans champ format infere depuis supplement.
const legacy = { id: 1, type: 'menu', quantite: 1, supplement_cents: 150, composition: {} };
assert.equal(buildOrderItem(legacy, { 1: slots() }).format, 'maxi');
});
/* --- buildOrderPayload --------------------------------------------------- */
test('buildOrderPayload: dine_in inclut service_tag ; takeaway l omet', () => {

View file

@ -12,7 +12,7 @@ import { JSDOM } from 'jsdom';
let buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable, optionLabel;
before(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://kiosk.test/product.html' });
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://kiosk.test/products.html' });
global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
@ -71,6 +71,7 @@ test('buildMenuCartItem Normal: prix normal, pas de supplement, taille N, compos
assert.equal(item.type, 'menu');
assert.equal(item.prix_cents, 880);
assert.equal(item.supplement_cents, 0);
assert.equal(item.format, 'normal'); // format explicite transporte
assert.equal(item.composition.burger.libelle, 'Le 280');
// Normal : l'accompagnement garde son nom de base (pas la variante Maxi).
assert.deepEqual(item.composition.accompagnement, { id: 22, libelle: 'Moyenne Frite', taille: 'N' });
@ -83,6 +84,7 @@ test('buildMenuCartItem Maxi: supplement = maxi - normal, taille G sur side/drin
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.prix_cents, 880);
assert.equal(item.supplement_cents, 150); // 1030 - 880
assert.equal(item.format, 'maxi'); // format explicite transporte
assert.equal(item.composition.accompagnement.taille, 'G');
assert.equal(item.composition.boisson.taille, 'G');
});
@ -91,10 +93,21 @@ test('buildMenuCartItem Maxi: l accompagnement prend sa variante (Grande Frite),
const m = buildComposerSteps(detail(), byId());
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.composition.accompagnement.libelle, 'Grande Frite'); // pas "Moyenne Frite"
// Boisson sans maxiNom : garde son nom de base meme en Maxi (le Maxi ne l agrandit pas).
// Boisson sans maxiNom : garde son nom de base meme en Maxi (cas bouteille).
assert.equal(item.composition.boisson.libelle, 'Coca');
});
test('buildMenuCartItem Maxi: la boisson AVEC variante (50cl) prend son nom agrandi', () => {
// Apres le seed 0006, une boisson fontaine porte maxiNom (ex. "Coca Cola 50cl") :
// en Maxi, le libelle et la taille refletent la grande boisson (meme regle que
// l'accompagnement). Aucune logique borne specifique : maxiNom suffit.
const byIdDrinkVariant = { ...byId(), 14: { id: 14, nom: 'Coca Cola', prix: 0, image: 'c.png', type: 'produit', maxiNom: 'Coca Cola 50cl' } };
const m = buildComposerSteps(detail(), byIdDrinkVariant);
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.composition.boisson.libelle, 'Coca Cola 50cl');
assert.equal(item.composition.boisson.taille, 'G');
});
test('buildMenuCartItem Normal: l accompagnement garde "Moyenne Frite" (pas de variante)', () => {
const m = buildComposerSteps(detail(), byId());
const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22, 31: 47 } });

View file

@ -0,0 +1,67 @@
/*
* Tests de confirm-modal.js (node:test + jsdom). Modale de confirmation d'un geste
* destructeur : onConfirm n'est appele QUE sur confirmation explicite ; Annuler /
* Echap / clic-fond ferment sans agir. Import dynamique apres pose des globals jsdom.
*/
import { test, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
let confirmAction;
before(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://kiosk.test/products.html' });
global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
global.requestAnimationFrame = (cb) => cb();
({ confirmAction } = await import('../../src/public/borne/assets/js/confirm-modal.js'));
});
beforeEach(() => { document.body.innerHTML = ''; });
test('confirmAction: affiche une modale role=dialog avec le message', () => {
confirmAction({ message: 'Abandonner ?', onConfirm: () => {} });
const modal = document.querySelector('.confirm-overlay .confirm-modal[role="dialog"]');
assert.ok(modal);
assert.equal(modal.getAttribute('aria-modal'), 'true');
assert.match(modal.querySelector('.confirm-modal__message').textContent, /Abandonner/);
});
test('confirmAction: Confirmer appelle onConfirm puis ferme', () => {
let called = 0;
confirmAction({ message: 'x', onConfirm: () => { called++; } });
document.querySelector('.confirm-modal__confirm').click();
assert.equal(called, 1);
assert.equal(document.querySelector('.confirm-overlay'), null);
});
test('confirmAction: Annuler ferme sans appeler onConfirm', () => {
let called = 0;
confirmAction({ message: 'x', onConfirm: () => { called++; } });
document.querySelector('.confirm-modal__cancel').click();
assert.equal(called, 0);
assert.equal(document.querySelector('.confirm-overlay'), null);
});
test('confirmAction: Echap ferme sans appeler onConfirm', () => {
let called = 0;
confirmAction({ message: 'x', onConfirm: () => { called++; } });
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
assert.equal(called, 0);
assert.equal(document.querySelector('.confirm-overlay'), null);
});
test('confirmAction: clic sur le fond ferme sans appeler onConfirm', () => {
let called = 0;
confirmAction({ message: 'x', onConfirm: () => { called++; } });
const overlay = document.querySelector('.confirm-overlay');
overlay.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
assert.equal(called, 0);
assert.equal(document.querySelector('.confirm-overlay'), null);
});
test('confirmAction: le message est echappe (anti-XSS)', () => {
confirmAction({ message: '<img src=x onerror=alert(1)>', onConfirm: () => {} });
assert.equal(document.querySelectorAll('img[onerror]').length, 0);
});

View file

@ -1,13 +1,18 @@
/*
* Tests du composeur de commande comptoir/drive (counter-order.js, sous-lot 3c).
* node:test + jsdom. Couvre la serialisation du panier dans #items_json :
* - ajout produit (champ quantite) -> item {type:'product', ...}
* - personnalisation produit (retrait + ajout d'ingredients) -> modifiers:[...]
* - configuration menu (slots + format Maxi + modificateurs burger) -> item menu
* Tests du POS tactile de commande comptoir/drive (counter-order.js). node:test + jsdom.
* Couvre la logique pure (serialisation du panier dans #items_json, calcul prix/total,
* onglets categories) et l'UI a tuiles :
* - tap d'une tuile produit simple -> item {type:'product', quantity}, fusion sur re-tap
* - tap d'une tuile produit a modificateurs -> modale -> modifiers:[...]
* - tap d'une tuile menu -> modale (slots + format Maxi + modificateurs burger)
* - stepper +/- du panneau commande (ajuste qty, retire a 0)
* - slot requis non choisi -> message inline (pas d'ajout muet)
* - menu non configurable (slot_type non gere) ignore (anti-perte silencieuse)
*
* Le serveur revalide la forme (RG-T18), revalide chaque modificateur (resolveModifiers)
* et recalcule les prix (RG-T16) : on n'asserte que la FORME emise, pas un prix.
* et recalcule les prix (RG-T16) : on n'asserte que la FORME emise. Le prix affiche
* cote client (total + libelle du bouton) est INDICATIF : on verrouille seulement
* l'affichage local (somme price + surcouts), pas une verite metier serveur.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
@ -18,15 +23,15 @@ import counterOrder from '../../src/public/admin/assets/js/counter-order.js';
const PRODUCTS = [
{
id: 12, name: 'Cheeseburger', price: 890,
id: 12, name: 'Cheeseburger', price: 890, image: '', category_id: 1, category_name: 'Burgers',
modifiers: [
{ ingredient_id: 3, name: 'Oignon', is_removable: 1, is_addable: 0, extra_price_cents: 0 },
{ ingredient_id: 8, name: 'Bacon', is_removable: 0, is_addable: 1, extra_price_cents: 50 },
],
},
{ id: 22, name: 'Frites', price: 250, modifiers: [] },
{ id: 14, name: 'Coca', price: 200, modifiers: [] },
{ id: 47, name: 'Ketchup', price: 0, modifiers: [] },
{ id: 22, name: 'Frites', price: 250, image: '', category_id: 2, category_name: 'Accompagnements', modifiers: [] },
{ id: 14, name: 'Coca', price: 200, image: '', category_id: 3, category_name: 'Boissons', modifiers: [] },
{ id: 47, name: 'Ketchup', price: 0, image: '', category_id: 2, category_name: 'Accompagnements', modifiers: [] },
];
const MENUS = [
@ -35,6 +40,9 @@ const MENUS = [
name: 'Menu Cheeseburger',
price_normal: 990,
price_maxi: 1190,
image: '',
category_id: 4,
category_name: 'Menus',
burger_modifiers: [
{ ingredient_id: 3, name: 'Oignon', is_removable: 1, is_addable: 0, extra_price_cents: 0 },
{ ingredient_id: 8, name: 'Bacon', is_removable: 0, is_addable: 1, extra_price_cents: 50 },
@ -47,29 +55,28 @@ const MENUS = [
},
];
function setup(menus = MENUS) {
const menuItems = menus
.map(m => `<li><button class="menu-configure" type="button" data-menu-id="${m.id}">Configurer</button></li>`)
.join('');
// qty_<id> pour tous les produits (repli sans JS) ; bouton "Personnaliser" pour
// ceux dont la recette offre des modificateurs (calque la vue new.php).
const productRows = PRODUCTS
.map(p => {
const configure = (p.modifiers && p.modifiers.length)
? `<button class="product-configure" type="button" data-product-id="${p.id}">Personnaliser</button>`
: '';
return `<input class="order-qty" type="number" id="qty_${p.id}" name="qty_${p.id}" data-product-id="${p.id}" value="0">${configure}`;
})
.join('');
function setup(products = PRODUCTS, menus = MENUS) {
// Le catalogue est embarque dans deux scripts JSON inertes (CSP-safe), lus par le JS.
const dom = new JSDOM(
'<!DOCTYPE html><html><body>' +
'<form id="counter-order-form" method="post" action="/counter/orders" ' +
` data-products='${JSON.stringify(PRODUCTS)}' data-menus='${JSON.stringify(menus)}'>` +
'<form id="counter-order-form" method="post" action="/counter/orders">' +
' <input type="hidden" name="items_json" id="items_json" value="">' +
productRows +
' <ul id="menu-list">' + menuItems + '</ul>' +
' <ul id="order-cart"><li id="order-cart-empty">Panier vide.</li></ul>' +
' <button type="submit">Encaisser</button>' +
` <script type="application/json" id="pos-products">${JSON.stringify(products)}</script>` +
` <script type="application/json" id="pos-menus">${JSON.stringify(menus)}</script>` +
' <div class="pos__main">' +
' <div class="pos__catalogue">' +
' <div class="pos__tabs" id="pos-tabs" role="tablist"></div>' +
' <div class="pos__grid" id="pos-grid" role="tabpanel" tabindex="0"></div>' +
' </div>' +
' <aside class="pos__panel">' +
' <select id="service_mode" name="service_mode"><option value="dine_in" selected>Sur place</option><option value="takeaway">A emporter</option></select>' +
' <div id="service_tag_group"><input type="text" id="service_tag" name="service_tag"></div>' +
' <ul class="order-cart" id="order-cart"><li class="order-cart__empty" id="order-cart-empty">Panier vide.</li></ul>' +
' <p id="order-total">Total <span id="order-total-value">0,00 EUR</span></p>' +
' <button type="submit" id="order-submit">Encaisser 0,00 EUR</button>' +
' <span class="sr-only" id="pos-announce" role="status" aria-live="polite"></span>' +
' </aside>' +
' </div>' +
'</form>' +
'<div id="menu-composer-modal" hidden></div>' +
'</body></html>',
@ -86,30 +93,71 @@ function itemsJson(dom) {
return JSON.parse(dom.window.document.getElementById('items_json').value || '[]');
}
test('ajout produit sans modificateur (quantite) -> items_json contient {type:product}', () => {
function click(dom, node) {
node.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
}
// Active l'onglet d'une categorie par son libelle (les tuiles d'une seule categorie sont
// rendues a la fois). Renvoie la liste des tuiles affichees apres activation.
function activateCategory(dom, label) {
const doc = dom.window.document;
const tab = Array.prototype.find.call(
doc.querySelectorAll('.pos__tab'),
t => t.textContent === label,
);
assert.ok(tab, 'onglet "' + label + '" present');
click(dom, tab);
return Array.prototype.slice.call(doc.querySelectorAll('.pos-tile'));
}
// Tuile par nom de produit/menu (dans la grille de la categorie active).
function tileByName(dom, name) {
const doc = dom.window.document;
return Array.prototype.find.call(
doc.querySelectorAll('.pos-tile'),
t => t.querySelector('.pos-tile__name') && t.querySelector('.pos-tile__name').textContent === name,
);
}
test('onglets categories : un onglet par categorie distincte (produits + menus)', () => {
const dom = setup();
counterOrder.init(dom.window.document);
// Frites (22) n'a pas de modificateur -> pas de bouton Personnaliser -> chemin qty_<id>.
const qty = dom.window.document.getElementById('qty_22');
qty.value = '2';
fireSubmit(dom);
const items = itemsJson(dom);
assert.deepEqual(items, [{ type: 'product', product_id: 22, quantity: 2 }]);
const labels = Array.prototype.map.call(
dom.window.document.querySelectorAll('.pos__tab'),
t => t.textContent,
);
assert.deepEqual(labels, ['Burgers', 'Accompagnements', 'Boissons', 'Menus']);
});
test('produit personnalisable : qty directe ignoree (route par la modale, anti-double-comptage)', () => {
test('tuile produit simple : tap ajoute {type:product, quantity:1} ; re-tap fusionne (qty 2)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
// Cheeseburger (12) porte un bouton Personnaliser : son qty_<id> est ignore par le
// JS pour eviter le double comptage avec la ligne configuree.
doc.getElementById('qty_12').value = '3';
fireSubmit(dom);
activateCategory(dom, 'Accompagnements'); // Frites (22), Ketchup (47)
const frites = tileByName(dom, 'Frites');
click(dom, frites);
assert.ok(doc.querySelector('.order-cart__line'));
assert.deepEqual(itemsJson(dom), []);
click(dom, frites); // re-tap -> fusion (qty 2), pas une 2e ligne.
assert.equal(doc.querySelectorAll('.order-cart__line').length, 1);
fireSubmit(dom);
assert.deepEqual(itemsJson(dom), [{ type: 'product', product_id: 22, quantity: 2, modifiers: [] }]);
});
test('tuile produit a modificateurs : tap ouvre la modale (pas d ajout direct)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Burgers'); // Cheeseburger (12) a modificateurs
click(dom, tileByName(dom, 'Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false); // modale ouverte
assert.equal(doc.querySelector('.order-cart__line'), null); // rien ajoute sans validation
});
test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[remove, add]', () => {
@ -117,8 +165,8 @@ test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[
const doc = dom.window.document;
counterOrder.init(doc);
// Ouvre la modale du produit 12 (Cheeseburger).
doc.querySelector('.product-configure[data-product-id="12"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
activateCategory(dom, 'Burgers');
click(dom, tileByName(dom, 'Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false);
@ -131,7 +179,7 @@ test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[
addBox.checked = true;
addBox.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
click(dom, modal.querySelector('.menu-composer__add'));
assert.equal(modal.hasAttribute('hidden'), true);
assert.ok(doc.querySelector('.order-cart__line'));
@ -148,7 +196,7 @@ test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[
]);
});
test('quantite 0 ignoree -> panier vide serialise []', () => {
test('panier vide -> items_json serialise []', () => {
const dom = setup();
counterOrder.init(dom.window.document);
@ -156,13 +204,48 @@ test('quantite 0 ignoree -> panier vide serialise []', () => {
assert.deepEqual(itemsJson(dom), []);
});
test('stepper +/- : + incremente, - decremente, 0 retire la ligne', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Accompagnements');
click(dom, tileByName(dom, 'Frites'));
const inc = doc.querySelector('.order-cart__qty-btn[aria-label^="Augmenter"]');
click(dom, inc); // qty 1 -> 2
assert.equal(doc.querySelector('.order-cart__qty-value').textContent, '2');
const dec = doc.querySelector('.order-cart__qty-btn[aria-label^="Diminuer"]');
click(dom, dec); // 2 -> 1
assert.equal(doc.querySelector('.order-cart__qty-value').textContent, '1');
click(dom, doc.querySelector('.order-cart__qty-btn[aria-label^="Diminuer"]')); // 1 -> 0 = retrait
assert.equal(doc.querySelector('.order-cart__line'), null);
fireSubmit(dom);
assert.deepEqual(itemsJson(dom), []);
});
test('retirer une ligne via le bouton Retirer', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Accompagnements');
click(dom, tileByName(dom, 'Frites'));
assert.ok(doc.querySelector('.order-cart__line'));
click(dom, doc.querySelector('.order-cart__remove'));
assert.equal(doc.querySelector('.order-cart__line'), null);
});
test('configuration menu (format Maxi + slots) -> items_json contient {type:menu, format:maxi, selections}', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
// Ouvre la modale du menu 5.
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false);
@ -183,7 +266,7 @@ test('configuration menu (format Maxi + slots) -> items_json contient {type:menu
sauceSelect.value = '47';
sauceSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
click(dom, modal.querySelector('.menu-composer__add'));
// Modale fermee, panier recap mis a jour.
assert.equal(modal.hasAttribute('hidden'), true);
@ -204,12 +287,63 @@ test('configuration menu (format Maxi + slots) -> items_json contient {type:menu
]);
});
test('quantite MENU : stepper + sur une ligne menu -> items_json porte quantity:2, un seul jeu de selections', () => {
// G : la quantite d'une ligne menu est ajustable au panneau (stepper) et serialisee
// dans quantity ; les selections de slot ne sont PAS dupliquees par la quantite.
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
click(dom, modal.querySelector('.menu-composer__add')); // ajoute le menu (requis pre-selectionnes)
// Stepper + sur la ligne menu : qty 1 -> 2.
click(dom, doc.querySelector('.order-cart__qty-btn[aria-label^="Augmenter"]'));
assert.equal(doc.querySelector('.order-cart__qty-value').textContent, '2');
fireSubmit(dom);
const items = itemsJson(dom);
assert.equal(items.length, 1);
assert.equal(items[0].type, 'menu');
assert.equal(items[0].quantity, 2);
// Un SEUL jeu de selections (requis : drink + side), pas duplique par la quantite.
assert.deepEqual(items[0].selections, [
{ menu_slot_id: 1, product_id: 14 },
{ menu_slot_id: 16, product_id: 22 },
]);
});
test('total : menu Maxi (11,90) x2 -> 23,80 EUR (quantite multipliee)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
const maxiRadio = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__format-input'),
r => r.value === 'maxi',
);
maxiRadio.checked = true;
maxiRadio.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
click(dom, modal.querySelector('.menu-composer__add'));
click(dom, doc.querySelector('.order-cart__qty-btn[aria-label^="Augmenter"]')); // x2
assert.equal(doc.querySelector('.order-cart__price').textContent, '23,80 EUR');
assert.equal(doc.getElementById('order-total-value').textContent, '23,80 EUR');
});
test('menu Normal sans la sauce optionnelle -> selections ne contient que les requis', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
// Laisse la sauce a "Sans" (valeur vide) ; ajoute directement.
@ -220,7 +354,7 @@ test('menu Normal sans la sauce optionnelle -> selections ne contient que les re
sauceSelect.value = '';
sauceSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
click(dom, modal.querySelector('.menu-composer__add'));
fireSubmit(dom);
const items = itemsJson(dom);
@ -236,12 +370,14 @@ test('produit + menu combines -> items_json contient les deux lignes', () => {
const doc = dom.window.document;
counterOrder.init(doc);
// Frites (22) sans modificateur -> chemin qty_<id>.
doc.getElementById('qty_22').value = '1';
// Frites (22) sans modificateur -> ajout direct par tap.
activateCategory(dom, 'Accompagnements');
click(dom, tileByName(dom, 'Frites'));
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
click(dom, modal.querySelector('.menu-composer__add'));
fireSubmit(dom);
const items = itemsJson(dom);
@ -255,7 +391,8 @@ test('configuration menu avec modificateur burger -> item menu porte modifiers:[
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
// Retire l'oignon du burger (ingredient 3, is_removable).
@ -263,7 +400,7 @@ test('configuration menu avec modificateur burger -> item menu porte modifiers:[
removeBox.checked = true;
removeBox.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
click(dom, modal.querySelector('.menu-composer__add'));
fireSubmit(dom);
const items = itemsJson(dom);
@ -271,6 +408,208 @@ test('configuration menu avec modificateur burger -> item menu porte modifiers:[
assert.deepEqual(items[0].modifiers, [{ ingredient_id: 3, action: 'remove' }]);
});
test('total + bouton : produit simple (Frites 2,50 x2) -> 5,00 EUR affiche', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Accompagnements');
const frites = tileByName(dom, 'Frites'); // 250c
click(dom, frites);
click(dom, frites); // qty 2
assert.equal(doc.getElementById('order-total-value').textContent, '5,00 EUR');
assert.equal(doc.getElementById('order-submit').textContent, 'Encaisser 5,00 EUR');
});
test('total : produit personnalise avec ajout (Cheeseburger 8,90 + Bacon 0,50) -> 9,40 EUR', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Burgers');
click(dom, tileByName(dom, 'Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
const addBox = modal.querySelector('.menu-composer__modifier-add[data-ingredient-id="8"]');
addBox.checked = true;
addBox.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
click(dom, modal.querySelector('.menu-composer__add'));
// Prix de ligne affiche dans le panier.
assert.equal(doc.querySelector('.order-cart__price').textContent, '9,40 EUR');
assert.equal(doc.getElementById('order-total-value').textContent, '9,40 EUR');
});
test('total : menu Maxi (11,90) inclus dans le total de ligne', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
const maxiRadio = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__format-input'),
r => r.value === 'maxi',
);
maxiRadio.checked = true;
maxiRadio.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
click(dom, modal.querySelector('.menu-composer__add'));
assert.equal(doc.querySelector('.order-cart__price').textContent, '11,90 EUR');
assert.equal(doc.getElementById('order-total-value').textContent, '11,90 EUR');
});
test('numero de table : masque hors sur place, visible en sur place (toggle service_mode)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const group = doc.getElementById('service_tag_group');
const select = doc.getElementById('service_mode');
// Init : dine_in pre-selectionne -> visible.
assert.equal(group.hasAttribute('hidden'), false);
select.value = 'takeaway';
select.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
assert.equal(group.hasAttribute('hidden'), true);
select.value = 'dine_in';
select.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
assert.equal(group.hasAttribute('hidden'), false);
});
test('modale menu : slot requis non choisi -> message inline, pas d ajout muet', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
// Vide un slot requis (drink, slot 1) : un slot requis n'a pas d'option Sans, mais
// jsdom autorise l'affectation d'une value vide -> change supprime la selection.
const drinkSelect = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__slot-select'),
s => s.dataset.slotId === '1',
);
drinkSelect.value = '';
drinkSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
// Le <p role=alert> est present des l'ouverture (vide), avant toute erreur.
const errAtOpen = modal.querySelector('.menu-composer__error');
assert.ok(errAtOpen);
assert.equal(errAtOpen.getAttribute('role'), 'alert');
assert.equal(errAtOpen.textContent, '');
assert.equal(errAtOpen.hasAttribute('hidden'), false); // present en permanence (a11y)
click(dom, modal.querySelector('.menu-composer__add'));
// Modale encore ouverte, message inline renseigne (textContent), aucune ligne.
assert.equal(modal.hasAttribute('hidden'), false);
assert.notEqual(errAtOpen.textContent, '');
assert.equal(doc.querySelector('.order-cart__line'), null);
});
test('tuile : pastille de repli quand aucune image (image vide)', () => {
const dom = setup();
counterOrder.init(dom.window.document);
activateCategory(dom, 'Burgers');
const tile = tileByName(dom, 'Cheeseburger');
// image vide -> aucune <img>, une pastille (initiale C) a la place.
assert.equal(tile.querySelector('.pos-tile__image'), null);
assert.equal(tile.querySelector('.pos-tile__pastille').textContent, 'C');
});
test('tuile : image rendue quand image fournie', () => {
const withImg = [{ id: 99, name: 'Special', price: 500, image: '/img/special.png', category_id: 1, category_name: 'Burgers', modifiers: [] }];
const dom = setup(withImg, []);
counterOrder.init(dom.window.document);
activateCategory(dom, 'Burgers');
const img = tileByName(dom, 'Special').querySelector('.pos-tile__image');
assert.ok(img);
assert.equal(img.getAttribute('src'), '/img/special.png');
});
test('modale : focus restaure sur la tuile declencheuse a la fermeture', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Menus');
const trigger = tileByName(dom, 'Menu Cheeseburger');
trigger.focus();
assert.equal(doc.activeElement, trigger);
click(dom, trigger);
const modal = doc.getElementById('menu-composer-modal');
// Le focus est entre dans la modale (plus sur la tuile declencheuse).
assert.notEqual(doc.activeElement, trigger);
doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
// Ferme -> focus restaure sur la tuile.
assert.equal(doc.activeElement, trigger);
});
test('modale : panel porte role=dialog, aria-modal et aria-labelledby (titre)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const panel = doc.querySelector('.menu-composer');
assert.equal(panel.getAttribute('role'), 'dialog');
assert.equal(panel.getAttribute('aria-modal'), 'true');
const labelledby = panel.getAttribute('aria-labelledby');
assert.ok(labelledby);
const title = doc.getElementById(labelledby);
assert.ok(title);
assert.equal(title.classList.contains('menu-composer__title'), true);
});
test('total : separateur de milliers aligne sur PHP (1 234,50 EUR)', () => {
// Produit a 617,25 EUR (61725c) x2 = 1 234,50 EUR -> espace separateur de milliers.
const PRICEY = [{ id: 99, name: 'Plateau', price: 61725, image: '', category_id: 1, category_name: 'Plateaux', modifiers: [] }];
const dom = setup(PRICEY, []);
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Plateaux');
const tile = tileByName(dom, 'Plateau');
click(dom, tile);
click(dom, tile); // qty 2
assert.equal(doc.getElementById('order-total-value').textContent, '1 234,50 EUR');
assert.equal(doc.getElementById('order-submit').textContent, 'Encaisser 1 234,50 EUR');
});
test('modale : touche Echap ferme la modale', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false);
doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
assert.equal(modal.hasAttribute('hidden'), true);
});
test('buildCategoryTabs: une entree par categorie, comptage cumule produits+menus', () => {
const tabs = counterOrder.buildCategoryTabs(PRODUCTS, MENUS);
assert.deepEqual(tabs.map(t => t.name), ['Burgers', 'Accompagnements', 'Boissons', 'Menus']);
// Accompagnements regroupe Frites + Ketchup.
assert.equal(tabs.find(t => t.name === 'Accompagnements').count, 2);
assert.equal(tabs.find(t => t.name === 'Menus').count, 1);
});
test('composerSteps: slot_type non gere (dessert) ignore, slots tries par display_order', () => {
const productById = {};
PRODUCTS.forEach(p => { productById[p.id] = p; });
@ -284,3 +623,160 @@ test('composerSteps: slot_type non gere (dessert) ignore, slots tries par displa
const steps = counterOrder.composerSteps(menu, productById);
assert.deepEqual(steps.map(s => s.slotType), ['drink', 'side', 'sauce']); // dessert exclu, tri display_order
});
test('A : changer d onglet conserve le focus clavier sur l onglet actif (pas de retour vers body)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const tabs = doc.querySelectorAll('.pos__tab');
const second = tabs[1]; // Accompagnements
second.focus();
assert.equal(doc.activeElement, second);
click(dom, second);
// Le bouton n'est PAS detruit (pas de reconstruction de la barre) : focus preserve.
assert.equal(doc.activeElement, second);
assert.equal(second.classList.contains('is-active'), true);
// Les autres onglets existent encore (memes references, simplement mutees).
assert.equal(doc.querySelectorAll('.pos__tab').length, tabs.length);
});
test('B : roving tabindex (actif=0, autres=-1) et aria-selected coherents', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const tabs = Array.prototype.slice.call(doc.querySelectorAll('.pos__tab'));
// Au depart : 1er onglet actif (tabindex 0), les autres -1.
assert.equal(tabs[0].tabIndex, 0);
assert.equal(tabs[0].getAttribute('aria-selected'), 'true');
tabs.slice(1).forEach(t => {
assert.equal(t.tabIndex, -1);
assert.equal(t.getAttribute('aria-selected'), 'false');
});
// Apres activation du 3e : le roving tabindex suit.
click(dom, tabs[2]);
assert.equal(tabs[2].tabIndex, 0);
assert.equal(tabs[2].getAttribute('aria-selected'), 'true');
assert.equal(tabs[0].tabIndex, -1);
});
test('B : Fleche droite/gauche deplace le focus ET active l onglet (cyclique)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const tabs = Array.prototype.slice.call(doc.querySelectorAll('.pos__tab'));
tabs[0].focus();
// L'event remonte (bubbles) jusqu'au conteneur tablist ; event.target est l'onglet
// focalise. On dispatche depuis l'element actif pour refleter le focus clavier reel.
function arrowFromActive(key) {
doc.activeElement.dispatchEvent(
new dom.window.KeyboardEvent('keydown', { key, bubbles: true }),
);
}
arrowFromActive('ArrowRight'); // 0 -> 1
assert.equal(doc.activeElement, tabs[1]);
assert.equal(tabs[1].getAttribute('aria-selected'), 'true');
arrowFromActive('ArrowLeft'); // 1 -> 0
assert.equal(doc.activeElement, tabs[0]);
arrowFromActive('ArrowLeft'); // 0 -> dernier (cyclique)
assert.equal(doc.activeElement, tabs[tabs.length - 1]);
arrowFromActive('Home'); // -> premier
assert.equal(doc.activeElement, tabs[0]);
arrowFromActive('End'); // -> dernier
assert.equal(doc.activeElement, tabs[tabs.length - 1]);
});
test('B : onglets relies au tabpanel (aria-controls vers la grille, grille labellisee par l onglet actif)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const grid = doc.getElementById('pos-grid');
const tabs = Array.prototype.slice.call(doc.querySelectorAll('.pos__tab'));
tabs.forEach(t => assert.equal(t.getAttribute('aria-controls'), 'pos-grid'));
// La grille (tabpanel) est libellee par l'onglet actif.
assert.equal(grid.getAttribute('aria-labelledby'), tabs[0].id);
click(dom, tabs[1]);
assert.equal(grid.getAttribute('aria-labelledby'), tabs[1].id);
});
test('C : region live concise mise a jour a chaque mutation (total + nombre d articles)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const announce = doc.getElementById('pos-announce');
// Init : panier vide.
assert.equal(announce.textContent, 'Panier vide');
activateCategory(dom, 'Accompagnements');
const frites = tileByName(dom, 'Frites'); // 250c
click(dom, frites);
assert.equal(announce.textContent, 'Total 2,50 EUR, 1 article');
click(dom, frites); // qty 2
assert.equal(announce.textContent, 'Total 5,00 EUR, 2 articles');
});
test('C : ni #order-cart ni #pos-grid ne portent aria-live (eviter la verbosite)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
assert.equal(doc.getElementById('order-cart').hasAttribute('aria-live'), false);
assert.equal(doc.getElementById('pos-grid').hasAttribute('aria-live'), false);
});
test('D : tuile qui ouvre la modale porte aria-haspopup=dialog et l intention dans l aria-label', () => {
const dom = setup();
counterOrder.init(dom.window.document);
activateCategory(dom, 'Burgers');
const burger = tileByName(dom, 'Cheeseburger'); // a modificateurs -> modale
assert.equal(burger.getAttribute('aria-haspopup'), 'dialog');
assert.match(burger.getAttribute('aria-label'), /a composer/);
activateCategory(dom, 'Menus');
const menu = tileByName(dom, 'Menu Cheeseburger');
assert.equal(menu.getAttribute('aria-haspopup'), 'dialog');
assert.match(menu.getAttribute('aria-label'), /menu a composer/);
});
test('D : tuile produit simple n a PAS aria-haspopup (ajout direct au tap)', () => {
const dom = setup();
counterOrder.init(dom.window.document);
activateCategory(dom, 'Accompagnements');
const frites = tileByName(dom, 'Frites'); // sans modificateur
assert.equal(frites.hasAttribute('aria-haspopup'), false);
});
test('E : quantite invalide dans la modale produit -> ramenee a 1 et reaffichee dans l input', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Burgers');
click(dom, tileByName(dom, 'Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
const qtyInput = modal.querySelector('#composer-product-qty');
qtyInput.value = '0';
qtyInput.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
assert.equal(qtyInput.value, '1'); // valeur corrigee reaffichee
qtyInput.value = '';
qtyInput.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
assert.equal(qtyInput.value, '1');
});

View file

@ -69,7 +69,8 @@ test('loadProducts groupe les produits par slug a la forme borne (type produit)'
assert.deepEqual(data.burgers, [
// sizes (R4) : tableau vide par defaut quand l'API n'en renvoie pas.
// maxiNom : null par defaut quand l'API n'envoie pas maxi_variant_name.
{ id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', maxiNom: null, sizes: [] },
// commandable : true par defaut quand l'API n'envoie pas is_orderable.
{ id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', maxiNom: null, sizes: [], commandable: true },
]);
});
@ -100,10 +101,21 @@ test('loadProducts glisse les menus sous la cle menus (type menu, prix = price_n
const data = await loadProducts();
assert.deepEqual(data.menus, [
{ id: 1, nom: 'Menu Big Mac', prix: 800, image: 'assets/images/produits/burgers/bigmac.png', type: 'menu' },
{ id: 1, nom: 'Menu Big Mac', prix: 800, image: 'assets/images/produits/burgers/bigmac.png', type: 'menu', commandable: true },
]);
});
test('loadProducts: is_orderable=false -> commandable=false (rupture RG-T21)', async () => {
const fx = fixtures();
fx['/api/products'].data[0].is_orderable = false;
fx['/api/menus'].data[0].is_orderable = false;
const { loadProducts } = await freshData(fx);
const data = await loadProducts();
assert.equal(data.burgers[0].commandable, false);
assert.equal(data.menus[0].commandable, false);
});
test('loadProducts consomme bien les trois endpoints /api/*', async () => {
const calls = [];
const { loadProducts } = await freshData(fixtures(), calls);

View file

@ -27,7 +27,7 @@ test('modeLabel: libelle humain ; vide si mode absent ou inconnu (ne ment pas)',
test('needsModeRedirect: page profonde SANS mode valide -> redirige', () => {
assert.equal(needsModeRedirect('/payment.html', null), true);
assert.equal(needsModeRedirect('/products.html', undefined), true);
assert.equal(needsModeRedirect('/cart.html', 'bidon'), true);
assert.equal(needsModeRedirect('/payment.html', 'bidon'), true);
assert.equal(needsModeRedirect('/categories.html', ''), true);
});

View file

@ -22,6 +22,7 @@ before(async () => {
global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
global.requestAnimationFrame = (cb) => cb();
({ lineCents, compositionLabels, buildPanelModel, renderOrderPanel } =
await import('../../src/public/borne/assets/js/order-panel.js'));
});
@ -138,6 +139,38 @@ test('renderOrderPanel: clic corbeille retire la ligne et re-rend', () => {
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
});
test('renderOrderPanel: stepper + augmente la quantite et re-rend', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ quantite: 1 })]));
const el = document.createElement('aside');
renderOrderPanel(el);
el.querySelector('.order-panel__qty-btn[data-action="inc"]').click();
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart'))[0].quantite, 2);
assert.equal(el.querySelector('.order-panel__qty-value').textContent, '2');
});
test('renderOrderPanel: stepper - a quantite 1 retire la ligne', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ quantite: 1 }), menu()]));
const el = document.createElement('aside');
renderOrderPanel(el);
el.querySelector('.order-panel__qty-btn[data-action="dec"]').click(); // 1re ligne 1 -> 0 -> retiree
assert.equal(el.querySelectorAll('.order-panel__line').length, 1);
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
});
test('renderOrderPanel: Abandon demande confirmation avant d effacer', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple()]));
const el = document.createElement('aside');
renderOrderPanel(el);
el.querySelector('.order-panel__abandon').click();
// Une modale de confirmation apparait ; le panier n'est PAS encore efface.
assert.ok(document.querySelector('.confirm-overlay'));
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
// Annuler conserve le panier et ferme la modale.
document.querySelector('.confirm-modal__cancel').click();
assert.equal(document.querySelector('.confirm-overlay'), null);
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
});
test('renderOrderPanel: libelle de ligne echappe (anti-XSS RG-T15)', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ libelle: '<img src=x onerror=alert(1)>' })]));
const el = document.createElement('aside');