Compare commits

..

2 commits

Author SHA1 Message Date
3dee190a8c Merge pull request 'release: dev -> main v0.2.0' (#93) from dev into main
All checks were successful
CI / secret-scan (push) Successful in 19s
CI / php-lint (push) Successful in 29s
CI / static-tests (push) Successful in 1m1s
CI / js-tests (push) Successful in 39s
2026-06-23 10:09:57 +02:00
510404013c Merge pull request 'release: dev -> main (P1 conception v0.2 + front P5 + admin shell)' (#1) from dev into main
Reviewed-on: #1
2026-06-04 17:44:29 +02:00
47 changed files with 133 additions and 2592 deletions

View file

@ -131,16 +131,3 @@ 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

View file

@ -1,75 +0,0 @@
# 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

@ -1,45 +0,0 @@
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,9 +56,6 @@ 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

@ -8,15 +8,10 @@
-- 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 (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.
-- 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).
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
-- =============================================================================

View file

@ -1,50 +0,0 @@
-- =============================================================================
-- 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

@ -1,184 +0,0 @@
# 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,12 +89,6 @@ 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

@ -1,115 +0,0 @@
# 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

@ -52,40 +52,25 @@ 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
printf 'Confirmer le deploiement en production ? [oui/NON] '
read -r answer
if [ "$answer" != "oui" ]; then
echo "deploy: annule."
exit 1
fi
echo "[1/5] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
echo "[1/4] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
git fetch --prune "$REMOTE" "$BRANCH"
git checkout "$BRANCH"
git merge --ff-only "$REMOTE/$BRANCH"
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)"
echo "[2/4] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
docker compose -f "$COMPOSE_FILE" build --pull
echo "[4/5] demarrage de la stack (migrate + seed idempotents puis app)"
echo "[3/4] demarrage de la stack (migrate + seed idempotents puis app)"
docker compose -f "$COMPOSE_FILE" up -d
echo "[5/5] etat des services"
echo "[4/4] etat des services"
docker compose -f "$COMPOSE_FILE" ps
echo "Deploiement termine ($SHA)."
echo "Deploiement termine."

View file

@ -1,98 +0,0 @@
<?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)),
);
}
}
}

View file

@ -1,101 +0,0 @@
<?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

@ -1,28 +0,0 @@
<?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

@ -1,95 +0,0 @@
<?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,16 +18,12 @@ final class UserDirectory
}
/**
* 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}
* @return array{name: string, role_label: string, email: 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, r.order_source '
'SELECT u.first_name, u.last_name, u.email, r.label AS role_label '
. 'FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id',
['id' => $userId],
);
@ -37,10 +33,9 @@ final class UserDirectory
$name = trim($first . ' ' . $last);
return [
'name' => $name !== '' ? $name : 'Utilisateur',
'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '',
'email' => is_string($row['email'] ?? null) ? $row['email'] : '',
'order_source' => is_string($row['order_source'] ?? null) ? $row['order_source'] : '',
'name' => $name !== '' ? $name : 'Utilisateur',
'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '',
'email' => is_string($row['email'] ?? null) ? $row['email'] : '',
];
}
}

View file

@ -63,9 +63,8 @@ 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, 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).
* disponibilite du burger impose (B1) reste un raffinement de la dispo calculee
* RG-T21, differe au seed des recettes.
*
* @return array<int, array<string, mixed>>
*/

View file

@ -63,10 +63,8 @@ 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 (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.
* disponibilite = flag is_available ; la dispo CALCULEE RG-T21 (exclusion des
* ruptures auto via autoUnavailableIds) se branchera au seed des recettes.
*
* 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
@ -84,11 +82,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, c.name AS category_name, mv.name AS maxi_variant_name '
. 'p.image_path, p.display_order, 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 c.display_order, c.name, p.display_order, p.name',
. 'ORDER BY p.display_order, p.name',
);
}

View file

@ -68,11 +68,6 @@ 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,16 +56,8 @@ 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)] ?? [],
!isset($unavailable[(int) ($row['id'] ?? 0)]),
),
fn (array $row): array => $this->presentProduct($row, $sizesByBase[(int) ($row['id'] ?? 0)] ?? []),
$repo->availableForCatalogue(),
);
@ -92,10 +84,8 @@ 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 : [], $orderable)]);
return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [])]);
}
/**
@ -103,15 +93,8 @@ 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,
!isset($unavailable[(int) ($row['burger_product_id'] ?? 0)]),
),
fn (array $row): array => $this->presentMenu($row),
$this->menusRepo()->availableForCatalogue(),
);
@ -134,10 +117,8 @@ 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, $orderable) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
$menu = $this->presentMenu($row) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
return $this->json(['data' => $menu]);
}
@ -219,9 +200,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}>, is_orderable: bool}
* @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}>}
*/
private function presentProduct(array $row, array $sizes = [], bool $isOrderable = true): array
private function presentProduct(array $row, array $sizes = []): array
{
return [
'id' => (int) ($row['id'] ?? 0),
@ -248,19 +229,14 @@ 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, is_orderable: bool}
* @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}
*/
private function presentMenu(array $row, bool $isOrderable = true): array
private function presentMenu(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
@ -272,9 +248,6 @@ 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,25 +56,18 @@ 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(
$orderQuery->recent(50),
$this->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,
'title' => $this->channelTitle($source) . ' - Wakdo Admin',
'orders' => $orders,
], $guard);
}
@ -122,11 +115,6 @@ 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).
@ -139,14 +127,9 @@ 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(
$req,
['service_mode' => $serviceMode, 'items' => $items],
$guard->userId ?? 0,
$source,
);
@ -377,7 +360,6 @@ 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,13 +12,9 @@ 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. 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).
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond.
*/
class HealthController extends Controller
final class HealthController extends Controller
{
/**
* @param array<string, string> $params
@ -39,8 +35,6 @@ class HealthController extends Controller
$httpStatus = 503;
}
$version = $this->readVersion();
return $this->json(
[
'status' => $dbStatus === 'ok' ? 'ok' : 'degraded',
@ -48,45 +42,8 @@ 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

@ -7,13 +7,9 @@ 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;
@ -128,33 +124,7 @@ class PasswordResetController extends Controller
$this->database,
$this->config,
new PasswordHasher($this->config),
$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',
new LogMailer(),
);
}

View file

@ -184,14 +184,8 @@ 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, $unavailable), $items);
$lines = array_map(fn (array $item): array => $this->resolveLine($item), $items);
$totalTtc = 0;
$totalHt = 0;
@ -603,10 +597,9 @@ 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 $unavailable = []): array
private function resolveLine(array $item): array
{
$type = (string) ($item['type'] ?? '');
$quantity = max(1, (int) ($item['quantity'] ?? 1));
@ -617,10 +610,6 @@ 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']);
@ -634,14 +623,6 @@ 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,15 +4,11 @@ declare(strict_types=1);
/**
* Liste des commandes du canal (comptoir ou drive), injectee dans admin/layout.php.
* 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.
* 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).
*
* @var list<array<string, mixed>> $orders historique recent (tous statuts)
* @var list<array<string, mixed>> $inProgress file "En cours" (paid non livre, canal)
* @var list<array<string, mixed>> $orders
* @var string $channelTitle
* @var string $newPath
*/
@ -43,8 +39,6 @@ $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';
?>
@ -54,45 +48,8 @@ $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>
<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,8 +3,8 @@
declare(strict_types=1);
/**
* Composeur de commande comptoir/drive COMPLET (sous-lot 3c + refonte saisie),
* injecte dans admin/layout.php. Produits commandables ET menus composes (slots
* 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).
*
* Le panier est construit cote client par counter-order.js (CSP 'self', vanilla JS,
@ -12,24 +12,16 @@ declare(strict_types=1);
* #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). Les prix affiches
* cote client (par ligne + total + libelle du bouton) sont INDICATIFS : le serveur
* reste seul juge. Le tableau de quantites produit `qty_<id>` reste present comme
* repli sans JS (3a) : le champ quantite est rendu EDITABLE pour TOUS les produits.
* Pour un produit personnalisable, c'est counter-order.js qui neutralise le champ au
* cablage (desactivation + indice "via Personnaliser") et route la saisie vers la
* modale ; sans JS, le champ qty de base fonctionne (commande sans modificateurs, ce
* que legacyQuantities sait traiter). La gestion des modificateurs depend donc de JS.
* tout (RG-T18, resolveModifiers) et recalcule les prix (RG-T16). Le tableau de
* quantites produit `qty_<id>` reste present comme repli sans JS (3a).
*
* Partage par les deux canaux ; la source/landing viennent du controleur. Au canal
* 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.
* drive, service_mode est verrouille a 'drive' (RG-T09). 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
@ -51,7 +43,6 @@ $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 */
@ -111,20 +102,10 @@ $jsMenus = array_map(
$menuRows,
);
// Regroupement des produits par categorie (7b) : sous-titre par categorie pour que
// l'equipier scanne plus vite. availableForCatalogue trie deja par categorie puis
// display_order ; on conserve cet ordre et on agrege les lignes consecutives de meme
// categorie. category_name absent -> groupe "Autres" (evite une cle vide a l'ecran).
$productGroups = [];
foreach ($productRows as $p) {
$catName = isset($p['category_name']) && is_string($p['category_name']) && $p['category_name'] !== ''
? $p['category_name']
: 'Autres';
if (!isset($productGroups[$catName])) {
$productGroups[$catName] = [];
}
$productGroups[$catName][] = $p;
}
// 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'];
?>
<div class="page-header">
<h1 class="page-title">Nouvelle commande <?= $chan === 'drive' ? 'drive' : 'comptoir' ?></h1>
@ -142,86 +123,55 @@ foreach ($productRows as $p) {
<div class="form-group">
<label class="form-label" for="service_mode">Mode de service</label>
<?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: ?>
<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; ?>
<select class="form-input" id="service_mode" name="service_mode"<?= $chan === 'drive' ? ' readonly' : '' ?>>
<?php foreach ($modeOptions as $value => $label): ?>
<option value="<?= $esc($value) ?>"<?= $mode === $value ? ' selected' : '' ?>><?= $esc($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php if ($chan !== 'drive'): ?>
<?php /* 7a : numero de table, utile uniquement en sur place. Masque par defaut
(toggle JS sur le mode) ; le champ reste soumis tel quel, persist()
l'ignore hors dine_in. */ ?>
<div class="form-group" id="service_tag_group"<?= $mode === 'dine_in' ? '' : ' hidden' ?>>
<label class="form-label" for="service_tag">Numero de 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 class="form-group">
<legend>Produits</legend>
<?php if ($productRows === []): ?>
<p class="admin-empty">Aucun produit commandable pour le moment.</p>
<?php else: ?>
<?php foreach ($productGroups as $catName => $catProducts): ?>
<h3 class="order-group__title"><?= $esc($catName) ?></h3>
<table class="admin-table">
<thead>
<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>
<th>Produit</th>
<th>Prix</th>
<th>Quantite</th>
<th>Personnaliser</th>
<td><?= $esc($p['name'] ?? '') ?></td>
<td><?= $esc($euros($p['price_cents'] ?? 0)) ?></td>
<td>
<input class="form-input order-qty" type="number" min="0" value="0"
id="qty_<?= $pid ?>" name="qty_<?= $pid ?>"
data-product-id="<?= $pid ?>"
aria-label="Quantite <?= $esc($p['name'] ?? '') ?>">
</td>
<td>
<?php if ($hasModifiers): ?>
<button class="btn btn-secondary product-configure" type="button" data-product-id="<?= $pid ?>">
Personnaliser
</button>
<?php endif; ?>
</td>
</tr>
</thead>
<tbody>
<?php foreach ($catProducts 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>
<?php /* 4 (progressive enhancement) : le champ quantite est
rendu EDITABLE pour TOUS les produits, pour que la saisie
marche SANS JS (qty de base, sans modificateurs). C'est
counter-order.js qui neutralise ce champ au cablage pour un
produit personnalisable et route la saisie vers la modale.
L'indice "via Personnaliser" est cache par defaut et revele
par le JS, pour ne pas perturber le rendu sans JS. */ ?>
<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'] ?? '') ?>">
<?php if ($hasModifiers): ?>
<span class="order-qty-hint" data-qty-hint="<?= $pid ?>" hidden>via Personnaliser</span>
<?php endif; ?>
</td>
<td>
<?php if ($hasModifiers): ?>
<button class="btn btn-secondary product-configure" type="button" data-product-id="<?= $pid ?>">
Personnaliser
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endforeach; ?>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</fieldset>
@ -232,18 +182,10 @@ foreach ($productRows as $p) {
<?php else: ?>
<ul class="menu-list" id="menu-list">
<?php foreach ($menuRows as $m): ?>
<?php
$mid = (int) ($m['id'] ?? 0);
$priceNormal = (int) ($m['price_normal_cents'] ?? 0);
$priceMaxi = (int) ($m['price_maxi_cents'] ?? 0);
?>
<?php $mid = (int) ($m['id'] ?? 0); ?>
<li class="menu-list__item">
<span class="menu-list__name"><?= $esc($m['name'] ?? '') ?></span>
<?php /* 6 : afficher les deux prix qualifies (Normal / Maxi) pour que
le choix de format soit lisible avant d'ouvrir la modale. */ ?>
<span class="menu-list__price">
Normal <?= $esc($euros($priceNormal)) ?> / Maxi <?= $esc($euros($priceMaxi)) ?>
</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>
@ -258,12 +200,10 @@ foreach ($productRows as $p) {
<ul class="order-cart" id="order-cart" aria-live="polite">
<li class="order-cart__empty" id="order-cart-empty">Panier vide.</li>
</ul>
<?php /* 1 : 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>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit" id="order-submit">Encaisser <?= $esc($euros(0)) ?></button>
<button class="btn btn-primary" type="submit">Encaisser la commande</button>
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
</div>
</form>

View file

@ -18,7 +18,6 @@ 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
*/
@ -28,13 +27,6 @@ $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);
@ -127,9 +119,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 /* 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 /* 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 endif; ?>
<?php if ($can('stats.read')): ?>
<a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a>

View file

@ -1431,123 +1431,3 @@ tbody td.mono {
padding: 0;
margin-bottom: 4px;
}
/* =============================================================================
Saisie de commande comptoir/drive (counter-order.js + admin/counter/new.php)
Composeur : groupes produits, liste menus, panier chiffre, total, modale.
============================================================================= */
/* 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;
}
/* Sous-titre de groupe de produits (regroupement par categorie). */
.order-group__title {
font-size: 14px;
font-weight: 700;
color: var(--color-yellow-ink);
margin: 14px 0 6px;
}
/* Champ quantite desactive pour un produit personnalisable (saisie en modale). */
.order-qty--disabled {
background: var(--color-surface);
color: var(--color-text-muted);
cursor: not-allowed;
}
/* Indice "via Personnaliser" revele par JS a cote du champ qty neutralise. */
.order-qty-hint {
display: inline-block;
margin-left: 8px;
font-size: 13px;
color: var(--color-text-muted);
font-style: italic;
}
/* Deux prix d'un menu (Normal / Maxi) dans la liste. */
.menu-list { list-style: none; padding: 0; margin: 0; }
.menu-list__item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border);
}
.menu-list__name { font-weight: 600; flex: 1; }
.menu-list__price { color: var(--color-text-sec); font-variant-numeric: tabular-nums; }
/* Panier : une ligne = libelle + prix + bouton retirer. */
.order-cart { list-style: none; padding: 0; margin: 0; }
.order-cart__empty { color: var(--color-text-muted); padding: 8px 0; }
.order-cart__line {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border);
}
.order-cart__label { flex: 1; }
.order-cart__price { font-weight: 700; font-variant-numeric: tabular-nums; }
/* Total indicatif du panier. */
.order-total {
text-align: right;
font-size: 16px;
font-weight: 800;
margin: 12px 0 0;
}
/* 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;
}

View file

@ -41,51 +41,10 @@
}
}
// 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 >= 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 '+' + 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);
return '+' + (Number(cents) / 100).toFixed(2).replace('.', ',') + ' EUR';
}
// Etapes composables d'un menu : burger impose ignore (non choisi ici), un pas par
@ -123,15 +82,6 @@
return;
}
// Elements de prix (1) : valeur du total + libelle du bouton d'encaissement.
// Optionnels (le rendu degrade sans eux) -> garde-fous au moment d'ecrire.
var totalValue = doc.getElementById('order-total-value');
var submitBtn = doc.getElementById('order-submit');
// 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');
var products = parseData(form, 'products', '[]'); // [{id, name, price, modifiers:[...]}]
var menus = parseData(form, 'menus', '[]'); // [{id, name, price_normal, price_maxi, burger_modifiers:[...], slots:[...]}]
@ -150,24 +100,10 @@
// 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. Progressive enhancement (4) : le champ qty est EDITABLE dans le HTML
// (repli sans JS) ; ici, en presence de JS, on le neutralise et on revele
// l'indice "via Personnaliser" pour que l'equipier sache ou saisir la quantite.
// comptage (le champ reste present pour le repli sans JS).
var configurableIds = {};
Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) {
var pid = Number(btn.dataset.productId);
configurableIds[pid] = true;
var qtyInput = doc.getElementById('qty_' + pid);
if (qtyInput) {
qtyInput.disabled = true;
qtyInput.classList.add('order-qty--disabled');
qtyInput.setAttribute('aria-label', (qtyInput.getAttribute('aria-label') || 'Quantite') + ' (via Personnaliser)');
}
var hint = doc.querySelector('[data-qty-hint="' + pid + '"]');
if (hint) {
hint.hidden = false;
}
configurableIds[Number(btn.dataset.productId)] = true;
});
function el(tag, className) {
@ -317,56 +253,6 @@
hidden.value = JSON.stringify(items);
}
/* ----------------------------------------------------------------- */
/* Prix indicatifs (1, 6) : par ligne + total + libelle du bouton */
/* ----------------------------------------------------------------- */
// Prix d'une ligne PRODUIT (configuree par la modale) : 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);
}
// Prix d'une ligne MENU : price_maxi si format maxi sinon price_normal, plus le
// surcout des ajouts sur le burger. 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;
}
// Total indicatif du panier : derive des champs qty_<id> (produits simples) +
// des lignes configurees (produits personnalises + menus). Met a jour le pied
// de panier ET le libelle du bouton ("Encaisser X,XX EUR").
function updateTotal() {
var total = 0;
Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) {
var productId = Number(input.dataset.productId);
if (configurableIds[productId]) {
return; // route par la modale -> compte plus bas (pas de double comptage).
}
var quantity = parseInt(input.value, 10);
if (productId > 0 && quantity >= 1) {
total += ((productById[productId] || {}).price || 0) * quantity;
}
});
productLines.forEach(function (line) { total += productLineTotal(line); });
menuLines.forEach(function (line) { total += menuLineTotal(line); });
if (totalValue) {
totalValue.textContent = formatEuros(total);
}
if (submitBtn) {
submitBtn.textContent = 'Encaisser ' + formatEuros(total);
}
}
/* ----------------------------------------------------------------- */
/* Rendu du panier (recap des lignes configurees) */
/* ----------------------------------------------------------------- */
@ -388,10 +274,6 @@
label.textContent = text;
li.appendChild(label);
var price = el('span', 'order-cart__price');
price.textContent = formatEuros(productLineTotal(line));
li.appendChild(price);
var removeBtn = el('button', 'btn btn-secondary order-cart__remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
@ -423,10 +305,6 @@
label.textContent = text;
li.appendChild(label);
var price = el('span', 'order-cart__price');
price.textContent = formatEuros(menuLineTotal(line));
li.appendChild(price);
var removeBtn = el('button', 'btn btn-secondary order-cart__remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
@ -442,113 +320,15 @@
if (cartEmpty) {
cartEmpty.style.display = (productLines.length || menuLines.length) ? 'none' : '';
}
updateTotal();
}
/* ----------------------------------------------------------------- */
/* 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 (bouton Personnaliser/Configurer).
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).
@ -556,6 +336,7 @@
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');
@ -608,7 +389,8 @@
actions.appendChild(cancelBtn);
panel.appendChild(actions);
openModal(panel);
modalHost.appendChild(panel);
modalHost.removeAttribute('hidden');
}
// Ouvre la modale d'un menu : choix du format, une selection par slot, puis les
@ -623,6 +405,7 @@
}
});
modalHost.textContent = '';
var panel = el('div', 'menu-composer');
var title = el('h2', 'menu-composer__title');
@ -695,15 +478,6 @@
// 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);
// 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');
@ -713,7 +487,6 @@
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 = [];
@ -744,7 +517,8 @@
actions.appendChild(cancelBtn);
panel.appendChild(actions);
openModal(panel);
modalHost.appendChild(panel);
modalHost.removeAttribute('hidden');
}
/* ----------------------------------------------------------------- */
@ -771,34 +545,6 @@
});
});
// 1 : le total et le libelle du bouton suivent la saisie des quantites des
// produits simples (les lignes configurees rafraichissent via renderCart).
Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) {
if (configurableIds[Number(input.dataset.productId)]) {
return; // champ desactive (route par la modale).
}
input.addEventListener('input', updateTotal);
input.addEventListener('change', updateTotal);
});
// 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;
}
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();
});

View file

@ -676,35 +676,6 @@ 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;

View file

@ -8,8 +8,7 @@
* Traduction panier borne -> contrat API :
* - produit simple -> { type:'product', product_id, quantity }
* - menu -> { type:'menu', menu_id, quantity, format, selections }
* format = cartItem.format (choix Normal/Maxi porte par l'item panier) ; repli
* historique sur supplement_cents>0 pour un panier serialise avant cette version.
* format = 'maxi' si supplement_cents>0, sinon 'normal'.
* 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'.
@ -65,10 +64,7 @@ export function buildOrderItem(cartItem, menuSlotsById) {
type: 'menu',
menu_id: cartItem.id,
quantity: cartItem.quantite,
// 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'),
format: (cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal',
selections: buildSelections(cartItem.composition, menuSlotsById[cartItem.id] || []),
};
}

View file

@ -89,16 +89,12 @@ 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', commandable: m.is_orderable !== false });
bySlug[slug].push({ id: m.id, nom: m.name, prix: m.price_normal_cents, image: m.image_path, type: 'menu' });
}
return bySlug;
}).catch(e => { _productsPromise = null; throw e; });

View file

@ -121,11 +121,6 @@ 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,
};
}

View file

@ -63,14 +63,10 @@ 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 = orderable ? 'product-card' : 'product-card product-card--unavailable';
card.className = 'product-card';
card.href = `product.html?id=${product.id}&category=${categorySlug}`;
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}${orderable ? '' : ' - indisponible'}`);
if (!orderable) card.setAttribute('aria-disabled', 'true');
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}`);
card.innerHTML = `
<div class="product-card__image-wrap">
@ -81,7 +77,6 @@ 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>
@ -96,11 +91,9 @@ async function renderProducts() {
// 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). Une tuile en
// rupture ne fait rien (ni navigation ni modale).
// (L3). Le <a href> reste un repli (lien direct / sans JS).
card.addEventListener('click', (e) => {
e.preventDefault();
if (!orderable) return;
if (product.type === 'menu') openMenuComposer(product, categorySlug);
else openProductOptions(product, categorySlug);
});

View file

@ -79,14 +79,6 @@ 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 = [];
/**
* Trace des lectures pour asserter le court-circuit du detail (id <= 0).
*
@ -117,12 +109,6 @@ 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')) {

View file

@ -28,8 +28,6 @@ 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 */
@ -95,11 +93,6 @@ 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

@ -1,66 +0,0 @@
<?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,7 +18,6 @@ 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
{
@ -30,22 +29,6 @@ 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
@ -473,178 +456,6 @@ 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 testCreateRendersEditableQuantityWithHintForConfigurableProduct(): void
{
// 4 (progressive enhancement) : le champ quantite d'un produit personnalisable
// est rendu EDITABLE en HTML (repli sans JS marche : commande de base sans
// modificateurs). Le PHP ne pose PAS readonly (c'est le JS qui neutralise au
// cablage). Un indice "via Personnaliser" (cache en HTML) accompagne le champ.
$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 champ qty est present et EDITABLE (aucun readonly pose par le PHP).
self::assertStringContainsString('name="qty_12"', $body);
self::assertDoesNotMatchRegularExpression('/id="qty_12"[^>]*readonly/', $body);
// Indice de redirection vers la modale (revele par le JS).
self::assertStringContainsString('data-qty-hint="12"', $body);
self::assertStringContainsString('via Personnaliser', $body);
}
public function testCreateGroupsProductsByCategory(): void
{
// 7b : les produits sont regroupes par categorie (sous-titre = category_name).
$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('Burgers', $body);
self::assertStringContainsString('Accompagnements', $body);
}
public function testCreateShowsBothMenuPrices(): void
{
// 6 : la liste des menus affiche les deux prix (Normal / Maxi).
$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('9,90 EUR', $body);
self::assertStringContainsString('11,90 EUR', $body);
self::assertStringContainsString('Maxi', $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

@ -1,107 +0,0 @@
<?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

@ -1,68 +0,0 @@
<?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

@ -23,42 +23,24 @@ final class UserDirectoryTest extends TestCase
public function testDisplayInfoReturnsNameAndRoleLabel(): void
{
$this->db->userDisplayRow = [
'first_name' => 'Corentin',
'last_name' => 'J',
'email' => 'corentin@wakdo.local',
'role_label' => 'Administrateur',
'order_source' => null,
'first_name' => 'Corentin',
'last_name' => 'J',
'email' => 'corentin@wakdo.local',
'role_label' => 'Administrateur',
];
self::assertSame(
['name' => 'Corentin J', 'role_label' => 'Administrateur', 'email' => 'corentin@wakdo.local', 'order_source' => ''],
['name' => 'Corentin J', 'role_label' => 'Administrateur', 'email' => 'corentin@wakdo.local'],
(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' => '', 'order_source' => ''],
['name' => 'Utilisateur', 'role_label' => '', 'email' => ''],
(new UserDirectory($this->db))->displayInfo(999),
);
}

View file

@ -115,7 +115,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', 'is_orderable'],
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'maxi_variant_name', 'sizes'],
array_keys($product),
);
self::assertSame(12, $product['id']);
@ -125,41 +125,6 @@ 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
@ -326,52 +291,18 @@ 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', 'is_orderable'],
['id', 'category_id', 'burger_product_id', 'name', 'description', 'price_normal_cents', 'price_maxi_cents', 'image_path', 'display_order'],
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

@ -1,72 +0,0 @@
<?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,86 +121,6 @@ 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

@ -60,17 +60,6 @@ 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

@ -71,7 +71,6 @@ 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' });
@ -84,7 +83,6 @@ 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');
});
@ -93,21 +91,10 @@ 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 (cas bouteille).
// Boisson sans maxiNom : garde son nom de base meme en Maxi (le Maxi ne l agrandit pas).
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

@ -7,9 +7,7 @@
* - 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. 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.
* et recalcule les prix (RG-T16) : on n'asserte que la FORME emise, pas un prix.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
@ -54,20 +52,13 @@ function setup(menus = 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). Progressive
// enhancement (etape 4) : le champ qty est rendu EDITABLE en HTML pour TOUS les
// produits ; c'est le JS qui neutralise le champ d'un produit configurable au cablage
// et revele l'indice "via Personnaliser" (span data-qty-hint, hidden en HTML).
// ceux dont la recette offre des modificateurs (calque la vue new.php).
const productRows = PRODUCTS
.map(p => {
const hasMods = p.modifiers && p.modifiers.length;
const configure = hasMods
const configure = (p.modifiers && p.modifiers.length)
? `<button class="product-configure" type="button" data-product-id="${p.id}">Personnaliser</button>`
: '';
const hint = hasMods
? `<span class="order-qty-hint" data-qty-hint="${p.id}" hidden>via Personnaliser</span>`
: '';
return `<input class="order-qty" type="number" id="qty_${p.id}" name="qty_${p.id}" data-product-id="${p.id}" value="0">${hint}${configure}`;
return `<input class="order-qty" type="number" id="qty_${p.id}" name="qty_${p.id}" data-product-id="${p.id}" value="0">${configure}`;
})
.join('');
const dom = new JSDOM(
@ -75,13 +66,10 @@ function setup(menus = MENUS) {
'<form id="counter-order-form" method="post" action="/counter/orders" ' +
` data-products='${JSON.stringify(PRODUCTS)}' data-menus='${JSON.stringify(menus)}'>` +
' <input type="hidden" name="items_json" id="items_json" value="">' +
' <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>' +
productRows +
' <ul id="menu-list">' + menuItems + '</ul>' +
' <ul id="order-cart"><li 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>' +
' <button type="submit">Encaisser</button>' +
'</form>' +
'<div id="menu-composer-modal" hidden></div>' +
'</body></html>',
@ -283,210 +271,6 @@ 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);
const qty = doc.getElementById('qty_22'); // Frites, 250c
qty.value = '2';
qty.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
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);
doc.querySelector('.product-configure[data-product-id="12"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
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 }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
// 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);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
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 }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
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);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
// Vide un slot requis (drink, slot 1) en le passant a une valeur absente : on force
// l'etat en deselectionnant via un select dont l'option vide n'existe pas. On
// simule en retirant la selection requise par un slot side (16) mis a vide n'est pas
// possible (requis) ; on retire plutot la pre-selection en posant une valeur hors options.
// Plus simple : on supprime l'option pre-cochee du slot requis 'drink' (1) en le
// forcant a une chaine vide via le select (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)
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
// 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('produit personnalisable : champ qty editable en HTML, desactive par JS + indice revele', () => {
const dom = setup();
const doc = dom.window.document;
// Avant init : le champ qty d'un produit a modificateurs est editable (repli sans JS)
// et l'indice "via Personnaliser" est cache.
const qty = doc.getElementById('qty_12');
const hint = doc.querySelector('[data-qty-hint="12"]');
assert.equal(qty.disabled, false);
assert.equal(hint.hidden, true);
counterOrder.init(doc);
// Apres init (JS present) : champ neutralise et indice revele.
assert.equal(qty.disabled, true);
assert.equal(hint.hidden, false);
// Un produit SANS modificateur reste editable (pas d'indice).
assert.equal(doc.getElementById('qty_22').disabled, false);
assert.equal(doc.querySelector('[data-qty-hint="22"]'), null);
});
test('modale : focus restaure sur le bouton declencheur a la fermeture', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const trigger = doc.querySelector('.menu-configure[data-menu-id="5"]');
trigger.focus();
assert.equal(doc.activeElement, trigger);
trigger.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
// Le focus est entre dans la modale (plus sur le bouton declencheur).
assert.notEqual(doc.activeElement, trigger);
doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
// Ferme -> focus restaure sur le declencheur.
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);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
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, modifiers: [] }];
const dom = new JSDOM(
'<!DOCTYPE html><html><body>' +
'<form id="counter-order-form" method="post" action="/counter/orders" ' +
` data-products='${JSON.stringify(PRICEY)}' data-menus='[]'>` +
' <input type="hidden" name="items_json" id="items_json" value="">' +
' <input class="order-qty" type="number" id="qty_99" name="qty_99" data-product-id="99" value="0">' +
' <ul id="menu-list"></ul>' +
' <ul id="order-cart"><li id="order-cart-empty">Panier vide.</li></ul>' +
' <p><span id="order-total-value">0,00 EUR</span></p>' +
' <button type="submit" id="order-submit">Encaisser 0,00 EUR</button>' +
'</form>' +
'<div id="menu-composer-modal" hidden></div>' +
'</body></html>',
);
const doc = dom.window.document;
counterOrder.init(doc);
const qty = doc.getElementById('qty_99');
qty.value = '2';
qty.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
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);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
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('composerSteps: slot_type non gere (dessert) ignore, slots tries par display_order', () => {
const productById = {};
PRODUCTS.forEach(p => { productById[p.id] = p; });

View file

@ -69,8 +69,7 @@ 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.
// 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 },
{ id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', maxiNom: null, sizes: [] },
]);
});
@ -101,21 +100,10 @@ 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', commandable: true },
{ id: 1, nom: 'Menu Big Mac', prix: 800, image: 'assets/images/produits/burgers/bigmac.png', type: 'menu' },
]);
});
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);