Compare commits
2 commits
feat/backo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dee190a8c | |||
| 510404013c |
47 changed files with 133 additions and 2592 deletions
13
.env.example
13
.env.example
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
3
.gitignore
vendored
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
-- =============================================================================
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'] : '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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: ?>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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] || []),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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']] ?? [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
|
|
@ -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; });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue